feat(cli): add installs several named items in one call

This commit is contained in:
Miguel Angel Simon Sierra
2026-10-30 00:56:14 -04:00
parent 21eefcd006
commit 1e558bfcc8
4 changed files with 112 additions and 53 deletions
+1
View File
@@ -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"),
+29 -17
View File
@@ -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();
+80 -30
View File
@@ -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.