mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
feat(cli): define every command through one factory and test them all as an agent (#6616)
* feat(cli): define every command through one factory and test them all as an agent Every command now comes from defineCommand in utils/defineCommand.ts. A lint rule bans importing defineCommand from citty anywhere else, and a new contract test walks the command registry in cli.ts, checks each command was built by the factory, and runs it the way a coding agent does: no terminal, stdin closed, CI=1, CLAUDECODE=1 and a home folder of its own. Usage errors now exit 2: unknown flags, extra positionals, and citty's own parse errors (missing argument, bad value, unknown or missing subcommand). An unknown flag names the help command instead of printing nothing else. isUnattended() in utils/attendedTerminal.ts is the one answer to "can a person respond here". It is true without a TTY on stdin or stdout, under CI (any value except "false" or "0"), when the existing agent detection finds an agent, or with HYPERFRAMES_AGENT=1. Color is off when it is true. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(cli): spawn preview in the agent-mode test only when the studio packages are built From source, preview refuses to load until the studio workspace packages are built, and the CLI test jobs do not build them. preview is still checked for being defined with the shared factory. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(cli): count only agent-run shells as unattended, and exit 2 for missing arguments isUnattended() used the telemetry fingerprint detectAgentRuntime(), which also matches a person typing in Cursor's or Windsurf's terminal (TERM_PROGRAM) and any Replit shell (REPL_ID, REPLIT_USER). It now uses isAgentDriven(), which keeps only markers an agent sets on the shells it runs: CURSOR_AGENT counts, TERM_PROGRAM=cursor, TERM_PROGRAM=windsurf and the Replit variables do not. The hand-written argument checks in lambda, cloudrun, lambda policies, capture, remove-background, init (--resolution), tts (--lang) and cloud render (--idempotency-key, --dry-run) exited 1. They are usage errors and now exit 2. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
815ff9ec8d
commit
22e6da0233
@@ -87,6 +87,24 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/cli/src/**/*.ts"],
|
||||
"excludeFiles": ["**/*.test.ts", "packages/cli/src/utils/defineCommand.ts"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "citty",
|
||||
"importNames": ["defineCommand"],
|
||||
"message": "Define commands with defineCommand from packages/cli/src/utils/defineCommand.ts so the shared guards and the agent-mode contract test cover them."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -71,6 +71,10 @@ example rather than waiting for input. Two commands are interactive on a TTY:
|
||||
`--non-interactive`, and `catalog --human-friendly` opens a picker. Nothing else
|
||||
prompts, apart from confirmations you can skip with `--yes` or `--no-confirm`.
|
||||
|
||||
**Exit codes.** `0` means the command succeeded, `1` means it ran and failed, and
|
||||
`2` means the command line itself is wrong: an unknown command or flag, or a
|
||||
missing or invalid value.
|
||||
|
||||
**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
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { CommandDef } from "citty";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { isDefinedCommand } from "./utils/defineCommand.js";
|
||||
import { checkStudioWorkspaceBuild } from "./utils/workspaceBuildCheck.js";
|
||||
|
||||
// Runs every registered command the way a coding agent does: no terminal, stdin closed, CI set,
|
||||
// an agent marker in the environment, and a home folder of its own. A new command is covered as
|
||||
// soon as it is added to `commandLoaders` in cli.ts.
|
||||
|
||||
const srcDir = dirname(fileURLToPath(import.meta.url));
|
||||
const cliEntry = join(srcDir, "cli.ts");
|
||||
const home = mkdtempSync(join(tmpdir(), "hf-agent-mode-"));
|
||||
afterAll(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
const ANSI = "\u001b[";
|
||||
const PROMPT = /\(y\/N\)|Yes \/ No|[◆◇]\s+[^\n]*\?\s*$/m;
|
||||
const NAG = /Update available|auto-updated to|skills? out of date or missing/;
|
||||
|
||||
interface Registered {
|
||||
path: string[];
|
||||
command: CommandDef;
|
||||
}
|
||||
|
||||
type Resolvable = CommandDef | Promise<CommandDef> | (() => CommandDef | Promise<CommandDef>);
|
||||
|
||||
async function resolveCommand(entry: Resolvable): Promise<CommandDef> {
|
||||
return typeof entry === "function" ? await entry() : await entry;
|
||||
}
|
||||
|
||||
function registeredModules(): { name: string; file: string; exportName: string }[] {
|
||||
const source = readFileSync(cliEntry, "utf8");
|
||||
const block = source.match(/const commandLoaders = \{([\s\S]*?)\n\};/)?.[1] ?? "";
|
||||
const entry =
|
||||
/^\s+"?([\w-]+)"?:[\s\S]*?import\("\.\/commands\/([\w/-]+)\.js"\)\.then\(\(m\) => m\.(\w+)\)/gm;
|
||||
return [...block.matchAll(entry)].map(([, name, file, exportName]) => ({
|
||||
name: name!,
|
||||
file: file!,
|
||||
exportName: exportName!,
|
||||
}));
|
||||
}
|
||||
|
||||
async function walk(path: string[], command: CommandDef, out: Registered[]): Promise<void> {
|
||||
out.push({ path, command });
|
||||
const subCommands = (await resolveCommand(
|
||||
(command.subCommands ?? {}) as Resolvable,
|
||||
)) as unknown as Record<string, Resolvable>;
|
||||
for (const [name, sub] of Object.entries(subCommands)) {
|
||||
await walk([...path, name], await resolveCommand(sub), out);
|
||||
}
|
||||
}
|
||||
|
||||
async function registeredCommands(): Promise<Registered[]> {
|
||||
const out: Registered[] = [];
|
||||
for (const { name, file, exportName } of registeredModules()) {
|
||||
const mod = (await import(`./commands/${file}.ts`)) as Record<string, CommandDef>;
|
||||
await walk([name], mod[exportName]!, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface Run {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
function runAsAgent(args: string[]): Promise<Run> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("bun", ["run", cliEntry, ...args], {
|
||||
cwd: home,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: 15_000,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
CI: "1",
|
||||
CLAUDECODE: "1",
|
||||
},
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString()));
|
||||
child.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString()));
|
||||
child.on("error", reject);
|
||||
child.on("close", (code, signal) => resolve({ code, signal, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
function expectQuiet(run: Run): void {
|
||||
expect(run.signal, "finished before the timeout").toBeNull();
|
||||
const output = run.stdout + run.stderr;
|
||||
expect(output).not.toContain(ANSI);
|
||||
expect(output).not.toMatch(PROMPT);
|
||||
expect(output).not.toMatch(NAG);
|
||||
}
|
||||
|
||||
const commands = await registeredCommands();
|
||||
// From source, `preview` refuses to load until the studio packages are built; the test job does
|
||||
// not build them, so preview is only spawned where they are.
|
||||
const studioBuilt = checkStudioWorkspaceBuild(join(srcDir, "..", "..", "..")).length === 0;
|
||||
const spawnable = commands.filter((c) => studioBuilt || c.path[0] !== "preview");
|
||||
|
||||
describe("agent-mode contract", () => {
|
||||
it("finds the registered commands", () => {
|
||||
expect(commands.length).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it.each(commands.map((c) => [c.path.join(" "), c] as const))(
|
||||
"%s is defined with the shared factory",
|
||||
(_label, { command }) => {
|
||||
expect(isDefinedCommand(command)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
describe.concurrent("each command", () => {
|
||||
it.each(spawnable.map((c) => [c.path.join(" "), c.path] as const))(
|
||||
"%s --help prints plain help and exits 0",
|
||||
async (_label, path) => {
|
||||
const run = await runAsAgent([...path, "--help"]);
|
||||
expectQuiet(run);
|
||||
expect(run.code, run.stderr).toBe(0);
|
||||
expect(run.stdout).toContain("USAGE");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(spawnable.map((c) => [c.path.join(" "), c.path] as const))(
|
||||
"%s rejects an unknown flag with exit 2",
|
||||
async (_label, path) => {
|
||||
const run = await runAsAgent([...path, "--not-a-real-flag"]);
|
||||
expectQuiet(run);
|
||||
expect(run.code, run.stderr).toBe(2);
|
||||
expect(run.stdout).toBe("");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["lambda progress"],
|
||||
["cloudrun progress"],
|
||||
["capture"],
|
||||
["remove-background in.mp4"],
|
||||
["init demo --resolution huge --non-interactive"],
|
||||
])("`%s` is missing or misusing an argument, a usage error with exit 2", async (args) => {
|
||||
const run = await runAsAgent(args.split(" "));
|
||||
expectQuiet(run);
|
||||
expect(run.code, run.stderr).toBe(2);
|
||||
});
|
||||
});
|
||||
+18
-2
@@ -82,7 +82,8 @@ if (!captureEnv || process.env.HYPERFRAMES_NO_CAPTURE_DOTENV !== "1") {
|
||||
// Telemetry, update checks, and heavy modules are imported only when needed.
|
||||
// For --help we skip telemetry entirely.
|
||||
|
||||
import { defineCommand, runCommand } from "citty";
|
||||
import { runCommand } from "citty";
|
||||
import { defineCommand } from "./utils/defineCommand.js";
|
||||
import type { ArgsDef, CommandDef } from "citty";
|
||||
import { getRunId } from "./telemetry/runId.js";
|
||||
import { reportCommandFailure, trackCommandFailures } from "./utils/command-failure-tracking.js";
|
||||
@@ -93,6 +94,7 @@ import {
|
||||
CliResultSignal,
|
||||
CliRuntimeError,
|
||||
CliUsageError,
|
||||
USAGE_EXIT_CODE,
|
||||
consumeCommandResult,
|
||||
outputWritten,
|
||||
registerRootExitCodeSanitizer,
|
||||
@@ -520,9 +522,21 @@ async function showRequestedUsage(): Promise<void> {
|
||||
return showUsage(requested.command, requested.parent);
|
||||
}
|
||||
|
||||
// citty's own parse errors (missing argument, bad enum value, unknown or missing subcommand).
|
||||
const CITTY_USAGE_CODES = new Set(["EARG", "E_UNKNOWN_COMMAND", "E_NO_COMMAND"]);
|
||||
|
||||
function isCittyUsageError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
error.name === "CLIError" &&
|
||||
CITTY_USAGE_CODES.has(String((error as { code?: unknown }).code))
|
||||
);
|
||||
}
|
||||
|
||||
function commandResultForError(error: unknown): CommandResult {
|
||||
if (error instanceof CliResultSignal) return error.result;
|
||||
if (error instanceof CliUsageError || error instanceof CliRuntimeError) return error.result;
|
||||
if (isCittyUsageError(error)) return { exitCode: USAGE_EXIT_CODE, kind: "usage_error" };
|
||||
return { exitCode: 1, kind: "runtime_error" };
|
||||
}
|
||||
|
||||
@@ -540,10 +554,12 @@ async function executeCli(): Promise<void> {
|
||||
commandFailed = true;
|
||||
await reportCommandFailure(command, error);
|
||||
const typed = error instanceof CliUsageError || error instanceof CliRuntimeError;
|
||||
if (error instanceof CliUsageError && !error.result.presented) await showRequestedUsage();
|
||||
const usage = error instanceof CliUsageError && !error.result.presented;
|
||||
if (usage && !error.hint) await showRequestedUsage();
|
||||
if (!typed || !error.result.presented) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
if (usage && error.hint) console.error(error.hint);
|
||||
}
|
||||
} finally {
|
||||
const pending = consumeCommandResult();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { failCommand } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
|
||||
export const examples: Example[] = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { join, resolve } from "node:path";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type {
|
||||
AnalysisQuality,
|
||||
AudioCapability,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* when the user is doing auth work.
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import { envFlagEnabled, isAttendedTerminal } from "../../utils/attendedTerminal
|
||||
* the transient-blip rationale.
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../../utils/defineCommand.js";
|
||||
import { stdin as input } from "node:process";
|
||||
import {
|
||||
AuthClient,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { failCommand } from "../../utils/commandResult.js";
|
||||
* `HEYGEN_ACCESS_TOKEN`) can't be cleared by this command — we tell the user to unset them.
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../../utils/defineCommand.js";
|
||||
import {
|
||||
ENV_CREDENTIAL_VAR,
|
||||
clearOAuth,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { failCommand } from "../../utils/commandResult.js";
|
||||
* job. Status's 401-retry path already does this automatically.
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../../utils/defineCommand.js";
|
||||
import {
|
||||
assertOAuthConfiguredOrExit,
|
||||
isAuthError,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { failCommand, setCommandExitCode } from "../../utils/commandResult.js";
|
||||
* place instead of each workflow improvising its own.
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../../utils/defineCommand.js";
|
||||
import {
|
||||
ENV_CREDENTIAL_VAR,
|
||||
AuthClient,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { failCommand } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import { existsSync, readFileSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join, dirname, resolve } from "node:path";
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { failCommand, setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { failCommand } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { c } from "../ui/colors.js";
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { CliRuntimeError, registerRootExitRequester } from "../utils/commandResult.js";
|
||||
import {
|
||||
CliRuntimeError,
|
||||
CliUsageError,
|
||||
registerRootExitRequester,
|
||||
} from "../utils/commandResult.js";
|
||||
import { CAPTURE_PHASE_SCHEMA } from "../capture/types.js";
|
||||
|
||||
const { captureWebsiteMock } = vi.hoisted(() => ({
|
||||
@@ -228,7 +232,7 @@ describe("capture command — vision control", () => {
|
||||
captureCommand.run!({
|
||||
args: { ...target, output: "/tmp/hf-local-refused", json: true },
|
||||
} as never),
|
||||
).rejects.toThrow(CliRuntimeError);
|
||||
).rejects.toThrow("local-root" in target ? CliUsageError : CliRuntimeError);
|
||||
expect(error.mock.calls.flat().join("\n")).toContain(message);
|
||||
expect(captureWebsiteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DEFAULT_MAX_SCREENSHOTS } from "../capture/types.js";
|
||||
import { failCommand, scheduleCliExit } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "citty";
|
||||
import { failCommand, scheduleCliExit, failUsage } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import { resolve } from "node:path";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
@@ -163,13 +163,13 @@ export default defineCommand({
|
||||
} else {
|
||||
if (localRoot !== undefined) {
|
||||
console.error("--local-root requires --local-file.");
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
if (!url) {
|
||||
console.error(
|
||||
"Missing URL. Pass a website URL, --local-file <path>, or --video <project> for video download.",
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
try {
|
||||
new URL(url);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
|
||||
export const examples: Example[] = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import {
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
import { formatFindingTimes } from "../utils/checkFindings.js";
|
||||
import { withProjectLock } from "../utils/projectLock.js";
|
||||
import { readToolContext, reportArtifacts, type ToolContext } from "../utils/toolContext.js";
|
||||
import { defineCommand, parseArgs } from "citty";
|
||||
import { parseArgs } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { ArgsDef } from "citty";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { parseAt } from "./layout.js";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { existsSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import {
|
||||
EXTRACT_CACHE_MIN_AGE_MS,
|
||||
directorySizeBytes,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* credentials store and no new env var.
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { failCommand } from "../../utils/commandResult.js";
|
||||
* shortly after. There's no undo from the CLI side.
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../../utils/defineCommand.js";
|
||||
import { createCloudClient } from "../../cloud/index.js";
|
||||
import { reportApiError } from "../../cloud/errors.js";
|
||||
import { withMeta } from "../../utils/updateCheck.js";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* docs / chat — fetch them on demand.
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../../utils/defineCommand.js";
|
||||
import { createCloudClient } from "../../cloud/index.js";
|
||||
import { reportApiError } from "../../cloud/errors.js";
|
||||
import { colorStatus } from "../../cloud/statusColor.js";
|
||||
|
||||
@@ -7,7 +7,7 @@ import { failCommand } from "../../utils/commandResult.js";
|
||||
* mirrors the API default (10).
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../../utils/defineCommand.js";
|
||||
import { createCloudClient } from "../../cloud/index.js";
|
||||
import { padEndVisible } from "../../cloud/ansi.js";
|
||||
import { reportApiError } from "../../cloud/errors.js";
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
validateDryRunSource,
|
||||
type ProjectInputSource,
|
||||
} from "./render.js";
|
||||
import { CliRuntimeError } from "../../utils/commandResult.js";
|
||||
import { CliRuntimeError, CliUsageError } from "../../utils/commandResult.js";
|
||||
|
||||
const cliEntry = resolve(fileURLToPath(import.meta.url), "..", "..", "..", "cli.ts");
|
||||
|
||||
@@ -39,11 +39,11 @@ describe("validateDryRunSource", () => {
|
||||
it("accepts a local directory and rejects already-uploaded sources", () => {
|
||||
expect(() => validateDryRunSource({ kind: "dir", dir: "." }, true)).not.toThrow();
|
||||
expect(() => validateDryRunSource({ kind: "asset_id", assetId: "asst_123" }, true)).toThrow(
|
||||
CliRuntimeError,
|
||||
CliUsageError,
|
||||
);
|
||||
expect(() =>
|
||||
validateDryRunSource({ kind: "url", url: "https://example.com/project.zip" }, true),
|
||||
).toThrow(CliRuntimeError);
|
||||
).toThrow(CliUsageError);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { failCommand } from "../../utils/commandResult.js";
|
||||
import { failCommand, failUsage } from "../../utils/commandResult.js";
|
||||
/**
|
||||
* `hyperframes cloud render` — orchestrate a cloud-rendered HyperFrames
|
||||
* composition end-to-end:
|
||||
@@ -28,7 +28,7 @@ import { failCommand } from "../../utils/commandResult.js";
|
||||
* HeyGen's external API OpenAPI spec; never hand-edit it.
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../../utils/defineCommand.js";
|
||||
|
||||
import {
|
||||
detectAspectRatioFromHtml,
|
||||
@@ -384,7 +384,7 @@ function validateIdempotencyKey(key: string | undefined): void {
|
||||
if (key === undefined) return;
|
||||
if (!IDEMPOTENCY_KEY_RE.test(key)) {
|
||||
errorBox("Invalid --idempotency-key", `Got "${key}". Must be 1-255 chars from [A-Za-z0-9_:.-]`);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,7 +394,7 @@ export function validateDryRunSource(source: ProjectInputSource, dryRun: boolean
|
||||
"Invalid --dry-run input",
|
||||
"--dry-run inspects a local project directory and cannot be combined with --asset-id or --url.",
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { failCommand } from "../utils/commandResult.js";
|
||||
import { failCommand, failUsage } from "../utils/commandResult.js";
|
||||
/**
|
||||
* `hyperframes cloudrun` — deploy + drive distributed renders on Google
|
||||
* Cloud Run + Cloud Workflows.
|
||||
@@ -18,7 +18,7 @@ import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import { type CanvasResolution } from "@hyperframes/core";
|
||||
import { parseOutputResolutionFlag } from "../utils/parseOutputResolution.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
@@ -327,7 +327,7 @@ async function runDeploy(args: Record<string, unknown>): Promise<void> {
|
||||
const project = args.project as string | undefined;
|
||||
if (!project) {
|
||||
console.error("[cloudrun deploy] --project <gcp-project-id> is required.");
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const region = (args.region as string | undefined) ?? "us-central1";
|
||||
const repo = (args.repo as string | undefined) ?? "hyperframes";
|
||||
@@ -355,7 +355,7 @@ async function runDeploy(args: Record<string, unknown>): Promise<void> {
|
||||
console.error(
|
||||
"[cloudrun deploy] --image is required when not running from a hyperframes checkout (no Dockerfile context found).",
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
// Ensure the Artifact Registry repo exists.
|
||||
const exists =
|
||||
@@ -497,7 +497,7 @@ async function runSites(args: Record<string, unknown>): Promise<void> {
|
||||
const projectDir = args.extra as string | undefined;
|
||||
if (!projectDir) {
|
||||
console.error("[cloudrun sites create] usage: hyperframes cloudrun sites create <projectDir>");
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const state = readState(args);
|
||||
const { deploySite } = await loadCloudRunAdapter();
|
||||
@@ -538,7 +538,7 @@ async function runRender(args: Record<string, unknown>): Promise<void> {
|
||||
console.error(
|
||||
"[cloudrun render] usage: hyperframes cloudrun render <projectDir> --width <px> --height <px>",
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const width = parsePositiveInt(args.width, "--width");
|
||||
const height = parsePositiveInt(args.height, "--height");
|
||||
@@ -604,7 +604,7 @@ async function runProgress(args: Record<string, unknown>): Promise<void> {
|
||||
const executionName = args.target as string | undefined;
|
||||
if (!executionName) {
|
||||
console.error("[cloudrun progress] usage: hyperframes cloudrun progress <executionName>");
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const { getRenderProgress } = await loadCloudRunAdapter();
|
||||
const progress = await getRenderProgress({ executionName });
|
||||
@@ -644,7 +644,7 @@ async function runRenderBatch(args: Record<string, unknown>): Promise<void> {
|
||||
console.error(
|
||||
"[cloudrun render-batch] usage: hyperframes cloudrun render-batch <projectDir> --batch <file.jsonl> --width <px> --height <px>",
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const width = parsePositiveInt(args.width, "--width");
|
||||
const height = parsePositiveInt(args.height, "--height");
|
||||
@@ -774,7 +774,7 @@ async function runDestroy(args: Record<string, unknown>): Promise<void> {
|
||||
const image = (args.image as string | undefined) ?? "unused:latest";
|
||||
if (!project) {
|
||||
console.error("[cloudrun destroy] --project is required (or deploy first to cache it).");
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const vars = [
|
||||
"-var",
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import { createContactSheet } from "../capture/contactSheet.js";
|
||||
import {
|
||||
openSettledCompositionPage,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readProjectFile } from "@hyperframes/parsers/asset-resolution";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { failCommand } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// fallow-ignore-file complexity
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import { trackEvent, flushSync } from "../telemetry/client.js";
|
||||
import { SKILL_SLUG } from "../telemetry/skill.js";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { failCommand } from "../utils/commandResult.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import * as clack from "@clack/prompts";
|
||||
import open from "open";
|
||||
import type { Example } from "./_examples.js";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Requires FIGMA_TOKEN (personal access token from figma.com/settings).
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* provenance in the shared manifest, print a composition snippet.
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../../utils/defineCommand.js";
|
||||
import {
|
||||
appendRecord,
|
||||
buildAssetSnippet,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Phase-1 asset import, packaged as a registry item.
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../../utils/defineCommand.js";
|
||||
import {
|
||||
createFigmaClient,
|
||||
FigmaClientError,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* component-import time, Phase 3).
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../../utils/defineCommand.js";
|
||||
import {
|
||||
createFigmaClient,
|
||||
FigmaClientError,
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
serializeHfColorGrading,
|
||||
} from "@hyperframes/core";
|
||||
import { gsapCdnDist } from "@hyperframes/core/gsap-cdn";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { openSettledCompositionPage, runFfmpegOnce } from "../capture/captureCompositionFrame.js";
|
||||
import { findFFmpeg } from "../browser/ffmpeg.js";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineCommand, type ArgsDef } from "citty";
|
||||
import { type ArgsDef } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import {
|
||||
HISTORY_START,
|
||||
HistoryBusyError,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ describe("hyperframes init flag rename", () => {
|
||||
const target = join(dir, "proj");
|
||||
try {
|
||||
const res = runInit([target, "--example", "--non-interactive"]);
|
||||
expect(res.status).toBe(1);
|
||||
expect(res.status).toBe(2);
|
||||
expect(res.stderr).toContain("Missing value for --example");
|
||||
expect(existsSync(target)).toBe(false);
|
||||
} finally {
|
||||
@@ -682,7 +682,7 @@ describe("applyResolutionPreset", () => {
|
||||
"--non-interactive",
|
||||
"--skip-skills",
|
||||
]);
|
||||
expect(res.status).toBe(1);
|
||||
expect(res.status).toBe(2);
|
||||
expect(res.stderr).toContain("Invalid --resolution");
|
||||
expect(existsSync(target)).toBe(false);
|
||||
} finally {
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
// This branch only repointed the scaffolded npm scripts; the refactor is its
|
||||
// own task.
|
||||
// fallow-ignore-file complexity
|
||||
import { failCommand, finishCommand } from "../utils/commandResult.js";
|
||||
import { failCommand, finishCommand, failUsage } from "../utils/commandResult.js";
|
||||
import { writeNewFileSync } from "../utils/writeNewFile.js";
|
||||
import { defineCommand, runCommand } from "citty";
|
||||
import { runCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { patchMediaPlaceholders, type InitMediaOptions } from "./initMedia.js";
|
||||
|
||||
@@ -837,7 +838,7 @@ export default defineCommand({
|
||||
`(or aliases 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square).`,
|
||||
),
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { readProjectFile } from "@hyperframes/parsers/asset-resolution";
|
||||
import { resolve, dirname, basename, join, relative, sep } from "node:path";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { failCommand } from "../utils/commandResult.js";
|
||||
import { failCommand, failUsage } from "../utils/commandResult.js";
|
||||
/**
|
||||
* `hyperframes lambda` — top-level dispatcher for AWS Lambda subcommands.
|
||||
*
|
||||
@@ -9,7 +9,7 @@ import { failCommand } from "../utils/commandResult.js";
|
||||
* lives in `@hyperframes/aws-lambda/sdk`.
|
||||
*/
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { DistributedFormat } from "@hyperframes/aws-lambda/sdk";
|
||||
import { type CanvasResolution, DISTRIBUTED_FPS, isDistributedFps } from "@hyperframes/core";
|
||||
import { parseOutputResolutionFlag } from "../utils/parseOutputResolution.js";
|
||||
@@ -296,7 +296,7 @@ export default defineCommand({
|
||||
console.error(
|
||||
"[lambda sites create] usage: hyperframes lambda sites create <projectDir>",
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const { runSitesCreate } = await import("./lambda/sites.js");
|
||||
await runSitesCreate({
|
||||
@@ -313,7 +313,7 @@ export default defineCommand({
|
||||
console.error(
|
||||
"[lambda render] usage: hyperframes lambda render <projectDir> --width <px> --height <px>",
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const width = parsePositiveInt(args.width, "--width");
|
||||
const height = parsePositiveInt(args.height, "--height");
|
||||
@@ -363,14 +363,14 @@ export default defineCommand({
|
||||
console.error(
|
||||
"[lambda render-batch] usage: hyperframes lambda render-batch <projectDir> --batch <path.jsonl> --width <px> --height <px>",
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const batch = args.batch as string | undefined;
|
||||
if (!batch) {
|
||||
console.error(
|
||||
"[lambda render-batch] --batch <path.jsonl> is required. Each line is a JSON object with at least { outputKey: '...' }.",
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const width = parsePositiveInt(args.width, "--width");
|
||||
const height = parsePositiveInt(args.height, "--height");
|
||||
@@ -417,7 +417,7 @@ export default defineCommand({
|
||||
console.error(
|
||||
"[lambda progress] usage: hyperframes lambda progress <renderId | executionArn>",
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const { runProgress } = await import("./lambda/progress.js");
|
||||
await runProgress({ target, stackName, json: Boolean(args.json) });
|
||||
@@ -434,7 +434,7 @@ export default defineCommand({
|
||||
console.error(
|
||||
`[lambda policies] usage: hyperframes lambda policies <role|user|validate> [args]`,
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
const { runPolicies } = await import("./lambda/policies.js");
|
||||
await runPolicies({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { setCommandExitCode } from "../../utils/commandResult.js";
|
||||
import { CliUsageError, failUsage, setCommandExitCode } from "../../utils/commandResult.js";
|
||||
/**
|
||||
* `hyperframes lambda policies role|user|validate` — IAM bootstrap.
|
||||
*
|
||||
@@ -265,10 +265,9 @@ export async function runPolicies(args: PoliciesArgs): Promise<void> {
|
||||
"[lambda policies validate] usage: hyperframes lambda policies validate <policy.json>";
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify({ ok: false, error: msg }, null, 2));
|
||||
setCommandExitCode(1);
|
||||
return;
|
||||
failUsage();
|
||||
}
|
||||
throw new Error(msg);
|
||||
throw new CliUsageError(msg);
|
||||
}
|
||||
let result: ValidateResult;
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { launchManagedBrowser, resolveManagedGpuMode } from "../browser/launch.js";
|
||||
import { failCommand, setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
parseSourceDocument as parseMutableSource,
|
||||
} from "@hyperframes/studio-server/source-mutation";
|
||||
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import { lintHyperframeHtml } from "@hyperframes/lint";
|
||||
import { parseHTML } from "linkedom";
|
||||
import type { Example } from "./_examples.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import * as clack from "@clack/prompts";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type LoudnessLimit,
|
||||
} from "@hyperframes/studio-server/loudness";
|
||||
import { resolveAuthoredAssetFile } from "@hyperframes/parsers/asset-resolution";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { projectCompositionFiles } from "../utils/mountedCompositions.js";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { setCommandExitCode } from "../utils/commandResult.js";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { setCommandExitCode, requestCliExit } from "../utils/commandResult.js";
|
||||
// fallow-ignore-file code-duplication
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { join, posix, relative, resolve } from "node:path";
|
||||
import { failCommand, setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { existsSync } from "node:fs";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import * as clack from "@clack/prompts";
|
||||
|
||||
import type { Example } from "./_examples.js";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { failCommand } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "citty";
|
||||
import { failCommand, failUsage } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import { existsSync } from "node:fs";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { c } from "../ui/colors.js";
|
||||
@@ -102,23 +102,23 @@ export default defineCommand({
|
||||
"Input file is required. Run `hyperframes remove-background --info` for providers.",
|
||||
),
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
if (!args.output) {
|
||||
console.error(c.error("--output (-o) is required. Use a .webm, .mov, or .png path."));
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
if (!isDevice(args.device)) {
|
||||
console.error(
|
||||
c.error(`Invalid --device '${String(args.device)}'. Use: ${DEVICES.join(", ")}.`),
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
if (!isQuality(args.quality)) {
|
||||
console.error(
|
||||
c.error(`Invalid --quality '${String(args.quality)}'. Use: ${QUALITIES.join(", ")}.`),
|
||||
);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
|
||||
const spin = args.json ? null : clack.spinner();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { captureArtifactRevision } from "../utils/artifactCapture.js";
|
||||
import { recordRenderSuccess, registerRenderOutput } from "@hyperframes/studio-server/history";
|
||||
import { failCommand, requestCliExit, scheduleCliExit } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { createRenderPlan, resolveBrowserGpuForCli, type RenderFormat } from "./render/plan.js";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { setCommandExitCode, CliResultSignal } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { c } from "../ui/colors.js";
|
||||
|
||||
@@ -31,7 +31,7 @@ import { pngDimensions } from "../utils/pngDimensions.js";
|
||||
import { runWithConcurrencyLimit } from "../utils/concurrency.js";
|
||||
import { CliRuntimeError, failCommand } from "../utils/commandResult.js";
|
||||
// fallow-ignore-file complexity
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import {
|
||||
writeConfigWithResult,
|
||||
readConfigFresh,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineCommand, type ArgDef } from "citty";
|
||||
import { type ArgDef } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { describeProject } from "../timeline/describeProject.js";
|
||||
import { formatTimeline } from "../timeline/formatTimeline.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createProgressWriter } from "../whisper/progress.js";
|
||||
import { failCommand, setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { c } from "../ui/colors.js";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { failCommand, failUsage } from "../utils/commandResult.js";
|
||||
import { isTextFile } from "../utils/textFile.js";
|
||||
// fallow-ignore-file code-duplication
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -134,7 +134,7 @@ export default defineCommand({
|
||||
const requested = String(args.lang).toLowerCase();
|
||||
if (!isSupportedLang(requested)) {
|
||||
errorBox("Invalid --lang", `Got "${args.lang}". Must be one of: ${langList}.`);
|
||||
failCommand();
|
||||
failUsage();
|
||||
}
|
||||
lang = requested;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import { readHarnessUsage } from "../utils/harnessUsage.js";
|
||||
|
||||
export default defineCommand({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { launchManagedBrowser, resolveManagedGpuMode } from "../browser/launch.j
|
||||
// window drifting as the file is edited, hence the file-level suppression.
|
||||
// fallow-ignore-file code-duplication
|
||||
import { failCommand, setCommandExitCode } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "citty";
|
||||
import { defineCommand } from "../utils/defineCommand.js";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
@@ -612,3 +612,19 @@ describe("detectSandboxRuntime — kernel-string path", () => {
|
||||
expect(detectSandboxRuntime()).not.toBe("gvisor");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAgentDriven", () => {
|
||||
it.each([
|
||||
[{ CLAUDECODE: "1" }, true],
|
||||
[{ CODEX_THREAD_ID: "t" }, true],
|
||||
[{ CURSOR_AGENT: "1", TERM_PROGRAM: "cursor" }, true],
|
||||
[{ TERM_PROGRAM: "cursor" }, false],
|
||||
[{ TERM_PROGRAM: "windsurf" }, false],
|
||||
[{ REPL_ID: "r" }, false],
|
||||
[{ REPLIT_USER: "u" }, false],
|
||||
[{}, false],
|
||||
])("%j -> %s", async (env, expected) => {
|
||||
const { isAgentDriven } = await import("./agent_runtime.js");
|
||||
expect(isAgentDriven(env)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,8 @@ interface VendorRule {
|
||||
name: Exclude<AgentRuntime, null>;
|
||||
/** Check returns true when the named agent is driving the CLI. */
|
||||
check: (env: NodeJS.ProcessEnv) => boolean;
|
||||
/** Narrower than `check` when `check` also matches a person in that editor's terminal or host. */
|
||||
agentOnly?: (env: NodeJS.ProcessEnv) => boolean;
|
||||
}
|
||||
|
||||
// Ordering matters: the FIRST rule that matches wins. Put more specific rules
|
||||
@@ -93,6 +95,7 @@ const VENDOR_RULES: VendorRule[] = [
|
||||
{
|
||||
name: "cursor",
|
||||
check: (env) => Boolean(env["CURSOR_AGENT"]) || env["TERM_PROGRAM"] === "cursor",
|
||||
agentOnly: (env) => Boolean(env["CURSOR_AGENT"]),
|
||||
},
|
||||
// Windsurf (Codeium) integrated terminal — exports TERM_PROGRAM=windsurf.
|
||||
// Attested across many independent detectors (nx
|
||||
@@ -105,6 +108,7 @@ const VENDOR_RULES: VendorRule[] = [
|
||||
{
|
||||
name: "windsurf",
|
||||
check: (env) => env["TERM_PROGRAM"]?.toLowerCase() === "windsurf",
|
||||
agentOnly: () => false,
|
||||
},
|
||||
// GitHub Copilot Coding Agent — runs inside GitHub Actions and the
|
||||
// workflow injects an additional marker to distinguish from generic CI.
|
||||
@@ -123,6 +127,7 @@ const VENDOR_RULES: VendorRule[] = [
|
||||
{
|
||||
name: "replit",
|
||||
check: (env) => typeof env["REPL_ID"] === "string" || typeof env["REPLIT_USER"] === "string",
|
||||
agentOnly: () => false,
|
||||
},
|
||||
// Nous Research Hermes Agent — cli.py:50 unconditionally executes
|
||||
// os.environ["HERMES_QUIET"] = "1"
|
||||
@@ -253,6 +258,12 @@ export function detectAgentRuntime(): AgentRuntime {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** An agent is running this shell, not a person typing in an editor's terminal or a hosted workspace. */
|
||||
export function isAgentDriven(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
if (isGeminiManagedAgent()) return true;
|
||||
return VENDOR_RULES.some((rule) => (rule.agentOnly ?? rule.check)(env));
|
||||
}
|
||||
|
||||
/** Observed harness marker, not proof of a vendor, benchmark, or human user. */
|
||||
export function detectExecutionHarnessHint(): "harbor" | null {
|
||||
// Read presence only. Preserve this context even when the child agent is known.
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import pc from "picocolors";
|
||||
import { isUnattended } from "../utils/attendedTerminal.js";
|
||||
|
||||
const isColorSupported =
|
||||
process.stdout.isTTY === true && !process.env["NO_COLOR"] && process.env["FORCE_COLOR"] !== "0";
|
||||
process.stdout.isTTY === true &&
|
||||
!process.env["NO_COLOR"] &&
|
||||
process.env["FORCE_COLOR"] !== "0" &&
|
||||
!isUnattended();
|
||||
|
||||
function wrap(fn: (s: string) => string): (s: string) => string {
|
||||
return isColorSupported ? fn : (s: string) => s;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// `driven: undefined` runs the real detection against process.env.
|
||||
const agent = vi.hoisted(() => ({ driven: false as boolean | undefined }));
|
||||
vi.mock("../telemetry/agent_runtime.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../telemetry/agent_runtime.js")>();
|
||||
return { ...actual, isAgentDriven: () => agent.driven ?? actual.isAgentDriven() };
|
||||
});
|
||||
|
||||
const ttys = { stdin: process.stdin.isTTY, stdout: process.stdout.isTTY };
|
||||
|
||||
function setTty(stdin: boolean, stdout: boolean): void {
|
||||
Object.defineProperty(process.stdin, "isTTY", { value: stdin, configurable: true });
|
||||
Object.defineProperty(process.stdout, "isTTY", { value: stdout, configurable: true });
|
||||
}
|
||||
|
||||
async function load() {
|
||||
vi.resetModules();
|
||||
return import("./attendedTerminal.js");
|
||||
}
|
||||
|
||||
describe("isCI", () => {
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
it.each([
|
||||
["true", true],
|
||||
["1", true],
|
||||
["github-actions", true],
|
||||
["false", false],
|
||||
["0", false],
|
||||
["", false],
|
||||
])("CI=%j is CI: %s", async (value, expected) => {
|
||||
vi.stubEnv("CI", value);
|
||||
expect((await load()).isCI()).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUnattended", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("CI", "");
|
||||
vi.stubEnv("HYPERFRAMES_AGENT", "");
|
||||
agent.driven = false;
|
||||
setTty(true, true);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
setTty(ttys.stdin === true, ttys.stdout === true);
|
||||
});
|
||||
|
||||
it("is attended at a terminal with no CI and no agent", async () => {
|
||||
expect((await load()).isUnattended()).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[false, true],
|
||||
[true, false],
|
||||
])("is unattended when stdin is a TTY: %s, stdout is a TTY: %s", async (stdin, stdout) => {
|
||||
setTty(stdin, stdout);
|
||||
expect((await load()).isUnattended()).toBe(true);
|
||||
});
|
||||
|
||||
it("is unattended in CI", async () => {
|
||||
vi.stubEnv("CI", "buildkite");
|
||||
expect((await load()).isUnattended()).toBe(true);
|
||||
});
|
||||
|
||||
it("is unattended when a coding agent is detected", async () => {
|
||||
agent.driven = true;
|
||||
expect((await load()).isUnattended()).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["TERM_PROGRAM", "cursor"],
|
||||
["TERM_PROGRAM", "windsurf"],
|
||||
["REPL_ID", "abc"],
|
||||
])(
|
||||
"a person in an editor or hosted terminal (%s=%s) at a TTY is attended",
|
||||
async (name, value) => {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (
|
||||
/^(CLAUDE|CODEX_|CURSOR_|BOBSHELL|GEMINI_CLI|CLINE_|HERMES_|PI_CODING|CRUSH|OPENCLAW)/.test(
|
||||
key,
|
||||
)
|
||||
) {
|
||||
vi.stubEnv(key, undefined);
|
||||
}
|
||||
}
|
||||
vi.stubEnv(name, value);
|
||||
agent.driven = undefined;
|
||||
expect((await load()).isUnattended()).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("Cursor's agent shell is unattended", async () => {
|
||||
vi.stubEnv("CURSOR_AGENT", "1");
|
||||
agent.driven = undefined;
|
||||
expect((await load()).isUnattended()).toBe(true);
|
||||
});
|
||||
|
||||
it("is unattended with HYPERFRAMES_AGENT=1", async () => {
|
||||
vi.stubEnv("HYPERFRAMES_AGENT", "1");
|
||||
expect((await load()).isUnattended()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,25 @@
|
||||
import { isAgentDriven } from "../telemetry/agent_runtime.js";
|
||||
|
||||
export function envFlagEnabled(name: string): boolean {
|
||||
const value = process.env[name]?.trim().toLowerCase();
|
||||
return Boolean(value && value !== "0" && value !== "false" && value !== "no");
|
||||
}
|
||||
|
||||
export function isCI(): boolean {
|
||||
const value = process.env["CI"]?.trim().toLowerCase();
|
||||
return Boolean(value) && value !== "0" && value !== "false";
|
||||
}
|
||||
|
||||
/** A person can answer here: stdin and stdout are both terminals and this is not CI. */
|
||||
export function isAttendedTerminal(): boolean {
|
||||
return !envFlagEnabled("CI") && process.stdin.isTTY === true && process.stdout.isTTY === true;
|
||||
return !isCI() && process.stdin.isTTY === true && process.stdout.isTTY === true;
|
||||
}
|
||||
|
||||
let agentDetected: boolean | undefined;
|
||||
|
||||
/** Nobody is at the keyboard: no terminal, CI, a coding agent, or HYPERFRAMES_AGENT=1. Never prompt then. */
|
||||
export function isUnattended(): boolean {
|
||||
if (!isAttendedTerminal() || process.env["HYPERFRAMES_AGENT"] === "1") return true;
|
||||
agentDetected ??= isAgentDriven();
|
||||
return agentDetected;
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ function guardLeafFlags(
|
||||
ctx: CommandContext<any>,
|
||||
rawArgs: string[],
|
||||
): void {
|
||||
assertKnownFlags(cmd, rawArgs);
|
||||
assertKnownFlags(cmd, rawArgs, path);
|
||||
const guarded = guardSwallowedFlagValues(cmd, path, rawArgs);
|
||||
if (!guarded.rewritten) return;
|
||||
ctx.rawArgs = guarded.rawArgs;
|
||||
|
||||
@@ -12,17 +12,21 @@ let pendingResult: CommandResult = SUCCESS_RESULT;
|
||||
let rootExitRequester: ((exitCode: number) => void) | undefined;
|
||||
let rootExitCodeSanitizer: (() => void) | undefined;
|
||||
|
||||
export const USAGE_EXIT_CODE = 2;
|
||||
|
||||
export class CliUsageError extends Error {
|
||||
readonly result: CommandResult;
|
||||
readonly hint?: string;
|
||||
|
||||
constructor(
|
||||
message = "Invalid command usage",
|
||||
options: { exitCode?: number; presented?: boolean } = {},
|
||||
options: { exitCode?: number; presented?: boolean; hint?: string } = {},
|
||||
) {
|
||||
super(message);
|
||||
this.name = "CliUsageError";
|
||||
this.hint = options.hint;
|
||||
this.result = {
|
||||
exitCode: options.exitCode ?? 1,
|
||||
exitCode: options.exitCode ?? USAGE_EXIT_CODE,
|
||||
kind: "usage_error",
|
||||
presented: options.presented,
|
||||
};
|
||||
@@ -61,7 +65,7 @@ export function failCommand(exitCode = 1, cause?: unknown): never {
|
||||
throw new CliRuntimeError("Command failed", { exitCode, presented: true, cause });
|
||||
}
|
||||
|
||||
export function failUsage(exitCode = 1): never {
|
||||
export function failUsage(exitCode = USAGE_EXIT_CODE): never {
|
||||
throw new CliUsageError("Invalid command usage", { exitCode, presented: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineCommand as defineCittyCommand } from "citty";
|
||||
import type { ArgsDef, CommandDef } from "citty";
|
||||
|
||||
const definedCommands = new WeakSet<object>();
|
||||
|
||||
// The one way to define a command: cli.ts wraps what it returns with the shared guards,
|
||||
// and the agent-mode contract test rejects any registered command not built here.
|
||||
export function defineCommand<const T extends ArgsDef = ArgsDef>(
|
||||
def: CommandDef<T>,
|
||||
): CommandDef<T> {
|
||||
const command = defineCittyCommand(def);
|
||||
definedCommands.add(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function isDefinedCommand(command: unknown): boolean {
|
||||
return typeof command === "object" && command !== null && definedCommands.has(command);
|
||||
}
|
||||
@@ -272,7 +272,7 @@ describe("guardSwallowedFlagValues end-to-end (via citty's real runCommand + the
|
||||
|
||||
it("leaves stdout genuinely empty on the real built CLI, not just presented:true internally", () => {
|
||||
const res = runCli(["catalog", "--query", "--json"]);
|
||||
expect(res.status).toBe(1);
|
||||
expect(res.status).toBe(2);
|
||||
expect(res.stderr).toContain("Missing value for --query");
|
||||
expect(res.stdout).toBe("");
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ function unknownFlagIn(tok: string, known: Set<string>): string | null {
|
||||
* + the global set). Only dash-prefixed tokens are inspected, so positionals and
|
||||
* flag values pass through untouched. Stops at `--`.
|
||||
*/
|
||||
export function assertKnownFlags(cmd: CommandDef<ArgsDef>, rawArgs: string[]): void {
|
||||
export function assertKnownFlags(cmd: CommandDef<ArgsDef>, rawArgs: string[], path = ""): void {
|
||||
if (!Array.isArray(rawArgs)) return;
|
||||
// citty types `args` as Resolvable<ArgsDef> (it may be a fn/promise); every
|
||||
// hyperframes command uses a static object, so treat anything else as "no
|
||||
@@ -81,7 +81,10 @@ export function assertKnownFlags(cmd: CommandDef<ArgsDef>, rawArgs: string[]): v
|
||||
for (const tok of rawArgs) {
|
||||
if (tok === "--") break;
|
||||
const bad = unknownFlagIn(tok, known);
|
||||
if (bad) throw new Error(`Unknown flag: ${bad}`);
|
||||
if (bad) {
|
||||
const hint = `Run 'hyperframes ${path ? `${path} ` : ""}--help' to see its flags.`;
|
||||
throw new CliUsageError(`Unknown flag: ${bad}`, { hint });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user