From 5b2908a6c0cde6908a70a4d8c99d0ace18d91638 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Tue, 20 Oct 2026 01:44:52 -0400 Subject: [PATCH 1/4] ci(docs): docs fail when they show a CLI command or flag the CLI does not have scripts/cli-command-tree.ts loads every command module that cli.ts's commandLoaders registers and prints the citty command tree (subcommands, flags, aliases, value types, hidden and deprecated markers) as JSON. It reads the real definitions, not --help text, so it sees exactly what citty parses. scripts/check-docs-cli-references.mjs scans docs/**/*.mdx (fenced blocks with backslash continuations, inline code, and the catalog's InstallCommand command props) for `hyperframes ...` / `npx hyperframes ...` invocations and resolves each one the way citty does: the first non-flag word that is not a string flag's value names the subcommand. Unknown commands, subcommands and flags are errors with a "did you mean"; deprecated commands and flags and hidden flags are warnings. Placeholders (, [opts], {id}, $VAR, ...), comments, `--`, other tools (`npx skills add heygen-com/hyperframes`, `npm i -g hyperframes`) and the release logs are left alone. Quoted CLI output opts out with {/* cli-references: ignore */}. The Docs workflow runs it on docs and CLI source changes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docs.yml | 31 +++ package.json | 3 +- scripts/check-docs-cli-references.mjs | 259 +++++++++++++++++++++ scripts/check-docs-cli-references.test.mjs | 187 +++++++++++++++ scripts/cli-command-tree.ts | 115 +++++++++ 5 files changed, 594 insertions(+), 1 deletion(-) create mode 100644 scripts/check-docs-cli-references.mjs create mode 100644 scripts/check-docs-cli-references.test.mjs create mode 100644 scripts/cli-command-tree.ts diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 9417b77a0f9..88580a484c1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -16,6 +16,11 @@ on: - "DOCS_GUIDELINES.md" - "packages/core/schemas/**" - "scripts/sync-schemas.ts" + # A CLI change can strand a command or flag the docs still show. + - "packages/cli/src/**" + - "scripts/cli-command-tree.ts" + - "scripts/check-docs-cli-references.mjs" + - ".github/workflows/docs.yml" push: branches: [main] paths: @@ -23,6 +28,11 @@ on: - "DOCS_GUIDELINES.md" - "packages/core/schemas/**" - "scripts/sync-schemas.ts" + # A CLI change can strand a command or flag the docs still show. + - "packages/cli/src/**" + - "scripts/cli-command-tree.ts" + - "scripts/check-docs-cli-references.mjs" + - ".github/workflows/docs.yml" concurrency: group: docs-${{ github.ref }} @@ -52,3 +62,24 @@ jobs: # --check-redirects also resolves every redirect destination, so a # restructure cannot leave a redirect pointing at a page it removed. run: npx mint broken-links --check-redirects + + cli-references: + name: CLI references + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + - run: bash scripts/ci/install-workspace-dependencies.sh + + # The command tree loads the CLI's command modules, which import core's generated sources. + - run: bun run --cwd packages/core build + + - name: Check every command and flag the docs show exists in the CLI + run: node scripts/check-docs-cli-references.mjs diff --git a/package.json b/package.json index b7bf2373174..c5d3e29797d 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "check:tracked-artifacts": "node scripts/check-tracked-artifacts.mjs", "check:docs-snippet-motion": "node scripts/check-docs-snippet-motion.mjs", "check:docs-catalog": "node scripts/check-docs-catalog.mjs", + "check:docs-cli-references": "node scripts/check-docs-cli-references.mjs", "generate:catalog": "bun scripts/generate-catalog.ts", "check:catalog-drift": "tsx scripts/catalog-drift.ts", "verify:catalog-payloads": "tsx scripts/verify-catalog-payloads.ts", @@ -53,7 +54,7 @@ "player:perf": "bun run --filter @hyperframes/player perf", "format:check": "oxfmt --check .", "knip": "knip", - "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/edit-accuracy-retry.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/ci/base-file.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts", + "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/check-docs-cli-references.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/edit-accuracy-retry.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/ci/base-file.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts", "typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json", "test:skills": "HYPERFRAMES_MEDIA_HOME_REQUIRED=1 node --import ./packages/cli/scripts/test-home.mjs --test 'skills/**/*.test.mjs' 'packages/cli/src/media-use/**/*.test.mjs'", "generate:previews": "tsx scripts/generate-template-previews.ts", diff --git a/scripts/check-docs-cli-references.mjs b/scripts/check-docs-cli-references.mjs new file mode 100644 index 00000000000..26c1f8f70be --- /dev/null +++ b/scripts/check-docs-cli-references.mjs @@ -0,0 +1,259 @@ +// Every `hyperframes [--flag]` the docs show in code must exist in the CLI the repo builds. +// The command tree comes from scripts/cli-command-tree.ts, which loads the real citty definitions, so a +// renamed or removed command or flag fails here instead of in a reader's terminal. +// +// Code that only looks like a command (CLI output quoted inline) opts out with +// `{/* cli-references: ignore */}` on its line or the line above. +// +// node scripts/check-docs-cli-references.mjs [--tree ] [docs-dir] +import { execFileSync } from "node:child_process"; +import { readFileSync, readdirSync, realpathSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const ROOT = resolve(import.meta.dirname, ".."); + +// Release logs record what the CLI accepted at the time; old flags there are history, not drift. +const HISTORY_PAGES = new Set(["changelog.mdx", "weekly-updates.mdx"]); +const IGNORE = "{/* cli-references: ignore */}"; + +// Flags citty and the CLI accept on every command (packages/cli/src/utils/reject-unknown-flags.ts). +const ALWAYS_KNOWN = ["help", "h", "version", "v", "json"]; + +// Words that may come before `hyperframes` when a line runs it: package runners and shell prompts. +const RUNNERS = new Set(["$", ">", "npx", "bunx", "pnpx", "pnpm", "yarn", "npm", "dlx", "exec"]); +const RUNNER_FLAGS = new Set(["-y", "--yes"]); +const INVOCATION = /^(?:hyperframes|@hyperframes\/cli)(?:@[\w.-]+)?$/; +const ENV_ASSIGNMENT = /^[A-Z_][A-Z0-9_]*=/; +// ``, `[options]`, `{id}`, `$VAR`, `...`: the docs' stand-ins for a value the reader supplies. +const PLACEHOLDER = /[<>[\]{}$|]|\.\.\.|…/; + +/** Each piece of code on an MDX page as `{ line, text }`: fenced lines (joined across `\` continuations), inline code, command props. */ +export function codeSpans(source) { + const spans = []; + const lines = source.split("\n"); + let fence = null; + let pending = null; + for (const [index, raw] of lines.entries()) { + const ignored = raw.includes(IGNORE) || (lines[index - 1] ?? "").includes(IGNORE); + const marker = raw.match(/^\s*(`{3,}|~{3,})/); + if (marker && (!fence || raw.trim().startsWith(fence))) { + fence = fence ? null : marker[1]; + pending = null; + continue; + } + if (fence) { + const line = pending?.line ?? index + 1; + const text = pending ? `${pending.text} ${raw.trim()}` : raw; + if (/\\\s*$/.test(text)) { + pending = { line, text: text.replace(/\\\s*$/, "") }; + continue; + } + pending = null; + if (!ignored) spans.push({ line, text }); + continue; + } + if (ignored) continue; + for (const m of raw.matchAll(/(`+)([^`]+?)\1(?!`)/g)) + spans.push({ line: index + 1, text: m[2] }); + // Catalog pages hand the install line to a component: . + for (const m of raw.matchAll(/\bcommand=(?:"([^"]*)"|\{"([^"]*)"\})/g)) + spans.push({ line: index + 1, text: m[1] ?? m[2] }); + } + return spans; +} + +/** Shell-ish words per command: quotes group, an unquoted `#` starts a comment, `&&`/`||`/`|`/`;`/`&` end a command. */ +export function shellCommands(text) { + const commands = [[]]; + const pattern = /"(?:\\.|[^"\\])*"|'[^']*'|&&|\|\||[|;&]|#.*$|[^\s"'|;&#]+(?:#\S*)?/g; + for (const [token] of text.matchAll(pattern)) { + if (token.startsWith("#")) break; + if (["&&", "||", "|", ";", "&"].includes(token)) commands.push([]); + else commands.at(-1).push(token); + } + return commands.filter((words) => words.length > 0); +} + +/** The words after `hyperframes` when the command runs our CLI, else null (`npm i -g hyperframes`, `skills add …/hyperframes`). */ +export function cliArguments(words) { + const at = words.findIndex((word) => INVOCATION.test(word)); + if (at < 0) return null; + const lead = words.slice(0, at); + const runsCli = lead.every( + (word) => RUNNERS.has(word) || RUNNER_FLAGS.has(word) || ENV_ASSIGNMENT.test(word), + ); + return runsCli ? words.slice(at + 1) : null; +} + +const camel = (name) => name.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); +const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); +const unquote = (word) => word.replace(/^(["'])(.*)\1$/, "$2"); +const isFlag = (word) => + word.startsWith("-") && word !== "-" && word !== "--" && !/^-\d/.test(word); + +/** Every spelling (`x` of `--x` / `-x`) the command accepts, mapped to its flag. Mirrors reject-unknown-flags.ts. */ +export function flagSpellings(command) { + const spellings = new Map(ALWAYS_KNOWN.map((name) => [name, { type: "boolean", global: true }])); + for (const [name, flag] of Object.entries(command.flags)) { + for (const spelling of [name, kebab(name), camel(name), ...flag.aliases]) + spellings.set(spelling, flag); + } + return spellings; +} + +// citty's findSubCommandIndex: the first word that is neither a flag nor a string/enum flag's value; `--` ends the search. +function subCommandIndex(words, spellings) { + for (let i = 0; i < words.length; i++) { + if (words[i] === "--") return -1; + if (!isFlag(words[i])) return i; + const flag = spellings.get(words[i].replace(/^-{1,2}/, "")); + if (!words[i].includes("=") && (flag?.type === "string" || flag?.type === "enum")) i++; + } + return -1; +} + +function editDistance(a, b) { + const row = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i++) { + let diagonal = row[0]; + row[0] = i; + for (let j = 1; j <= b.length; j++) { + const above = row[j]; + row[j] = Math.min(row[j] + 1, row[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1)); + diagonal = above; + } + } + return row[b.length]; +} + +/** ` (did you mean x?)` for the nearest candidate a typo or a shortened name could have meant. */ +function suggestion(word, candidates, prefix = "") { + const near = candidates + .map((candidate) => ({ candidate, distance: editDistance(word, candidate) })) + .filter( + ({ candidate, distance }) => + distance <= Math.max(1, word.length >> 2) || candidate.startsWith(word), + ) + .sort((a, b) => a.distance - b.distance)[0]; + return near ? ` (did you mean ${prefix}${near.candidate}?)` : ""; +} + +// `--name`, `--name=value`, `--no-name` -> the names citty could read it as; `-abc` -> one name per letter. +function flagReadings(word) { + if (word.startsWith("--")) { + const name = word.slice(2).split("=")[0]; + return [name.startsWith("no-") ? [name, name.slice(3)] : [name]]; + } + return [...word.slice(1).split("=")[0]].map((letter) => [letter]); +} + +function flagProblems(command, shown, words) { + const spellings = flagSpellings(command); + const problems = []; + for (const word of words) { + if (word === "--") break; + if (!isFlag(word) || PLACEHOLDER.test(word.split("=")[0])) continue; + const dashes = word.startsWith("--") ? "--" : "-"; + for (const readings of flagReadings(word)) { + const flag = readings.map((name) => spellings.get(name)).find(Boolean); + const name = `${dashes}${readings[0]}`; + if (!flag) { + const hint = dashes === "--" ? suggestion(readings[0], [...spellings.keys()], "--") : ""; + problems.push({ severity: "error", message: `\`${shown}\` has no ${name}${hint}` }); + } else if (flag.deprecated || flag.hidden) { + const state = flag.deprecated ? "deprecated" : "hidden from --help"; + problems.push({ severity: "warning", message: `\`${shown} ${name}\` is ${state}` }); + } + } + } + return problems; +} + +/** Problems with one invocation (the words after `hyperframes`), as `{ severity: "error" | "warning", message }`. */ +export function invocationProblems(tree, args) { + const problems = []; + let command = tree; + let words = args; + const path = ["hyperframes"]; + for (;;) { + const hasSubs = Object.keys(command.subCommands).length > 0; + const at = hasSubs ? subCommandIndex(words, flagSpellings(command)) : -1; + problems.push(...flagProblems(command, path.join(" "), at < 0 ? words : words.slice(0, at))); + if (at < 0) return problems; + const name = unquote(words[at]); + const next = command.subCommands[name]; + if (!next) { + if (PLACEHOLDER.test(name)) return problems; + const kind = path.length === 1 ? "command" : "subcommand"; + const hint = suggestion(name, Object.keys(command.subCommands)); + problems.push({ + severity: "error", + message: `unknown ${kind} \`${path.join(" ")} ${name}\`${hint}`, + }); + return problems; + } + path.push(name); + if (next.deprecated) + problems.push({ severity: "warning", message: `\`${path.join(" ")}\` is deprecated` }); + command = next; + words = words.slice(at + 1); + } +} + +/** Problems on one page, each with the line its command starts on. */ +export function pageProblems(tree, source) { + return codeSpans(source).flatMap(({ line, text }) => + shellCommands(text) + .map(cliArguments) + .filter(Boolean) + .flatMap((args) => invocationProblems(tree, args).map((problem) => ({ line, ...problem }))), + ); +} + +function mdxFiles(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return mdxFiles(path); + return entry.name.endsWith(".mdx") ? [path] : []; + }); +} + +export function checkDocs(tree, docsDir) { + return mdxFiles(docsDir) + .filter((file) => !HISTORY_PAGES.has(relative(docsDir, file))) + .sort() + .flatMap((file) => + pageProblems(tree, readFileSync(file, "utf8")).map((problem) => ({ + file: relative(ROOT, file), + ...problem, + })), + ); +} + +function loadTree(treePath) { + if (treePath) return JSON.parse(readFileSync(treePath, "utf8")); + const json = execFileSync("bun", [join(ROOT, "scripts", "cli-command-tree.ts")], { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + return JSON.parse(json); +} + +function main() { + const args = process.argv.slice(2); + const treeAt = args.indexOf("--tree"); + const treePath = treeAt < 0 ? undefined : args[treeAt + 1]; + const docsArg = args.find((arg, i) => !arg.startsWith("--") && (treeAt < 0 || i !== treeAt + 1)); + const docsDir = resolve(docsArg ?? join(ROOT, "docs")); + const problems = checkDocs(loadTree(treePath), docsDir); + const errors = problems.filter((p) => p.severity === "error").length; + for (const p of problems) console.log(`${p.file}:${p.line}: ${p.severity}: ${p.message}`); + console.log( + `${errors} error(s), ${problems.length - errors} warning(s) in the docs' CLI references.`, + ); + if (errors > 0) process.exitCode = 1; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) + main(); diff --git a/scripts/check-docs-cli-references.test.mjs b/scripts/check-docs-cli-references.test.mjs new file mode 100644 index 00000000000..789c87e5425 --- /dev/null +++ b/scripts/check-docs-cli-references.test.mjs @@ -0,0 +1,187 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { + cliArguments, + codeSpans, + flagSpellings, + invocationProblems, + pageProblems, + shellCommands, +} from "./check-docs-cli-references.mjs"; + +const flag = (type, aliases = [], extra = {}) => ({ + type, + aliases, + hidden: false, + deprecated: false, + ...extra, +}); +const command = (flags = {}, subCommands = {}, extra = {}) => ({ + hidden: false, + deprecated: false, + flags, + subCommands, + ...extra, +}); + +const TREE = command( + { version: flag("boolean", ["V"]) }, + { + render: command({ + output: flag("string", ["o"]), + quality: flag("enum"), + browserGpu: flag("boolean"), + legacy: flag("boolean", [], { deprecated: true }), + }), + init: command({ + example: flag("string", ["e"]), + template: flag("string", ["t"], { hidden: true }), + }), + validate: command({}, {}, { deprecated: true }), + cloud: command( + { region: flag("string") }, + { render: command({ "dry-run": flag("boolean") }), list: command() }, + ), + }, +); + +const messages = (args) => invocationProblems(TREE, args).map((p) => `${p.severity}: ${p.message}`); + +test("codeSpans joins backslash-continued fenced lines and reports the first line", () => { + const page = [ + "intro", + "```bash", + "npx hyperframes render \\", + " --output out.mp4 \\", + " --quality high", + "```", + ].join("\n"); + assert.deepEqual(codeSpans(page), [ + { line: 3, text: "npx hyperframes render --output out.mp4 --quality high" }, + ]); +}); + +test("codeSpans reads inline code, catalog command props, and fences nested in components", () => { + const page = [ + "Run `hyperframes lint` then ``hyperframes check``.", + '', + "", + " ```bash", + " hyperframes preview", + " ```", + "", + ].join("\n"); + assert.deepEqual(codeSpans(page), [ + { line: 1, text: "hyperframes lint" }, + { line: 1, text: "hyperframes check" }, + { line: 2, text: "npx hyperframes add grain" }, + { line: 5, text: " hyperframes preview" }, + ]); +}); + +test("codeSpans skips code marked cli-references: ignore on its line or the line above", () => { + const page = [ + "{/* cli-references: ignore */}", + "prints `hyperframes auto-updated to v1`", + "`hyperframes lint`", + ].join("\n"); + assert.deepEqual(codeSpans(page), [{ line: 3, text: "hyperframes lint" }]); +}); + +test("shellCommands splits chained commands and drops comments, keeping quoted words whole", () => { + assert.deepEqual( + shellCommands('cd app && npx hyperframes render --output "my film.mp4" # then share'), + [ + ["cd", "app"], + ["npx", "hyperframes", "render", "--output", '"my film.mp4"'], + ], + ); + assert.deepEqual(shellCommands("hyperframes lint | tee log; hyperframes check"), [ + ["hyperframes", "lint"], + ["tee", "log"], + ["hyperframes", "check"], + ]); +}); + +test("cliArguments recognises runners, pinned versions and env prefixes, and leaves other tools alone", () => { + const args = (line) => cliArguments(shellCommands(line)[0]); + assert.deepEqual(args("hyperframes render"), ["render"]); + assert.deepEqual(args("npx -y hyperframes@latest lint --json"), ["lint", "--json"]); + assert.deepEqual(args("$ bunx @hyperframes/cli@0.8.0 check"), ["check"]); + assert.deepEqual(args("HYPERFRAMES_NO_TELEMETRY=1 npx hyperframes doctor"), ["doctor"]); + assert.equal(args("npx skills add heygen-com/hyperframes --skill hyperframes-cli"), null); + assert.equal(args("npm install -g hyperframes"), null); + assert.equal(args("/hyperframes make me a video"), null); +}); + +test("flagSpellings accepts kebab and camel spellings, aliases and the global flags", () => { + const spellings = flagSpellings(TREE.subCommands.render); + for (const spelling of ["browser-gpu", "browserGpu", "o", "output", "help", "h", "json", "v"]) + assert.ok(spellings.has(spelling), spelling); + assert.ok(!spellings.has("out")); +}); + +test("known commands, flags, negations, aliases and combined short flags pass", () => { + assert.deepEqual( + messages(["render", "./film", "-o", "out.mp4", "--no-browser-gpu", "--quality=high"]), + [], + ); + assert.deepEqual(messages(["cloud", "render", "--dry-run"]), []); + assert.deepEqual(messages(["--version"]), []); + assert.deepEqual(messages(["render", "-oh", "x"]), []); +}); + +test("unknown commands, subcommands and flags are errors with a suggestion when one is close", () => { + assert.deepEqual(messages(["rendr"]), [ + "error: unknown command `hyperframes rendr` (did you mean render?)", + ]); + assert.deepEqual(messages(["cloud", "lst"]), [ + "error: unknown subcommand `hyperframes cloud lst` (did you mean list?)", + ]); + assert.deepEqual(messages(["render", "--out", "x.mp4"]), [ + "error: `hyperframes render` has no --out (did you mean --output?)", + ]); + assert.deepEqual(messages(["render", "--fps", "30"]), [ + "error: `hyperframes render` has no --fps", + ]); + assert.deepEqual(messages(["init", "-ez"]), ["error: `hyperframes init` has no -z"]); +}); + +test("placeholders, values, negative numbers and words after -- are not read as commands or flags", () => { + assert.deepEqual(messages(["render", "", "--output", "", "--", "..."]), []); + assert.deepEqual(messages(["render", "--output", "-1"]), []); + assert.deepEqual(messages(["render", "--", "--anything"]), []); + assert.deepEqual(messages(["cloud", ""]), []); + // A string flag's value is not where citty looks for the subcommand. + assert.deepEqual(messages(["cloud", "--region", "eu", "list"]), []); +}); + +test("deprecated commands and flags, and hidden flags, are warnings", () => { + assert.deepEqual(messages(["validate"]), ["warning: `hyperframes validate` is deprecated"]); + assert.deepEqual(messages(["render", "--legacy"]), [ + "warning: `hyperframes render --legacy` is deprecated", + ]); + assert.deepEqual(messages(["init", "-t", "blank"]), [ + "warning: `hyperframes init -t` is hidden from --help", + ]); +}); + +test("pageProblems reports the line a multi-line command starts on", () => { + const page = [ + "# Render", + "", + "```bash", + "npx hyperframes render \\", + " --output out.mp4 \\", + " --qualty high", + "```", + ].join("\n"); + assert.deepEqual(pageProblems(TREE, page), [ + { + line: 4, + severity: "error", + message: "`hyperframes render` has no --qualty (did you mean --quality?)", + }, + ]); +}); diff --git a/scripts/cli-command-tree.ts b/scripts/cli-command-tree.ts new file mode 100644 index 00000000000..11571b099a4 --- /dev/null +++ b/scripts/cli-command-tree.ts @@ -0,0 +1,115 @@ +// Prints the CLI's command tree as JSON: every command and subcommand, with the flags each one declares. +// It loads the real citty definitions rather than parsing --help, so docs checks see exactly what the CLI parses. +// Run with bun after `bun run --cwd packages/core build`: the command modules import core's generated sources. +// +// bun scripts/cli-command-tree.ts [--root ] +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +type Resolvable = T | Promise | (() => T | Promise); + +interface CittyArg { + type?: string; + alias?: string | string[]; + hidden?: boolean; + description?: string; +} + +interface CittyCommand { + meta?: Resolvable<{ name?: string; description?: string; hidden?: boolean }>; + args?: Resolvable>; + subCommands?: Resolvable>>; +} + +export interface TreeFlag { + type: string; + aliases: string[]; + hidden: boolean; + deprecated: boolean; +} + +export interface TreeCommand { + hidden: boolean; + deprecated: boolean; + flags: Record; + subCommands: Record; +} + +const DEPRECATED = /\bdeprecated\b/i; + +async function settle(value: Resolvable | undefined): Promise { + return typeof value === "function" ? (value as () => T | Promise)() : value; +} + +/** Each `name: () => import("./commands/.js").then((m) => m.)` entry of cli.ts's commandLoaders. */ +export function commandLoaders( + cliSource: string, +): { name: string; file: string; exportName: string }[] { + const block = cliSource.match(/const commandLoaders = \{([\s\S]*?)\n\};/)?.[1]; + if (!block) throw new Error("cli.ts no longer declares `const commandLoaders = { ... };`"); + const entries = [ + ...block.matchAll( + /^ {2}"?([a-z][a-z-]*)"?: \(\) =>[\s\S]*?import\("\.\/commands\/([\w-]+)\.js"\)\.then\(\(m\) => m\.(\w+)\)/gm, + ), + ].map((m) => ({ name: m[1]!, file: m[2]!, exportName: m[3]! })); + const declared = block.match(/^ {2}"?[a-z][a-z-]*"?: \(\) =>/gm)?.length ?? 0; + if (entries.length !== declared) + throw new Error( + `Read ${entries.length} of cli.ts's ${declared} command loaders; update the pattern`, + ); + return entries; +} + +async function describe(command: CittyCommand): Promise { + const meta = (await settle(command.meta)) ?? {}; + const args = (await settle(command.args)) ?? {}; + const subs = (await settle(command.subCommands)) ?? {}; + const flags: Record = {}; + for (const [name, def] of Object.entries(args)) { + if (def.type === "positional") continue; + flags[name] = { + type: def.type ?? "string", + aliases: def.alias === undefined ? [] : [def.alias].flat(), + hidden: def.hidden === true, + deprecated: DEPRECATED.test(def.description ?? ""), + }; + } + const subCommands: Record = {}; + for (const [name, sub] of Object.entries(subs)) { + const resolved = await settle(sub); + if (resolved) subCommands[name] = await describe(resolved); + } + return { + hidden: meta.hidden === true, + deprecated: DEPRECATED.test(meta.description ?? ""), + flags, + subCommands, + }; +} + +export async function cliCommandTree(root: string): Promise { + const cliDir = join(root, "packages", "cli", "src"); + const subCommands: Record = {}; + for (const loader of commandLoaders(readFileSync(join(cliDir, "cli.ts"), "utf8"))) { + const url = pathToFileURL(join(cliDir, "commands", `${loader.file}.ts`)).href; + const mod = (await import(url)) as Record; + const command = mod[loader.exportName]; + if (!command) throw new Error(`commands/${loader.file}.ts has no export ${loader.exportName}`); + subCommands[loader.name] = await describe(command); + } + // The root answers --version/-V before citty runs (cli.ts's fast path) and --help/-h through citty. + const version = { type: "boolean", aliases: ["V"], hidden: false, deprecated: false }; + return { hidden: false, deprecated: false, flags: { version }, subCommands }; +} + +if (import.meta.main) { + const rootFlag = process.argv.indexOf("--root"); + const root = resolve( + rootFlag < 0 ? join(import.meta.dirname, "..") : process.argv[rootFlag + 1]!, + ); + process.env.HYPERFRAMES_NO_TELEMETRY = "1"; + const json = `${JSON.stringify(await cliCommandTree(root), null, 2)}\n`; + // Some command modules start timers or handles at import, so exit once a piped stdout has drained. + process.stdout.write(json, () => process.exit(0)); +} From 722d6bf183a8aae83cf853cd5af7fa63bc8889df Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Tue, 20 Oct 2026 01:45:17 -0400 Subject: [PATCH 2/4] ci(docs): a PR that changes CLI flags or public exports must change docs or say why not scripts/check-docs-impact.mjs compares the merge base with HEAD on two surfaces and fails when one changed and nothing under docs/ did: - CLI: every visible command, subcommand, flag, alias and flag value type, from scripts/cli-command-tree.ts. The base tree is built in a temporary worktree (bun install + core build), only when packages/cli/src changed outside tests and fixtures. - Public exports: for every package.json that is not private and declares exports (package-subpaths.mjs's rule), its exports subpaths, bin names and the names its root entry exports through `export *` chains, read from git with the TypeScript parser (scripts/public-api-surface.mjs). A PR opts out with the docs-not-needed label or a `docs-impact: none` line in its body, passed in as PR_LABELS / PR_BODY. --dry-run prints the verdict and exits 0; --base-tree/--head-tree take prebuilt trees. The Docs impact workflow runs it on every PR to main, including label and body edits, so changing the opt-out re-runs it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docs-impact.yml | 62 +++++++++++ package.json | 3 +- scripts/check-docs-impact.mjs | 164 ++++++++++++++++++++++++++++ scripts/check-docs-impact.test.mjs | 92 ++++++++++++++++ scripts/public-api-surface.mjs | 135 +++++++++++++++++++++++ scripts/public-api-surface.test.mjs | 83 ++++++++++++++ 6 files changed, 538 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/docs-impact.yml create mode 100644 scripts/check-docs-impact.mjs create mode 100644 scripts/check-docs-impact.test.mjs create mode 100644 scripts/public-api-surface.mjs create mode 100644 scripts/public-api-surface.test.mjs diff --git a/.github/workflows/docs-impact.yml b/.github/workflows/docs-impact.yml new file mode 100644 index 00000000000..ec99799f37b --- /dev/null +++ b/.github/workflows/docs-impact.yml @@ -0,0 +1,62 @@ +name: Docs impact + +permissions: + contents: read + +# Suppress hyperframes CLI telemetry from HeyGen's own CI runs. +# External users' CI continues to emit telemetry unless they set this themselves. +env: + HYPERFRAMES_NO_TELEMETRY: "1" + +# No path filter: a public export can change from any package source, and the script passes at once when +# neither the CLI nor a package changed. `edited`, `labeled` and `unlabeled` start a fresh run when the +# opt-out changes; re-running an old run would reuse the event's stale body and labels. Reading the label +# and body from the event payload needs no extra permission, fork PRs included. Not a merge-queue check: +# a merge group has no PR body (see merge-queue-workflows.test.mjs before adding merge_group). +on: + pull_request: + types: [opened, synchronize, reopened, edited, labeled, unlabeled] + branches: [main] + +concurrency: + group: docs-impact-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + docs-impact: + name: Docs impact + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + # HEAD is the PR merge ref; its first parent is the current base tip, unlike the payload's + # base.sha, which goes stale and would count the base's newer commits as the PR's changes. + - name: Find the pull request's current base + id: base + run: | + git rev-parse --verify -q HEAD^2 > /dev/null || { echo "::error::HEAD is not the pull request merge commit"; exit 1; } + sha="$(git rev-parse --verify HEAD^1)" + echo "sha=$sha" >> "$GITHUB_OUTPUT" + git diff --quiet "$sha" HEAD -- packages/cli/src || echo "cli=true" >> "$GITHUB_OUTPUT" + + - run: bash scripts/ci/install-workspace-dependencies.sh + + # Only a CLI change needs the command tree, which imports core's generated sources. + - if: steps.base.outputs.cli == 'true' + run: bun run --cwd packages/core build + + - name: Require docs for CLI and public export changes + env: + BASE_SHA: ${{ steps.base.outputs.sha }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + run: node scripts/check-docs-impact.mjs --base "$BASE_SHA" diff --git a/package.json b/package.json index c5d3e29797d..93a13957506 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "check:docs-snippet-motion": "node scripts/check-docs-snippet-motion.mjs", "check:docs-catalog": "node scripts/check-docs-catalog.mjs", "check:docs-cli-references": "node scripts/check-docs-cli-references.mjs", + "check:docs-impact": "node scripts/check-docs-impact.mjs", "generate:catalog": "bun scripts/generate-catalog.ts", "check:catalog-drift": "tsx scripts/catalog-drift.ts", "verify:catalog-payloads": "tsx scripts/verify-catalog-payloads.ts", @@ -54,7 +55,7 @@ "player:perf": "bun run --filter @hyperframes/player perf", "format:check": "oxfmt --check .", "knip": "knip", - "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/check-docs-cli-references.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/edit-accuracy-retry.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/ci/base-file.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts", + "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/check-docs-cli-references.test.mjs scripts/check-docs-impact.test.mjs scripts/public-api-surface.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/edit-accuracy-retry.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/ci/base-file.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts", "typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json", "test:skills": "HYPERFRAMES_MEDIA_HOME_REQUIRED=1 node --import ./packages/cli/scripts/test-home.mjs --test 'skills/**/*.test.mjs' 'packages/cli/src/media-use/**/*.test.mjs'", "generate:previews": "tsx scripts/generate-template-previews.ts", diff --git a/scripts/check-docs-impact.mjs b/scripts/check-docs-impact.mjs new file mode 100644 index 00000000000..d527e93b58b --- /dev/null +++ b/scripts/check-docs-impact.mjs @@ -0,0 +1,164 @@ +// A pull request that changes what users type or import must change the docs with it. +// +// Fails when, between the merge base and HEAD, either surface changed and nothing under docs/ did: +// CLI every command, subcommand and flag the citty definitions declare (scripts/cli-command-tree.ts), +// hidden ones excepted; flag value types and deprecation count as changes. +// exports every public package's exports subpaths, bin names and root-entry exports +// (scripts/public-api-surface.mjs). +// The PR opts out with the `docs-not-needed` label or a `docs-impact: none` line in its body +// (PR_LABELS: JSON array of label names or label objects; PR_BODY: the body). +// +// node scripts/check-docs-impact.mjs [--base ] [--dry-run] [--base-tree ] [--head-tree ] +// +// The base CLI tree is built from a temporary worktree (bun install + core build), only when the CLI's +// sources changed. --dry-run prints the verdict and exits 0. +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { publicApiSurface } from "./public-api-surface.mjs"; + +const ROOT = resolve(import.meta.dirname, ".."); +export const OPT_OUT_LABEL = "docs-not-needed"; +const OPT_OUT_BODY = /^\s*docs-impact:\s*none\b/im; + +const CLI_SOURCE = + /^packages\/cli\/src\/(?!.*\.test\.tsx?$)(?!(?:.*\/)?(?:__fixtures__|fixtures)\/)/; +const PACKAGE_SOURCE = /^packages\/[^/]+\/(?:package\.json$|src\/)/; + +/** One line per user-visible command, flag and alias, e.g. `hyperframes render --output `. */ +export function cliSurface(tree, path = ["hyperframes"]) { + const shown = path.join(" "); + const lines = [path.length > 1 ? `${shown}${tree.deprecated ? " (deprecated)" : ""}` : shown]; + for (const [name, flag] of Object.entries(tree.flags)) { + if (flag.hidden) continue; + const value = flag.type === "boolean" ? "" : ` <${flag.type}>`; + lines.push(`${shown} --${name}${value}${flag.deprecated ? " (deprecated)" : ""}`); + for (const alias of flag.aliases) lines.push(`${shown} -${alias} = --${name}`); + } + for (const [name, sub] of Object.entries(tree.subCommands)) { + if (!sub.hidden) lines.push(...cliSurface(sub, [...path, name])); + } + return lines; +} + +/** `-` lines the base had and HEAD lost, then `+` lines HEAD added. */ +export function surfaceDiff(base, head) { + const before = new Set(base); + const after = new Set(head); + return [ + ...base.filter((line) => !after.has(line)).map((line) => `- ${line}`), + ...head.filter((line) => !before.has(line)).map((line) => `+ ${line}`), + ]; +} + +/** Why the PR may skip docs, or null. */ +export function optOutReason({ labels = [], body = "" }) { + const names = labels.map((label) => (typeof label === "string" ? label : label?.name)); + if (names.includes(OPT_OUT_LABEL)) return `labelled ${OPT_OUT_LABEL}`; + if (OPT_OUT_BODY.test(body)) return "the body says `docs-impact: none`"; + return null; +} + +export const touchesDocs = (files) => files.some((file) => file.startsWith("docs/")); +export const touchesCli = (files) => files.some((file) => CLI_SOURCE.test(file)); +export const touchesPackages = (files) => files.some((file) => PACKAGE_SOURCE.test(file)); + +function git(args, cwd = ROOT) { + return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 26 }).trim(); +} + +// Output is kept for the error a failed step throws, not printed: installs and builds are noisy. +function run(command, args, cwd) { + return execFileSync(command, args, { + cwd, + encoding: "utf8", + maxBuffer: 1 << 26, + env: { ...process.env, HYPERFRAMES_NO_TELEMETRY: "1", GIT_LFS_SKIP_SMUDGE: "1" }, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +const treeAt = (root) => + JSON.parse(run("bun", [join(ROOT, "scripts", "cli-command-tree.ts"), "--root", root], root)); + +// The base revision's command modules need their own dependencies and generated core sources. +function baseTree(sha) { + const dir = mkdtempSync(join(tmpdir(), "hf-docs-impact-")); + try { + run("git", ["worktree", "add", "--detach", dir, sha], ROOT); + run("bun", ["install", "--frozen-lockfile", "--ignore-scripts"], dir); + run("bun", ["run", "--cwd", "packages/core", "build"], dir); + return treeAt(dir); + } finally { + git(["worktree", "remove", "--force", dir]); + rmSync(dir, { recursive: true, force: true }); + } +} + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function flag(args, name) { + const at = args.indexOf(name); + return at < 0 ? undefined : args[at + 1]; +} + +function parseLabels(raw) { + if (!raw) return []; + const labels = JSON.parse(raw); + return Array.isArray(labels) ? labels : []; +} + +function report(heading, lines) { + if (lines.length === 0) return; + console.log(heading); + for (const line of lines) console.log(` ${line}`); +} + +function main() { + const args = process.argv.slice(2); + const dryRun = args.includes("--dry-run"); + const base = git(["merge-base", flag(args, "--base") ?? "origin/main", "HEAD"]); + const files = git(["diff", "--name-only", base, "HEAD"]).split("\n").filter(Boolean); + const pr = { labels: parseLabels(process.env.PR_LABELS), body: process.env.PR_BODY ?? "" }; + const excuse = touchesDocs(files) ? "it changes docs/" : optOutReason(pr); + if (excuse && !dryRun) { + console.log(`Docs impact: passes, ${excuse}.`); + return; + } + + const baseTreePath = flag(args, "--base-tree"); + const headTreePath = flag(args, "--head-tree"); + const cliChanges = + touchesCli(files) || baseTreePath + ? surfaceDiff( + cliSurface(baseTreePath ? readJson(baseTreePath) : baseTree(base)), + cliSurface(headTreePath ? readJson(headTreePath) : treeAt(ROOT)), + ) + : []; + const exportChanges = touchesPackages(files) + ? surfaceDiff(publicApiSurface(base, ROOT), publicApiSurface("HEAD", ROOT)) + : []; + + report("CLI commands and flags changed:", cliChanges); + report("Public package exports changed:", exportChanges); + if (cliChanges.length + exportChanges.length === 0) { + console.log("Docs impact: passes, no CLI command, flag or public export changed."); + return; + } + if (excuse) { + console.log(`Docs impact: passes, ${excuse}.`); + return; + } + console.log( + `\nDocs impact${dryRun ? " (dry run)" : ""}: update docs/ for the changes above, or opt out with the \`${OPT_OUT_LABEL}\` label` + + " or a `docs-impact: none` line in the PR body (say why).", + ); + if (!dryRun) process.exitCode = 1; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) + main(); diff --git a/scripts/check-docs-impact.test.mjs b/scripts/check-docs-impact.test.mjs new file mode 100644 index 00000000000..1b5ec0dbb89 --- /dev/null +++ b/scripts/check-docs-impact.test.mjs @@ -0,0 +1,92 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { + OPT_OUT_LABEL, + cliSurface, + optOutReason, + surfaceDiff, + touchesCli, + touchesDocs, + touchesPackages, +} from "./check-docs-impact.mjs"; + +const flag = (type, aliases = [], extra = {}) => ({ + type, + aliases, + hidden: false, + deprecated: false, + ...extra, +}); +const command = (flags = {}, subCommands = {}, extra = {}) => ({ + hidden: false, + deprecated: false, + flags, + subCommands, + ...extra, +}); + +test("cliSurface lists commands, flags with their value type, and aliases, leaving hidden ones out", () => { + const tree = command( + {}, + { + render: command({ + output: flag("string", ["o"]), + quiet: flag("boolean"), + old: flag("boolean", [], { hidden: true }), + }), + validate: command({}, {}, { deprecated: true }), + events: command({}, {}, { hidden: true }), + auth: command({}, { login: command({ token: flag("string") }) }), + }, + ); + assert.deepEqual(cliSurface(tree), [ + "hyperframes", + "hyperframes render", + "hyperframes render --output ", + "hyperframes render -o = --output", + "hyperframes render --quiet", + "hyperframes validate (deprecated)", + "hyperframes auth", + "hyperframes auth login", + "hyperframes auth login --token ", + ]); +}); + +test("surfaceDiff reports removals before additions and ignores reordering", () => { + assert.deepEqual(surfaceDiff(["a", "b", "c"], ["c", "a", "d"]), ["- b", "+ d"]); + assert.deepEqual(surfaceDiff(["a"], ["a"]), []); +}); + +test("a flag that changes from boolean to string-valued is a surface change", () => { + const before = cliSurface(command({}, { lint: command({ fix: flag("boolean") }) })); + const after = cliSurface(command({}, { lint: command({ fix: flag("string") }) })); + assert.deepEqual(surfaceDiff(before, after), [ + "- hyperframes lint --fix", + "+ hyperframes lint --fix ", + ]); +}); + +test("optOutReason accepts the label as a name or a label object, or a docs-impact: none body line", () => { + assert.equal(optOutReason({ labels: [OPT_OUT_LABEL] }), `labelled ${OPT_OUT_LABEL}`); + assert.equal(optOutReason({ labels: [{ name: OPT_OUT_LABEL }] }), `labelled ${OPT_OUT_LABEL}`); + assert.ok(optOutReason({ body: "## What\n\nDocs-Impact: none, internal helper only\n" })); + assert.equal( + optOutReason({ labels: ["docs"], body: "Mentions docs-impact: none mid-sentence." }), + null, + ); + assert.equal(optOutReason({}), null); +}); + +test("changed-file filters: CLI sources without tests or fixtures, package entries and manifests, docs", () => { + assert.ok(touchesCli(["packages/cli/src/commands/render.ts"])); + assert.ok(touchesCli(["packages/cli/src/cli.ts"])); + assert.ok(!touchesCli(["packages/cli/src/commands/render.test.ts"])); + assert.ok(!touchesCli(["packages/cli/src/commands/__fixtures__/clip.mp4"])); + assert.ok(!touchesCli(["packages/cli/scripts/build-copy.mjs"])); + assert.ok(touchesPackages(["packages/sdk/src/index.ts"])); + assert.ok(touchesPackages(["packages/core/package.json"])); + assert.ok(!touchesPackages(["packages/core/README.md"])); + assert.ok(touchesDocs(["docs/packages/cli.mdx"])); + assert.ok(!touchesDocs(["DOCS_GUIDELINES.md"])); +}); diff --git a/scripts/public-api-surface.mjs b/scripts/public-api-surface.mjs new file mode 100644 index 00000000000..ea9425f6f0d --- /dev/null +++ b/scripts/public-api-surface.mjs @@ -0,0 +1,135 @@ +// The public API the workspace packages publish at a git revision, as sorted lines: +// " exports " every key of `exports` / `publishConfig.exports` +// " bin " every executable in `bin` +// " export " every name the root entry (`exports["."]`) exports, through `export *` chains +// A package is public when its package.json is not `private` and declares `exports`, the same rule as +// scripts/package-subpaths.mjs. Reading from git (not the working tree) lets one checkout compare two revisions. +import { execFileSync } from "node:child_process"; +import { posix } from "node:path"; +import ts from "typescript"; + +const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".js", ".mjs"]; + +/** A reader for files at `ref`: `read(path)` is the file's text, or undefined when the revision has no such file. */ +export function gitReader(ref, cwd) { + const files = new Set( + execFileSync("git", ["ls-tree", "-r", "--name-only", ref, "--", "packages"], { + cwd, + encoding: "utf8", + }) + .split("\n") + .filter(Boolean), + ); + const cache = new Map(); + return (path) => { + if (!files.has(path)) return undefined; + if (!cache.has(path)) + cache.set( + path, + execFileSync("git", ["show", `${ref}:${path}`], { + cwd, + encoding: "utf8", + maxBuffer: 1 << 26, + }), + ); + return cache.get(path); + }; +} + +// Same precedence as package-subpaths.mjs's sourceTarget: the file a workspace consumer actually loads. +function sourceTarget(target) { + if (typeof target === "string") return target; + return target?.bun ?? target?.browser ?? target?.import ?? target?.node ?? target?.default; +} + +function resolveModule(from, specifier, read) { + const base = posix.join(posix.dirname(from), specifier); + const stem = base.replace(/\.(?:[mc]?js|tsx?)$/, ""); + const candidates = [ + base, + ...SOURCE_EXTENSIONS.map((ext) => stem + ext), + ...SOURCE_EXTENSIONS.map((ext) => `${base}/index${ext}`), + ]; + return candidates.find((path) => read(path) !== undefined); +} + +function bindingNames(name) { + if (ts.isIdentifier(name)) return [name.text]; + return name.elements.flatMap((element) => + ts.isOmittedExpression(element) ? [] : bindingNames(element.name), + ); +} + +function declaredExports(statement) { + const modifiers = ts.canHaveModifiers(statement) ? (ts.getModifiers(statement) ?? []) : []; + if (!modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) return []; + if (modifiers.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)) return ["default"]; + if (ts.isVariableStatement(statement)) + return statement.declarationList.declarations.flatMap((d) => bindingNames(d.name)); + return statement.name && ts.isIdentifier(statement.name) ? [statement.name.text] : []; +} + +/** Every name the module at `path` exports, following relative `export *`; a package re-export stays `* from ""`. */ +export function moduleExports(path, read, seen = new Set()) { + if (seen.has(path)) return []; + seen.add(path); + const file = ts.createSourceFile(path, read(path) ?? "", ts.ScriptTarget.Latest, false); + const names = []; + for (const statement of file.statements) { + if (ts.isExportAssignment(statement)) + names.push(statement.isExportEquals ? "export=" : "default"); + else if (!ts.isExportDeclaration(statement)) names.push(...declaredExports(statement)); + else if (statement.exportClause && ts.isNamedExports(statement.exportClause)) + names.push(...statement.exportClause.elements.map((element) => element.name.text)); + else if (statement.exportClause) names.push(statement.exportClause.name.text); + else { + const specifier = statement.moduleSpecifier.text; + const target = specifier.startsWith(".") ? resolveModule(path, specifier, read) : undefined; + if (!target) names.push(`* from "${specifier}"`); + else names.push(...moduleExports(target, read, seen).filter((name) => name !== "default")); + } + } + return names; +} + +// A root entry built into dist/ (player) is read from the source file tsup builds it from. +function rootSource(dir, target, read) { + if (typeof target !== "string") return undefined; + const path = posix.normalize(posix.join(dir, target)); + if (read(path) !== undefined) return path; + const source = path.replace(`${dir}/dist/`, `${dir}/src/`).replace(/\.[mc]?js$/, ".ts"); + return read(source) !== undefined ? source : undefined; +} + +/** Surface lines for one package.json (its path and text) read through `read`. */ +export function packageSurface(manifestPath, read) { + const pkg = JSON.parse(read(manifestPath)); + if (pkg.private === true || pkg.exports == null) return []; + const dir = posix.dirname(manifestPath); + const subpaths = new Set([ + ...Object.keys(pkg.exports), + ...Object.keys(pkg.publishConfig?.exports ?? {}), + ]); + const bins = typeof pkg.bin === "string" ? [pkg.name] : Object.keys(pkg.bin ?? {}); + const entry = rootSource(dir, sourceTarget(pkg.exports["."]), read); + const symbols = entry ? moduleExports(entry, read) : []; + return [ + ...[...subpaths].map((subpath) => `${pkg.name} exports ${subpath}`), + ...bins.map((bin) => `${pkg.name} bin ${bin}`), + ...symbols.map((symbol) => `${pkg.name} export ${symbol}`), + ]; +} + +/** The public API of every workspace package at `ref`, sorted and deduplicated. */ +export function publicApiSurface(ref, cwd) { + const read = gitReader(ref, cwd); + const manifests = execFileSync("git", ["ls-tree", "--name-only", ref, "packages/"], { + cwd, + encoding: "utf8", + }) + .split("\n") + .filter(Boolean) + .map((dir) => `${dir}/package.json`) + .filter((path) => read(path) !== undefined); + return [...new Set(manifests.flatMap((path) => packageSurface(path, read)))].sort(); +} diff --git a/scripts/public-api-surface.test.mjs b/scripts/public-api-surface.test.mjs new file mode 100644 index 00000000000..e63d29ea7e2 --- /dev/null +++ b/scripts/public-api-surface.test.mjs @@ -0,0 +1,83 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { moduleExports, packageSurface } from "./public-api-surface.mjs"; + +const reader = (files) => (path) => files[path]; + +test("moduleExports follows relative export * chains and keeps package re-exports opaque", () => { + const read = reader({ + "p/src/index.ts": [ + 'export * from "./a.js";', + 'export * as helpers from "./helpers";', + 'export { b as renamed, type C } from "./b.js";', + 'export * from "@hyperframes/core";', + "export const one = 1, two = 2;", + "export function make() {}", + "export interface Options {}", + "export type Id = string;", + "export default class Thing {}", + "const hidden = 3;", + "export { hidden as visible };", + ].join("\n"), + "p/src/a.ts": 'export enum Mode { A }\nexport default 1;\nexport * from "./index.js";', + "p/src/helpers/index.ts": "export const helper = 1;", + "p/src/b.ts": "export const b = 1;\nexport type C = number;", + }); + assert.deepEqual(moduleExports("p/src/index.ts", read).sort(), [ + '* from "@hyperframes/core"', + "C", + "Id", + "Mode", + "Options", + "default", + "helpers", + "make", + "one", + "renamed", + "two", + "visible", + ]); +}); + +test("packageSurface lists subpaths, bins and root exports, and skips private packages", () => { + const read = reader({ + "packages/a/package.json": JSON.stringify({ + name: "@x/a", + bin: { a: "./bin/a.mjs" }, + exports: { + ".": { bun: "./src/index.ts", node: "./dist/index.js" }, + "./extra": "./src/extra.ts", + }, + publishConfig: { exports: { "./published-only": "./dist/p.js" } }, + }), + "packages/a/src/index.ts": "export const run = 1;", + "packages/b/package.json": JSON.stringify({ + name: "@x/b", + private: true, + exports: { ".": "./src/index.ts" }, + }), + }); + assert.deepEqual(packageSurface("packages/a/package.json", read), [ + "@x/a exports .", + "@x/a exports ./extra", + "@x/a exports ./published-only", + "@x/a bin a", + "@x/a export run", + ]); + assert.deepEqual(packageSurface("packages/b/package.json", read), []); +}); + +test("packageSurface reads a root entry built into dist/ from its src/ source", () => { + const read = reader({ + "packages/player/package.json": JSON.stringify({ + name: "@x/player", + exports: { ".": { types: "./dist/player.d.ts", import: "./dist/player.js" } }, + }), + "packages/player/src/player.ts": "export class Player {}", + }); + assert.deepEqual(packageSurface("packages/player/package.json", read), [ + "@x/player exports .", + "@x/player export Player", + ]); +}); From b849f70834f6eff7cc3545beb78837f58c30a676 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Tue, 20 Oct 2026 01:51:46 -0400 Subject: [PATCH 3/4] ci(docs): keep the docs checks under the complexity budget and known to the workflow tests Splits codeSpans, the flag and subcommand walk, the export walker and the docs-impact entry point into small functions so fallow's audit passes, adds scripts/cli-command-tree.test.ts (cli.ts's loader block and a citty command described end to end), and lists docs-impact.yml among the workflows that read the PR body on an edit. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- scripts/check-docs-cli-references.mjs | 152 ++++++++++++++------------ scripts/check-docs-impact.mjs | 60 +++++----- scripts/check-docs-impact.test.mjs | 12 ++ scripts/cli-command-tree.test.ts | 59 ++++++++++ scripts/cli-command-tree.ts | 41 +++---- scripts/pr-edit-concurrency.test.mjs | 5 +- scripts/public-api-surface.mjs | 36 +++--- 8 files changed, 229 insertions(+), 138 deletions(-) create mode 100644 scripts/cli-command-tree.test.ts diff --git a/package.json b/package.json index 93a13957506..2d9e8e2faa3 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "player:perf": "bun run --filter @hyperframes/player perf", "format:check": "oxfmt --check .", "knip": "knip", - "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/check-docs-cli-references.test.mjs scripts/check-docs-impact.test.mjs scripts/public-api-surface.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/edit-accuracy-retry.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/ci/base-file.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts", + "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/cli-command-tree.test.ts scripts/check-docs-cli-references.test.mjs scripts/check-docs-impact.test.mjs scripts/public-api-surface.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/edit-accuracy-retry.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/ci/base-file.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts", "typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json", "test:skills": "HYPERFRAMES_MEDIA_HOME_REQUIRED=1 node --import ./packages/cli/scripts/test-home.mjs --test 'skills/**/*.test.mjs' 'packages/cli/src/media-use/**/*.test.mjs'", "generate:previews": "tsx scripts/generate-template-previews.ts", diff --git a/scripts/check-docs-cli-references.mjs b/scripts/check-docs-cli-references.mjs index 26c1f8f70be..3efc7159a64 100644 --- a/scripts/check-docs-cli-references.mjs +++ b/scripts/check-docs-cli-references.mjs @@ -28,37 +28,51 @@ const ENV_ASSIGNMENT = /^[A-Z_][A-Z0-9_]*=/; // ``, `[options]`, `{id}`, `$VAR`, `...`: the docs' stand-ins for a value the reader supplies. const PLACEHOLDER = /[<>[\]{}$|]|\.\.\.|…/; +// Labels each line as a fence marker, a line inside a fence, or prose. +function lineKinds(lines) { + let fence = null; + return lines.map((raw) => { + const marker = raw.match(/^\s*(`{3,}|~{3,})/); + if (!marker || (fence && !raw.trim().startsWith(fence))) return fence ? "code" : "prose"; + fence = fence ? null : marker[1]; + return "fence"; + }); +} + +function proseSpans(raw, line) { + return [ + ...[...raw.matchAll(/(`+)([^`]+?)\1(?!`)/g)].map((m) => ({ line, text: m[2] })), + // Catalog pages hand the install line to a component: . + ...[...raw.matchAll(/\bcommand=(?:"([^"]*)"|\{"([^"]*)"\})/g)].map((m) => ({ + line, + text: m[1] ?? m[2], + })), + ]; +} + +const CONTINUED = /\\\s*$/; + +// Joins a fenced line onto a command a `\` continued; a command that ends here comes back as `span`. +function fencedStep(pending, raw, index) { + const line = pending?.line ?? index + 1; + const text = pending ? `${pending.text} ${raw.trim()}` : raw; + if (CONTINUED.test(text)) return { pending: { line, text: text.replace(CONTINUED, "") } }; + return { pending: null, span: { line, text } }; +} + /** Each piece of code on an MDX page as `{ line, text }`: fenced lines (joined across `\` continuations), inline code, command props. */ export function codeSpans(source) { - const spans = []; const lines = source.split("\n"); - let fence = null; + const kinds = lineKinds(lines); + const spans = []; let pending = null; for (const [index, raw] of lines.entries()) { const ignored = raw.includes(IGNORE) || (lines[index - 1] ?? "").includes(IGNORE); - const marker = raw.match(/^\s*(`{3,}|~{3,})/); - if (marker && (!fence || raw.trim().startsWith(fence))) { - fence = fence ? null : marker[1]; - pending = null; - continue; - } - if (fence) { - const line = pending?.line ?? index + 1; - const text = pending ? `${pending.text} ${raw.trim()}` : raw; - if (/\\\s*$/.test(text)) { - pending = { line, text: text.replace(/\\\s*$/, "") }; - continue; - } - pending = null; - if (!ignored) spans.push({ line, text }); - continue; - } + const step = kinds[index] === "code" ? fencedStep(pending, raw, index) : { pending: null }; + pending = step.pending; if (ignored) continue; - for (const m of raw.matchAll(/(`+)([^`]+?)\1(?!`)/g)) - spans.push({ line: index + 1, text: m[2] }); - // Catalog pages hand the install line to a component: . - for (const m of raw.matchAll(/\bcommand=(?:"([^"]*)"|\{"([^"]*)"\})/g)) - spans.push({ line: index + 1, text: m[1] ?? m[2] }); + if (step.span) spans.push(step.span); + if (kinds[index] === "prose") spans.push(...proseSpans(raw, index + 1)); } return spans; } @@ -148,57 +162,57 @@ function flagReadings(word) { return [...word.slice(1).split("=")[0]].map((letter) => [letter]); } +function flagProblem(spellings, shown, dashes, readings) { + const flag = readings.map((name) => spellings.get(name)).find(Boolean); + const name = `${dashes}${readings[0]}`; + if (!flag) { + const hint = dashes === "--" ? suggestion(readings[0], [...spellings.keys()], "--") : ""; + return { severity: "error", message: `\`${shown}\` has no ${name}${hint}` }; + } + if (flag.deprecated) + return { severity: "warning", message: `\`${shown} ${name}\` is deprecated` }; + if (flag.hidden) + return { severity: "warning", message: `\`${shown} ${name}\` is hidden from --help` }; + return null; +} + function flagProblems(command, shown, words) { const spellings = flagSpellings(command); - const problems = []; - for (const word of words) { - if (word === "--") break; - if (!isFlag(word) || PLACEHOLDER.test(word.split("=")[0])) continue; - const dashes = word.startsWith("--") ? "--" : "-"; - for (const readings of flagReadings(word)) { - const flag = readings.map((name) => spellings.get(name)).find(Boolean); - const name = `${dashes}${readings[0]}`; - if (!flag) { - const hint = dashes === "--" ? suggestion(readings[0], [...spellings.keys()], "--") : ""; - problems.push({ severity: "error", message: `\`${shown}\` has no ${name}${hint}` }); - } else if (flag.deprecated || flag.hidden) { - const state = flag.deprecated ? "deprecated" : "hidden from --help"; - problems.push({ severity: "warning", message: `\`${shown} ${name}\` is ${state}` }); - } - } - } - return problems; + const end = words.indexOf("--"); + return (end < 0 ? words : words.slice(0, end)) + .filter((word) => isFlag(word) && !PLACEHOLDER.test(word.split("=")[0])) + .flatMap((word) => { + const dashes = word.startsWith("--") ? "--" : "-"; + return flagReadings(word).map((readings) => flagProblem(spellings, shown, dashes, readings)); + }) + .filter(Boolean); +} + +function unknownSubcommand(command, path, name) { + if (PLACEHOLDER.test(name)) return []; + const kind = path.length === 1 ? "command" : "subcommand"; + const hint = suggestion(name, Object.keys(command.subCommands)); + return [{ severity: "error", message: `unknown ${kind} \`${path.join(" ")} ${name}\`${hint}` }]; +} + +function commandProblems(command, path, words) { + const hasSubs = Object.keys(command.subCommands).length > 0; + const at = hasSubs ? subCommandIndex(words, flagSpellings(command)) : -1; + const own = flagProblems(command, path.join(" "), at < 0 ? words : words.slice(0, at)); + if (at < 0) return own; + const name = unquote(words[at]); + const next = command.subCommands[name]; + if (!next) return [...own, ...unknownSubcommand(command, path, name)]; + const nextPath = [...path, name]; + const deprecated = next.deprecated + ? [{ severity: "warning", message: `\`${nextPath.join(" ")}\` is deprecated` }] + : []; + return [...own, ...deprecated, ...commandProblems(next, nextPath, words.slice(at + 1))]; } /** Problems with one invocation (the words after `hyperframes`), as `{ severity: "error" | "warning", message }`. */ export function invocationProblems(tree, args) { - const problems = []; - let command = tree; - let words = args; - const path = ["hyperframes"]; - for (;;) { - const hasSubs = Object.keys(command.subCommands).length > 0; - const at = hasSubs ? subCommandIndex(words, flagSpellings(command)) : -1; - problems.push(...flagProblems(command, path.join(" "), at < 0 ? words : words.slice(0, at))); - if (at < 0) return problems; - const name = unquote(words[at]); - const next = command.subCommands[name]; - if (!next) { - if (PLACEHOLDER.test(name)) return problems; - const kind = path.length === 1 ? "command" : "subcommand"; - const hint = suggestion(name, Object.keys(command.subCommands)); - problems.push({ - severity: "error", - message: `unknown ${kind} \`${path.join(" ")} ${name}\`${hint}`, - }); - return problems; - } - path.push(name); - if (next.deprecated) - problems.push({ severity: "warning", message: `\`${path.join(" ")}\` is deprecated` }); - command = next; - words = words.slice(at + 1); - } + return commandProblems(tree, ["hyperframes"], args); } /** Problems on one page, each with the line its command starts on. */ diff --git a/scripts/check-docs-impact.mjs b/scripts/check-docs-impact.mjs index d527e93b58b..795077e5f08 100644 --- a/scripts/check-docs-impact.mjs +++ b/scripts/check-docs-impact.mjs @@ -118,6 +118,29 @@ function report(heading, lines) { for (const line of lines) console.log(` ${line}`); } +/** The outcome for a PR: `{ pass, message }`, given how many surface lines changed and any excuse. */ +export function verdict({ changed, excuse, dryRun = false }) { + if (changed === 0) + return { pass: true, message: "passes, no CLI command, flag or public export changed." }; + if (excuse) return { pass: true, message: `passes, ${excuse}.` }; + const optOut = `opt out with the \`${OPT_OUT_LABEL}\` label or a \`docs-impact: none\` line in the PR body (say why)`; + return { pass: dryRun, message: `update docs/ for the changes above, or ${optOut}.` }; +} + +function cliChanges(args, files, base) { + const baseTreePath = flag(args, "--base-tree"); + const headTreePath = flag(args, "--head-tree"); + if (!touchesCli(files) && !baseTreePath) return []; + const before = baseTreePath ? readJson(baseTreePath) : baseTree(base); + const after = headTreePath ? readJson(headTreePath) : treeAt(ROOT); + return surfaceDiff(cliSurface(before), cliSurface(after)); +} + +function exportChanges(files, base) { + if (!touchesPackages(files)) return []; + return surfaceDiff(publicApiSurface(base, ROOT), publicApiSurface("HEAD", ROOT)); +} + function main() { const args = process.argv.slice(2); const dryRun = args.includes("--dry-run"); @@ -125,39 +148,18 @@ function main() { const files = git(["diff", "--name-only", base, "HEAD"]).split("\n").filter(Boolean); const pr = { labels: parseLabels(process.env.PR_LABELS), body: process.env.PR_BODY ?? "" }; const excuse = touchesDocs(files) ? "it changes docs/" : optOutReason(pr); + // Past the excuse, the surfaces only matter to a dry run, which shows them anyway. if (excuse && !dryRun) { console.log(`Docs impact: passes, ${excuse}.`); return; } - - const baseTreePath = flag(args, "--base-tree"); - const headTreePath = flag(args, "--head-tree"); - const cliChanges = - touchesCli(files) || baseTreePath - ? surfaceDiff( - cliSurface(baseTreePath ? readJson(baseTreePath) : baseTree(base)), - cliSurface(headTreePath ? readJson(headTreePath) : treeAt(ROOT)), - ) - : []; - const exportChanges = touchesPackages(files) - ? surfaceDiff(publicApiSurface(base, ROOT), publicApiSurface("HEAD", ROOT)) - : []; - - report("CLI commands and flags changed:", cliChanges); - report("Public package exports changed:", exportChanges); - if (cliChanges.length + exportChanges.length === 0) { - console.log("Docs impact: passes, no CLI command, flag or public export changed."); - return; - } - if (excuse) { - console.log(`Docs impact: passes, ${excuse}.`); - return; - } - console.log( - `\nDocs impact${dryRun ? " (dry run)" : ""}: update docs/ for the changes above, or opt out with the \`${OPT_OUT_LABEL}\` label` + - " or a `docs-impact: none` line in the PR body (say why).", - ); - if (!dryRun) process.exitCode = 1; + const cli = cliChanges(args, files, base); + const exports = exportChanges(files, base); + report("CLI commands and flags changed:", cli); + report("Public package exports changed:", exports); + const result = verdict({ changed: cli.length + exports.length, excuse, dryRun }); + console.log(`Docs impact${dryRun ? " (dry run)" : ""}: ${result.message}`); + if (!result.pass) process.exitCode = 1; } if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) diff --git a/scripts/check-docs-impact.test.mjs b/scripts/check-docs-impact.test.mjs index 1b5ec0dbb89..ac0a83d33e2 100644 --- a/scripts/check-docs-impact.test.mjs +++ b/scripts/check-docs-impact.test.mjs @@ -9,6 +9,7 @@ import { touchesCli, touchesDocs, touchesPackages, + verdict, } from "./check-docs-impact.mjs"; const flag = (type, aliases = [], extra = {}) => ({ @@ -90,3 +91,14 @@ test("changed-file filters: CLI sources without tests or fixtures, package entri assert.ok(touchesDocs(["docs/packages/cli.mdx"])); assert.ok(!touchesDocs(["DOCS_GUIDELINES.md"])); }); + +test("verdict passes with no surface change or with an excuse, and fails otherwise unless dry-run", () => { + assert.equal(verdict({ changed: 0, excuse: null }).pass, true); + assert.deepEqual(verdict({ changed: 2, excuse: "it changes docs/" }), { + pass: true, + message: "passes, it changes docs/.", + }); + assert.equal(verdict({ changed: 2, excuse: null }).pass, false); + assert.match(verdict({ changed: 2, excuse: null }).message, /docs-not-needed/); + assert.equal(verdict({ changed: 2, excuse: null, dryRun: true }).pass, true); +}); diff --git a/scripts/cli-command-tree.test.ts b/scripts/cli-command-tree.test.ts new file mode 100644 index 00000000000..c84fe1442c8 --- /dev/null +++ b/scripts/cli-command-tree.test.ts @@ -0,0 +1,59 @@ +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { commandLoaders, describeCommand, type CittyCommand } from "./cli-command-tree.ts"; + +const cliSource = readFileSync( + join(import.meta.dirname, "..", "packages", "cli", "src", "cli.ts"), + "utf8", +); + +test("commandLoaders reads every entry of cli.ts's commandLoaders, including multi-line ones", () => { + const loaders = commandLoaders(cliSource); + const byName = new Map(loaders.map((loader) => [loader.name, loader])); + assert.ok(loaders.length > 40); + assert.deepEqual(byName.get("render"), { name: "render", file: "render", exportName: "default" }); + assert.deepEqual(byName.get("media-treatment"), { + name: "media-treatment", + file: "media-treatment", + exportName: "mediaTreatmentCommand", + }); + assert.equal(byName.get("preview")?.file, "preview"); +}); + +test("commandLoaders fails loudly when an entry stops matching the pattern", () => { + const changed = cliSource.replace( + 'render: () => import("./commands/render.js").then((m) => m.default),', + 'render: () => load("render"),', + ); + assert.throws(() => commandLoaders(changed), /update the pattern/); + assert.throws(() => commandLoaders("export {};"), /commandLoaders/); +}); + +test("describeCommand resolves lazy parts and records flags, aliases, hidden and deprecated", async () => { + const command: CittyCommand = { + meta: () => ({ name: "cloud", description: "Cloud renders" }), + args: { + dir: { type: "positional" }, + output: { type: "string", alias: "o" }, + quiet: { type: "boolean", alias: ["q", "s"] }, + legacy: { type: "boolean", hidden: true, description: "Deprecated alias for --quiet" }, + }, + subCommands: { + list: () => + Promise.resolve({ meta: { description: "(deprecated, use ls)" }, args: () => ({}) }), + }, + }; + assert.deepEqual(await describeCommand(command), { + hidden: false, + deprecated: false, + flags: { + output: { type: "string", aliases: ["o"], hidden: false, deprecated: false }, + quiet: { type: "boolean", aliases: ["q", "s"], hidden: false, deprecated: false }, + legacy: { type: "boolean", aliases: [], hidden: true, deprecated: true }, + }, + subCommands: { list: { hidden: false, deprecated: true, flags: {}, subCommands: {} } }, + }); +}); diff --git a/scripts/cli-command-tree.ts b/scripts/cli-command-tree.ts index 11571b099a4..9bdac545aaf 100644 --- a/scripts/cli-command-tree.ts +++ b/scripts/cli-command-tree.ts @@ -16,7 +16,7 @@ interface CittyArg { description?: string; } -interface CittyCommand { +export interface CittyCommand { meta?: Resolvable<{ name?: string; description?: string; hidden?: boolean }>; args?: Resolvable>; subCommands?: Resolvable>>; @@ -61,29 +61,30 @@ export function commandLoaders( return entries; } -async function describe(command: CittyCommand): Promise { +function describeFlag(def: CittyArg): TreeFlag { + return { + type: def.type ?? "string", + aliases: def.alias === undefined ? [] : [def.alias].flat(), + hidden: def.hidden === true, + deprecated: DEPRECATED.test(def.description ?? ""), + }; +} + +/** One citty command and its subcommands, with every lazy part resolved. Positionals are not flags. */ +export async function describeCommand(command: CittyCommand): Promise { const meta = (await settle(command.meta)) ?? {}; - const args = (await settle(command.args)) ?? {}; - const subs = (await settle(command.subCommands)) ?? {}; - const flags: Record = {}; - for (const [name, def] of Object.entries(args)) { - if (def.type === "positional") continue; - flags[name] = { - type: def.type ?? "string", - aliases: def.alias === undefined ? [] : [def.alias].flat(), - hidden: def.hidden === true, - deprecated: DEPRECATED.test(def.description ?? ""), - }; - } + const args = Object.entries((await settle(command.args)) ?? {}); + const subs = Object.entries((await settle(command.subCommands)) ?? {}); const subCommands: Record = {}; - for (const [name, sub] of Object.entries(subs)) { - const resolved = await settle(sub); - if (resolved) subCommands[name] = await describe(resolved); - } + for (const [name, sub] of subs) subCommands[name] = await describeCommand((await settle(sub))!); return { hidden: meta.hidden === true, deprecated: DEPRECATED.test(meta.description ?? ""), - flags, + flags: Object.fromEntries( + args + .filter(([, def]) => def.type !== "positional") + .map(([name, def]) => [name, describeFlag(def)]), + ), subCommands, }; } @@ -96,7 +97,7 @@ export async function cliCommandTree(root: string): Promise { const mod = (await import(url)) as Record; const command = mod[loader.exportName]; if (!command) throw new Error(`commands/${loader.file}.ts has no export ${loader.exportName}`); - subCommands[loader.name] = await describe(command); + subCommands[loader.name] = await describeCommand(command); } // The root answers --version/-V before citty runs (cli.ts's fast path) and --help/-h through citty. const version = { type: "boolean", aliases: ["V"], hidden: false, deprecated: false }; diff --git a/scripts/pr-edit-concurrency.test.mjs b/scripts/pr-edit-concurrency.test.mjs index 2b94b7dba6b..b6b59a2d7e6 100644 --- a/scripts/pr-edit-concurrency.test.mjs +++ b/scripts/pr-edit-concurrency.test.mjs @@ -13,8 +13,8 @@ const workflows = readdirSync(workflowsDir) // fail-closed `Test` stays red on the PR. Only a push or a base change cancels. const CANCEL_UNLESS_PLAIN_EDIT = "${{ github.event.action != 'edited' || github.event.changes.base != null }}"; -// Reads the PR body itself, so the newest edit should replace an older run. -const BODY_WORKFLOWS = new Set(["pr-captures.yml"]); +// Read the PR body itself, so the newest edit should replace an older run. +const BODY_WORKFLOWS = new Set(["docs-impact.yml", "pr-captures.yml"]); const editedWorkflows = workflows.filter(({ config }) => config.on?.pull_request?.types?.includes("edited"), @@ -23,6 +23,7 @@ const editedWorkflows = workflows.filter(({ config }) => test("the workflows that re-run on a PR edit are known", () => { assert.deepEqual(editedWorkflows.map(({ file }) => file).sort(), [ "ci.yml", + "docs-impact.yml", "pr-captures.yml", "regression.yml", "windows-render.yml", diff --git a/scripts/public-api-surface.mjs b/scripts/public-api-surface.mjs index ea9425f6f0d..8a5532a8505 100644 --- a/scripts/public-api-surface.mjs +++ b/scripts/public-api-surface.mjs @@ -37,9 +37,10 @@ export function gitReader(ref, cwd) { } // Same precedence as package-subpaths.mjs's sourceTarget: the file a workspace consumer actually loads. +const SOURCE_CONDITIONS = ["bun", "browser", "import", "node", "default"]; function sourceTarget(target) { if (typeof target === "string") return target; - return target?.bun ?? target?.browser ?? target?.import ?? target?.node ?? target?.default; + return SOURCE_CONDITIONS.map((condition) => target?.[condition]).find(Boolean); } function resolveModule(from, specifier, read) { @@ -69,27 +70,28 @@ function declaredExports(statement) { return statement.name && ts.isIdentifier(statement.name) ? [statement.name.text] : []; } +function starExports(path, specifier, read, seen) { + const target = specifier.startsWith(".") ? resolveModule(path, specifier, read) : undefined; + if (!target) return [`* from "${specifier}"`]; + return moduleExports(target, read, seen).filter((name) => name !== "default"); +} + +function statementExports(statement, path, read, seen) { + if (ts.isExportAssignment(statement)) return [statement.isExportEquals ? "export=" : "default"]; + if (!ts.isExportDeclaration(statement)) return declaredExports(statement); + const clause = statement.exportClause; + if (!clause) return starExports(path, statement.moduleSpecifier.text, read, seen); + return ts.isNamedExports(clause) + ? clause.elements.map((element) => element.name.text) + : [clause.name.text]; +} + /** Every name the module at `path` exports, following relative `export *`; a package re-export stays `* from ""`. */ export function moduleExports(path, read, seen = new Set()) { if (seen.has(path)) return []; seen.add(path); const file = ts.createSourceFile(path, read(path) ?? "", ts.ScriptTarget.Latest, false); - const names = []; - for (const statement of file.statements) { - if (ts.isExportAssignment(statement)) - names.push(statement.isExportEquals ? "export=" : "default"); - else if (!ts.isExportDeclaration(statement)) names.push(...declaredExports(statement)); - else if (statement.exportClause && ts.isNamedExports(statement.exportClause)) - names.push(...statement.exportClause.elements.map((element) => element.name.text)); - else if (statement.exportClause) names.push(statement.exportClause.name.text); - else { - const specifier = statement.moduleSpecifier.text; - const target = specifier.startsWith(".") ? resolveModule(path, specifier, read) : undefined; - if (!target) names.push(`* from "${specifier}"`); - else names.push(...moduleExports(target, read, seen).filter((name) => name !== "default")); - } - } - return names; + return file.statements.flatMap((statement) => statementExports(statement, path, read, seen)); } // A root entry built into dist/ (player) is read from the source file tsup builds it from. From c45ecc136c688581c7655f841fdac1794c1d0668 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Tue, 20 Oct 2026 01:53:49 -0400 Subject: [PATCH 4/4] docs: install steps lead with the linux .deb; finder lists models and clean The Studio download page hands Linux browsers the .deb by default and the AppImage only on request, so the install steps now match what people get. models and clean had full sections but no row in the command finder. The quoted auto-update line is output, not a command, so the CLI-references check skips it, and its versions now look current. Co-Authored-By: Claude Opus 5.5 --- docs/packages/cli.mdx | 9 +++++---- docs/studio-app/index.mdx | 20 +++++++++++++++----- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 684a0a85150..b984ae425d1 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -23,13 +23,13 @@ npm install -g hyperframes | You want to | Use | | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Start a project | [`init`](#init), [`add`](#add), [`catalog`](#catalog) | -| Bring in source material | [`capture`](#capture), [`transcribe`](#transcribe), [`tts`](#tts), [`remove-background`](#remove-background), [`media-treatment`](#media-treatment), [`beats`](#beats), [`audio`](#audio), [`normalize-audio`](#normalize-audio) | +| Bring in source material | [`capture`](#capture), [`transcribe`](#transcribe), [`tts`](#tts), [`remove-background`](#remove-background), [`media-treatment`](#media-treatment), [`beats`](#beats), [`audio`](#audio), [`normalize-audio`](#normalize-audio), [`models`](#models) | | Look at it, or share it | [`preview`](#preview), [`present`](#present-and-play), [`play`](#present-and-play), [`publish`](#publish), [`open`](#open-and-catch-up), [`catch-up`](#open-and-catch-up) | | Find problems | [`lint`](#lint), [`check`](#check), [`snapshot`](#snapshot), [`keyframes`](#keyframes), [`compare`](#compare-and-grade-compare), [`grade-compare`](#compare-and-grade-compare) | | Edit from the terminal | [`timeline`](#timeline), [`history`](#history) | | Make a file | [`render`](#render), [`benchmark`](#benchmark) | | Render somewhere else | [`cloud`](#hyperframes-cloud), [`lambda`](#hyperframes-lambda), [`cloudrun`](#hyperframes-cloudrun) | -| Fix your setup | [`doctor`](#doctor), [`browser`](#browser), [`upgrade`](#upgrade), [`docs`](#docs), [`info`](#info-and-compositions), [`compositions`](#info-and-compositions) | +| Fix your setup | [`doctor`](#doctor), [`browser`](#browser), [`upgrade`](#upgrade), [`docs`](#docs), [`info`](#info-and-compositions), [`compositions`](#info-and-compositions), [`clean`](#clean) | | Connect other tools | [`auth`](#hyperframes-auth), [`skills`](#skills), [`figma`](#figma-and-events), [`telemetry`](#telemetry), [`feedback`](#feedback) | `validate`, `inspect`, and `layout` still work but are @@ -73,11 +73,12 @@ prompts, apart from confirmations you can skip with `--yes` or `--no-confirm`. **Version notices.** An owned install (npm, bun, pnpm, brew) updates itself quietly in the background and prints one line on the next run: -`hyperframes auto-updated to v0.1.5`. It never crosses a major version. Anywhere +{/* cli-references: ignore */} +`hyperframes auto-updated to v0.8.181`. It never crosses a major version. Anywhere it cannot install — `npx`, an unknown installer — you get a notice instead: ``` - Update available: 0.1.4 → 0.1.5 + Update available: 0.8.180 → 0.8.181 Run: npx hyperframes@latest ``` diff --git a/docs/studio-app/index.mdx b/docs/studio-app/index.mdx index 7e3c8665b8e..a170e388b35 100644 --- a/docs/studio-app/index.mdx +++ b/docs/studio-app/index.mdx @@ -38,19 +38,29 @@ green guide, points you to the next step along the way. - Go to [hyperframes.dev/studio](https://hyperframes.dev/studio) and choose **Download** next to Linux. You get - one file, `HyperFrames-x86_64.AppImage`, for 64-bit Intel and AMD computers. + Go to [hyperframes.dev/studio](https://hyperframes.dev/studio) and choose **Download** next to Linux (.deb). + You get `HyperFrames-amd64-.deb` for 64-bit Intel and AMD computers on Debian, Ubuntu and their + relatives. - + In a terminal, in the folder you downloaded it to: ```bash - chmod +x HyperFrames-x86_64.AppImage - ./HyperFrames-x86_64.AppImage + sudo apt install ./HyperFrames-amd64-*.deb ``` + + Then open **HyperFrames** from your app menu. + Prefer one portable file, or not on a Debian-based system? Choose **Linux (AppImage)** instead, then in the + folder you downloaded it to: + + ```bash + chmod +x HyperFrames-x86_64-*.AppImage + ./HyperFrames-x86_64-*.AppImage + ``` + The AppImage is the whole app. Keep it anywhere you like and open it the same way next time.