mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
feat(cli): add installs several named items in one call
This commit is contained in:
@@ -19,6 +19,7 @@ function commandLoaderBlock(): string {
|
||||
|
||||
// Vite cannot resolve a template-string import, so each listed top-level command is named here.
|
||||
const LIST_COMMANDS: Record<string, () => Promise<{ default: unknown }>> = {
|
||||
add: () => import("./commands/add.js"),
|
||||
audio: () => import("./commands/audio.js"),
|
||||
catalog: () => import("./commands/catalog.js"),
|
||||
compare: () => import("./commands/compare.js"),
|
||||
|
||||
@@ -17,7 +17,6 @@ import addCommand, {
|
||||
tagAddJson,
|
||||
} from "./add.js";
|
||||
import { trackRegistryItemAdded } from "../telemetry/events.js";
|
||||
import { CliUsageError } from "../utils/commandResult.js";
|
||||
import { trackCommandFailures } from "../utils/command-failure-tracking.js";
|
||||
|
||||
// Assert the emitted payload rather than the transport: `shouldTrack()` is
|
||||
@@ -525,17 +524,17 @@ describe("variable values in the snippet", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("add command run() — extra positional arguments", () => {
|
||||
describe("add command run() — several names", () => {
|
||||
let dir: string;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = tmp();
|
||||
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
writeRegistryConfig(dir);
|
||||
mockFetch();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
errorSpy.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
@@ -545,22 +544,35 @@ describe("add command run() — extra positional arguments", () => {
|
||||
await runCittyCommand(cmd, { rawArgs });
|
||||
}
|
||||
|
||||
it("rejects `add a --dir <dir> b c` through the CLI wrapper, before any registry call", async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
it("installs every name in one call and reports them together", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await expect(runWrapped(["a", "--dir", dir, "b", "c"])).rejects.toThrow(CliUsageError);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(errorSpy.mock.calls.flat().join(" ")).toContain(
|
||||
"Unexpected extra arguments for hyperframes add: b, c",
|
||||
);
|
||||
expect(errorSpy.mock.calls.flat().join(" ")).toContain("Run add once per item");
|
||||
await runWrapped(["my-block", "--dir", dir, "deprecated-block", "--no-clipboard", "--json"]);
|
||||
|
||||
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({
|
||||
ok: true,
|
||||
installed: ["my-block", "deprecated-block"],
|
||||
warnings: expect.any(Array),
|
||||
errors: [],
|
||||
});
|
||||
expect(existsSync(join(dir, "compositions", "my-block.html"))).toBe(true);
|
||||
expect(existsSync(join(dir, "compositions", "deprecated-block.html"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not fire on a normal single-item invocation", async () => {
|
||||
mockFetch();
|
||||
writeRegistryConfig(dir);
|
||||
it("keeps the installs that worked when one name is unknown, and fails the call", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await expect(
|
||||
runWrapped(["my-block", "no-such-block", "--dir", dir, "--no-clipboard", "--json"]),
|
||||
).rejects.toMatchObject({ result: { exitCode: 1 } });
|
||||
|
||||
const out = JSON.parse(String(log.mock.calls[0]?.[0]));
|
||||
expect(out).toMatchObject({ ok: false, installed: ["my-block"] });
|
||||
expect(out.errors).toEqual([{ name: "no-such-block", error: expect.any(String) }]);
|
||||
expect(existsSync(join(dir, "compositions", "my-block.html"))).toBe(true);
|
||||
});
|
||||
|
||||
it("still installs a single name the way it did", async () => {
|
||||
await expect(
|
||||
runWrapped(["my-block", "--dir", dir, "--no-clipboard", "--json"]),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
@@ -148,19 +148,63 @@ export function buildSnippet(
|
||||
return "";
|
||||
}
|
||||
|
||||
function installJson(results: RunAddResult[], errors: Array<{ name: string; error: string }>) {
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
installed: results.map((r) => r.name),
|
||||
warnings: results.flatMap((r) => r.warnings.map((w) => `${r.name}: ${w}`)),
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/** `add <tag> --json` output: successful installs and each item's warnings or failure. */
|
||||
export function tagAddJson(
|
||||
tag: string,
|
||||
results: RunAddResult[],
|
||||
errors: Array<{ name: string; error: string }> = [],
|
||||
) {
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
tag,
|
||||
installed: results.map((r) => r.name),
|
||||
warnings: results.flatMap((r) => r.warnings.map((w) => `${r.name}: ${w}`)),
|
||||
errors,
|
||||
};
|
||||
return { ...installJson(results, errors), tag };
|
||||
}
|
||||
|
||||
export function severalAddJson(
|
||||
results: RunAddResult[],
|
||||
errors: Array<{ name: string; error: string }> = [],
|
||||
) {
|
||||
return installJson(results, errors);
|
||||
}
|
||||
|
||||
async function installEach(
|
||||
names: readonly string[],
|
||||
context: {
|
||||
projectDir: string;
|
||||
force: boolean | undefined;
|
||||
vars: string | undefined;
|
||||
json: boolean;
|
||||
},
|
||||
) {
|
||||
const results: RunAddResult[] = [];
|
||||
const errors: Array<{ name: string; error: string }> = [];
|
||||
for (const name of names) {
|
||||
try {
|
||||
const result = await runAdd({
|
||||
name,
|
||||
projectDir: context.projectDir,
|
||||
skipClipboard: true,
|
||||
force: context.force,
|
||||
vars: context.vars,
|
||||
});
|
||||
results.push(result);
|
||||
for (const warning of result.warnings) {
|
||||
if (!context.json) console.log(` ${c.warn("Warning:")} ${warning}`);
|
||||
}
|
||||
if (!context.json) console.log(` ${c.success("✓")} ${result.name}`);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
errors.push({ name, error: reason });
|
||||
if (!context.json) console.log(` ${c.error("✗")} ${name} (skipped: ${reason})`);
|
||||
}
|
||||
}
|
||||
return { results, errors };
|
||||
}
|
||||
|
||||
/** `--vars` is JSON, a file or `-`; a malformed one is an error, not a snippet that drops the values. */
|
||||
@@ -458,7 +502,8 @@ export default defineCommand({
|
||||
type: "positional",
|
||||
description:
|
||||
"Registry item name or tag. Single items install directly (e.g. shader-wipe). " +
|
||||
"If the name matches a tag instead, all blocks with that tag are installed (e.g. html-in-canvas, captions).",
|
||||
"If the name matches a tag instead, all blocks with that tag are installed (e.g. html-in-canvas, captions). " +
|
||||
"Give several names to install each of them in one call.",
|
||||
required: true,
|
||||
},
|
||||
dir: {
|
||||
@@ -502,6 +547,29 @@ export default defineCommand({
|
||||
|
||||
const hasConfigBefore = existsSync(projectConfigPath(projectDir));
|
||||
|
||||
const names = Array.isArray(args._) && args._.length > 1 ? args._.map(String) : [args.name];
|
||||
if (names.length > 1) {
|
||||
if (!json) {
|
||||
console.log("");
|
||||
console.log(`${c.accent("◆")} Installing ${c.accent(String(names.length))} items`);
|
||||
}
|
||||
const { results, errors } = await installEach(names, {
|
||||
projectDir,
|
||||
force: args.force,
|
||||
vars: args.vars,
|
||||
json,
|
||||
});
|
||||
if (json) {
|
||||
console.log(JSON.stringify(severalAddJson(results, errors)));
|
||||
} else {
|
||||
console.log("");
|
||||
const summary = `Installed ${results.length}/${names.length} items`;
|
||||
console.log(`${errors.length > 0 ? c.error("✗") : c.success("✓")} ${summary}`);
|
||||
}
|
||||
if (errors.length > 0) failCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
// Try single item first. If it fails, check if the name matches a tag.
|
||||
try {
|
||||
const result = await runAdd({
|
||||
@@ -586,28 +654,10 @@ export default defineCommand({
|
||||
);
|
||||
}
|
||||
|
||||
const results: RunAddResult[] = [];
|
||||
const errors: Array<{ name: string; error: string }> = [];
|
||||
for (const item of items) {
|
||||
try {
|
||||
const result = await runAdd({
|
||||
name: item.name,
|
||||
projectDir,
|
||||
skipClipboard: true,
|
||||
force: args.force,
|
||||
vars: args.vars,
|
||||
});
|
||||
results.push(result);
|
||||
for (const warning of result.warnings) {
|
||||
if (!json) console.log(` ${c.warn("Warning:")} ${warning}`);
|
||||
}
|
||||
if (!json) console.log(` ${c.success("✓")} ${result.name}`);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
errors.push({ name: item.name, error: reason });
|
||||
if (!json) console.log(` ${c.error("✗")} ${item.name} (skipped: ${reason})`);
|
||||
}
|
||||
}
|
||||
const { results, errors } = await installEach(
|
||||
items.map((item) => item.name),
|
||||
{ projectDir, force: args.force, vars: args.vars, json },
|
||||
);
|
||||
|
||||
if (json) {
|
||||
console.log(JSON.stringify(tagAddJson(args.name, results, errors)));
|
||||
|
||||
@@ -8,6 +8,7 @@ import { jsonFailure } from "./jsonError.js";
|
||||
|
||||
// Leaf commands that read extra positionals from `args._` on purpose, by command path.
|
||||
export const ACCEPTS_EXTRA_POSITIONALS = new Set([
|
||||
"add",
|
||||
"audio",
|
||||
"compare",
|
||||
"figma asset",
|
||||
@@ -18,10 +19,6 @@ export const ACCEPTS_EXTRA_POSITIONALS = new Set([
|
||||
// Free-text commands: their last positional takes every remaining word (`tts hello world`).
|
||||
export const JOINS_EXTRA_POSITIONALS = new Set(["catalog", "tts"]);
|
||||
|
||||
const EXTRA_POSITIONAL_HINTS: Record<string, string> = {
|
||||
add: "Run add once per item, or pass a single tag to install every item tagged with it.",
|
||||
};
|
||||
|
||||
// citty parses `--json` on every command (`--json=1` as "1"); after `--` it is a positional.
|
||||
function wantsJson(value: unknown): boolean {
|
||||
return value === true || (typeof value === "string" && value !== "false" && value !== "0");
|
||||
@@ -71,8 +68,7 @@ export function resolveExtraPositionals(
|
||||
const plural = extra.length === 1 ? "" : "s";
|
||||
const message =
|
||||
`Unexpected extra argument${plural} for hyperframes ${path}: ${extra.join(", ")}\n` +
|
||||
`Usage: ${usageLine(path, args)}` +
|
||||
(EXTRA_POSITIONAL_HINTS[path] ? `\n${EXTRA_POSITIONAL_HINTS[path]}` : "");
|
||||
`Usage: ${usageLine(path, args)}`;
|
||||
if (wantsJson(parsed?.json)) console.log(JSON.stringify(jsonFailure("usage_error", message)));
|
||||
else console.error(message);
|
||||
// Telemetry reads the thrown message: the command path and a count, never the arguments.
|
||||
|
||||
Reference in New Issue
Block a user