Initial import: open-design source for helix-mind.ai distribution
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
github-metrics / Generate repository metrics SVG (push) Failing after 2m6s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 12s
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
github-metrics / Generate repository metrics SVG (push) Failing after 2m6s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 12s
This repository contains the open-design daemon CLI source code, built and packaged at https://helix-mind.ai/cli/open-design/latest.tgz for use by the HelixMind /design slash command. Licenses: Apache-2.0 (root) + MIT (skills/*)
This commit is contained in:
199
scripts/bake-community-pets.ts
Normal file
199
scripts/bake-community-pets.ts
Normal file
@@ -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);
|
||||
});
|
||||
133
scripts/bake-html-ppt-examples.mjs
Normal file
133
scripts/bake-html-ppt-examples.mjs
Normal file
@@ -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(', ')}`);
|
||||
361
scripts/guard.ts
Normal file
361
scripts/guard.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "..");
|
||||
|
||||
type GuardCheck = {
|
||||
name: string;
|
||||
run: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
function toRepositoryPath(filePath: string): string {
|
||||
return path.relative(repoRoot, filePath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
const residualExtensions = new Set([".js", ".mjs", ".cjs"]);
|
||||
|
||||
const residualSkippedDirectories = new Set([
|
||||
".agents",
|
||||
".astro",
|
||||
".claude",
|
||||
".claude-sessions",
|
||||
".codex",
|
||||
".cursor",
|
||||
".git",
|
||||
".od",
|
||||
".od-e2e",
|
||||
".opencode",
|
||||
".task",
|
||||
".tmp",
|
||||
".vite",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"out",
|
||||
]);
|
||||
|
||||
const residualAllowedExactPaths = new Set([
|
||||
// esbuild config entrypoints are executed directly by Node before package
|
||||
// dist output exists.
|
||||
"packages/contracts/esbuild.config.mjs",
|
||||
"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",
|
||||
// electron-builder hook path; CJS compatibility entry used by tools-pack mac builds.
|
||||
"tools/pack/resources/mac/web-standalone-after-pack.cjs",
|
||||
]);
|
||||
|
||||
const residualAllowedPathPrefixes = [
|
||||
"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/",
|
||||
"e2e/ui/.od-data/",
|
||||
"e2e/ui/reports/playwright-html-report/",
|
||||
"e2e/ui/reports/test-results/",
|
||||
"e2e/ui/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 isResidualAllowedPath(repositoryPath: string): boolean {
|
||||
if (residualAllowedExactPaths.has(repositoryPath)) return true;
|
||||
return residualAllowedPathPrefixes.some((prefix) => repositoryPath.startsWith(prefix));
|
||||
}
|
||||
|
||||
function isResidualSkippedDirectoryName(directoryName: string): boolean {
|
||||
return (
|
||||
residualSkippedDirectories.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 (isResidualSkippedDirectoryName(entry.name) || isResidualAllowedPath(`${repositoryPath}/`)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
residualFiles.push(...(await collectResidualJavaScript(fullPath)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile() || !residualExtensions.has(path.extname(entry.name))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isResidualAllowedPath(repositoryPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
residualFiles.push(repositoryPath);
|
||||
}
|
||||
|
||||
return residualFiles;
|
||||
}
|
||||
|
||||
async function checkResidualJavaScript(): Promise<boolean> {
|
||||
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.");
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("Residual JavaScript check passed: project-owned code is TypeScript-only.");
|
||||
return true;
|
||||
}
|
||||
|
||||
const testLayoutScopedDirectories = ["apps", "packages", "tools"];
|
||||
const testLayoutSkippedDirectories = new Set([".next", ".od-data", "dist", "node_modules", "out", "reports", "test-results"]);
|
||||
|
||||
function isTestFile(fileName: string): boolean {
|
||||
return /\.test\.tsx?$/.test(fileName);
|
||||
}
|
||||
|
||||
function expectedTestPath(repositoryPath: string): string {
|
||||
const [scope, project, ...relativeParts] = repositoryPath.split("/");
|
||||
if (!testLayoutScopedDirectories.includes(scope ?? "") || project == null || relativeParts.length === 0) {
|
||||
return repositoryPath;
|
||||
}
|
||||
|
||||
const normalizedRelativeParts = relativeParts[0] === "src" ? relativeParts.slice(1) : relativeParts;
|
||||
return [scope, project, "tests", ...normalizedRelativeParts].join("/");
|
||||
}
|
||||
|
||||
function isAllowedScopedTestPath(repositoryPath: string): boolean {
|
||||
const [scope, project, directory] = repositoryPath.split("/");
|
||||
return testLayoutScopedDirectories.includes(scope ?? "") && project != null && directory === "tests";
|
||||
}
|
||||
|
||||
async function collectTestLayoutViolations(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const violations: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (testLayoutSkippedDirectories.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
violations.push(...(await collectTestLayoutViolations(fullPath)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile() || !isTestFile(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const repositoryPath = toRepositoryPath(fullPath);
|
||||
if (!isAllowedScopedTestPath(repositoryPath)) {
|
||||
violations.push(repositoryPath);
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
async function checkTestLayout(): Promise<boolean> {
|
||||
const violations = (
|
||||
await Promise.all(
|
||||
testLayoutScopedDirectories.map((directory) => collectTestLayoutViolations(path.join(repoRoot, directory))),
|
||||
)
|
||||
).flat();
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error("Test files under apps/, packages/, and tools/ must live in tests/ sibling to src/:");
|
||||
for (const violation of violations) {
|
||||
console.error(`- ${violation} -> ${expectedTestPath(violation)}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("Test layout check passed: apps/packages/tools tests live in sibling tests directories.");
|
||||
return true;
|
||||
}
|
||||
|
||||
const e2ePackageJsonPath = path.join(repoRoot, "e2e", "package.json");
|
||||
const e2eSkippedDirectories = new Set([".od-data", "node_modules", "reports", "test-results"]);
|
||||
const e2eAllowedScripts = ["test", "typecheck"];
|
||||
|
||||
async function collectRepositoryFiles(directory: string, skippedDirectoryNames = new Set<string>()): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (skippedDirectoryNames.has(entry.name)) continue;
|
||||
files.push(...(await collectRepositoryFiles(fullPath, skippedDirectoryNames)));
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile()) files.push(toRepositoryPath(fullPath));
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async function checkE2eLayout(): Promise<boolean> {
|
||||
const violations: string[] = [];
|
||||
const packageJson = JSON.parse(await readFile(e2ePackageJsonPath, "utf8")) as {
|
||||
scripts?: Record<string, unknown>;
|
||||
};
|
||||
const scriptNames = Object.keys(packageJson.scripts ?? {}).sort();
|
||||
if (scriptNames.join("\0") !== e2eAllowedScripts.join("\0")) {
|
||||
violations.push(
|
||||
`e2e/package.json scripts must be exactly ${e2eAllowedScripts.join(", ")} (found: ${scriptNames.join(", ")})`,
|
||||
);
|
||||
}
|
||||
|
||||
const e2eRoot = path.join(repoRoot, "e2e");
|
||||
for (const repositoryPath of await collectRepositoryFiles(e2eRoot, e2eSkippedDirectories)) {
|
||||
if (
|
||||
repositoryPath === "e2e/package.json" ||
|
||||
repositoryPath === "e2e/tsconfig.json" ||
|
||||
repositoryPath === "e2e/vitest.config.ts" ||
|
||||
repositoryPath === "e2e/playwright.config.ts" ||
|
||||
repositoryPath === "e2e/AGENTS.md"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (repositoryPath.startsWith("e2e/specs/")) {
|
||||
if (!/\.spec\.ts$/.test(repositoryPath)) {
|
||||
violations.push(`${repositoryPath} -> e2e specs must be *.spec.ts`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (repositoryPath.startsWith("e2e/tests/")) {
|
||||
if (!/\.test\.ts$/.test(repositoryPath)) {
|
||||
violations.push(`${repositoryPath} -> e2e tests must be *.test.ts`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (repositoryPath.startsWith("e2e/ui/")) {
|
||||
const relativePath = repositoryPath.slice("e2e/ui/".length);
|
||||
if (relativePath.includes("/") || !/\.test\.ts$/.test(repositoryPath)) {
|
||||
violations.push(`${repositoryPath} -> e2e UI files must be flat Playwright *.test.ts files under ui/`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (repositoryPath.startsWith("e2e/resources/")) {
|
||||
const relativePath = repositoryPath.slice("e2e/resources/".length);
|
||||
if (relativePath.includes("/") || !/\.ts$/.test(repositoryPath)) {
|
||||
violations.push(`${repositoryPath} -> e2e resources must be flat TypeScript files under resources/`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (repositoryPath.startsWith("e2e/lib/")) {
|
||||
if (!/\.ts$/.test(repositoryPath)) {
|
||||
violations.push(`${repositoryPath} -> e2e lib files must be TypeScript`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (repositoryPath.startsWith("e2e/scripts/")) {
|
||||
if (repositoryPath !== "e2e/scripts/playwright.ts") {
|
||||
violations.push(`${repositoryPath} -> e2e scripts currently allow only scripts/playwright.ts`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
violations.push(`${repositoryPath} -> e2e source files must live in specs/, tests/, ui/, resources/, lib/, or scripts/playwright.ts`);
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error("E2E package layout violations found:");
|
||||
for (const violation of violations) console.error(`- ${violation}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("E2E layout check passed: Vitest, Playwright UI, resources, lib, and scripts stay in their lanes.");
|
||||
return true;
|
||||
}
|
||||
|
||||
const webTestSkippedDirectories = new Set([".od-data", "reports", "test-results"]);
|
||||
|
||||
async function checkWebTestLayout(): Promise<boolean> {
|
||||
const violations: string[] = [];
|
||||
const webTestsRoot = path.join(repoRoot, "apps", "web", "tests");
|
||||
|
||||
for (const repositoryPath of await collectRepositoryFiles(webTestsRoot, webTestSkippedDirectories)) {
|
||||
if (repositoryPath.startsWith("apps/web/tests/vitest/") || repositoryPath.startsWith("apps/web/tests/playwright/")) {
|
||||
violations.push(`${repositoryPath} -> web tests should stay lightweight under apps/web/tests/ without vitest/playwright nesting`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\.(spec|test)\.tsx?$/.test(repositoryPath) && !/\.test\.tsx?$/.test(repositoryPath)) {
|
||||
violations.push(`${repositoryPath} -> web Vitest test files must be *.test.ts or *.test.tsx`);
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error("Web test layout violations found:");
|
||||
for (const violation of violations) console.error(`- ${violation}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("Web test layout check passed: web tests stay lightweight and Vitest-only.");
|
||||
return true;
|
||||
}
|
||||
|
||||
const checks: GuardCheck[] = [
|
||||
{ name: "residual JavaScript", run: checkResidualJavaScript },
|
||||
{ name: "test layout", run: checkTestLayout },
|
||||
{ name: "e2e layout", run: checkE2eLayout },
|
||||
{ name: "web test layout", run: checkWebTestLayout },
|
||||
];
|
||||
|
||||
const results: boolean[] = [];
|
||||
for (const check of checks) {
|
||||
try {
|
||||
results.push(await check.run());
|
||||
} catch (error) {
|
||||
console.error(`Guard check failed unexpectedly: ${check.name}`);
|
||||
console.error(error);
|
||||
results.push(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (results.some((passed) => !passed)) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
280
scripts/i18n-check.ts
Normal file
280
scripts/i18n-check.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { LOCALE_LABEL, LOCALES, type Locale } from "../apps/web/src/i18n/types.ts";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "..");
|
||||
const localesDirectory = path.join(repoRoot, "apps/web/src/i18n/locales");
|
||||
const i18nIndexPath = path.join(repoRoot, "apps/web/src/i18n/index.tsx");
|
||||
|
||||
type CheckResult = {
|
||||
name: string;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
type ReadmeSwitcherEntry = {
|
||||
label: string;
|
||||
href: string | null;
|
||||
bold: boolean;
|
||||
};
|
||||
|
||||
type CoreDocLink = {
|
||||
label: string;
|
||||
target: string;
|
||||
syntax: "html" | "markdown";
|
||||
};
|
||||
|
||||
const coreDocTargetPattern = "(QUICKSTART(?:\\.[A-Za-z0-9-]+)?\\.md|CONTRIBUTING(?:\\.[A-Za-z0-9-]+)?\\.md)";
|
||||
|
||||
function repositoryPath(filePath: string): string {
|
||||
return path.relative(repoRoot, filePath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function localeFileName(locale: string): string {
|
||||
return `${locale}.ts`;
|
||||
}
|
||||
|
||||
async function pathExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await readFile(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function extractDictKeys(indexSource: string): string[] {
|
||||
const match = indexSource.match(/const DICTS:\s*Record<Locale, Dict>\s*=\s*{([\s\S]*?)};/);
|
||||
if (!match?.[1]) return [];
|
||||
|
||||
return Array.from(match[1].matchAll(/["']([^"']+)["']\s*:/g))
|
||||
.map((entry) => entry[1])
|
||||
.filter((entry): entry is string => entry != null && entry.length > 0);
|
||||
}
|
||||
|
||||
async function checkUiLocaleRegistration(): Promise<CheckResult> {
|
||||
const errors: string[] = [];
|
||||
const localeSet = new Set<string>(LOCALES);
|
||||
const localeFiles = (await readdir(localesDirectory)).filter((fileName) => fileName.endsWith(".ts")).sort();
|
||||
const localeFileSet = new Set(localeFiles);
|
||||
const dictKeys = extractDictKeys(await readFile(i18nIndexPath, "utf8"));
|
||||
const dictKeySet = new Set(dictKeys);
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
const fileName = localeFileName(locale);
|
||||
if (!localeFileSet.has(fileName)) {
|
||||
errors.push(`${locale} is listed in LOCALES but ${repositoryPath(path.join(localesDirectory, fileName))} is missing.`);
|
||||
}
|
||||
|
||||
if (!(locale in LOCALE_LABEL)) {
|
||||
errors.push(`${locale} is listed in LOCALES but LOCALE_LABEL has no entry.`);
|
||||
}
|
||||
|
||||
if (!dictKeySet.has(locale)) {
|
||||
errors.push(`${locale} is listed in LOCALES but DICTS has no entry in ${repositoryPath(i18nIndexPath)}.`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const fileName of localeFiles) {
|
||||
const locale = fileName.replace(/\.ts$/, "");
|
||||
if (!localeSet.has(locale)) {
|
||||
errors.push(`${repositoryPath(path.join(localesDirectory, fileName))} exists but ${locale} is not listed in LOCALES.`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const dictKey of dictKeys) {
|
||||
if (!localeSet.has(dictKey)) {
|
||||
errors.push(`DICTS contains ${dictKey}, but ${dictKey} is not listed in LOCALES.`);
|
||||
}
|
||||
}
|
||||
|
||||
return { name: "UI locale registration", errors };
|
||||
}
|
||||
|
||||
async function rootReadmeFiles(): Promise<string[]> {
|
||||
const entries = await readdir(repoRoot, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && /^README(?:\.[A-Za-z0-9-]+)?\.md$/.test(entry.name))
|
||||
.map((entry) => entry.name)
|
||||
.sort((left, right) => (left === "README.md" ? -1 : right === "README.md" ? 1 : left.localeCompare(right)));
|
||||
}
|
||||
|
||||
function extractReadmeSwitcher(source: string): ReadmeSwitcherEntry[] | null {
|
||||
const line = source.split("\n").find((candidate) => candidate.includes('<p align="center">') && candidate.includes("README"));
|
||||
if (!line) return null;
|
||||
|
||||
const entries: ReadmeSwitcherEntry[] = [];
|
||||
const tokenPattern = /<a\s+href="([^"]+)">([^<]+)<\/a>|<b>([^<]+)<\/b>/g;
|
||||
|
||||
for (const match of line.matchAll(tokenPattern)) {
|
||||
const href = match[1] ?? null;
|
||||
const label = match[2] ?? match[3];
|
||||
if (!label) continue;
|
||||
entries.push({ label, href, bold: href == null });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function readmeTarget(fileName: string): string {
|
||||
return fileName === "README.md" ? "README.md" : fileName;
|
||||
}
|
||||
|
||||
function readmeLocale(fileName: string): string | null {
|
||||
if (fileName === "README.md") return null;
|
||||
const match = fileName.match(/^README\.([A-Za-z0-9-]+)\.md$/);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function coreDocSourceName(target: string): "QUICKSTART.md" | "CONTRIBUTING.md" | null {
|
||||
if (target.startsWith("QUICKSTART")) return "QUICKSTART.md";
|
||||
if (target.startsWith("CONTRIBUTING")) return "CONTRIBUTING.md";
|
||||
return null;
|
||||
}
|
||||
|
||||
function localizedCoreDocName(sourceName: "QUICKSTART.md" | "CONTRIBUTING.md", locale: string): string {
|
||||
return sourceName.replace(/\.md$/, `.${locale}.md`);
|
||||
}
|
||||
|
||||
function isExplicitEnglishCoreDocLink(link: CoreDocLink): boolean {
|
||||
return link.syntax === "markdown" && link.label.trim() === "English";
|
||||
}
|
||||
|
||||
function extractCoreDocLinks(source: string): CoreDocLink[] {
|
||||
const links: CoreDocLink[] = [];
|
||||
const markdownPattern = new RegExp(`\\[([^\\]]*)\\]\\(${coreDocTargetPattern}\\)`, "g");
|
||||
const htmlHrefPattern = new RegExp(`<a\\b[^>]*\\bhref=(["'])${coreDocTargetPattern}\\1[^>]*>`, "g");
|
||||
|
||||
for (const match of source.matchAll(markdownPattern)) {
|
||||
const label = match[1];
|
||||
const target = match[2];
|
||||
if (label == null || target == null) continue;
|
||||
links.push({ label, target, syntax: "markdown" });
|
||||
}
|
||||
|
||||
for (const match of source.matchAll(htmlHrefPattern)) {
|
||||
const target = match[2];
|
||||
if (target == null) continue;
|
||||
links.push({ label: "", target, syntax: "html" });
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
async function checkReadmeSwitchers(): Promise<CheckResult> {
|
||||
const errors: string[] = [];
|
||||
const readmes = await rootReadmeFiles();
|
||||
const readmeSet = new Set(readmes);
|
||||
const canonicalName = "README.md";
|
||||
const canonicalSource = await readFile(path.join(repoRoot, canonicalName), "utf8");
|
||||
const canonicalEntries = extractReadmeSwitcher(canonicalSource);
|
||||
|
||||
if (!canonicalEntries) {
|
||||
return { name: "root README language switchers", errors: [`${canonicalName} has no root README language switcher.`] };
|
||||
}
|
||||
|
||||
const canonicalTargets = canonicalEntries.map((entry) => entry.href ?? canonicalName);
|
||||
const expectedTargets = new Set(readmes.map(readmeTarget));
|
||||
const canonicalTargetSet = new Set(canonicalTargets);
|
||||
|
||||
if (
|
||||
canonicalTargetSet.size !== expectedTargets.size ||
|
||||
canonicalTargets.some((target) => !expectedTargets.has(target)) ||
|
||||
Array.from(expectedTargets).some((target) => !canonicalTargetSet.has(target))
|
||||
) {
|
||||
errors.push(
|
||||
`${canonicalName} switcher targets differ from root README files. Expected ${Array.from(expectedTargets).join(", ")}; found ${canonicalTargets.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const readme of readmes) {
|
||||
const source = await readFile(path.join(repoRoot, readme), "utf8");
|
||||
const entries = extractReadmeSwitcher(source);
|
||||
if (!entries) {
|
||||
errors.push(`${readme} has no root README language switcher.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const targets = entries.map((entry) => entry.href ?? readme);
|
||||
if (targets.join("\n") !== canonicalTargets.join("\n")) {
|
||||
errors.push(`${readme} switcher order differs. Expected ${canonicalTargets.join(", ")}; found ${targets.join(", ")}.`);
|
||||
}
|
||||
|
||||
const boldEntries = entries.filter((entry) => entry.bold);
|
||||
if (boldEntries.length !== 1) {
|
||||
errors.push(`${readme} must have exactly one bold current-language entry; found ${boldEntries.length}.`);
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.href == null) continue;
|
||||
if (!readmeSet.has(entry.href)) {
|
||||
errors.push(`${readme} links to missing root README ${entry.href}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { name: "root README language switchers", errors };
|
||||
}
|
||||
|
||||
async function checkCoreDocLinks(): Promise<CheckResult> {
|
||||
const errors: string[] = [];
|
||||
const readmes = await rootReadmeFiles();
|
||||
|
||||
for (const readme of readmes) {
|
||||
const source = await readFile(path.join(repoRoot, readme), "utf8");
|
||||
const locale = readmeLocale(readme);
|
||||
const links = extractCoreDocLinks(source);
|
||||
const linkedTargets = new Set(links.map((link) => link.target));
|
||||
|
||||
for (const link of links) {
|
||||
const target = link.target;
|
||||
if (!(await pathExists(path.join(repoRoot, target)))) {
|
||||
errors.push(`${readme} links to missing core doc ${target}.`);
|
||||
}
|
||||
|
||||
if (locale == null) continue;
|
||||
|
||||
const sourceName = coreDocSourceName(target);
|
||||
if (sourceName == null || target !== sourceName || isExplicitEnglishCoreDocLink(link)) continue;
|
||||
|
||||
const localizedName = localizedCoreDocName(sourceName, locale);
|
||||
if (await pathExists(path.join(repoRoot, localizedName))) {
|
||||
errors.push(`${readme} links to ${sourceName}, but ${localizedName} exists for this README locale.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (locale == null) continue;
|
||||
for (const sourceName of ["QUICKSTART.md", "CONTRIBUTING.md"] as const) {
|
||||
const localizedName = localizedCoreDocName(sourceName, locale);
|
||||
if ((await pathExists(path.join(repoRoot, localizedName))) && links.some((link) => coreDocSourceName(link.target) === sourceName)) {
|
||||
if (!linkedTargets.has(localizedName)) {
|
||||
errors.push(`${readme} links to ${sourceName} docs, but does not link to localized ${localizedName}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { name: "core documentation links", errors };
|
||||
}
|
||||
|
||||
const checks = [checkUiLocaleRegistration, checkReadmeSwitchers, checkCoreDocLinks];
|
||||
const results: CheckResult[] = [];
|
||||
|
||||
for (const check of checks) {
|
||||
try {
|
||||
results.push(await check());
|
||||
} catch (error) {
|
||||
results.push({ name: check.name, errors: [`Unexpected check failure: ${String(error)}`] });
|
||||
}
|
||||
}
|
||||
|
||||
const failures = results.flatMap((result) => result.errors.map((error) => ({ check: result.name, error })));
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("i18n P0 check failed:");
|
||||
for (const failure of failures) {
|
||||
console.error(`- [${failure.check}] ${failure.error}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("i18n P0 check passed: locale registration, README switchers, and core doc links are consistent.");
|
||||
}
|
||||
403
scripts/import-prompt-templates.mjs
Normal file
403
scripts/import-prompt-templates.mjs
Normal file
@@ -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);
|
||||
});
|
||||
50
scripts/postinstall.mjs
Normal file
50
scripts/postinstall.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
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/contracts",
|
||||
"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);
|
||||
}
|
||||
}
|
||||
236
scripts/release-beta.ts
Normal file
236
scripts/release-beta.ts
Normal file
@@ -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);
|
||||
141
scripts/release-stable.ts
Normal file
141
scripts/release-stable.ts
Normal file
@@ -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);
|
||||
297
scripts/scaffold-html-ppt-skills.mjs
Normal file
297
scripts/scaffold-html-ppt-skills.mjs
Normal file
@@ -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`);
|
||||
433
scripts/seed-test-projects.ts
Normal file
433
scripts/seed-test-projects.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
#!/usr/bin/env node
|
||||
// Seed the running daemon with pre-baked test projects so the UI has
|
||||
// real slide decks and web prototypes to work with without waiting for
|
||||
// an LLM run. Pulls each project's content straight from a skill's
|
||||
// `example.html`, drops it in as `index.html`, and adds a couple of
|
||||
// fake chat messages so the conversation panel isn't empty.
|
||||
//
|
||||
// Usage (daemon must be running — e.g. `pnpm tools-dev`):
|
||||
// pnpm seed:test-projects # default bundle
|
||||
// pnpm seed:test-projects --decks 2 --webs 2 # cap counts
|
||||
// pnpm seed:test-projects --daemon http://127.0.0.1:17456
|
||||
// pnpm seed:test-projects --clear # remove previously seeded projects
|
||||
//
|
||||
// The daemon URL is resolved in this order: --daemon flag > $OD_DAEMON_URL >
|
||||
// http://127.0.0.1:$OD_PORT > whatever `pnpm tools-dev status --json` reports
|
||||
// for the daemon app. The discovery step is what makes the two-shell flow
|
||||
// (`pnpm tools-dev` then `pnpm seed:test-projects`) work without extra flags,
|
||||
// because tools-dev defaults to an ephemeral daemon port that isn't exported
|
||||
// to sibling shells.
|
||||
//
|
||||
// Seeded project ids start with `seed-` so `--clear` only touches the
|
||||
// fixtures this script created.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(HERE, '..');
|
||||
const SKILLS_DIR = path.join(REPO_ROOT, 'skills');
|
||||
const SEED_PREFIX = 'seed-';
|
||||
|
||||
type SeedKind = 'deck' | 'prototype';
|
||||
|
||||
interface SeedFixture {
|
||||
skillId: string;
|
||||
kind: SeedKind;
|
||||
name: string;
|
||||
pendingPrompt: string;
|
||||
// optional: path to the file inside skills/<skillId>/ to load as index.html
|
||||
// (defaults to example.html)
|
||||
source?: string;
|
||||
}
|
||||
|
||||
// Local mirror of the daemon `ProjectFile` shape. Kept in sync with
|
||||
// `packages/contracts/src/api/files.ts` — the assistant message stores
|
||||
// `producedFiles: ProjectFile[]`, so we type the upload response against
|
||||
// it instead of fabricating a string array.
|
||||
interface ProjectFile {
|
||||
name: string;
|
||||
path?: string;
|
||||
type?: 'file' | 'dir';
|
||||
size: number;
|
||||
mtime: number;
|
||||
kind: string;
|
||||
mime: string;
|
||||
}
|
||||
|
||||
interface ProjectFileResponse {
|
||||
file: ProjectFile;
|
||||
}
|
||||
|
||||
interface SeedProjectSummary {
|
||||
id: string;
|
||||
metadata?: {
|
||||
seeded?: boolean;
|
||||
source?: string;
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const DECKS: SeedFixture[] = [
|
||||
{
|
||||
skillId: 'html-ppt-pitch-deck',
|
||||
kind: 'deck',
|
||||
name: 'Pitch deck — Series A',
|
||||
pendingPrompt:
|
||||
'Make a 10-slide investor pitch deck for an AI design tool. Cover problem, solution, market, traction, ask.',
|
||||
},
|
||||
{
|
||||
skillId: 'kami-deck',
|
||||
kind: 'deck',
|
||||
name: 'Kami deck — quarterly review',
|
||||
pendingPrompt:
|
||||
'Build a print-grade kami deck summarizing Q2 results: revenue, top wins, risks, next quarter.',
|
||||
},
|
||||
{
|
||||
skillId: 'html-ppt-weekly-report',
|
||||
kind: 'deck',
|
||||
name: 'Weekly report — eng team',
|
||||
pendingPrompt:
|
||||
'Weekly report deck for an engineering team: shipped, in-progress, blockers, next-week plan.',
|
||||
},
|
||||
{
|
||||
skillId: 'html-ppt-product-launch',
|
||||
kind: 'deck',
|
||||
name: 'Product launch — v2.0',
|
||||
pendingPrompt:
|
||||
'Product launch deck for v2.0: hero feature, before/after, pricing, rollout plan.',
|
||||
},
|
||||
];
|
||||
|
||||
const WEBS: SeedFixture[] = [
|
||||
{
|
||||
skillId: 'open-design-landing',
|
||||
kind: 'prototype',
|
||||
name: 'Editorial landing — Atelier Zero',
|
||||
pendingPrompt:
|
||||
'Single-page editorial landing page for an AI design tool. Magazine collage hero, sticky nav, scroll reveal.',
|
||||
},
|
||||
{
|
||||
skillId: 'kami-landing',
|
||||
kind: 'prototype',
|
||||
name: 'Kami landing — white paper',
|
||||
pendingPrompt:
|
||||
'Print-grade kami landing — parchment canvas, ink-blue accent. Treat it like a studio one-pager.',
|
||||
},
|
||||
{
|
||||
skillId: 'dashboard',
|
||||
kind: 'prototype',
|
||||
name: 'Admin dashboard — analytics',
|
||||
pendingPrompt:
|
||||
'Admin dashboard with KPI cards, a revenue chart, and a recent activity table. Fixed left sidebar.',
|
||||
},
|
||||
];
|
||||
|
||||
interface Args {
|
||||
daemonUrl: string | null;
|
||||
decks: number;
|
||||
webs: number;
|
||||
clear: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const out: Args = {
|
||||
daemonUrl: null,
|
||||
decks: DECKS.length,
|
||||
webs: WEBS.length,
|
||||
clear: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--daemon' || a === '--daemon-url') {
|
||||
const value = argv[++i];
|
||||
if (!value) {
|
||||
console.error(`${a} requires a URL argument`);
|
||||
process.exit(2);
|
||||
}
|
||||
out.daemonUrl = value;
|
||||
} else if (a === '--decks') {
|
||||
out.decks = Math.max(0, Number(argv[++i]) || 0);
|
||||
} else if (a === '--webs' || a === '--prototypes') {
|
||||
out.webs = Math.max(0, Number(argv[++i]) || 0);
|
||||
} else if (a === '--clear') {
|
||||
out.clear = true;
|
||||
} else if (a === '-h' || a === '--help') {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(`unknown flag: ${a}`);
|
||||
printHelp();
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: pnpm seed:test-projects [opts]
|
||||
|
||||
Seeds the running daemon with pre-baked slide decks and web prototypes
|
||||
loaded from each skill's example.html. Useful for working on the UI
|
||||
without waiting for an LLM run.
|
||||
|
||||
Options:
|
||||
--daemon <url> Daemon base URL. When omitted, the script reads
|
||||
\$OD_DAEMON_URL, then \$OD_PORT, and finally falls back
|
||||
to discovering the URL from \`pnpm tools-dev status --json\`.
|
||||
--decks <n> Number of slide decks to seed (default: ${DECKS.length}, max: ${DECKS.length})
|
||||
--webs <n> Number of web prototypes to seed (default: ${WEBS.length}, max: ${WEBS.length})
|
||||
--clear Delete every previously seeded project (id prefix '${SEED_PREFIX}')
|
||||
-h, --help Show this help
|
||||
|
||||
Daemon URL resolution (first match wins):
|
||||
1. \`--daemon <url>\` on the command line.
|
||||
2. \`OD_DAEMON_URL\` env var.
|
||||
3. \`http://127.0.0.1:\$OD_PORT\` when \`OD_PORT\` is set to a real port.
|
||||
4. Auto-discovered from \`pnpm tools-dev status --json\`. \`tools-dev\` defaults
|
||||
to an ephemeral daemon port, so a typical two-shell flow works without
|
||||
extra flags:
|
||||
pnpm tools-dev # in one shell
|
||||
pnpm seed:test-projects # in another — discovers the running daemon
|
||||
`);
|
||||
}
|
||||
|
||||
function isDiscoverablePort(value: string | undefined): value is string {
|
||||
if (value == null || value.length === 0) return false;
|
||||
// tools-dev sets OD_PORT=0 to mean "ephemeral, look at runtime status",
|
||||
// which is unusable as a target. Treat it the same as unset so we fall
|
||||
// through to the discovery path.
|
||||
const n = Number(value);
|
||||
return Number.isInteger(n) && n > 0 && n < 65536;
|
||||
}
|
||||
|
||||
async function discoverDaemonUrlFromToolsDev(): Promise<string | null> {
|
||||
return await new Promise<string | null>((resolve) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn('pnpm', ['exec', 'tools-dev', 'status', '--json'], {
|
||||
cwd: REPO_ROOT,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
let stdout = '';
|
||||
child.stdout?.on('data', (chunk: Buffer | string) => {
|
||||
stdout += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
||||
});
|
||||
child.stderr?.resume();
|
||||
child.on('error', () => resolve(null));
|
||||
child.on('exit', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
apps?: { daemon?: { url?: string | null } };
|
||||
url?: string | null;
|
||||
};
|
||||
const url = parsed?.apps?.daemon?.url ?? parsed?.url ?? null;
|
||||
resolve(typeof url === 'string' && url.length > 0 ? url : null);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveDaemonUrl(args: Args): Promise<string> {
|
||||
if (args.daemonUrl) return args.daemonUrl;
|
||||
if (process.env.OD_DAEMON_URL) return process.env.OD_DAEMON_URL;
|
||||
if (isDiscoverablePort(process.env.OD_PORT)) {
|
||||
return `http://127.0.0.1:${process.env.OD_PORT}`;
|
||||
}
|
||||
const discovered = await discoverDaemonUrlFromToolsDev();
|
||||
if (discovered) return discovered;
|
||||
throw new Error(
|
||||
'cannot determine daemon URL: no --daemon flag, no OD_DAEMON_URL, ' +
|
||||
'no usable OD_PORT, and `pnpm tools-dev status --json` did not report a ' +
|
||||
'running daemon. Start the daemon (e.g. `pnpm tools-dev`) or pass ' +
|
||||
'`--daemon http://127.0.0.1:<port>` explicitly.',
|
||||
);
|
||||
}
|
||||
|
||||
async function api<T = unknown>(
|
||||
daemonUrl: string,
|
||||
method: string,
|
||||
pathPart: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const url = `${daemonUrl.replace(/\/$/, '')}${pathPart}`;
|
||||
const init: RequestInit = { method };
|
||||
if (body !== undefined) {
|
||||
init.headers = { 'content-type': 'application/json' };
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(url, init);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`cannot reach daemon at ${daemonUrl} — start it with \`pnpm tools-dev\` ` +
|
||||
`(underlying error: ${(err as Error).message || String(err)})`,
|
||||
);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
throw new Error(`${method} ${pathPart} → ${resp.status}: ${text}`);
|
||||
}
|
||||
if (resp.status === 204) return undefined as T;
|
||||
return (await resp.json()) as T;
|
||||
}
|
||||
|
||||
function makeSeedId(skillId: string): string {
|
||||
// unique-ish, sortable, easy to spot in the UI / db
|
||||
const ts = Date.now().toString(36);
|
||||
const rand = Math.random().toString(36).slice(2, 6);
|
||||
// Slug must match [A-Za-z0-9._-]{1,128}, see daemon validation.
|
||||
const slug = skillId.replace(/[^A-Za-z0-9._-]/g, '-').slice(0, 60);
|
||||
return `${SEED_PREFIX}${slug}-${ts}-${rand}`.slice(0, 128);
|
||||
}
|
||||
|
||||
async function loadExample(fix: SeedFixture): Promise<string> {
|
||||
const file = path.join(SKILLS_DIR, fix.skillId, fix.source ?? 'example.html');
|
||||
return readFile(file, 'utf8');
|
||||
}
|
||||
|
||||
async function seedOne(daemonUrl: string, fix: SeedFixture): Promise<void> {
|
||||
const html = await loadExample(fix);
|
||||
const id = makeSeedId(fix.skillId);
|
||||
process.stdout.write(` - ${fix.kind.padEnd(9)} ${id} (${fix.skillId})\n`);
|
||||
|
||||
const created = await api<{
|
||||
project: { id: string };
|
||||
conversationId: string;
|
||||
}>(daemonUrl, 'POST', '/api/projects', {
|
||||
id,
|
||||
name: fix.name,
|
||||
skillId: fix.skillId,
|
||||
pendingPrompt: fix.pendingPrompt,
|
||||
metadata: { kind: fix.kind, seeded: true, source: 'seed-test-projects' },
|
||||
});
|
||||
|
||||
const uploaded = await api<ProjectFileResponse>(
|
||||
daemonUrl,
|
||||
'POST',
|
||||
`/api/projects/${id}/files`,
|
||||
{
|
||||
name: 'index.html',
|
||||
content: html,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
|
||||
await api(daemonUrl, 'PUT', `/api/projects/${id}/tabs`, {
|
||||
tabs: ['index.html'],
|
||||
active: 'index.html',
|
||||
});
|
||||
|
||||
// Fake chat history so the conversation panel isn't empty. Two messages
|
||||
// is enough for the recent-activity sort and for the assistant bubble
|
||||
// to render with a producedFiles chip.
|
||||
const cid = created.conversationId;
|
||||
const userMid = `seed-msg-user-${Date.now().toString(36)}`;
|
||||
const asstMid = `seed-msg-asst-${Date.now().toString(36)}`;
|
||||
const now = Date.now();
|
||||
await api(
|
||||
daemonUrl,
|
||||
'PUT',
|
||||
`/api/projects/${id}/conversations/${cid}/messages/${userMid}`,
|
||||
{
|
||||
role: 'user',
|
||||
content: fix.pendingPrompt,
|
||||
createdAt: now,
|
||||
},
|
||||
);
|
||||
await api(
|
||||
daemonUrl,
|
||||
'PUT',
|
||||
`/api/projects/${id}/conversations/${cid}/messages/${asstMid}`,
|
||||
{
|
||||
role: 'assistant',
|
||||
content:
|
||||
`Seeded \`index.html\` from \`skills/${fix.skillId}/example.html\` ` +
|
||||
`as a starting point. Open the preview tab to see the rendered ${fix.kind}.`,
|
||||
agentId: 'seed-script',
|
||||
agentName: 'seed-test-projects',
|
||||
runStatus: 'succeeded',
|
||||
startedAt: now,
|
||||
endedAt: now,
|
||||
producedFiles: [uploaded.file],
|
||||
createdAt: now,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function clearSeeded(daemonUrl: string): Promise<void> {
|
||||
const { projects } = await api<{ projects: SeedProjectSummary[] }>(
|
||||
daemonUrl,
|
||||
'GET',
|
||||
'/api/projects',
|
||||
);
|
||||
// Project ids are caller-supplied through the public daemon API, so
|
||||
// the `seed-` prefix alone is not a strong enough marker for a
|
||||
// destructive delete. Require both the prefix AND the metadata stamp
|
||||
// we wrote in `seedOne` so a manually-created project that happens to
|
||||
// share the prefix is left alone.
|
||||
const seeded = projects.filter(
|
||||
(p) =>
|
||||
p.id.startsWith(SEED_PREFIX) &&
|
||||
p.metadata?.seeded === true &&
|
||||
p.metadata?.source === 'seed-test-projects',
|
||||
);
|
||||
if (seeded.length === 0) {
|
||||
console.log('no seeded projects to remove.');
|
||||
return;
|
||||
}
|
||||
console.log(`removing ${seeded.length} seeded project(s):`);
|
||||
for (const p of seeded) {
|
||||
process.stdout.write(` - ${p.id}\n`);
|
||||
await api(daemonUrl, 'DELETE', `/api/projects/${p.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const daemonUrl = await resolveDaemonUrl(args);
|
||||
|
||||
if (args.clear) {
|
||||
await clearSeeded(daemonUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const decks = DECKS.slice(0, args.decks);
|
||||
const webs = WEBS.slice(0, args.webs);
|
||||
if (decks.length === 0 && webs.length === 0) {
|
||||
console.error('--decks 0 and --webs 0 — nothing to do.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
console.log(`seeding ${decks.length} deck(s) + ${webs.length} web prototype(s) → ${daemonUrl}`);
|
||||
const failures: string[] = [];
|
||||
for (const fix of [...decks, ...webs]) {
|
||||
try {
|
||||
await seedOne(daemonUrl, fix);
|
||||
} catch (err) {
|
||||
failures.push(fix.skillId);
|
||||
console.error(` ! ${fix.skillId} failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
console.error(
|
||||
`done with ${failures.length} failure(s): ${failures.join(', ')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('done. Open the web UI — the seeded projects show up in the project list.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
406
scripts/sync-community-pets.ts
Normal file
406
scripts/sync-community-pets.ts
Normal file
@@ -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);
|
||||
});
|
||||
154
scripts/sync-design-systems.ts
Normal file
154
scripts/sync-design-systems.ts
Normal file
@@ -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();
|
||||
188
scripts/sync-hyperframes-skill.mjs
Normal file
188
scripts/sync-hyperframes-skill.mjs
Normal file
@@ -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);
|
||||
});
|
||||
80
scripts/sync-litellm-models.ts
Normal file
80
scripts/sync-litellm-models.ts
Normal file
@@ -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);
|
||||
});
|
||||
19
scripts/tsconfig.json
Normal file
19
scripts/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"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,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
170
scripts/verify-media-models.mjs
Normal file
170
scripts/verify-media-models.mjs
Normal file
@@ -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