publish.mjs
338 lines · scripts/publish.mjs at 1482e84
1import { spawnSync } from "node:child_process";
2import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
4import { fileURLToPath } from "node:url";
5import { fingerprintOfSkill } from "./fingerprint.mjs";
6import { describe, fromThisSession, parseMinutes, parseTokens } from "./session.mjs";
8const SKILL = resolve(dirname(fileURLToPath(import.meta.url)), "..");
9const SOURCE_TYPES = [".mjs", ".js", ".css", ".html", ".json", ".md", ".txt", ".svg"];
10const SKIP_DIRS = ["node_modules", "shots", ".git"];
11const ID = /^[a-z0-9]+(-[a-z0-9]+)*$/;
12const AGENTS = ["Claude Code", "OpenCode", "Codex", "Antigravity", "Other"];
13const LICENSES = ["MIT", "CC-BY-4.0"];
14const KINDS = ["SVG", "SVG + WebGL", "SVG + 3D", "SVG + WebGL + 3D"];
16const posix = (path) => path.split(sep).join("/");
17const inside = (root, path) => path === root || path.startsWith(root + sep);
19function usage(message) {
20 if (message) console.error(`publish: ${message}\n`);
21 console.log(`Copy a figure into a local clone of the community gallery, ready for a pull request.
23 node scripts/publish.mjs <figure folder> --into <anatomy-community clone> --id <kebab-id> --title "..." --prompt "..." --author <github user>
24 [--agent "Claude Code"|OpenCode|Codex|Antigravity|Other] [--model "..."] [--minutes 45] [--tokens 1.2M] [--no-session]
25 [--license MIT|CC-BY-4.0] [--tags a,b]
26 [--build build.mjs] [--pages a.html,b.html] [--kind "SVG + WebGL"] [--audit] [--no-preview] [--force]
27 [--submit [--yes] [--dry-run] [--upstream owner/repo]]
29The figure must be plain HTML: a build script that writes standalone pages next to itself, in any theme.
30Only the source is copied, to figures/<author>/<id>/: the gallery's CI builds the pages and the poster.
31In Claude Code, the time to make and the tokens spent are read from this session's own log (from the request that
32made the figure up to the request to publish it, with idle gaps over 10 minutes left out); --minutes and --tokens
33override them, and --no-session leaves them out. Other agents pass them if they know them.
34Without --submit nothing is pushed: the command prints the git and gh commands to run when you are ready.
35With --submit it also sends the figure as a pull request, using the GitHub CLI (gh): it shows the plan and asks
36first (--yes skips the question, --dry-run only shows the plan). People who can push to the gallery get a branch
37there; everyone else gets a fork, made for them.`);
38 process.exit(message ? 1 : 0);
39}
41function parse(argv) {
42 const options = { agent: "Claude Code", license: "MIT", build: "build.mjs", tags: [], upstream: "wheresryan22/anatomy-community" };
43 const take = (index) => {
44 const value = argv[index + 1];
45 if (value === undefined || value.startsWith("--")) usage(`${argv[index]} needs a value`);
46 return value;
47 };
48 for (let index = 0; index < argv.length; index++) {
49 const arg = argv[index];
50 if (arg === "--help" || arg === "-h") usage();
51 else if (["--into", "--id", "--title", "--prompt", "--author", "--agent", "--model", "--license", "--build", "--kind", "--date"].includes(arg)) options[arg.slice(2)] = take(index++);
52 else if (arg === "--tags") options.tags = take(index++).split(",").map((tag) => tag.trim()).filter(Boolean);
53 else if (arg === "--pages") options.pages = take(index++).split(",").map((page) => page.trim());
54 else if (arg === "--minutes") {
55 options.minutes = parseMinutes(take(index++));
56 if (!options.minutes) usage("--minutes takes a duration like 45, 45m, 2h or 1h30m");
57 } else if (arg === "--tokens") {
58 options.tokens = parseTokens(take(index++));
59 if (!options.tokens) usage("--tokens takes a count like 380000, 450k or 1.2M");
60 } else if (arg === "--no-session") options.noSession = true;
61 else if (arg === "--audit") options.audit = true;
62 else if (arg === "--force") options.force = true;
63 else if (arg === "--no-preview") options.noPreview = true;
64 else if (arg === "--submit") options.submit = true;
65 else if (arg === "--yes") options.yes = true;
66 else if (arg === "--dry-run") options.dryRun = true;
67 else if (arg === "--upstream") options.upstream = take(index++);
68 else if (arg.startsWith("--")) usage(`unknown option ${arg}`);
69 else if (!options.source) options.source = arg;
70 else usage(`unexpected argument ${arg}`);
71 }
72 for (const key of ["source", "into", "id", "title", "prompt", "author"]) if (!options[key]) usage(`missing ${key === "source" ? "the figure folder" : `--${key}`}`);
73 if (!ID.test(options.id)) usage("--id must be kebab-case, like token-bucket-bench");
74 if (!AGENTS.includes(options.agent)) usage(`--agent must be one of: ${AGENTS.join(", ")}`);
75 if (!LICENSES.includes(options.license)) usage(`--license must be one of: ${LICENSES.join(", ")}`);
76 if (options.kind && !KINDS.includes(options.kind)) usage(`--kind must be one of: ${KINDS.join(", ")}`);
77 return options;
78}
80function listFiles(root, dir = root, out = []) {
81 for (const name of readdirSync(dir).sort()) {
82 const full = join(dir, name);
83 const stat = lstatSync(full);
84 if (name.startsWith(".") || stat.isSymbolicLink()) continue;
85 if (stat.isDirectory()) {
86 if (!SKIP_DIRS.includes(name)) listFiles(root, full, out);
87 } else out.push(posix(relative(root, full)));
88 }
89 return out;
90}
92function detectPages(source, files, given) {
93 const html = files.filter((file) => !file.includes("/") && file.endsWith(".html"));
94 if (given) {
95 const named = given.filter(Boolean);
96 for (const page of named) if (!files.includes(page)) throw new Error(`page ${page} not found in ${source}; build the figure first`);
97 const pages = {};
98 for (const page of named) pages[themeOf(source, page) === "light" ? "light" : themeOf(source, page) === "dark" ? "dark" : page.replace(/\.html$/, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 24)] = page;
99 return pages;
100 }
101 const pairs = html.filter((file) => !file.endsWith("-light.html") && html.includes(file.replace(/\.html$/, "-light.html")));
102 if (pairs.length === 1) return { light: pairs[0].replace(/\.html$/, "-light.html"), dark: pairs[0] };
103 const singles = html.filter((file) => !/-verify(?:-light)?\.html$/.test(file) && !/^turning-stress/.test(file));
104 if (!pairs.length && singles.length === 1) return { [themeOf(source, singles[0]) === "light" ? "light" : "page"]: singles[0] };
105 throw new Error(`can't tell which pages are the figure (found: ${html.join(", ") || "none"}); pass --pages a.html,b.html`);
106}
108const themeOf = (source, page) => (readFileSync(join(source, page), "utf8").match(/<body[^>]*\bdata-theme="([a-z-]+)"/) ?? [])[1] ?? "";
110function detectKind(text) {
111 const webgl = /getContext\(\s*["']webgl2?["']|\bGLSL_ISO\b|\bglLayer\b/.test(text);
112 const turning = /\bTURN\.mount\b|\bturnScript\b|\bTURN_CSS\b|class="[^"]*iso-turn/.test(text);
113 return `SVG${webgl ? " + WebGL" : ""}${turning ? " + 3D" : ""}`;
114}
116export function rewriteSkillPaths(text, { fromDir, toDir, source, skill, skillLink }) {
117 const outside = [];
118 const rewritten = text.replace(/(["'])((?:\.{1,2}\/|\/)[^"'\n]*)\1/g, (literal, quote, path) => {
119 const target = resolve(fromDir, path);
120 const dotted = (from, to) => {
121 const r = posix(relative(from, to));
122 return r.startsWith(".") ? r : `./${r}`;
123 };
124 if (!/\.[a-z]+$/i.test(path) && !inside(skill, target)) return literal;
125 if (inside(source, target)) return isAbsolute(path) ? `${quote}${dotted(fromDir, target)}${quote}` : literal;
126 if (inside(skill, target)) return `${quote}${dotted(toDir, join(skillLink, relative(skill, target)))}${quote}`;
127 if (/\.(m?js|css|json|html|svg)$/.test(path)) outside.push(path);
128 return literal;
129 });
130 return { text: rewritten, outside };
131}
133function run(args, cwd) {
134 const result = spawnSync(process.execPath, args, { cwd, encoding: "utf8", maxBuffer: 64 << 20 });
135 if (result.status !== 0) throw new Error(`${args.map((a) => basename(a)).join(" ")} failed:\n${`${result.stdout}${result.stderr}`.trim().split("\n").slice(-12).join("\n")}`);
136 return result.stdout;
137}
139export function publish(options) {
140 const log = options.log ?? (() => {});
141 const source = resolve(options.source);
142 const repo = resolve(options.into);
143 if (!existsSync(join(source, options.build))) throw new Error(`no ${options.build} in ${source}`);
144 if (!existsSync(join(repo, "figures")) || !existsSync(join(repo, "scripts", "validate.mjs"))) throw new Error(`${repo} doesn't look like a clone of anatomy-community (no figures/ or scripts/validate.mjs)`);
145 if (inside(SKILL, source) && !inside(join(SKILL, "examples"), source)) throw new Error("the figure folder is inside the skill itself; publish the folder where the figure was built");
146 const author = options.author.replace(/^@/, "");
147 if (!/^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/.test(author)) throw new Error("--author must be a GitHub username");
148 const dest = join(repo, "figures", author.toLowerCase(), options.id);
149 if (existsSync(dest)) {
150 if (!options.force) throw new Error(`figures/${author.toLowerCase()}/${options.id} already exists; pick another --id or pass --force to replace it`);
151 }
152 const replaced = existsSync(dest) ? `${dest}.replaced` : null;
153 if (replaced) {
154 rmSync(replaced, { recursive: true, force: true });
155 renameSync(dest, replaced);
156 }
157 try {
158 const all = listFiles(source);
159 const typed = all.filter((file) => SOURCE_TYPES.includes(extname(file).toLowerCase()));
160 const pages = detectPages(source, typed, options.pages);
161 const lightPage = pages.light ?? null;
162 for (const page of Object.values(pages)) if (page && !typed.includes(page)) throw new Error(`page ${page} not found in ${source}; build the figure first`);
163 const outputs = new Set(Object.values(pages).filter(Boolean));
164 const files = typed.filter((file) => !outputs.has(file) && !/-verify(?:-light)?\.html$/.test(file));
165 const skipped = all.filter((file) => !files.includes(file) && !outputs.has(file));
167 const skillLink = join(repo, ".skill");
168 const kit = fingerprintOfSkill(SKILL);
169 const outside = [];
170 for (const file of files) {
171 const from = join(source, file);
172 const to = join(dest, "src", file);
173 mkdirSync(dirname(to), { recursive: true });
174 if (/\.m?js$/.test(file)) {
175 const result = rewriteSkillPaths(readFileSync(from, "utf8"), { fromDir: dirname(from), toDir: dirname(to), source, skill: SKILL, skillLink });
176 outside.push(...result.outside.map((path) => `${file}: ${path}`));
177 writeFileSync(to, result.text);
178 } else cpSync(from, to);
179 }
180 if (outside.length) {
181 rmSync(dest, { recursive: true, force: true });
182 throw new Error(`these files reach outside the figure folder and the skill; move what they need into the folder:\n ${outside.join("\n ")}`);
183 }
185 let linked = "existing";
186 if (!existsSync(skillLink)) {
187 symlinkSync(SKILL, skillLink, process.platform === "win32" ? "junction" : "dir");
188 linked = "created";
189 } else if (fingerprintOfSkill(skillLink) !== kit) {
190 const where = lstatSync(skillLink).isSymbolicLink() ? readlinkSync(skillLink) : skillLink;
191 throw new Error(`${repo}/.skill (${where}) is a different kit version than this skill; remove it and run again`);
192 }
194 const build = [];
195 if (pages.light) build.push([`src/${options.build}`, "--light"]);
196 if (Object.keys(pages).some((name) => name !== "light")) build.push([`src/${options.build}`]);
197 for (const command of build) {
198 log(`building ${command.includes("--light") ? "the light page" : "the page"} (heavy figures take a minute or two)…`);
199 run(command, dest);
200 }
201 let preview = null;
202 if (!options.noPreview) {
203 log("taking a preview…");
204 const shots = join(repo, "shots", `${author.toLowerCase()}-${options.id}`);
205 mkdirSync(shots, { recursive: true });
206 preview = join(shots, "preview.png");
207 run([join(SKILL, "scripts", "capture.mjs"), join(dest, "src", lightPage ?? Object.values(pages)[0]), "--reduced", "--width", "1200", "--out", preview], dest);
208 }
209 const measured = options.noSession || (options.minutes && options.tokens) ? null : fromThisSession({ prompt: options.prompt });
210 const minutes = options.minutes ?? measured?.minutes;
211 const tokens = options.tokens ?? measured?.tokens;
212 if (measured) log(`time and tokens from this session: ${describe(measured)}`);
213 const pageText = Object.values(pages).filter(Boolean).map((page) => readFileSync(join(dest, "src", page), "utf8")).join("\n");
214 for (const page of outputs) rmSync(join(dest, "src", page), { force: true });
215 const figure = {
216 id: options.id,
217 title: options.title.trim(),
218 author,
219 prompt: options.prompt.trim(),
220 kind: options.kind ?? detectKind(pageText),
221 agent: options.agent,
222 ...(options.model ? { model: options.model } : {}),
223 ...(minutes ? { minutes } : {}),
224 ...(tokens ? { tokens } : {}),
225 skill: { kit },
226 build,
227 pages: Object.fromEntries(Object.entries(pages).filter(([, page]) => page).map(([key, page]) => [key, `src/${page}`])),
228 preview: lightPage ? "light" : Object.keys(pages)[0],
229 ...(options.audit ? { audit: true } : {}),
230 license: options.license,
231 ...(options.tags.length ? { tags: options.tags } : {}),
232 created: options.date ?? new Date().toISOString().slice(0, 10),
233 };
234 writeFileSync(join(dest, "figure.json"), JSON.stringify(figure, null, 2) + "\n");
236 if (replaced) rmSync(replaced, { recursive: true, force: true });
237 return { dest, figure, skipped, linked, preview, measured };
238 } catch (error) {
239 rmSync(dest, { recursive: true, force: true });
240 if (replaced) renameSync(replaced, dest);
241 throw error;
242 }
243}
245export function submitPlan({ login, canPush, upstream, author, id, title }) {
246 const name = upstream.split("/")[1];
247 const target = canPush ? upstream : `${login}/${name}`;
248 const folder = `figures/${author.toLowerCase()}/${id}`;
249 const branch = `figure/${id}`;
250 const steps = [
251 ...(canPush ? [] : [{ say: `make (or reuse) your fork, ${target}`, gh: ["repo", "fork", upstream, "--clone=false"] }]),
252 { say: "let git use your GitHub CLI login", gh: ["auth", "setup-git"] },
253 { say: `start ${branch} from the gallery's latest main`, git: ["fetch", "--quiet", `https://github.com/${upstream}.git`, "main"] },
254 { git: ["switch", "--quiet", "-C", branch, "FETCH_HEAD"] },
255 { say: `add ${folder} and nothing else`, publish: true },
256 { git: ["add", "--", folder] },
257 { git: ["commit", "--quiet", "-m", `Add ${title}`, "--", folder] },
258 { say: `push it to ${target}`, git: ["push", "--quiet", "--force", `https://github.com/${target}.git`, `HEAD:refs/heads/${branch}`] },
259 { say: `open the pull request on ${upstream}`, pr: { repo: upstream, head: canPush ? branch : `${login}:${branch}`, title: `Add ${title}` } },
260 ];
261 return { target, branch, folder, steps };
262}
264async function confirm(question) {
265 if (!process.stdin.isTTY) return false;
266 const { createInterface } = await import("node:readline/promises");
267 const rl = createInterface({ input: process.stdin, output: process.stdout });
268 const answer = await rl.question(question);
269 rl.close();
270 return /^y(es)?$/i.test(answer.trim());
271}
273async function submit(options) {
274 const gh = (args) => spawnSync("gh", args, { encoding: "utf8" });
275 const repo = resolve(options.into);
276 const who = gh(["api", "user", "--jq", ".login"]);
277 if (who.error || who.status !== 0) throw new Error("--submit needs the GitHub CLI, signed in: install it from https://cli.github.com and run gh auth login");
278 const login = who.stdout.trim();
279 const author = options.author.replace(/^@/, "");
280 if (login.toLowerCase() !== author.toLowerCase()) throw new Error(`you're signed in to GitHub as ${login}, but --author is ${author}; they must match`);
281 const perm = gh(["api", `repos/${options.upstream}`, "--jq", ".permissions.push"]);
282 if (perm.status !== 0) throw new Error(`can't see ${options.upstream} with your GitHub login`);
283 const plan = submitPlan({ login, canPush: perm.stdout.trim() === "true", upstream: options.upstream, author, id: options.id, title: options.title.trim() });
284 console.log(`\nSubmitting as @${login}:`);
285 for (const step of plan.steps) if (step.say) console.log(` · ${step.say}`);
286 if (!options.noSession && !(options.minutes && options.tokens)) {
287 const measured = fromThisSession({ prompt: options.prompt });
288 const shown = { minutes: options.minutes ?? measured?.minutes, tokens: options.tokens ?? measured?.tokens };
289 console.log(shown.minutes && shown.tokens ? ` · record ${describe(shown)} to make it${measured ? " (read from this session)" : ""}` : " · no time or token count (not run inside Claude Code; pass --minutes and --tokens to add them)");
290 } else if (options.minutes || options.tokens) console.log(` · record${options.minutes ? ` ${options.minutes} min` : ""}${options.tokens ? ` ${options.tokens} tokens` : ""}`);
291 console.log(` (in ${repo})`);
292 if (options.dryRun) return console.log("\ndry run: nothing was done");
293 if (!options.yes && !(await confirm("\nGo ahead? [y/N] "))) throw new Error("not submitted (answer y, or pass --yes after asking)");
294 const run = (tool, args) => {
295 const result = spawnSync(tool, args, { cwd: repo, encoding: "utf8" });
296 if (result.status !== 0) throw new Error(`${tool} ${args.slice(0, 2).join(" ")} failed:\n${`${result.stdout}${result.stderr}`.trim()}`);
297 return result.stdout.trim();
298 };
299 let published;
300 for (const step of plan.steps) {
301 if (step.gh) run("gh", step.gh);
302 if (step.git) run("git", step.git);
303 if (step.publish) {
304 const onMain = run("git", ["ls-tree", "--name-only", "HEAD", "--", plan.folder]) !== "";
305 published = publish({ ...options, force: options.force || !onMain, log: (line) => console.log(` ${line}`) });
306 }
307 if (step.pr) {
308 const open = gh(["pr", "list", "--repo", step.pr.repo, "--head", plan.branch, "--state", "open", "--json", "url", "--jq", ".[0].url"]).stdout.trim();
309 const url = open || run("gh", ["pr", "create", "--repo", step.pr.repo, "--base", "main", "--head", step.pr.head, "--title", step.pr.title, "--body", `${published.figure.prompt}\n\nPublished with Anatomy's publish.mjs.`]);
310 console.log(`\nSubmitted: ${url}\nThe checks run there, and a preview of your figure appears on it when they finish.`);
311 }
312 }
313}
315if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
316 const options = parse(process.argv.slice(2));
317 if (options.submit) {
318 submit(options).catch((error) => {
319 console.error(`publish: ${error.message}`);
320 process.exit(1);
321 });
322 } else try {
323 console.log(`publishing ${options.id}…`);
324 const { dest, figure, skipped, linked, preview } = publish({ ...options, log: (line) => console.log(` ${line}`) });
325 const repo = resolve(options.into);
326 console.log(`published figures/${figure.author.toLowerCase()}/${figure.id} (${figure.kind}, kit ${figure.skill.kit.slice(7, 19)}): source only, the gallery builds the pages`);
327 if (figure.minutes && figure.tokens) console.log(` made in ${describe(figure)}`);
328 if (skipped.length) console.log(` left out (not a source type): ${skipped.join(", ")}`);
329 console.log(` .skill link: ${linked}`);
330 if (preview) console.log(` preview: ${preview} (not committed; CI makes the real one)`);
331 const where = `figures/${figure.author.toLowerCase()}/${figure.id}`;
332 console.log(`\nCheck it:\n cd ${repo}\n node scripts/validate.mjs ${where}`);
333 console.log(`\nWhen you are happy with it, from your fork of anatomy-community:\n git switch -c figure/${figure.id}\n git add ${where}\n git commit -m "Add ${figure.title.replace(/"/g, "'")}"\n git push -u origin figure/${figure.id}\n gh pr create --repo wheresryan22/anatomy-community --fill`);
334 } catch (error) {
335 console.error(`publish: ${error.message}`);
336 process.exit(1);
337 }
338}