first-commit
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env node
|
||||
// Bake a curated handful of community pets from Codex Pet Share into
|
||||
// the repo so they ship out-of-the-box without users having to hit the
|
||||
// "Download community pets" button in Pet settings. The daemon scans
|
||||
// `assets/community-pets/` alongside `${CODEX_HOME:-$HOME/.codex}/pets/`
|
||||
// so anything written here shows up in the "Recently hatched" grid as
|
||||
// a built-in pet that any user can adopt with one click.
|
||||
//
|
||||
// Run after editing the `BUNDLED_PETS` list below:
|
||||
// node --experimental-strip-types scripts/bake-community-pets.ts
|
||||
//
|
||||
// Flags:
|
||||
// --force Re-download pets that already exist on disk.
|
||||
// --out <dir> Destination folder (defaults to assets/community-pets).
|
||||
|
||||
import { mkdir, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const PETSHARE_BASE = 'https://ihzwckyzfcuktrljwpha.supabase.co/functions/v1/petshare';
|
||||
|
||||
// Hand-picked pets that should ship with the repo. Add to this list
|
||||
// (and re-run this script) to bundle a new pet. Keep entries sorted
|
||||
// alphabetically by id so review diffs stay clean.
|
||||
const BUNDLED_PETS = [
|
||||
'clippit',
|
||||
'dario',
|
||||
'nyako-shigure',
|
||||
'slavik',
|
||||
'trump',
|
||||
'tux',
|
||||
'yelling-dario',
|
||||
'yorha-sit-2b',
|
||||
];
|
||||
|
||||
interface PetShareDetail {
|
||||
id: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
spritesheetPath?: string;
|
||||
ownerName?: string;
|
||||
tags?: string[];
|
||||
spritesheetUrl: string;
|
||||
}
|
||||
|
||||
interface PetShareEnvelope {
|
||||
pet: PetShareDetail;
|
||||
}
|
||||
|
||||
interface ParsedArgs {
|
||||
out: string;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(here, '..');
|
||||
const args: ParsedArgs = {
|
||||
out: path.join(repoRoot, 'assets', 'community-pets'),
|
||||
force: false,
|
||||
};
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const flag = argv[i];
|
||||
if (flag === '--force') {
|
||||
args.force = true;
|
||||
continue;
|
||||
}
|
||||
if (flag === '--out') {
|
||||
const value = argv[++i];
|
||||
if (!value) throw new Error('--out expects a value');
|
||||
args.out = path.resolve(value);
|
||||
continue;
|
||||
}
|
||||
if (flag === '-h' || flag === '--help') {
|
||||
console.log('Usage: bake-community-pets.ts [--force] [--out <dir>]');
|
||||
process.exit(0);
|
||||
}
|
||||
throw new Error(`unknown flag: ${flag}`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function extOf(url: string | undefined): 'webp' | 'png' | 'gif' {
|
||||
if (!url) return 'webp';
|
||||
const clean = url.split('?')[0] ?? '';
|
||||
const ext = clean.split('.').pop()?.toLowerCase() ?? 'webp';
|
||||
if (ext === 'png' || ext === 'gif') return ext;
|
||||
return 'webp';
|
||||
}
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPetDetail(id: string): Promise<PetShareDetail> {
|
||||
const url = `${PETSHARE_BASE}/api/pets/${encodeURIComponent(id)}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`fetch ${id}: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
const data = (await resp.json()) as PetShareEnvelope;
|
||||
if (!data?.pet?.id) throw new Error(`fetch ${id}: empty pet payload`);
|
||||
return data.pet;
|
||||
}
|
||||
|
||||
async function downloadBinary(url: string): Promise<Buffer> {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`download ${url}: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
return Buffer.from(await resp.arrayBuffer());
|
||||
}
|
||||
|
||||
function isPlausibleSpritesheet(bytes: Buffer): boolean {
|
||||
if (bytes.length < 16) return false;
|
||||
const head = bytes.subarray(0, 12);
|
||||
const isWebp = head.toString('ascii', 0, 4) === 'RIFF' && head.toString('ascii', 8, 12) === 'WEBP';
|
||||
const isPng = head.toString('hex', 0, 8) === '89504e470d0a1a0a';
|
||||
const isGif = head.toString('ascii', 0, 6) === 'GIF87a' || head.toString('ascii', 0, 6) === 'GIF89a';
|
||||
return isWebp || isPng || isGif;
|
||||
}
|
||||
|
||||
async function bakePet(id: string, outRoot: string, force: boolean): Promise<'wrote' | 'skipped'> {
|
||||
const detail = await fetchPetDetail(id);
|
||||
const ext = extOf(detail.spritesheetPath ?? detail.spritesheetUrl);
|
||||
const dir = path.join(outRoot, id);
|
||||
const sheetPath = path.join(dir, `spritesheet.${ext}`);
|
||||
const manifestPath = path.join(dir, 'pet.json');
|
||||
if (!force && (await pathExists(sheetPath)) && (await pathExists(manifestPath))) {
|
||||
return 'skipped';
|
||||
}
|
||||
const spritesheetUrl = detail.spritesheetUrl.startsWith('http')
|
||||
? detail.spritesheetUrl
|
||||
: `${PETSHARE_BASE}${detail.spritesheetUrl}`;
|
||||
const bytes = await downloadBinary(spritesheetUrl);
|
||||
if (!isPlausibleSpritesheet(bytes)) {
|
||||
throw new Error(`${id}: spritesheet is not webp/png/gif`);
|
||||
}
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(sheetPath, bytes);
|
||||
// Mirror the manifest shape the daemon's `listCodexPets` reader
|
||||
// expects, plus an explicit `source` block so the in-repo origin is
|
||||
// documented next to the bytes (handy when re-baking).
|
||||
const manifest = {
|
||||
id: detail.id,
|
||||
displayName: detail.displayName,
|
||||
description: detail.description ?? '',
|
||||
spritesheetPath: `spritesheet.${ext}`,
|
||||
author: detail.ownerName,
|
||||
tags: detail.tags ?? [],
|
||||
source: 'codex-pet-share',
|
||||
sourceUrl: `https://codex-pet-share.pages.dev/#/pets/${encodeURIComponent(detail.id)}`,
|
||||
};
|
||||
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
||||
return 'wrote';
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
let args: ParsedArgs;
|
||||
try {
|
||||
args = parseArgs(process.argv);
|
||||
} catch (err) {
|
||||
console.error(String((err as Error).message ?? err));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Destination: ${args.out}`);
|
||||
await mkdir(args.out, { recursive: true });
|
||||
|
||||
let wrote = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
for (const id of BUNDLED_PETS) {
|
||||
try {
|
||||
const result = await bakePet(id, args.out, args.force);
|
||||
if (result === 'wrote') {
|
||||
wrote++;
|
||||
console.log(`+ ${id}`);
|
||||
} else {
|
||||
skipped++;
|
||||
console.log(`= ${id} (skipped, use --force to re-download)`);
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.error(`! ${id}: ${(err as Error).message ?? err}`);
|
||||
}
|
||||
}
|
||||
console.log(`\nDone. wrote=${wrote} skipped=${skipped} failed=${failed} (total=${BUNDLED_PETS.length})`);
|
||||
if (failed > 0) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env node
|
||||
// Bake self-contained example.html files for each html-ppt full-deck template.
|
||||
//
|
||||
// The Examples gallery in apps/web renders each skill's example via an iframe
|
||||
// `srcdoc`, which has no opener path and can't reach companion CSS files.
|
||||
// The upstream `templates/full-decks/<name>/index.html` references shared
|
||||
// assets via `../../../assets/...` paths — we inline those + the per-deck
|
||||
// `style.css`, drop the runtime <script> (the gallery only shows a static
|
||||
// snapshot of slide 1), and write the result to:
|
||||
//
|
||||
// skills/html-ppt-<name>/example.html
|
||||
//
|
||||
// Each per-template skill folder must already exist with a SKILL.md.
|
||||
|
||||
import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const HTML_PPT = path.join(ROOT, 'skills', 'html-ppt');
|
||||
const ASSETS = path.join(HTML_PPT, 'assets');
|
||||
const FULL_DECKS = path.join(HTML_PPT, 'templates', 'full-decks');
|
||||
const SKILLS = path.join(ROOT, 'skills');
|
||||
|
||||
async function readMaybe(p) {
|
||||
try {
|
||||
return await readFile(p, 'utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const sharedFonts = await readMaybe(path.join(ASSETS, 'fonts.css'));
|
||||
const sharedBase = await readMaybe(path.join(ASSETS, 'base.css'));
|
||||
const sharedAnimations = await readMaybe(
|
||||
path.join(ASSETS, 'animations', 'animations.css'),
|
||||
);
|
||||
|
||||
// Without runtime.js, no slide gets `.is-active`, so the deck would render
|
||||
// blank. For a static preview we surface every slide and stack them in
|
||||
// print-style flow: each slide is 100vh, so the gallery thumbnail iframe
|
||||
// (fixed viewport) naturally lands on slide 1, while the modal/export and
|
||||
// print-to-PDF flows scroll/page through the full deck. We deliberately do
|
||||
// not hide later slides — this artifact is also served via
|
||||
// `/api/skills/:id/example` and reused by share/export, where dropping
|
||||
// everything past slide 1 would silently truncate the deck.
|
||||
const STATIC_FALLBACK_CSS = `
|
||||
/* Static-preview fallback (runtime.js is absent — keep every slide visible) */
|
||||
.deck{height:auto;min-height:100vh;overflow:visible}
|
||||
.slide{position:relative;inset:auto;opacity:1;pointer-events:auto;transform:none;height:100vh;page-break-after:always}
|
||||
.deck-header,.deck-footer,.slide-number,.progress-bar,.notes-overlay,.overview{pointer-events:none}
|
||||
.notes{display:none!important}
|
||||
`;
|
||||
|
||||
function inlineLink(html, href, content) {
|
||||
// Replace <link rel="stylesheet" href="..."> regardless of attribute order.
|
||||
const re = new RegExp(
|
||||
`<link[^>]*href=["']${href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["'][^>]*>`,
|
||||
'g',
|
||||
);
|
||||
return html.replace(re, `<style>${content}\n</style>`);
|
||||
}
|
||||
|
||||
async function bakeOne(name) {
|
||||
const indexPath = path.join(FULL_DECKS, name, 'index.html');
|
||||
const stylePath = path.join(FULL_DECKS, name, 'style.css');
|
||||
let html = await readMaybe(indexPath);
|
||||
if (!html) {
|
||||
console.warn(`[bake] missing ${indexPath}`);
|
||||
return false;
|
||||
}
|
||||
const style = await readMaybe(stylePath);
|
||||
|
||||
html = inlineLink(html, '../../../assets/fonts.css', sharedFonts);
|
||||
html = inlineLink(html, '../../../assets/base.css', sharedBase);
|
||||
html = inlineLink(
|
||||
html,
|
||||
'../../../assets/animations/animations.css',
|
||||
sharedAnimations,
|
||||
);
|
||||
html = inlineLink(html, 'style.css', style);
|
||||
|
||||
// Some templates ship a `<link id="theme-link" href="../../../assets/themes/<theme>.css">`
|
||||
// so the runtime can cycle themes via `T`. The static gallery has no runtime
|
||||
// and srcdoc can't follow `../../../`, so inline whatever theme the template
|
||||
// shipped with — that's the look the upstream README screenshots show.
|
||||
html = html.replace(
|
||||
/<link[^>]*href=["']\.\.\/\.\.\/\.\.\/assets\/themes\/([\w-]+)\.css["'][^>]*>/g,
|
||||
(_match, themeName) => {
|
||||
try {
|
||||
const css = readFileSync(
|
||||
path.join(ASSETS, 'themes', `${themeName}.css`),
|
||||
'utf8',
|
||||
);
|
||||
return `<style data-theme="${themeName}">${css}\n</style>`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Drop the runtime + any FX runtime references — the static gallery only
|
||||
// shows slide 1 and these scripts would 404 inside the srcdoc sandbox.
|
||||
html = html.replace(
|
||||
/<script[^>]*src=["'][^"']*runtime\.js["'][^>]*><\/script>/g,
|
||||
'',
|
||||
);
|
||||
html = html.replace(
|
||||
/<script[^>]*src=["'][^"']*fx-runtime\.js["'][^>]*><\/script>/g,
|
||||
'',
|
||||
);
|
||||
|
||||
// Append the static fallback at the very end of <head> so it overrides
|
||||
// base.css's `.slide{opacity:0}`. We append rather than prepend to win
|
||||
// specificity ties without bumping selectors.
|
||||
html = html.replace(/<\/head>/i, `<style>${STATIC_FALLBACK_CSS}</style></head>`);
|
||||
|
||||
const outDir = path.join(SKILLS, `html-ppt-${name}`);
|
||||
await mkdir(outDir, { recursive: true });
|
||||
await writeFile(path.join(outDir, 'example.html'), html, 'utf8');
|
||||
return true;
|
||||
}
|
||||
|
||||
const entries = await readdir(FULL_DECKS, { withFileTypes: true });
|
||||
const names = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
|
||||
let baked = 0;
|
||||
for (const name of names) {
|
||||
if (await bakeOne(name)) baked++;
|
||||
}
|
||||
console.log(`[bake] wrote ${baked}/${names.length} example.html files`);
|
||||
console.log(`[bake] templates: ${names.join(', ')}`);
|
||||
@@ -0,0 +1,120 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "..");
|
||||
const residualExtensions = new Set([".js", ".mjs", ".cjs"]);
|
||||
|
||||
const skippedDirectories = new Set([
|
||||
".agents",
|
||||
".astro",
|
||||
".claude",
|
||||
".claude-sessions",
|
||||
".codex",
|
||||
".cursor",
|
||||
".git",
|
||||
".od",
|
||||
".od-e2e",
|
||||
".opencode",
|
||||
".task",
|
||||
".tmp",
|
||||
".vite",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"out",
|
||||
]);
|
||||
|
||||
const allowedExactPaths = new Set([
|
||||
"packages/platform/esbuild.config.mjs",
|
||||
"packages/sidecar/esbuild.config.mjs",
|
||||
"packages/sidecar-proto/esbuild.config.mjs",
|
||||
// Maintainer utility scripts ported from the media branch. They are
|
||||
// executed directly by Node and are not loaded by the app runtime.
|
||||
"scripts/import-prompt-templates.mjs",
|
||||
"scripts/postinstall.mjs",
|
||||
"apps/packaged/esbuild.config.mjs",
|
||||
// Browser service workers must be served as JavaScript files.
|
||||
"apps/web/public/od-notifications-sw.js",
|
||||
"scripts/bake-html-ppt-examples.mjs",
|
||||
"scripts/scaffold-html-ppt-skills.mjs",
|
||||
"scripts/sync-hyperframes-skill.mjs",
|
||||
"scripts/verify-media-models.mjs",
|
||||
"tools/dev/bin/tools-dev.mjs",
|
||||
"tools/dev/esbuild.config.mjs",
|
||||
"tools/pack/bin/tools-pack.mjs",
|
||||
"tools/pack/esbuild.config.mjs",
|
||||
"tools/pack/resources/mac/notarize.cjs",
|
||||
]);
|
||||
|
||||
const allowedPathPrefixes = [
|
||||
"apps/daemon/dist/",
|
||||
"apps/web/.next/",
|
||||
"apps/web/out/",
|
||||
"generated/",
|
||||
"e2e/playwright-report/",
|
||||
"e2e/reports/html/",
|
||||
"e2e/reports/playwright-html-report/",
|
||||
"e2e/reports/test-results/",
|
||||
// Vendored upstream HyperFrames skill helper scripts.
|
||||
"skills/hyperframes/scripts/",
|
||||
// Vendored upstream html-ppt skill runtime assets (lewislulu/html-ppt-skill).
|
||||
"skills/html-ppt/assets/",
|
||||
"test-results/",
|
||||
"vendor/",
|
||||
];
|
||||
|
||||
function toRepositoryPath(filePath: string): string {
|
||||
return path.relative(repoRoot, filePath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function isAllowedOutputPath(repositoryPath: string): boolean {
|
||||
if (allowedExactPaths.has(repositoryPath)) return true;
|
||||
return allowedPathPrefixes.some((prefix) => repositoryPath.startsWith(prefix));
|
||||
}
|
||||
|
||||
function isSkippedDirectoryName(directoryName: string): boolean {
|
||||
return skippedDirectories.has(directoryName) || directoryName === ".next" || directoryName.startsWith(".next-");
|
||||
}
|
||||
|
||||
async function collectResidualJavaScript(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const residualFiles: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
const repositoryPath = toRepositoryPath(fullPath);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (isSkippedDirectoryName(entry.name) || isAllowedOutputPath(`${repositoryPath}/`)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
residualFiles.push(...(await collectResidualJavaScript(fullPath)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile() || !residualExtensions.has(path.extname(entry.name))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isAllowedOutputPath(repositoryPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
residualFiles.push(repositoryPath);
|
||||
}
|
||||
|
||||
return residualFiles;
|
||||
}
|
||||
|
||||
const residualFiles = await collectResidualJavaScript(repoRoot);
|
||||
|
||||
if (residualFiles.length > 0) {
|
||||
console.error("Residual project-owned JavaScript files found:");
|
||||
for (const filePath of residualFiles) {
|
||||
console.error(`- ${filePath}`);
|
||||
}
|
||||
console.error("Convert these files to TypeScript or add a documented generated/vendor/output allowlist entry.");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("Residual JavaScript check passed: project-owned code is TypeScript-only.");
|
||||
}
|
||||
Executable
+403
@@ -0,0 +1,403 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Pulls down the upstream prompt corpora (CC BY 4.0) and emits curated
|
||||
* JSON files under `prompt-templates/{image,video}/`. Re-run anytime to
|
||||
* pick up new featured prompts.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/import-prompt-templates.mjs
|
||||
*
|
||||
* Source READMEs:
|
||||
* - https://github.com/YouMind-OpenLab/awesome-gpt-image-2 (CC BY 4.0)
|
||||
* - https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts (CC BY 4.0)
|
||||
*
|
||||
* Each upstream README is a structured catalog. Two patterns we care about:
|
||||
*
|
||||
* Featured block:
|
||||
* ### No. N: <Title>
|
||||
* <badges>
|
||||
* #### 📖 Description
|
||||
* <description paragraph>
|
||||
* #### 📝 Prompt
|
||||
* ```
|
||||
* <prompt body>
|
||||
* ```
|
||||
* #### 🎬 Video (or 🖼️ Generated Images)
|
||||
* <preview img / video link>
|
||||
* #### 📌 Details
|
||||
* - **Author:** [Name](url)
|
||||
* - **Source:** [Twitter Post](url)
|
||||
* - **Published:** ...
|
||||
*
|
||||
* All-Prompts block:
|
||||
* ### <Title>
|
||||
* <badges>
|
||||
* > <description>
|
||||
* #### 📝 Prompt
|
||||
* ```
|
||||
* <prompt body>
|
||||
* ```
|
||||
* <img src="<thumb>"> | <a href=...>
|
||||
* **Author:** [Name](url) | **Source:** [Link](url) | **Published:** ...
|
||||
*
|
||||
* We pick the featured 6 from each repo (always good) plus a sampled slice
|
||||
* of the All-Prompts head so the gallery has breadth across categories.
|
||||
*
|
||||
* All output JSON carries a `source` block so attribution stays intact.
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile, readdir, unlink, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const OUT_IMAGE = path.join(ROOT, 'prompt-templates', 'image');
|
||||
const OUT_VIDEO = path.join(ROOT, 'prompt-templates', 'video');
|
||||
|
||||
const SOURCES = [
|
||||
{
|
||||
surface: 'image',
|
||||
repo: 'YouMind-OpenLab/awesome-gpt-image-2',
|
||||
license: 'CC-BY-4.0',
|
||||
readmeUrl:
|
||||
'https://raw.githubusercontent.com/YouMind-OpenLab/awesome-gpt-image-2/main/README.md',
|
||||
defaultModel: 'gpt-image-2',
|
||||
defaultAspect: '1:1',
|
||||
// Cap how many entries we pull from the "All Prompts" tail to keep the
|
||||
// committed dataset reviewable. The featured block is always taken.
|
||||
sampleAllPrompts: 30,
|
||||
},
|
||||
{
|
||||
surface: 'video',
|
||||
repo: 'YouMind-OpenLab/awesome-seedance-2-prompts',
|
||||
license: 'CC-BY-4.0',
|
||||
readmeUrl:
|
||||
'https://raw.githubusercontent.com/YouMind-OpenLab/awesome-seedance-2-prompts/main/README.md',
|
||||
defaultModel: 'seedance-2.0',
|
||||
defaultAspect: '16:9',
|
||||
sampleAllPrompts: 30,
|
||||
},
|
||||
];
|
||||
|
||||
async function fetchText(url) {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`failed ${url}: ${resp.status}`);
|
||||
}
|
||||
return resp.text();
|
||||
}
|
||||
|
||||
function slugify(input) {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
// Featured blocks come between the "🔥 Featured Prompts" / "⭐ Featured" /
|
||||
// "## 🔥 Featured Prompts" header and the next H2.
|
||||
function sliceSection(md, headerRe) {
|
||||
const match = headerRe.exec(md);
|
||||
if (!match) return '';
|
||||
const start = match.index + match[0].length;
|
||||
const next = md.slice(start).search(/\n## /);
|
||||
if (next === -1) return md.slice(start);
|
||||
return md.slice(start, start + next);
|
||||
}
|
||||
|
||||
function parseFeaturedBlock(block, ctx) {
|
||||
const out = [];
|
||||
// Each featured prompt starts at "### No. N: Title".
|
||||
const headerRe = /^### No\. \d+: (.+?)\s*$/gm;
|
||||
const headers = [];
|
||||
let m;
|
||||
while ((m = headerRe.exec(block)) !== null) {
|
||||
headers.push({ index: m.index, end: m.index + m[0].length, title: m[1] });
|
||||
}
|
||||
for (let i = 0; i < headers.length; i += 1) {
|
||||
const h = headers[i];
|
||||
const next = headers[i + 1]?.index ?? block.length;
|
||||
const body = block.slice(h.end, next);
|
||||
const entry = parseEntryBody(body, h.title, ctx, true);
|
||||
if (entry) out.push(entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseAllPromptsBlock(block, ctx) {
|
||||
const out = [];
|
||||
// The "All Prompts" section uses "### <Title>" headers — sometimes
|
||||
// prefixed with "No. N:" (gpt-image-2 README), sometimes bare
|
||||
// (seedance README). Both shapes route through parseEntryBody which
|
||||
// strips the "No. N:" prefix where present.
|
||||
const headerRe = /^### (.+?)\s*$/gm;
|
||||
const headers = [];
|
||||
let m;
|
||||
while ((m = headerRe.exec(block)) !== null) {
|
||||
const title = m[1].replace(/^No\.\s*\d+:\s*/, '').trim();
|
||||
headers.push({ index: m.index, end: m.index + m[0].length, title });
|
||||
}
|
||||
for (let i = 0; i < headers.length && out.length < ctx.sampleAllPrompts; i += 1) {
|
||||
const h = headers[i];
|
||||
const next = headers[i + 1]?.index ?? block.length;
|
||||
const body = block.slice(h.end, next);
|
||||
const entry = parseEntryBody(body, h.title, ctx, false);
|
||||
if (entry) out.push(entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseEntryBody(body, title, ctx, featured) {
|
||||
const promptMatch = /#### 📝 Prompt\s*\n+```[a-zA-Z0-9_-]*\n([\s\S]*?)```/m.exec(
|
||||
body,
|
||||
);
|
||||
if (!promptMatch) return null;
|
||||
const prompt = promptMatch[1].trim();
|
||||
if (prompt.length < 40) return null;
|
||||
|
||||
// The image README structures every entry — featured AND in-list —
|
||||
// with a "#### 📖 Description" block. The seedance README only does
|
||||
// that for featured; in-list entries fall back to a leading blockquote.
|
||||
// Try the structured form first regardless, then fall back.
|
||||
const description =
|
||||
extractDescription(body) || extractBlockquoteSummary(body);
|
||||
const author = extractAuthor(body);
|
||||
const sourceUrl = extractSourceUrl(body) ?? null;
|
||||
const previewImage = extractFirstImage(body);
|
||||
const previewVideo = extractVideoLink(body);
|
||||
const category = inferCategory(title, ctx.surface);
|
||||
const tags = inferTags(title, prompt, ctx.surface);
|
||||
|
||||
return {
|
||||
id: slugify(title),
|
||||
surface: ctx.surface,
|
||||
title: cleanTitle(title),
|
||||
summary: (description || cleanTitle(title)).slice(0, 200),
|
||||
category,
|
||||
tags,
|
||||
model: ctx.defaultModel,
|
||||
aspect: ctx.defaultAspect,
|
||||
prompt,
|
||||
previewImageUrl: previewImage ?? undefined,
|
||||
previewVideoUrl: previewVideo ?? undefined,
|
||||
source: {
|
||||
repo: ctx.repo,
|
||||
license: ctx.license,
|
||||
author: author ?? undefined,
|
||||
url: sourceUrl ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extractDescription(body) {
|
||||
const m = /#### 📖 Description\s*\n+([\s\S]*?)(?=\n+####|\n+---)/m.exec(body);
|
||||
return m?.[1]?.trim().replace(/\s+/g, ' ') ?? '';
|
||||
}
|
||||
|
||||
function extractBlockquoteSummary(body) {
|
||||
const m = /^>\s*(.+?)\s*$/m.exec(body);
|
||||
return m?.[1]?.trim() ?? '';
|
||||
}
|
||||
|
||||
function extractAuthor(body) {
|
||||
// Featured: "- **Author:** [Name](url)"
|
||||
// All-prompts: "**Author:** [Name](url) | ..."
|
||||
const m = /\*\*Author:\*\*\s*\[([^\]]+)\]/.exec(body);
|
||||
return m?.[1]?.trim() ?? null;
|
||||
}
|
||||
|
||||
function extractSourceUrl(body) {
|
||||
const m = /\*\*Source:\*\*\s*\[[^\]]+\]\(([^)]+)\)/.exec(body);
|
||||
return m?.[1]?.trim() ?? null;
|
||||
}
|
||||
|
||||
function extractFirstImage(body) {
|
||||
const m = /<img[^>]*src=["']([^"']+)["']/.exec(body);
|
||||
if (!m) return null;
|
||||
return m[1];
|
||||
}
|
||||
|
||||
function extractVideoLink(body) {
|
||||
// 1) Featured entries embed an explicit "<a href=...releases/.../<id>.mp4">"
|
||||
// download link — prefer it. GitHub releases are stable and don't
|
||||
// rely on a per-request signed redirect. Catches all 6 featured
|
||||
// prompts in awesome-seedance-2-prompts.
|
||||
const releaseLink = /href=["']([^"']+\.mp4)["']/.exec(body);
|
||||
if (releaseLink) return releaseLink[1];
|
||||
// 2) All-prompts entries don't expose a static mp4 — they only embed
|
||||
// the Cloudflare Stream thumbnail. Reconstruct the playable mp4
|
||||
// from the Stream video id encoded in the thumbnail URL. The
|
||||
// /downloads/default.mp4 endpoint 302s to a freshly-signed CDN
|
||||
// URL on every request; the browser follows that transparently
|
||||
// when set as <video src>. CORS is permissive (`*` on origin)
|
||||
// and `accept-ranges: bytes` is honored, so seeking works too.
|
||||
// This is what unlocks an actual video preview for the other
|
||||
// ~30 sampled templates instead of a static thumbnail.
|
||||
const streamThumb =
|
||||
/https?:\/\/([a-z0-9-]+\.cloudflarestream\.com)\/([a-f0-9]{20,})\/thumbnails\/thumbnail\.jpg/i.exec(
|
||||
body,
|
||||
);
|
||||
if (streamThumb) {
|
||||
return `https://${streamThumb[1]}/${streamThumb[2]}/downloads/default.mp4`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cleanTitle(raw) {
|
||||
// "Profile / Avatar - Cyberpunk Anime …" → strip the leading category
|
||||
// prefix shared by every entry in the same gpt-image-2 bucket. Keeps
|
||||
// titles scannable on cards without losing meaning.
|
||||
return raw
|
||||
.replace(/\s*\(.*\)\s*$/, '')
|
||||
.replace(/^\s*[-–]\s*/, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function inferCategory(title, surface) {
|
||||
const lower = title.toLowerCase();
|
||||
if (surface === 'image') {
|
||||
if (/profile|avatar|portrait/.test(lower)) return 'Profile / Avatar';
|
||||
if (/social|post|carousel/.test(lower)) return 'Social Media Post';
|
||||
if (/info[ -]?graphic|chart|diagram/.test(lower)) return 'Infographic';
|
||||
if (/youtube|thumbnail/.test(lower)) return 'YouTube Thumbnail';
|
||||
if (/comic|storyboard|panel/.test(lower)) return 'Comic / Storyboard';
|
||||
if (/poster|flyer/.test(lower)) return 'Poster / Flyer';
|
||||
if (/ui|app|web design|mockup|landing/.test(lower)) return 'App / Web Design';
|
||||
if (/product|exploded|merch|packaging/.test(lower)) return 'Product Marketing';
|
||||
if (/anime|manga/.test(lower)) return 'Anime / Manga';
|
||||
if (/cinematic|film/.test(lower)) return 'Cinematic';
|
||||
if (/3d|render|isometric/.test(lower)) return '3D Render';
|
||||
if (/sketch|line art|pencil/.test(lower)) return 'Sketch / Line Art';
|
||||
if (/pixel/.test(lower)) return 'Pixel Art';
|
||||
if (/oil|water[- ]?color/.test(lower)) return 'Painterly';
|
||||
if (/cyberpunk|sci[- ]?fi|futuristic/.test(lower)) return 'Cyberpunk / Sci-Fi';
|
||||
if (/landscape|nature/.test(lower)) return 'Landscape';
|
||||
return 'Illustration';
|
||||
}
|
||||
// video
|
||||
if (/cinematic|film|movie|noir/.test(lower)) return 'Cinematic';
|
||||
if (/anime|manga/.test(lower)) return 'Anime';
|
||||
if (/ad|advert|commercial|brand/.test(lower)) return 'Advertising';
|
||||
if (/ugc|tutorial|vlog/.test(lower)) return 'UGC / Vlog';
|
||||
if (/meme|tiktok|viral/.test(lower)) return 'Social / Meme';
|
||||
if (/drama|short film|romance/.test(lower)) return 'Short Film / Drama';
|
||||
if (/intro|motion graphics|title sequence/.test(lower)) return 'Motion Graphics';
|
||||
if (/vfx|fantasy|magic/.test(lower)) return 'VFX / Fantasy';
|
||||
if (/race|action|combat|fight/.test(lower)) return 'Action';
|
||||
return 'General';
|
||||
}
|
||||
|
||||
function inferTags(title, prompt, surface) {
|
||||
const set = new Set();
|
||||
const blob = `${title} ${prompt}`.toLowerCase();
|
||||
const checks = [
|
||||
['portrait', /portrait|selfie|headshot/],
|
||||
['anime', /anime|manga/],
|
||||
['cinematic', /cinematic|filmic|grain|8k/],
|
||||
['cyberpunk', /cyberpunk|neon/],
|
||||
['fantasy', /fantasy|mage|elf|dragon/],
|
||||
['3d-render', /3d render|unreal engine|render/],
|
||||
['isometric', /isometric/],
|
||||
['typography', /typography|kerning|font|lettering/],
|
||||
['product', /product|packaging|exploded/],
|
||||
['ugc', /ugc|vlog|selfie cam/],
|
||||
['cinematic-romance', /romance|pure love|romantic/],
|
||||
['action', /chase|action|combat|race/],
|
||||
['food', /food|coffee|kitchen/],
|
||||
['nature', /forest|river|mountain|landscape/],
|
||||
];
|
||||
for (const [tag, re] of checks) {
|
||||
if (re.test(blob)) set.add(tag);
|
||||
}
|
||||
const lim = surface === 'image' ? 4 : 3;
|
||||
return Array.from(set).slice(0, lim);
|
||||
}
|
||||
|
||||
// Remove previously generated JSON files. Hand-authored templates (those
|
||||
// whose `source.repo` is not the upstream CC-BY corpus we import from) are
|
||||
// preserved so first-party curated prompts aren't wiped on re-run.
|
||||
async function clearDir(dir, upstreamRepo) {
|
||||
try {
|
||||
const files = await readdir(dir);
|
||||
for (const f of files) {
|
||||
if (!f.endsWith('.json')) continue;
|
||||
const filePath = path.join(dir, f);
|
||||
let keep = false;
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(filePath, 'utf8'));
|
||||
const repo = parsed?.source?.repo;
|
||||
if (repo && repo !== upstreamRepo) keep = true;
|
||||
} catch {
|
||||
// Unparseable file — treat as generated and remove.
|
||||
}
|
||||
if (!keep) await unlink(filePath);
|
||||
}
|
||||
} catch {
|
||||
// missing dir is fine — created below.
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAll(entries, outDir, upstreamRepo) {
|
||||
await mkdir(outDir, { recursive: true });
|
||||
await clearDir(outDir, upstreamRepo);
|
||||
// De-dup on slug; if two entries collide, keep the first (which is the
|
||||
// featured one — always parsed before "All Prompts"). Hand-authored
|
||||
// templates already on disk (preserved by clearDir) also take priority
|
||||
// so we never overwrite curated first-party prompts.
|
||||
const seen = new Set();
|
||||
try {
|
||||
const existing = await readdir(outDir);
|
||||
for (const f of existing) {
|
||||
if (f.endsWith('.json')) seen.add(f.replace(/\.json$/, ''));
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
let count = 0;
|
||||
for (const entry of entries) {
|
||||
if (seen.has(entry.id)) continue;
|
||||
seen.add(entry.id);
|
||||
const filePath = path.join(outDir, `${entry.id}.json`);
|
||||
await writeFile(filePath, `${JSON.stringify(entry, null, 2)}\n`, 'utf8');
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let totalImage = 0;
|
||||
let totalVideo = 0;
|
||||
for (const ctx of SOURCES) {
|
||||
const md = await fetchText(ctx.readmeUrl);
|
||||
const featuredBlock = sliceSection(md, /## 🔥 Featured Prompts/m)
|
||||
|| sliceSection(md, /## ⭐ Featured Prompts/m)
|
||||
|| sliceSection(md, /## Featured/m);
|
||||
const allPromptsBlock = sliceSection(md, /## (📋|🎬) All Prompts/m)
|
||||
|| sliceSection(md, /## All Prompts/m);
|
||||
const featured = parseFeaturedBlock(featuredBlock, ctx);
|
||||
const sampled = parseAllPromptsBlock(allPromptsBlock, ctx);
|
||||
const entries = [...featured, ...sampled];
|
||||
if (entries.length === 0) {
|
||||
console.error(`No entries parsed for ${ctx.repo}; check headers.`);
|
||||
process.exitCode = 1;
|
||||
continue;
|
||||
}
|
||||
const outDir = ctx.surface === 'image' ? OUT_IMAGE : OUT_VIDEO;
|
||||
const written = await writeAll(entries, outDir, ctx.repo);
|
||||
if (ctx.surface === 'image') totalImage += written;
|
||||
else totalVideo += written;
|
||||
console.log(
|
||||
`[${ctx.repo}] featured=${featured.length} sampled=${sampled.length} written=${written} → ${path.relative(ROOT, outDir)}`,
|
||||
);
|
||||
}
|
||||
console.log(`\nDone. ${totalImage} image + ${totalVideo} video templates.`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { dirname, extname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(scriptDir, "..");
|
||||
|
||||
const buildTargets = [
|
||||
"packages/sidecar-proto",
|
||||
"packages/sidecar",
|
||||
"packages/platform",
|
||||
"tools/dev",
|
||||
"tools/pack",
|
||||
];
|
||||
|
||||
const jsExtensions = new Set([".js", ".cjs", ".mjs"]);
|
||||
|
||||
function resolvePackageManagerInvocation() {
|
||||
const pnpmExecPath = process.env.npm_execpath;
|
||||
if (pnpmExecPath != null && pnpmExecPath.length > 0) {
|
||||
if (jsExtensions.has(extname(pnpmExecPath).toLowerCase())) {
|
||||
return { argsPrefix: [pnpmExecPath], command: process.execPath };
|
||||
}
|
||||
return { argsPrefix: [], command: pnpmExecPath };
|
||||
}
|
||||
|
||||
return { argsPrefix: [], command: process.platform === "win32" ? "pnpm.cmd" : "pnpm" };
|
||||
}
|
||||
|
||||
const packageManager = resolvePackageManagerInvocation();
|
||||
|
||||
for (const target of buildTargets) {
|
||||
const result = spawnSync(
|
||||
packageManager.command,
|
||||
[...packageManager.argsPrefix, "-C", target, "run", "build"],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
|
||||
if (result.error != null) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
const BETA_TAG = "open-design-beta";
|
||||
const stableVersionPattern = /^(\d+)\.(\d+)\.(\d+)$/;
|
||||
const stableTagPattern = /^open-design-v(\d+\.\d+\.\d+)$/;
|
||||
const betaVersionPattern = /^(\d+\.\d+\.\d+)-beta\.(\d+)$/;
|
||||
const betaTagPattern = /^open-design-v(\d+\.\d+\.\d+)-beta\.(\d+)(?:\.unsigned)?$/;
|
||||
|
||||
type GitHubReleaseAsset = {
|
||||
id?: number;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type GitHubRelease = {
|
||||
assets?: GitHubReleaseAsset[];
|
||||
body?: string | null;
|
||||
draft?: boolean;
|
||||
name?: string | null;
|
||||
prerelease?: boolean;
|
||||
tag_name?: string;
|
||||
};
|
||||
|
||||
type ParsedStableVersion = {
|
||||
parsed: [number, number, number];
|
||||
value: string;
|
||||
};
|
||||
|
||||
type ParsedBetaVersion = {
|
||||
baseVersion: string;
|
||||
betaNumber: number;
|
||||
betaVersion: string;
|
||||
};
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(`[release-beta] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseStableVersion(value: string): [number, number, number] | null {
|
||||
const match = stableVersionPattern.exec(value);
|
||||
if (match == null) return null;
|
||||
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
||||
function compareVersions(left: [number, number, number], right: [number, number, number]): number {
|
||||
const [leftMajor, leftMinor, leftPatch] = left;
|
||||
const [rightMajor, rightMinor, rightPatch] = right;
|
||||
const pairs = [
|
||||
[leftMajor, rightMajor],
|
||||
[leftMinor, rightMinor],
|
||||
[leftPatch, rightPatch],
|
||||
] as const;
|
||||
|
||||
for (const [leftPart, rightPart] of pairs) {
|
||||
if (leftPart > rightPart) return 1;
|
||||
if (leftPart < rightPart) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function extractStableVersion(release: GitHubRelease): ParsedStableVersion | null {
|
||||
const candidates = [release.tag_name, release.name].filter((value): value is string => typeof value === "string");
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const tagMatch = stableTagPattern.exec(candidate);
|
||||
const value = tagMatch?.[1] ?? candidate.match(/\b(\d+\.\d+\.\d+)\b/)?.[1];
|
||||
if (value == null) continue;
|
||||
|
||||
const parsed = parseStableVersion(value);
|
||||
if (parsed != null) return { parsed, value };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseBetaParts(baseVersion: string, betaNumber: string): ParsedBetaVersion {
|
||||
return {
|
||||
baseVersion,
|
||||
betaNumber: Number(betaNumber),
|
||||
betaVersion: `${baseVersion}-beta.${betaNumber}`,
|
||||
};
|
||||
}
|
||||
|
||||
function extractBetaVersion(release: GitHubRelease): ParsedBetaVersion | null {
|
||||
const tagMatch = typeof release.tag_name === "string" ? betaTagPattern.exec(release.tag_name) : null;
|
||||
if (tagMatch?.[1] != null && tagMatch[2] != null) {
|
||||
return parseBetaParts(tagMatch[1], tagMatch[2]);
|
||||
}
|
||||
|
||||
const candidates = [release.name, release.body].filter((value): value is string => typeof value === "string");
|
||||
for (const candidate of candidates) {
|
||||
const match = candidate.match(/(\d+\.\d+\.\d+)-beta\.(\d+)/);
|
||||
if (match?.[1] != null && match[2] != null) {
|
||||
return parseBetaParts(match[1], match[2]);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractBetaVersionFromLatestMacYml(value: string): ParsedBetaVersion | null {
|
||||
const match = value.match(/^version:\s*["']?([^"'\n]+)["']?\s*$/m);
|
||||
if (match?.[1] == null) return null;
|
||||
|
||||
const betaMatch = betaVersionPattern.exec(match[1]);
|
||||
if (betaMatch?.[1] == null || betaMatch[2] == null) return null;
|
||||
|
||||
return parseBetaParts(betaMatch[1], betaMatch[2]);
|
||||
}
|
||||
|
||||
async function readPackagedVersion(): Promise<string> {
|
||||
const packageJsonPath = join(process.cwd(), "apps", "packaged", "package.json");
|
||||
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as { version?: unknown };
|
||||
|
||||
if (typeof packageJson.version !== "string") {
|
||||
fail(`missing version in ${packageJsonPath}`);
|
||||
}
|
||||
|
||||
if (!stableVersionPattern.test(packageJson.version)) {
|
||||
fail(`apps/packaged/package.json version must be a stable x.y.z base version; got ${packageJson.version}`);
|
||||
}
|
||||
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
async function fetchReleases(repository: string): Promise<GitHubRelease[]> {
|
||||
const releases: GitHubRelease[] = [];
|
||||
for (let page = 1; ; page += 1) {
|
||||
const { stdout } = await execFile("gh", ["api", `repos/${repository}/releases?per_page=100&page=${page}`]);
|
||||
const batch = JSON.parse(stdout) as GitHubRelease[];
|
||||
if (batch.length === 0) break;
|
||||
releases.push(...batch);
|
||||
}
|
||||
return releases;
|
||||
}
|
||||
|
||||
async function fetchReleaseAssetText(repository: string, assetId: number): Promise<string> {
|
||||
const { stdout } = await execFile("gh", [
|
||||
"api",
|
||||
`repos/${repository}/releases/assets/${assetId}`,
|
||||
"--header",
|
||||
"Accept: application/octet-stream",
|
||||
]);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
function setOutput(name: string, value: string): void {
|
||||
const outputPath = process.env.GITHUB_OUTPUT;
|
||||
if (outputPath == null || outputPath.length === 0) return;
|
||||
appendFileSync(outputPath, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
const repository = process.env.GITHUB_REPOSITORY ?? fail("GITHUB_REPOSITORY is required");
|
||||
const signed = process.env.OPEN_DESIGN_RELEASE_SIGNED !== "false";
|
||||
const packagedVersion = await readPackagedVersion();
|
||||
const packagedParsed = parseStableVersion(packagedVersion) ?? fail(`invalid packaged version: ${packagedVersion}`);
|
||||
const releases = await fetchReleases(repository);
|
||||
|
||||
let latestStable: ParsedStableVersion | null = null;
|
||||
for (const release of releases) {
|
||||
if (release.draft === true || release.prerelease === true) continue;
|
||||
|
||||
const stableVersion = extractStableVersion(release);
|
||||
if (stableVersion == null) continue;
|
||||
|
||||
if (latestStable == null || compareVersions(stableVersion.parsed, latestStable.parsed) > 0) {
|
||||
latestStable = stableVersion;
|
||||
}
|
||||
}
|
||||
|
||||
if (latestStable != null && compareVersions(packagedParsed, latestStable.parsed) <= 0) {
|
||||
fail(`packaged base version ${packagedVersion} must be strictly greater than latest stable ${latestStable.value}`);
|
||||
}
|
||||
|
||||
const betaCandidates: ParsedBetaVersion[] = [];
|
||||
for (const release of releases) {
|
||||
const beta = extractBetaVersion(release);
|
||||
if (beta != null) betaCandidates.push(beta);
|
||||
}
|
||||
|
||||
const existingBetaRelease = releases.find((release) => release.tag_name === BETA_TAG);
|
||||
const latestMacAsset = existingBetaRelease?.assets?.find((asset) => asset.name === "latest-mac.yml");
|
||||
if (latestMacAsset?.id != null) {
|
||||
const beta = extractBetaVersionFromLatestMacYml(await fetchReleaseAssetText(repository, latestMacAsset.id));
|
||||
if (beta != null) betaCandidates.push(beta);
|
||||
}
|
||||
|
||||
let betaNumber = 1;
|
||||
for (const beta of betaCandidates) {
|
||||
const existingBase = parseStableVersion(beta.baseVersion);
|
||||
if (existingBase == null) continue;
|
||||
|
||||
const ordering = compareVersions(packagedParsed, existingBase);
|
||||
if (ordering < 0) {
|
||||
fail(`packaged base version ${packagedVersion} regressed below current beta base version ${beta.baseVersion}`);
|
||||
}
|
||||
|
||||
if (ordering === 0) {
|
||||
betaNumber = Math.max(betaNumber, beta.betaNumber + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const betaVersion = `${packagedVersion}-beta.${betaNumber}`;
|
||||
const unsignedSuffix = signed ? "" : ".unsigned";
|
||||
const versionTag = `open-design-v${betaVersion}${unsignedSuffix}`;
|
||||
const branch = process.env.GITHUB_REF_NAME ?? "";
|
||||
const commit = process.env.GITHUB_SHA ?? "";
|
||||
const releaseName = `Open Design Beta ${betaVersion}${signed ? "" : " (unsigned)"}`;
|
||||
|
||||
console.log(`[release-beta] channel: beta`);
|
||||
console.log(`[release-beta] base version: ${packagedVersion}`);
|
||||
console.log(`[release-beta] beta version: ${betaVersion}`);
|
||||
console.log(`[release-beta] signed: ${signed ? "true" : "false"}`);
|
||||
console.log(`[release-beta] fixed beta tag: ${BETA_TAG}`);
|
||||
console.log(`[release-beta] immutable beta tag: ${versionTag}`);
|
||||
if (latestStable != null) console.log(`[release-beta] latest stable: ${latestStable.value}`);
|
||||
|
||||
setOutput("asset_version_suffix", unsignedSuffix);
|
||||
setOutput("base_version", packagedVersion);
|
||||
setOutput("beta_number", String(betaNumber));
|
||||
setOutput("beta_tag", BETA_TAG);
|
||||
setOutput("beta_version", betaVersion);
|
||||
setOutput("branch", branch);
|
||||
setOutput("commit", commit);
|
||||
setOutput("latest_stable", latestStable?.value ?? "");
|
||||
setOutput("release_name", releaseName);
|
||||
setOutput("signed", signed ? "true" : "false");
|
||||
setOutput("version_tag", versionTag);
|
||||
@@ -0,0 +1,141 @@
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
const stableVersionPattern = /^(\d+)\.(\d+)\.(\d+)$/;
|
||||
const stableTagPattern = /^open-design-v(\d+\.\d+\.\d+)$/;
|
||||
|
||||
type GitHubRelease = {
|
||||
draft?: boolean;
|
||||
name?: string | null;
|
||||
prerelease?: boolean;
|
||||
tag_name?: string;
|
||||
};
|
||||
|
||||
type ParsedStableVersion = {
|
||||
parsed: [number, number, number];
|
||||
value: string;
|
||||
};
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(`[release-stable] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseStableVersion(value: string): [number, number, number] | null {
|
||||
const match = stableVersionPattern.exec(value);
|
||||
if (match == null) return null;
|
||||
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
||||
function compareVersions(left: [number, number, number], right: [number, number, number]): number {
|
||||
const [leftMajor, leftMinor, leftPatch] = left;
|
||||
const [rightMajor, rightMinor, rightPatch] = right;
|
||||
const pairs = [
|
||||
[leftMajor, rightMajor],
|
||||
[leftMinor, rightMinor],
|
||||
[leftPatch, rightPatch],
|
||||
] as const;
|
||||
|
||||
for (const [leftPart, rightPart] of pairs) {
|
||||
if (leftPart > rightPart) return 1;
|
||||
if (leftPart < rightPart) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function extractStableVersion(release: GitHubRelease): ParsedStableVersion | null {
|
||||
const candidates = [release.tag_name, release.name].filter((value): value is string => typeof value === "string");
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const tagMatch = stableTagPattern.exec(candidate);
|
||||
const value = tagMatch?.[1] ?? candidate.match(/\b(\d+\.\d+\.\d+)\b/)?.[1];
|
||||
if (value == null) continue;
|
||||
|
||||
const parsed = parseStableVersion(value);
|
||||
if (parsed != null) return { parsed, value };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readPackagedVersion(): Promise<string> {
|
||||
const packageJsonPath = join(process.cwd(), "apps", "packaged", "package.json");
|
||||
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as { version?: unknown };
|
||||
|
||||
if (typeof packageJson.version !== "string") {
|
||||
fail(`missing version in ${packageJsonPath}`);
|
||||
}
|
||||
|
||||
if (!stableVersionPattern.test(packageJson.version)) {
|
||||
fail(`apps/packaged/package.json version must be a stable x.y.z base version; got ${packageJson.version}`);
|
||||
}
|
||||
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
async function fetchReleases(repository: string): Promise<GitHubRelease[]> {
|
||||
const releases: GitHubRelease[] = [];
|
||||
for (let page = 1; ; page += 1) {
|
||||
const { stdout } = await execFile("gh", ["api", `repos/${repository}/releases?per_page=100&page=${page}`]);
|
||||
const batch = JSON.parse(stdout) as GitHubRelease[];
|
||||
if (batch.length === 0) break;
|
||||
releases.push(...batch);
|
||||
}
|
||||
return releases;
|
||||
}
|
||||
|
||||
function setOutput(name: string, value: string): void {
|
||||
const outputPath = process.env.GITHUB_OUTPUT;
|
||||
if (outputPath == null || outputPath.length === 0) return;
|
||||
appendFileSync(outputPath, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
const repository = process.env.GITHUB_REPOSITORY ?? fail("GITHUB_REPOSITORY is required");
|
||||
const stableVersion = await readPackagedVersion();
|
||||
const stableParsed = parseStableVersion(stableVersion) ?? fail(`invalid packaged version: ${stableVersion}`);
|
||||
const versionTag = `open-design-v${stableVersion}`;
|
||||
const releases = await fetchReleases(repository);
|
||||
|
||||
let latestStable: ParsedStableVersion | null = null;
|
||||
for (const release of releases) {
|
||||
if (release.draft === true || release.prerelease === true) continue;
|
||||
|
||||
const parsedRelease = extractStableVersion(release);
|
||||
if (parsedRelease == null) continue;
|
||||
|
||||
if (release.tag_name === versionTag) {
|
||||
fail(`stable release ${versionTag} already exists; bump apps/packaged/package.json before publishing`);
|
||||
}
|
||||
|
||||
if (latestStable == null || compareVersions(parsedRelease.parsed, latestStable.parsed) > 0) {
|
||||
latestStable = parsedRelease;
|
||||
}
|
||||
}
|
||||
|
||||
if (latestStable != null && compareVersions(stableParsed, latestStable.parsed) <= 0) {
|
||||
fail(`packaged stable version ${stableVersion} must be strictly greater than latest stable ${latestStable.value}`);
|
||||
}
|
||||
|
||||
const branch = process.env.GITHUB_REF_NAME ?? "";
|
||||
const commit = process.env.GITHUB_SHA ?? "";
|
||||
const releaseName = `Open Design ${stableVersion}`;
|
||||
|
||||
console.log(`[release-stable] channel: stable`);
|
||||
console.log(`[release-stable] version: ${stableVersion}`);
|
||||
console.log(`[release-stable] version tag: ${versionTag}`);
|
||||
if (latestStable != null) console.log(`[release-stable] previous stable: ${latestStable.value}`);
|
||||
|
||||
setOutput("base_version", stableVersion);
|
||||
setOutput("branch", branch);
|
||||
setOutput("commit", commit);
|
||||
setOutput("previous_stable", latestStable?.value ?? "");
|
||||
setOutput("release_name", releaseName);
|
||||
setOutput("stable_version", stableVersion);
|
||||
setOutput("version_tag", versionTag);
|
||||
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env node
|
||||
// Scaffold one Open Design skill per upstream html-ppt full-deck template.
|
||||
//
|
||||
// Each generated `skills/html-ppt-<name>/SKILL.md` ships only frontmatter +
|
||||
// a short body. Authoring guidance, layouts, themes, and animations live in
|
||||
// the master `skills/html-ppt/` skill — these wrappers only exist so each
|
||||
// template surfaces as its own card in the Examples gallery and so the
|
||||
// "Use this prompt" flow can prefill `mode=deck`, scenario, and the right
|
||||
// example_prompt.
|
||||
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SKILLS = path.join(ROOT, 'skills');
|
||||
const UPSTREAM_URL = 'https://github.com/lewislulu/html-ppt-skill';
|
||||
|
||||
// `featured` is a sort priority used by the Examples gallery — smaller wins
|
||||
// the tie-break, so a curated handful float to the top. Templates without
|
||||
// `featured` slot in alphabetically after the existing skills.
|
||||
const TEMPLATES = [
|
||||
{
|
||||
slug: 'pitch-deck',
|
||||
name: 'html-ppt-pitch-deck',
|
||||
title: 'HTML PPT · Pitch Deck',
|
||||
scenario: 'finance',
|
||||
featured: 20,
|
||||
description:
|
||||
'Investor-ready 10-slide HTML pitch deck — white + blue→purple gradient hero, big numbers, traction bar chart, $4.5M-style ask page. Use when the user wants a fundraising deck, seed-round pitch, or VC meeting slides.',
|
||||
triggers: ['pitch deck', 'pitch', 'fundraising', 'seed round', 'investor deck', 'vc deck', 'pitch slides'],
|
||||
examplePrompt:
|
||||
'Build a 10-slide pitch deck in HTML for my seed round. Use the html-ppt-pitch-deck full-deck template (white + blue→purple gradient, traction bars, $X.XM ask). Confirm three things first: (1) name + one-line pitch, (2) key traction numbers, (3) ask + use of funds.',
|
||||
},
|
||||
{
|
||||
slug: 'product-launch',
|
||||
name: 'html-ppt-product-launch',
|
||||
title: 'HTML PPT · Product Launch',
|
||||
scenario: 'marketing',
|
||||
featured: 21,
|
||||
description:
|
||||
'Launch keynote deck — dark hero + light content, warm orange→peach accent, feature cards, pricing tiers, CTA. Use when announcing a product, launching a feature, or doing a keynote-style reveal.',
|
||||
triggers: ['product launch', 'keynote', 'launch deck', 'feature reveal', 'launch slides', '发布会'],
|
||||
examplePrompt:
|
||||
'Make a product-launch keynote deck in HTML using the html-ppt-product-launch full-deck template (dark hero, warm orange accent, feature cards, pricing tiers). Confirm: product name + tagline, the 3 key features, and pricing tiers — then write the deck.',
|
||||
},
|
||||
{
|
||||
slug: 'tech-sharing',
|
||||
name: 'html-ppt-tech-sharing',
|
||||
title: 'HTML PPT · Tech Sharing',
|
||||
scenario: 'engineering',
|
||||
featured: 22,
|
||||
description:
|
||||
'Conference / internal tech-talk deck — GitHub-dark, JetBrains Mono, terminal code blocks, agenda + Q&A pages. Use for engineering presentations, internal sharing sessions, conference talks, and code-heavy walkthroughs.',
|
||||
triggers: ['tech sharing', 'tech talk', '技术分享', 'engineering talk', 'conference talk', 'dev talk'],
|
||||
examplePrompt:
|
||||
'帮我用 html-ppt-tech-sharing 模板做一份 8 页的技术分享 PPT。先确认:分享主题、目标听众(同事 / 社区 / 客户)、要不要包含代码片段和 benchmark。GitHub 暗色主题 + JetBrains Mono,agenda + Q&A 页备好。',
|
||||
},
|
||||
{
|
||||
slug: 'weekly-report',
|
||||
name: 'html-ppt-weekly-report',
|
||||
title: 'HTML PPT · Weekly Report',
|
||||
scenario: 'operations',
|
||||
featured: 23,
|
||||
description:
|
||||
'Team weekly / status-update deck — corporate clarity, 8-cell KPI grid, shipped list, 8-week bar chart, next-week table. Use for 周报, business reviews, team status updates, and exec dashboards.',
|
||||
triggers: ['weekly report', '周报', 'status update', 'team report', 'business review', 'wbr'],
|
||||
examplePrompt:
|
||||
'用 html-ppt-weekly-report 模板生成一份周报(7 页)。先问我四件事:本周时间范围、3-5 个核心 KPI 数字、本周已发布 / 已完成的事项、下周计划与风险。然后用模板填好 8 周柱状图和下周表格。',
|
||||
},
|
||||
{
|
||||
slug: 'xhs-post',
|
||||
name: 'html-ppt-xhs-post',
|
||||
title: 'HTML PPT · 小红书 图文',
|
||||
scenario: 'marketing',
|
||||
featured: 24,
|
||||
description:
|
||||
'小红书 / Instagram 风 9 页 3:4 竖版图文(810×1080)— 暖色 pastel、虚线 sticker 卡片、底部页码点点。用于发小红书图文、Instagram carousel、品牌种草内容。',
|
||||
triggers: ['小红书', 'xhs', 'xhs post', 'xiaohongshu', '图文', 'instagram carousel', '种草'],
|
||||
examplePrompt:
|
||||
'帮我用 html-ppt-xhs-post 模板做一组 9 张小红书图文(3:4 竖版,810×1080)。先告诉我主题,然后帮我把封面 + 7 页内容 + 结尾 CTA 排好,每页一句标题 + 一段正文 + 关键词 sticker。',
|
||||
},
|
||||
{
|
||||
slug: 'course-module',
|
||||
name: 'html-ppt-course-module',
|
||||
title: 'HTML PPT · Course Module',
|
||||
scenario: 'education',
|
||||
featured: 25,
|
||||
description:
|
||||
'Online-course / workshop module deck — warm paper background + Playfair serif, persistent left sidebar of learning objectives, MCQ self-check page. Use for teaching modules, training materials, workshop slides.',
|
||||
triggers: ['course module', 'course slides', 'workshop', 'training deck', 'lesson', '教学', '课件'],
|
||||
examplePrompt:
|
||||
'Use the html-ppt-course-module template to build a 7-slide module deck. Confirm: module title, 3-5 learning objectives (these stick on the left rail), and the MCQ self-check question. Then assemble the deck with serif headings on warm paper.',
|
||||
},
|
||||
{
|
||||
slug: 'presenter-mode-reveal',
|
||||
name: 'html-ppt-presenter-mode',
|
||||
title: 'HTML PPT · Presenter Mode (演讲者模式)',
|
||||
scenario: 'engineering',
|
||||
featured: 26,
|
||||
description:
|
||||
'演讲者模式专用 deck — tokyo-night 默认主题,5 套主题 T 键切换,每页带 150-300 字逐字稿示例(<aside class="notes">),按 S 打开 popup(CURRENT / NEXT / SCRIPT / TIMER 四张磁吸卡片)。用于技术分享、公开演讲、课程讲解,怕忘词或要提词器的场景。',
|
||||
triggers: ['presenter mode', '演讲者模式', '逐字稿', 'speaker notes', '提词器', 'presenter view', '演讲'],
|
||||
examplePrompt:
|
||||
'用 html-ppt-presenter-mode 模板做一份带逐字稿的演讲 PPT。先确认:演讲主题、时长(每页 2-3 分钟)、目标听众。然后帮我每页写 150-300 字的口语化逐字稿(不是讲稿,是提示信号),按 S 能打开 presenter 弹窗。',
|
||||
},
|
||||
{
|
||||
slug: 'xhs-white-editorial',
|
||||
name: 'html-ppt-xhs-white-editorial',
|
||||
title: 'HTML PPT · 白底杂志风',
|
||||
scenario: 'marketing',
|
||||
featured: 27,
|
||||
description:
|
||||
'白底杂志风 deck — 纯白背景 + 顶部 10 色彩虹 bar、80-110px display 标题、紫→蓝→绿→橙→粉渐变文字、马卡龙软卡片组(粉/紫/蓝/绿/橙)、黑底白字 .focus pill、引用大块。同时适合发小红书图文 + 横版 PPT 双用。',
|
||||
triggers: ['白底杂志', '杂志风', 'xhs editorial', 'white editorial', '小红书白底', 'editorial deck'],
|
||||
examplePrompt:
|
||||
'用 html-ppt-xhs-white-editorial 模板做一份白底杂志风 PPT,中文优先。要点:80-110px display 大标题、彩虹顶部 bar、马卡龙软卡片、黑底白字 .focus pill。先告诉我主题和受众,再写 8-12 页。',
|
||||
},
|
||||
{
|
||||
slug: 'graphify-dark-graph',
|
||||
name: 'html-ppt-graphify-dark-graph',
|
||||
title: 'HTML PPT · 暗底知识图谱',
|
||||
scenario: 'engineering',
|
||||
featured: 28,
|
||||
description:
|
||||
'暗底知识图谱 deck — #06060c→#0e1020 深夜渐变 + 漂浮 blur orbs、封面 SVG 力导向图谱、彩虹渐变标题、JetBrains Mono 命令行高亮、glass-morphism 卡片。适合 dev-tool / CLI / 知识图谱 / 数据可视化的发布会,"AI-native + 科幻 + 暖色" 调子。',
|
||||
triggers: ['知识图谱', 'graph deck', 'dark graph', 'dev tool launch', 'cli launch', 'data viz launch'],
|
||||
examplePrompt:
|
||||
'用 html-ppt-graphify-dark-graph 模板做一份 dev-tool 发布会 PPT。深夜渐变背景 + 力导向图谱封面 + 彩虹标题 + JetBrains Mono 命令行。先确认:工具名、核心能力、demo 步骤;要不要现场敲 CLI。',
|
||||
},
|
||||
{
|
||||
slug: 'knowledge-arch-blueprint',
|
||||
name: 'html-ppt-knowledge-arch-blueprint',
|
||||
title: 'HTML PPT · 奶油蓝图架构',
|
||||
scenario: 'engineering',
|
||||
featured: 29,
|
||||
description:
|
||||
'奶油蓝图架构 deck — 奶油纸 #F0EAE0 底色 + 单一锈红 #B5392A 高亮、48px 蓝图网格 mask、2px 黑边硬卡片、pipeline 步骤盒(其中一个抬高)、右侧锈红 insight callout、Playfair 衬线大字、SVG 虚线反馈环。零渐变零软阴影,认真且印刷友好。',
|
||||
triggers: ['architecture', 'blueprint', 'system design', '架构图', 'data flow', 'engineering whitepaper'],
|
||||
examplePrompt:
|
||||
'用 html-ppt-knowledge-arch-blueprint 模板做一份系统架构介绍 PPT。奶油纸底 + 锈红高亮 + 蓝图网格 + pipeline 抬高一格 + 衬线大字。先告诉我系统名 + 5-7 个核心模块 + 数据流方向,再写 8-10 页。',
|
||||
},
|
||||
{
|
||||
slug: 'hermes-cyber-terminal',
|
||||
name: 'html-ppt-hermes-cyber-terminal',
|
||||
title: 'HTML PPT · 暗终端测评',
|
||||
scenario: 'engineering',
|
||||
featured: 30,
|
||||
description:
|
||||
'暗终端 honest-review deck — #0a0c10 黑底 + 56px 赛博网格 + CRT 暗角 + 扫描线、窗口红绿灯 chrome、`$ prompt` 命令行标题、薄荷绿 #7ed3a4 大字、JetBrains Mono、stroke-only 柱状图、blinking 光标、琥珀/绿/红三档 tag、暗色代码块。适合 CLI / agent / dev tool 测评(含 trace、diff、benchmark)。',
|
||||
triggers: ['terminal review', 'cli review', 'agent review', 'honest review', 'dev tool review', '测评'],
|
||||
examplePrompt:
|
||||
'用 html-ppt-hermes-cyber-terminal 模板做一份 CLI / agent 测评 PPT。深色终端风 + scanlines + 命令行标题 + benchmark 柱状图。先确认:被测评对象、3-5 个对比维度、benchmark 数据。',
|
||||
},
|
||||
{
|
||||
slug: 'obsidian-claude-gradient',
|
||||
name: 'html-ppt-obsidian-claude-gradient',
|
||||
title: 'HTML PPT · GitHub 暗紫渐变',
|
||||
scenario: 'engineering',
|
||||
featured: 31,
|
||||
description:
|
||||
'GitHub 暗紫渐变 deck — GitHub-dark #0d1117 + 紫蓝 radial 环境光 + 60px 网格 mask、居中布局、紫色 pill 标签、三色渐变标题(#a855f7→#60a5fa→#34d399)、GitHub 风代码 palette、紫色左边框高亮块。适合开发者工作流 / MCP / Agent / dev tool 教程,类似 GitHub Blog / Linear Changelog。',
|
||||
triggers: ['github dark', 'developer tutorial', 'mcp tutorial', 'agent tutorial', 'dev workflow', 'changelog deck'],
|
||||
examplePrompt:
|
||||
'用 html-ppt-obsidian-claude-gradient 模板做一份开发者教程 PPT。GitHub 暗紫渐变 + 居中布局 + 紫色 pill + 三色渐变标题 + 配置/步骤代码块。先确认:教什么、目标受众、要不要 MCP/Agent 配置示例。',
|
||||
},
|
||||
{
|
||||
slug: 'testing-safety-alert',
|
||||
name: 'html-ppt-testing-safety-alert',
|
||||
title: 'HTML PPT · 红琥珀警示',
|
||||
scenario: 'engineering',
|
||||
featured: 32,
|
||||
description:
|
||||
'红琥珀警示 deck — 顶/底 45° 红黑 hazard 条纹、红色删除线否定标题、L1/L2/L3 绿/琥珀/红 tier 卡片、圆点状态 alert box、policy-yaml 代码块(红左边框 + bad 关键词高亮)、红绿 checklist、Q1 事故堆叠柱状图。适合安全 / 风险 / 事故复盘 / 红队 / 上线前 AI 评审 / policy-as-code。',
|
||||
triggers: ['safety alert', 'incident', 'red team', 'risk review', '事故复盘', '安全评审', 'policy as code'],
|
||||
examplePrompt:
|
||||
'用 html-ppt-testing-safety-alert 模板做一份事故复盘 / 安全评审 PPT。红黑 hazard 条 + 红色删除线 + L1/L2/L3 tier 卡片 + policy-yaml 代码块。先告诉我事件时间线、根因、影响范围。',
|
||||
},
|
||||
{
|
||||
slug: 'xhs-pastel-card',
|
||||
name: 'html-ppt-xhs-pastel-card',
|
||||
title: 'HTML PPT · 柔和马卡龙慢生活',
|
||||
scenario: 'personal',
|
||||
featured: 33,
|
||||
description:
|
||||
'柔和马卡龙慢生活 deck — 奶油 #fef8f1 底 + 三个柔光 blob、Playfair 斜体衬线 display 标题混 sans 正文、28px 圆角马卡龙卡片(桃 / 薄荷 / 天 / 紫 / 柠 / 玫)、Playfair 斜体 01-04 序号、SVG donut 图、chip+page 顶栏。适合生活方式 / 个人成长 / 慢生活 / 情绪类内容,"杂志、手作、不太科技"的感觉。',
|
||||
triggers: ['pastel', 'macaron', 'lifestyle', 'slow living', '慢生活', '生活方式', '个人成长'],
|
||||
examplePrompt:
|
||||
'用 html-ppt-xhs-pastel-card 模板做一份慢生活主题图文。奶油底 + 马卡龙圆角卡片 + Playfair 斜体序号 + donut 图。先告诉我主题(休息 / 暂停 / 自我照顾…)和 5-7 个想说的点。',
|
||||
},
|
||||
{
|
||||
slug: 'dir-key-nav-minimal',
|
||||
name: 'html-ppt-dir-key-nav-minimal',
|
||||
title: 'HTML PPT · 8 色极简方向键',
|
||||
scenario: 'personal',
|
||||
featured: 34,
|
||||
description:
|
||||
'8 页极简方向键 keynote — 每页一个独立单色背景(靛 / 奶 / 绛 / 翠 / 灰 / 紫 / 白 / 炭),各自配色,160px display 标题 + 4px 短粗 accent 线分隔、箭头 → 前缀的 Mono 列表、左下 ← → kbd 提示 + 右下页码、巨大呼吸留白。适合"有话要说但没什么可看"的 keynote、launch、公开演讲。',
|
||||
triggers: ['minimal keynote', '极简', 'mono color', 'one idea per slide', 'public talk', 'launch keynote'],
|
||||
examplePrompt:
|
||||
'用 html-ppt-dir-key-nav-minimal 模板做一份 8 页极简 keynote。每页一个单色背景 + 一句 160px 大标题 + 几条箭头列表。先告诉我演讲主题,然后帮我把 8 个核心观点拍成 8 页(每页一个 idea)。',
|
||||
},
|
||||
];
|
||||
|
||||
const SKILL_BODY = (t) => `# ${t.title}
|
||||
|
||||
A focused entry point into the [\`html-ppt\`](../html-ppt/SKILL.md) master skill that lands the user directly on the **\`${t.slug}\`** full-deck template.
|
||||
|
||||
## When this card is picked
|
||||
|
||||
The Examples gallery wires "Use this prompt" to the example_prompt above. When you accept that prompt, this card is the right pick if the user wants exactly the visual identity of \`${t.slug}\` (see the upstream [full-decks catalog](../html-ppt/references/full-decks.md) for screenshots and rationale).
|
||||
|
||||
## How to author the deck
|
||||
|
||||
1. **Read the master skill first.** All authoring rules live in
|
||||
[\`skills/html-ppt/SKILL.md\`](../html-ppt/SKILL.md) — content/audience checklist,
|
||||
token rules, layout reuse, presenter mode, the keyboard runtime, and the
|
||||
"never put presenter-only text on the slide" rule.
|
||||
2. **Start from the matching template folder:**
|
||||
\`skills/html-ppt/templates/full-decks/${t.slug}/\` — copy \`index.html\` and
|
||||
\`style.css\` into the project, keep the \`.tpl-${t.slug}\` body class.
|
||||
3. **Bring the shared runtime with the template.** The upstream
|
||||
\`index.html\` links the shared CSS/JS via \`../../../assets/...\` because it
|
||||
sits three folders deep inside \`skills/html-ppt/templates/full-decks/\`.
|
||||
Once you copy \`index.html\` into the project, those parent-relative URLs
|
||||
no longer resolve and \`base.css\`, \`animations.css\`, and \`runtime.js\`
|
||||
will 404 — meaning the deck never activates and slide navigation is
|
||||
dead. Pick one of these two recipes per project:
|
||||
- **Recipe A — copy + rewrite (preferred):** copy
|
||||
\`skills/html-ppt/assets/fonts.css\`, \`skills/html-ppt/assets/base.css\`,
|
||||
\`skills/html-ppt/assets/animations/animations.css\`, and
|
||||
\`skills/html-ppt/assets/runtime.js\` into a project-local
|
||||
\`assets/\` (with \`assets/animations/animations.css\`), then rewrite the
|
||||
four \`<link>\`/\`<script>\` tags in \`index.html\` from
|
||||
\`../../../assets/...\` to the matching project-local paths
|
||||
(\`assets/fonts.css\`, \`assets/base.css\`,
|
||||
\`assets/animations/animations.css\`, \`assets/runtime.js\`).
|
||||
- **Recipe B — inline:** read the same four files and replace each
|
||||
\`<link rel="stylesheet" href="../../../assets/...">\` with a
|
||||
\`<style>...</style>\` containing the file's contents, and the
|
||||
\`<script src="../../../assets/runtime.js">\` with a
|
||||
\`<script>...</script>\` containing \`runtime.js\`. Yields a single
|
||||
self-contained \`index.html\`.
|
||||
Either way, do not ship the upstream \`../../../assets/...\` URLs
|
||||
verbatim into a project artifact — they only work in-tree.
|
||||
4. **Pick a theme.** Default tokens look fine; if the user wants a different
|
||||
feel, swap in any of the 36 themes from \`skills/html-ppt/assets/themes/*.css\`
|
||||
via \`<link id="theme-link">\` and let \`T\` cycle.
|
||||
5. **Replace demo content, not classes.** The \`.tpl-${t.slug}\` scoped CSS only
|
||||
recognises the structural classes shipped in the template — keep them.
|
||||
6. **Speaker notes go inside \`<aside class="notes">\` or \`<div class="notes">\`** — never as visible text on the slide.
|
||||
|
||||
## Attribution
|
||||
|
||||
Visual system, layouts, themes and the runtime keyboard model come from
|
||||
the upstream MIT-licensed [\`lewislulu/html-ppt-skill\`](${UPSTREAM_URL}). The
|
||||
LICENSE file ships at \`skills/html-ppt/LICENSE\`; please keep it in place when
|
||||
redistributing.
|
||||
`;
|
||||
|
||||
function frontmatter(t) {
|
||||
const triggers = t.triggers
|
||||
.map((s) => ` - "${s.replace(/"/g, '\\"')}"`)
|
||||
.join('\n');
|
||||
return [
|
||||
'---',
|
||||
`name: ${t.name}`,
|
||||
`description: ${t.description}`,
|
||||
'triggers:',
|
||||
triggers,
|
||||
'od:',
|
||||
' mode: deck',
|
||||
` scenario: ${t.scenario}`,
|
||||
` featured: ${t.featured}`,
|
||||
` upstream: "${UPSTREAM_URL}"`,
|
||||
' preview:',
|
||||
' type: html',
|
||||
' entry: index.html',
|
||||
' design_system:',
|
||||
' requires: false',
|
||||
' speaker_notes: true',
|
||||
' animations: true',
|
||||
` example_prompt: ${JSON.stringify(t.examplePrompt)}`,
|
||||
'---',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
let wrote = 0;
|
||||
for (const t of TEMPLATES) {
|
||||
const dir = path.join(SKILLS, `html-ppt-${t.slug}`);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const skillMd = frontmatter(t) + SKILL_BODY(t);
|
||||
await writeFile(path.join(dir, 'SKILL.md'), skillMd, 'utf8');
|
||||
wrote++;
|
||||
}
|
||||
console.log(`[scaffold] wrote ${wrote} html-ppt-* SKILL.md files`);
|
||||
@@ -0,0 +1,406 @@
|
||||
#!/usr/bin/env node
|
||||
// Sync community Codex pets from the public catalogs into the local
|
||||
// `${CODEX_HOME:-$HOME/.codex}/pets/` registry that the daemon scans
|
||||
// in `apps/daemon/src/codex-pets.ts`. Once synced, every pet shows up
|
||||
// under Settings → Pets → Recently hatched and can be adopted with a
|
||||
// single click — no manual `pet.json` / `spritesheet.webp` upload.
|
||||
//
|
||||
// Sources:
|
||||
// - Codex Pet Share (https://codex-pet-share.pages.dev) — paginated
|
||||
// Supabase Functions endpoint, ~170 pets at the time of writing.
|
||||
// - j20 Hatchery (https://j20.nz/hatchery) — single-shot
|
||||
// JSON catalog, ~30 pets at the time of writing.
|
||||
//
|
||||
// Both catalogs serve a `pet.json` (Codex pet contract) and a
|
||||
// `spritesheet.webp` (8x9 atlas) per pet, so we just persist them to
|
||||
// disk in the canonical Codex layout.
|
||||
//
|
||||
// Usage:
|
||||
// node --experimental-strip-types scripts/sync-community-pets.ts
|
||||
// node --experimental-strip-types scripts/sync-community-pets.ts --out /tmp/pets
|
||||
// node --experimental-strip-types scripts/sync-community-pets.ts --source petshare
|
||||
// node --experimental-strip-types scripts/sync-community-pets.ts --force
|
||||
//
|
||||
// Flags:
|
||||
// --out <dir> Destination root. Defaults to
|
||||
// `${CODEX_HOME:-$HOME/.codex}/pets`.
|
||||
// --source <name> 'petshare' | 'hatchery' | 'all' (default).
|
||||
// --force Re-download pets that already have a folder.
|
||||
// --limit <n> Stop after N pets per source (handy for smoke
|
||||
// tests).
|
||||
// --concurrency <n> Parallel downloads. Defaults to 6.
|
||||
// --no-pet-share Skip the petshare catalog.
|
||||
// --no-hatchery Skip the hatchery catalog.
|
||||
|
||||
import { mkdir, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
const PETSHARE_BASE = 'https://ihzwckyzfcuktrljwpha.supabase.co/functions/v1/petshare';
|
||||
const HATCHERY_LIST = 'https://j20.nz/hatchery/api/pets.json';
|
||||
|
||||
interface Args {
|
||||
out: string;
|
||||
sources: Set<'petshare' | 'hatchery'>;
|
||||
force: boolean;
|
||||
limit: number | null;
|
||||
concurrency: number;
|
||||
}
|
||||
|
||||
interface PetTask {
|
||||
source: 'petshare' | 'hatchery';
|
||||
// Slug-safe folder name under <out>/.
|
||||
folder: string;
|
||||
// Manifest written verbatim to <folder>/pet.json.
|
||||
manifest: Record<string, unknown>;
|
||||
// URL of the spritesheet binary.
|
||||
spritesheetUrl: string;
|
||||
// Detected file extension ('webp' | 'png' | 'gif').
|
||||
spritesheetExt: string;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const home = process.env.CODEX_HOME?.trim() || path.join(os.homedir(), '.codex');
|
||||
const args: Args = {
|
||||
out: path.join(home, 'pets'),
|
||||
sources: new Set(['petshare', 'hatchery']),
|
||||
force: false,
|
||||
limit: null,
|
||||
concurrency: 6,
|
||||
};
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const flag = argv[i];
|
||||
const next = (): string => {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error(`flag ${flag} expects a value`);
|
||||
return v;
|
||||
};
|
||||
switch (flag) {
|
||||
case '--out':
|
||||
args.out = path.resolve(next());
|
||||
break;
|
||||
case '--source': {
|
||||
const value = next();
|
||||
if (value === 'all') {
|
||||
args.sources = new Set(['petshare', 'hatchery']);
|
||||
} else if (value === 'petshare' || value === 'hatchery') {
|
||||
args.sources = new Set([value]);
|
||||
} else {
|
||||
throw new Error(`unknown --source value: ${value}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case '--no-pet-share':
|
||||
args.sources.delete('petshare');
|
||||
break;
|
||||
case '--no-hatchery':
|
||||
args.sources.delete('hatchery');
|
||||
break;
|
||||
case '--force':
|
||||
args.force = true;
|
||||
break;
|
||||
case '--limit':
|
||||
args.limit = Math.max(1, Number.parseInt(next(), 10));
|
||||
break;
|
||||
case '--concurrency':
|
||||
args.concurrency = Math.max(1, Number.parseInt(next(), 10));
|
||||
break;
|
||||
case '-h':
|
||||
case '--help':
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
default:
|
||||
throw new Error(`unknown flag: ${flag}`);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`Sync community Codex pets into ~/.codex/pets
|
||||
|
||||
Usage:
|
||||
node --experimental-strip-types scripts/sync-community-pets.ts [flags]
|
||||
|
||||
Flags:
|
||||
--out <dir> Destination root (default: $CODEX_HOME/pets or ~/.codex/pets)
|
||||
--source <name> petshare | hatchery | all (default: all)
|
||||
--no-pet-share Skip the Codex Pet Share catalog
|
||||
--no-hatchery Skip the j20 Hatchery catalog
|
||||
--force Re-download pets that already exist on disk
|
||||
--limit <n> Cap each source at N pets (for smoke tests)
|
||||
--concurrency <n> Parallel downloads (default: 6)
|
||||
-h, --help Show this message`);
|
||||
}
|
||||
|
||||
function sanitizeFolder(value: string): string {
|
||||
return String(value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^[._-]+|[._-]+$/g, '')
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
function extOf(url: string): string {
|
||||
const clean = url.split('?')[0] ?? '';
|
||||
const ext = clean.split('.').pop()?.toLowerCase() ?? 'webp';
|
||||
if (ext === 'webp' || ext === 'png' || ext === 'gif') return ext;
|
||||
return 'webp';
|
||||
}
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface PetSharePet {
|
||||
id: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
ownerName?: string;
|
||||
tags?: string[];
|
||||
spritesheetUrl: string;
|
||||
spritesheetPath?: string;
|
||||
}
|
||||
|
||||
interface PetShareResponse {
|
||||
pets: PetSharePet[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
async function listPetSharePets(limit: number | null): Promise<PetTask[]> {
|
||||
const tasks: PetTask[] = [];
|
||||
let page = 1;
|
||||
const pageSize = 24;
|
||||
for (;;) {
|
||||
const url = `${PETSHARE_BASE}/api/pets?page=${page}&pageSize=${pageSize}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`petshare list page ${page} failed: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
const data = (await resp.json()) as PetShareResponse;
|
||||
for (const pet of data.pets) {
|
||||
const folder = sanitizeFolder(pet.id);
|
||||
if (!folder) continue;
|
||||
const spritesheetUrl = pet.spritesheetUrl.startsWith('http')
|
||||
? pet.spritesheetUrl
|
||||
: `${PETSHARE_BASE}${pet.spritesheetUrl}`;
|
||||
const ext = extOf(pet.spritesheetPath ?? spritesheetUrl);
|
||||
tasks.push({
|
||||
source: 'petshare',
|
||||
folder,
|
||||
manifest: {
|
||||
id: pet.id,
|
||||
displayName: pet.displayName,
|
||||
description: pet.description ?? '',
|
||||
spritesheetPath: `spritesheet.${ext}`,
|
||||
author: pet.ownerName,
|
||||
tags: pet.tags ?? [],
|
||||
source: 'codex-pet-share',
|
||||
sourceUrl: `https://codex-pet-share.pages.dev/#/pets/${encodeURIComponent(pet.id)}`,
|
||||
},
|
||||
spritesheetUrl,
|
||||
spritesheetExt: ext,
|
||||
});
|
||||
if (limit && tasks.length >= limit) return tasks;
|
||||
}
|
||||
if (page >= data.totalPages) break;
|
||||
page++;
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
interface HatcheryPet {
|
||||
id: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
petManifestId?: string;
|
||||
authorLabel?: string;
|
||||
authorXUrl?: string;
|
||||
galleryUrl?: string;
|
||||
petJsonUrl: string;
|
||||
spritesheetUrl: string;
|
||||
downloadCount?: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
interface HatcheryResponse {
|
||||
source: string;
|
||||
count: number;
|
||||
pets: HatcheryPet[];
|
||||
}
|
||||
|
||||
async function listHatcheryPets(limit: number | null): Promise<PetTask[]> {
|
||||
const resp = await fetch(HATCHERY_LIST);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`hatchery list failed: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
const data = (await resp.json()) as HatcheryResponse;
|
||||
const tasks: PetTask[] = [];
|
||||
for (const pet of data.pets) {
|
||||
// Prefer the human-readable manifest id when available — that is
|
||||
// what users see in their `~/.codex/pets/` listing.
|
||||
const folder = sanitizeFolder(pet.petManifestId || pet.id);
|
||||
if (!folder) continue;
|
||||
// We will rewrite pet.json from the live `petJsonUrl` content, but
|
||||
// also keep our enriched fields so users can trace the origin.
|
||||
tasks.push({
|
||||
source: 'hatchery',
|
||||
folder,
|
||||
manifest: {
|
||||
id: pet.petManifestId || pet.id,
|
||||
displayName: pet.displayName,
|
||||
description: pet.description ?? '',
|
||||
spritesheetPath: 'spritesheet.webp',
|
||||
author: pet.authorLabel,
|
||||
authorXUrl: pet.authorXUrl,
|
||||
source: 'j20-hatchery',
|
||||
sourceUrl: pet.galleryUrl,
|
||||
},
|
||||
spritesheetUrl: pet.spritesheetUrl,
|
||||
spritesheetExt: extOf(pet.spritesheetUrl),
|
||||
});
|
||||
if (limit && tasks.length >= limit) break;
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
async function downloadBinary(url: string): Promise<Buffer> {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`download ${url} failed: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
const ab = await resp.arrayBuffer();
|
||||
return Buffer.from(ab);
|
||||
}
|
||||
|
||||
async function writePet(
|
||||
task: PetTask,
|
||||
outRoot: string,
|
||||
force: boolean,
|
||||
): Promise<'wrote' | 'skipped'> {
|
||||
const dir = path.join(outRoot, task.folder);
|
||||
const sheetPath = path.join(dir, `spritesheet.${task.spritesheetExt}`);
|
||||
const manifestPath = path.join(dir, 'pet.json');
|
||||
if (!force && (await pathExists(sheetPath)) && (await pathExists(manifestPath))) {
|
||||
return 'skipped';
|
||||
}
|
||||
await mkdir(dir, { recursive: true });
|
||||
const bytes = await downloadBinary(task.spritesheetUrl);
|
||||
// Validate the magic bytes minimally — abort writes when the server
|
||||
// returns an HTML error page (every catalog has had transient hiccups
|
||||
// at some point), so callers do not end up with `.webp` files that
|
||||
// are actually `<!doctype html>`.
|
||||
if (bytes.length < 16) {
|
||||
throw new Error(`${task.folder}: spritesheet too small (${bytes.length} bytes)`);
|
||||
}
|
||||
const head = bytes.subarray(0, 12);
|
||||
const isWebp = head.toString('ascii', 0, 4) === 'RIFF' && head.toString('ascii', 8, 12) === 'WEBP';
|
||||
const isPng = head.toString('hex', 0, 8) === '89504e470d0a1a0a';
|
||||
const isGif = head.toString('ascii', 0, 6) === 'GIF87a' || head.toString('ascii', 0, 6) === 'GIF89a';
|
||||
if (!isWebp && !isPng && !isGif) {
|
||||
throw new Error(`${task.folder}: spritesheet is not webp/png/gif`);
|
||||
}
|
||||
await writeFile(sheetPath, bytes);
|
||||
await writeFile(manifestPath, JSON.stringify(task.manifest, null, 2) + '\n', 'utf8');
|
||||
return 'wrote';
|
||||
}
|
||||
|
||||
async function runPool<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
worker: (item: T, index: number) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
const results: R[] = new Array(items.length);
|
||||
let cursor = 0;
|
||||
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
||||
for (;;) {
|
||||
const idx = cursor++;
|
||||
if (idx >= items.length) return;
|
||||
results[idx] = await worker(items[idx]!, idx);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
let args: Args;
|
||||
try {
|
||||
args = parseArgs(process.argv);
|
||||
} catch (err) {
|
||||
console.error(String((err as Error).message ?? err));
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args.sources.size === 0) {
|
||||
console.error('No sources selected — nothing to do.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Destination: ${args.out}`);
|
||||
await mkdir(args.out, { recursive: true });
|
||||
|
||||
const tasks: PetTask[] = [];
|
||||
if (args.sources.has('petshare')) {
|
||||
process.stdout.write('Fetching codex-pet-share catalog…');
|
||||
const list = await listPetSharePets(args.limit);
|
||||
process.stdout.write(` ${list.length} pets\n`);
|
||||
tasks.push(...list);
|
||||
}
|
||||
if (args.sources.has('hatchery')) {
|
||||
process.stdout.write('Fetching j20 hatchery catalog…');
|
||||
const list = await listHatcheryPets(args.limit);
|
||||
process.stdout.write(` ${list.length} pets\n`);
|
||||
tasks.push(...list);
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.log('No pets to download.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Earlier sources win when two catalogs publish the same folder name
|
||||
// (e.g. an upstream "goku" appears in both feeds). De-duplicate so we
|
||||
// do not race two writers on the same folder.
|
||||
const dedup = new Map<string, PetTask>();
|
||||
for (const task of tasks) {
|
||||
if (!dedup.has(task.folder)) dedup.set(task.folder, task);
|
||||
}
|
||||
const unique = Array.from(dedup.values());
|
||||
|
||||
let wrote = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
await runPool(unique, args.concurrency, async (task) => {
|
||||
try {
|
||||
const result = await writePet(task, args.out, args.force);
|
||||
if (result === 'wrote') {
|
||||
wrote++;
|
||||
console.log(`+ ${task.source.padEnd(8)} ${task.folder}`);
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.error(`! ${task.source.padEnd(8)} ${task.folder}: ${(err as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\nDone. wrote=${wrote} skipped=${skipped} failed=${failed} (total=${unique.length})`);
|
||||
if (failed > 0) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env node
|
||||
// Sync design-systems/* from the upstream `getdesign` npm package.
|
||||
//
|
||||
// Usage:
|
||||
// 1) curl -sL $(npm view getdesign dist.tarball) -o /tmp/getdesign.tgz
|
||||
// tar -xzf /tmp/getdesign.tgz -C /tmp
|
||||
// 2) node --experimental-strip-types scripts/sync-design-systems.ts [/tmp/package/templates]
|
||||
//
|
||||
// The script re-creates each brand's design-systems/<slug>/DESIGN.md with a
|
||||
// `> Category: <name>` line inserted after the H1, mapped from the
|
||||
// awesome-design-md README. Hand-authored systems (default, warm-editorial)
|
||||
// are left untouched.
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
interface ManifestEntry {
|
||||
brand: string;
|
||||
file: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const SRC = process.argv[2] || '/tmp/package/templates';
|
||||
|
||||
const CATEGORY = {
|
||||
// AI & LLM
|
||||
claude: 'AI & LLM', cohere: 'AI & LLM', elevenlabs: 'AI & LLM',
|
||||
minimax: 'AI & LLM', 'mistral.ai': 'AI & LLM', ollama: 'AI & LLM',
|
||||
'opencode.ai': 'AI & LLM', replicate: 'AI & LLM', runwayml: 'AI & LLM',
|
||||
'together.ai': 'AI & LLM', voltagent: 'AI & LLM', 'x.ai': 'AI & LLM',
|
||||
// Developer Tools
|
||||
cursor: 'Developer Tools', expo: 'Developer Tools', lovable: 'Developer Tools',
|
||||
raycast: 'Developer Tools', superhuman: 'Developer Tools',
|
||||
vercel: 'Developer Tools', warp: 'Developer Tools',
|
||||
// Backend & Data
|
||||
clickhouse: 'Backend & Data', composio: 'Backend & Data',
|
||||
hashicorp: 'Backend & Data', mongodb: 'Backend & Data',
|
||||
posthog: 'Backend & Data', sanity: 'Backend & Data',
|
||||
sentry: 'Backend & Data', supabase: 'Backend & Data',
|
||||
// Productivity & SaaS
|
||||
cal: 'Productivity & SaaS', intercom: 'Productivity & SaaS',
|
||||
'linear.app': 'Productivity & SaaS', mintlify: 'Productivity & SaaS',
|
||||
notion: 'Productivity & SaaS', resend: 'Productivity & SaaS',
|
||||
zapier: 'Productivity & SaaS',
|
||||
// Design & Creative
|
||||
airtable: 'Design & Creative', clay: 'Design & Creative',
|
||||
figma: 'Design & Creative', framer: 'Design & Creative',
|
||||
miro: 'Design & Creative', webflow: 'Design & Creative',
|
||||
// Fintech & Crypto
|
||||
binance: 'Fintech & Crypto', coinbase: 'Fintech & Crypto',
|
||||
kraken: 'Fintech & Crypto', mastercard: 'Fintech & Crypto',
|
||||
revolut: 'Fintech & Crypto', stripe: 'Fintech & Crypto', wise: 'Fintech & Crypto',
|
||||
// E-Commerce & Retail
|
||||
airbnb: 'E-Commerce & Retail', meta: 'E-Commerce & Retail',
|
||||
nike: 'E-Commerce & Retail', shopify: 'E-Commerce & Retail',
|
||||
starbucks: 'E-Commerce & Retail',
|
||||
// Media & Consumer
|
||||
apple: 'Media & Consumer', ibm: 'Media & Consumer',
|
||||
nvidia: 'Media & Consumer', pinterest: 'Media & Consumer',
|
||||
playstation: 'Media & Consumer', spacex: 'Media & Consumer',
|
||||
spotify: 'Media & Consumer', theverge: 'Media & Consumer',
|
||||
uber: 'Media & Consumer', vodafone: 'Media & Consumer', wired: 'Media & Consumer',
|
||||
// Automotive
|
||||
bmw: 'Automotive', bugatti: 'Automotive', ferrari: 'Automotive',
|
||||
lamborghini: 'Automotive', renault: 'Automotive', tesla: 'Automotive',
|
||||
} as const;
|
||||
|
||||
type Brand = keyof typeof CATEGORY;
|
||||
|
||||
const slugOf = (brand: string): string => brand.replace(/\./g, '-');
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function readManifest(): ManifestEntry[] {
|
||||
const raw = readFileSync(path.join(SRC, 'manifest.json'), 'utf8');
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('manifest.json must contain an array');
|
||||
}
|
||||
return parsed.map((entry) => {
|
||||
if (
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
'brand' in entry &&
|
||||
'file' in entry &&
|
||||
'description' in entry &&
|
||||
typeof entry.brand === 'string' &&
|
||||
typeof entry.file === 'string' &&
|
||||
typeof entry.description === 'string'
|
||||
) {
|
||||
return entry;
|
||||
}
|
||||
throw new Error('manifest.json contains an invalid entry');
|
||||
});
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
let manifest: ManifestEntry[];
|
||||
try {
|
||||
manifest = readManifest();
|
||||
} catch (error) {
|
||||
console.error(`Could not read manifest.json under ${SRC}: ${errorMessage(error)}`);
|
||||
console.error('Did you extract the getdesign tarball? See scripts/sync-design-systems.ts header.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const written: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (const entry of manifest) {
|
||||
const { brand, file, description } = entry;
|
||||
const cat = CATEGORY[brand as Brand];
|
||||
if (!cat) { skipped.push(`${brand} (unmapped category)`); continue; }
|
||||
const slug = slugOf(brand);
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(path.join(SRC, file), 'utf8');
|
||||
} catch (error) {
|
||||
skipped.push(`${brand} (${errorMessage(error)})`);
|
||||
continue;
|
||||
}
|
||||
const lines = raw.split(/\r?\n/);
|
||||
const h1 = lines.findIndex((line) => /^#\s+/.test(line));
|
||||
if (h1 < 0) { skipped.push(`${brand} (no H1)`); continue; }
|
||||
const head = lines.slice(0, h1 + 1);
|
||||
const tail = lines.slice(h1 + 1);
|
||||
while (tail[0] === '') tail.shift();
|
||||
const body = [
|
||||
...head,
|
||||
'',
|
||||
`> Category: ${cat}`,
|
||||
`> ${description}`,
|
||||
'',
|
||||
...tail,
|
||||
].join('\n');
|
||||
const dir = path.join(ROOT, 'design-systems', slug);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(path.join(dir, 'DESIGN.md'), body);
|
||||
written.push(slug);
|
||||
}
|
||||
|
||||
console.log(`wrote ${written.length} design systems → design-systems/`);
|
||||
if (skipped.length) {
|
||||
console.log('skipped:');
|
||||
for (const entry of skipped) console.log(` - ${entry}`);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Executable
+188
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env node
|
||||
// Maintainer tool: refresh the vendored HyperFrames skill in
|
||||
// `skills/hyperframes/` from the upstream `heygen-com/hyperframes`
|
||||
// publication.
|
||||
//
|
||||
// Why vendor instead of relying on `npx skills add`? Coverage. The
|
||||
// `skills` CLI only symlinks into a known list of agent dirs (Claude
|
||||
// Code, Codex, Cursor, Trae, Factory, etc.) — but OD supports a wider
|
||||
// agent set (Hermes, Kimi, Qwen, BYOK CLIs that aren't on `skills`'s
|
||||
// allowlist). By vendoring under `skills/hyperframes/` and routing the
|
||||
// content through OD's own skill scanner (which injects the SKILL.md
|
||||
// body into the system prompt), every OD-supported agent — including
|
||||
// BYOK setups — gets HyperFrames guidance uniformly.
|
||||
//
|
||||
// This script does NOT auto-merge. Reasons:
|
||||
// 1. We add an OD-specific frontmatter shim (od.mode/surface/preview/…)
|
||||
// and an "Open Design integration" section near the top of
|
||||
// SKILL.md. An auto-merge would either drop the shim (breaking OD
|
||||
// classification) or duplicate it on every sync.
|
||||
// 2. Upstream may rename references, restructure subdirs, or change
|
||||
// `triggers`. A human eye catches that in one read.
|
||||
//
|
||||
// What it DOES do:
|
||||
// - Run `npx skills add heygen-com/hyperframes -y` into a temp dir
|
||||
// - Diff the upstream `hyperframes/` subtree against the vendored copy
|
||||
// - Print a summary of changed files (added / modified / removed)
|
||||
// - Exit non-zero when there's drift, so you notice
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/sync-hyperframes-skill.mjs # show diff
|
||||
// node scripts/sync-hyperframes-skill.mjs --apply # NOT IMPLEMENTED;
|
||||
// always reviewed
|
||||
// by hand
|
||||
//
|
||||
// To actually apply: copy the upstream files in by hand, re-add the OD
|
||||
// frontmatter shim and the "Open Design integration" section.
|
||||
|
||||
import { execFile as execFileCb } from 'node:child_process';
|
||||
import { mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const VENDORED = path.join(REPO_ROOT, 'skills', 'hyperframes');
|
||||
|
||||
async function main() {
|
||||
const tmpRoot = await mkdtemp(path.join(os.tmpdir(), 'od-hf-sync-'));
|
||||
try {
|
||||
console.log(`[sync] installing upstream into ${tmpRoot}`);
|
||||
// `-y` auto-accepts the install confirmation prompt; we install just
|
||||
// the `hyperframes` sub-skill (the main one we vendor) to keep the
|
||||
// probe focused.
|
||||
await execFile(
|
||||
'npx',
|
||||
['-y', 'skills', 'add', 'heygen-com/hyperframes', '-s', 'hyperframes', '-y'],
|
||||
{ cwd: tmpRoot, timeout: 90_000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
|
||||
const upstream = path.join(tmpRoot, '.agents', 'skills', 'hyperframes');
|
||||
if (!(await exists(upstream))) {
|
||||
console.error(
|
||||
`[sync] upstream not found at expected path: ${upstream}\n` +
|
||||
' The skills CLI may have changed where it installs to.',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const upstreamFiles = await collect(upstream);
|
||||
const vendoredFiles = await collect(VENDORED);
|
||||
|
||||
const upstreamMap = new Map(upstreamFiles.map((f) => [f.rel, f]));
|
||||
const vendoredMap = new Map(vendoredFiles.map((f) => [f.rel, f]));
|
||||
|
||||
const added = [];
|
||||
const modified = [];
|
||||
const removed = [];
|
||||
|
||||
for (const [rel, up] of upstreamMap) {
|
||||
const ven = vendoredMap.get(rel);
|
||||
if (!ven) {
|
||||
added.push(rel);
|
||||
continue;
|
||||
}
|
||||
// SKILL.md gets local edits (frontmatter shim + OD integration
|
||||
// section), so a byte-for-byte compare always reports drift.
|
||||
// Compare only the body AFTER our injected section by matching
|
||||
// upstream's first H2 heading. Imperfect but useful as a hint.
|
||||
if (rel === 'SKILL.md') {
|
||||
const upstreamMarker = '\n## Approach\n';
|
||||
const upBody = up.text.includes(upstreamMarker)
|
||||
? up.text.slice(up.text.indexOf(upstreamMarker))
|
||||
: up.text;
|
||||
const venBody = ven.text.includes(upstreamMarker)
|
||||
? ven.text.slice(ven.text.indexOf(upstreamMarker))
|
||||
: ven.text;
|
||||
if (upBody !== venBody) modified.push(`${rel} (body after ## Approach)`);
|
||||
continue;
|
||||
}
|
||||
if (up.text !== ven.text) modified.push(rel);
|
||||
}
|
||||
for (const rel of vendoredMap.keys()) {
|
||||
if (!upstreamMap.has(rel)) removed.push(rel);
|
||||
}
|
||||
|
||||
if (added.length === 0 && modified.length === 0 && removed.length === 0) {
|
||||
console.log('[sync] vendored copy matches upstream — nothing to do.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('\n[sync] DRIFT DETECTED — review and update by hand.\n');
|
||||
if (added.length) {
|
||||
console.log(` Added (in upstream, missing locally):`);
|
||||
for (const r of added) console.log(` + ${r}`);
|
||||
}
|
||||
if (modified.length) {
|
||||
console.log(` Modified upstream:`);
|
||||
for (const r of modified) console.log(` ~ ${r}`);
|
||||
}
|
||||
if (removed.length) {
|
||||
console.log(` Removed upstream (still vendored locally):`);
|
||||
for (const r of removed) console.log(` - ${r}`);
|
||||
}
|
||||
console.log(
|
||||
'\n Upstream copy lives at:\n' +
|
||||
` ${upstream}\n` +
|
||||
' (script does not auto-apply — re-run with diff tools, then\n' +
|
||||
' commit the merge by hand. Re-add OD frontmatter shim if it\n' +
|
||||
' gets dropped during the merge.)',
|
||||
);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
// Best-effort cleanup. Leaves the upstream dir behind if the user
|
||||
// wants to inspect it in the failure path.
|
||||
if (process.env.OD_KEEP_HF_SYNC_TMP) {
|
||||
console.log(`[sync] OD_KEEP_HF_SYNC_TMP set — leaving ${tmpRoot}`);
|
||||
} else {
|
||||
await rm(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function exists(p) {
|
||||
try {
|
||||
await stat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function collect(root) {
|
||||
const out = [];
|
||||
await walk(root, '', out);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function walk(root, rel, out) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(path.join(root, rel), { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const childRel = rel ? `${rel}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) {
|
||||
await walk(root, childRel, out);
|
||||
continue;
|
||||
}
|
||||
if (!e.isFile()) continue;
|
||||
const text = await readFile(path.join(root, childRel), 'utf8').catch(
|
||||
() => null,
|
||||
);
|
||||
if (text == null) continue;
|
||||
out.push({ rel: childRel, text });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[sync] failed:', err && err.message ? err.message : err);
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
// Sync apps/web/src/state/litellm-models.json from BerriAI/litellm.
|
||||
//
|
||||
// LiteLLM (MIT, https://github.com/BerriAI/litellm) maintains the de-facto
|
||||
// community catalog of model context/output caps and pricing across every
|
||||
// major provider. We vendor a filtered slice (chat-mode max_output_tokens
|
||||
// only) so the web client can default `max_tokens` per model without an
|
||||
// extra network call at runtime.
|
||||
//
|
||||
// Usage:
|
||||
// node --experimental-strip-types scripts/sync-litellm-models.ts
|
||||
//
|
||||
// Re-run periodically (or when a new model the user cares about lands) and
|
||||
// commit the regenerated JSON. Coverage gaps (e.g. mimo-v2.5-pro) are
|
||||
// filled by the hand-maintained override table in maxTokens.ts.
|
||||
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SOURCE_URL =
|
||||
'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_PATH = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'apps/web/src/state/litellm-models.json',
|
||||
);
|
||||
|
||||
interface LiteLLMEntry {
|
||||
mode?: string;
|
||||
max_tokens?: number | string;
|
||||
max_output_tokens?: number | string;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`fetching ${SOURCE_URL}`);
|
||||
const res = await fetch(SOURCE_URL);
|
||||
if (!res.ok) throw new Error(`fetch ${res.status}: ${res.statusText}`);
|
||||
const raw = (await res.json()) as Record<string, unknown>;
|
||||
|
||||
const out: Record<string, number> = {};
|
||||
let scanned = 0;
|
||||
for (const [id, value] of Object.entries(raw)) {
|
||||
if (id === 'sample_spec') continue;
|
||||
if (!value || typeof value !== 'object') continue;
|
||||
const entry = value as LiteLLMEntry;
|
||||
if (entry.mode !== 'chat') continue;
|
||||
scanned++;
|
||||
const candidate = entry.max_output_tokens ?? entry.max_tokens;
|
||||
if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate > 0) {
|
||||
out[id] = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort keys so diffs stay readable when models churn.
|
||||
const sorted = Object.fromEntries(
|
||||
Object.entries(out).sort(([a], [b]) => a.localeCompare(b)),
|
||||
);
|
||||
|
||||
const payload = {
|
||||
_source: SOURCE_URL,
|
||||
_generated_at: new Date().toISOString().slice(0, 10),
|
||||
_license:
|
||||
'BerriAI/litellm is MIT-licensed; see https://github.com/BerriAI/litellm/blob/main/LICENSE',
|
||||
models: sorted,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(payload, null, 2) + '\n';
|
||||
writeFileSync(OUT_PATH, json);
|
||||
console.log(
|
||||
`wrote ${OUT_PATH} (${Object.keys(sorted).length} models / ${scanned} chat-mode scanned)`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"typeRoots": ["../e2e/node_modules/@types"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
Executable
+170
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env node
|
||||
// Drift check between the TypeScript source-of-truth registry
|
||||
// (apps/web/src/media/models.ts) and the TS mirror used by the Node daemon
|
||||
// (apps/daemon/src/media-models.ts). The two are kept in sync by hand because the
|
||||
// daemon avoids a TS toolchain at runtime; this script lets CI fail the
|
||||
// build the moment they diverge.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/verify-media-models.mjs
|
||||
//
|
||||
// Exit codes:
|
||||
// 0 — registries match
|
||||
// 1 — drift detected (diff printed to stderr)
|
||||
// 2 — could not parse one of the registry files
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const TS_PATH = path.join(ROOT, 'apps', 'web', 'src', 'media', 'models.ts');
|
||||
const JS_PATH = path.join(ROOT, 'apps', 'daemon', 'src', 'media-models.ts');
|
||||
|
||||
function fail(msg) {
|
||||
process.stderr.write(`verify-media-models: ${msg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseError(msg) {
|
||||
process.stderr.write(`verify-media-models: ${msg}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Pull a top-level array literal of `{ id: 'x', ... }` records out of the
|
||||
// source. We deliberately avoid spinning up a TS compiler — we only need
|
||||
// the IDs and the bucket shapes the two files agree on.
|
||||
function extractIds(source, name) {
|
||||
const re = new RegExp(`export const ${name}[^=]*=\\s*\\[([\\s\\S]*?)\\];`, 'm');
|
||||
const m = source.match(re);
|
||||
if (!m) return null;
|
||||
const ids = [];
|
||||
const idRe = /\bid:\s*['\"]([^'\"]+)['\"]/g;
|
||||
let id;
|
||||
while ((id = idRe.exec(m[1])) != null) ids.push(id[1]);
|
||||
return ids;
|
||||
}
|
||||
|
||||
function extractAudioIds(source) {
|
||||
const re = /export const AUDIO_MODELS_BY_KIND[^=]*=\s*\{([\s\S]*?)\n\};/m;
|
||||
const m = source.match(re);
|
||||
if (!m) return null;
|
||||
const body = m[1];
|
||||
const out = {};
|
||||
for (const kind of ['music', 'speech', 'sfx']) {
|
||||
const kre = new RegExp(`${kind}\\s*:\\s*\\[([\\s\\S]*?)\\]`, 'm');
|
||||
const km = body.match(kre);
|
||||
if (!km) return null;
|
||||
const ids = [];
|
||||
const idRe = /\bid:\s*['\"]([^'\"]+)['\"]/g;
|
||||
let id;
|
||||
while ((id = idRe.exec(km[1])) != null) ids.push(id[1]);
|
||||
out[kind] = ids;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractNumberArray(source, name) {
|
||||
const re = new RegExp(`export const ${name}[^=]*=\\s*\\[([^\\]]*)\\]`, 'm');
|
||||
const m = source.match(re);
|
||||
if (!m) return null;
|
||||
return m[1]
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map(Number)
|
||||
.filter((n) => Number.isFinite(n));
|
||||
}
|
||||
|
||||
function dedupCheck(label, ids) {
|
||||
const seen = new Set();
|
||||
for (const id of ids) {
|
||||
if (seen.has(id)) fail(`duplicate id "${id}" in ${label}`);
|
||||
seen.add(id);
|
||||
}
|
||||
if (ids.length === 0) fail(`${label} is empty`);
|
||||
}
|
||||
|
||||
let ts;
|
||||
let js;
|
||||
try {
|
||||
ts = readFileSync(TS_PATH, 'utf8');
|
||||
} catch (err) {
|
||||
parseError(`could not read ${TS_PATH}: ${err.message}`);
|
||||
}
|
||||
try {
|
||||
js = readFileSync(JS_PATH, 'utf8');
|
||||
} catch (err) {
|
||||
parseError(`could not read ${JS_PATH}: ${err.message}`);
|
||||
}
|
||||
|
||||
const tsImage = extractIds(ts, 'IMAGE_MODELS');
|
||||
const tsVideo = extractIds(ts, 'VIDEO_MODELS');
|
||||
const tsAudio = extractAudioIds(ts);
|
||||
const tsLengths = extractNumberArray(ts, 'VIDEO_LENGTHS_SEC');
|
||||
const tsDurations = extractNumberArray(ts, 'AUDIO_DURATIONS_SEC');
|
||||
|
||||
const jsImage = extractIds(js, 'IMAGE_MODELS');
|
||||
const jsVideo = extractIds(js, 'VIDEO_MODELS');
|
||||
const jsAudio = extractAudioIds(js);
|
||||
const jsLengths = extractNumberArray(js, 'VIDEO_LENGTHS_SEC');
|
||||
const jsDurations = extractNumberArray(js, 'AUDIO_DURATIONS_SEC');
|
||||
|
||||
if (!tsImage || !tsVideo || !tsAudio) parseError('failed to parse TS registry');
|
||||
if (!jsImage || !jsVideo || !jsAudio) parseError('failed to parse JS registry');
|
||||
|
||||
dedupCheck('IMAGE_MODELS (ts)', tsImage);
|
||||
dedupCheck('VIDEO_MODELS (ts)', tsVideo);
|
||||
dedupCheck('IMAGE_MODELS (js)', jsImage);
|
||||
dedupCheck('VIDEO_MODELS (js)', jsVideo);
|
||||
for (const kind of ['music', 'speech', 'sfx']) {
|
||||
dedupCheck(`AUDIO_MODELS_BY_KIND.${kind} (ts)`, tsAudio[kind]);
|
||||
dedupCheck(`AUDIO_MODELS_BY_KIND.${kind} (js)`, jsAudio[kind]);
|
||||
}
|
||||
|
||||
function diffArrays(label, a, b) {
|
||||
const aSet = new Set(a);
|
||||
const bSet = new Set(b);
|
||||
const onlyA = [...aSet].filter((x) => !bSet.has(x));
|
||||
const onlyB = [...bSet].filter((x) => !aSet.has(x));
|
||||
if (onlyA.length === 0 && onlyB.length === 0) return null;
|
||||
return `${label}: ts only=[${onlyA.join(', ')}], js only=[${onlyB.join(', ')}]`;
|
||||
}
|
||||
|
||||
const diffs = [];
|
||||
const dImage = diffArrays('IMAGE_MODELS', tsImage, jsImage);
|
||||
if (dImage) diffs.push(dImage);
|
||||
const dVideo = diffArrays('VIDEO_MODELS', tsVideo, jsVideo);
|
||||
if (dVideo) diffs.push(dVideo);
|
||||
for (const kind of ['music', 'speech', 'sfx']) {
|
||||
const d = diffArrays(`AUDIO_MODELS_BY_KIND.${kind}`, tsAudio[kind], jsAudio[kind]);
|
||||
if (d) diffs.push(d);
|
||||
}
|
||||
if (tsLengths && jsLengths && tsLengths.join(',') !== jsLengths.join(',')) {
|
||||
diffs.push(
|
||||
`VIDEO_LENGTHS_SEC: ts=[${tsLengths.join(', ')}] js=[${jsLengths.join(', ')}]`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
tsDurations &&
|
||||
jsDurations &&
|
||||
tsDurations.join(',') !== jsDurations.join(',')
|
||||
) {
|
||||
diffs.push(
|
||||
`AUDIO_DURATIONS_SEC: ts=[${tsDurations.join(', ')}] js=[${jsDurations.join(', ')}]`,
|
||||
);
|
||||
}
|
||||
|
||||
if (diffs.length > 0) {
|
||||
process.stderr.write(
|
||||
'verify-media-models: drift detected between apps/web/src/media/models.ts and apps/daemon/src/media-models.ts\n',
|
||||
);
|
||||
for (const d of diffs) process.stderr.write(` - ${d}\n`);
|
||||
process.stderr.write(
|
||||
'\nFix: update both files in lockstep, then re-run this script.\n',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write('verify-media-models: OK (TS + JS registries match)\n');
|
||||
Reference in New Issue
Block a user