Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ The positional name is a registry item, or a tag — a tag installs every block
carrying it. `--dir` picks the project directory, `--json` prints the written
files and paste snippet, and `--no-clipboard` skips the clipboard copy for CI.

If any block in a tag fails to install, `add` keeps the successful installs and
exits with code 1. With `--json`, `ok` is `false` and `errors` lists each failed
block's `name` and `error`; `installed` lists the blocks that succeeded.

Every install produces files plus a **paste snippet**: the `<iframe>` tag for a
block, or the fragment path for a component. It goes to your clipboard by
default. Naming an example instead of a registry item (`add warm-grain`) gets
Expand Down
77 changes: 77 additions & 0 deletions packages/cli/src/commands/add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,83 @@ describe("tagAddJson", () => {
tag: "lower-thirds",
installed: ["a", "b"],
warnings: ["b: no id"],
errors: [],
});
});
});

describe("tag installation failures", () => {
let dir: string;

beforeEach(() => {
dir = tmp();
writeRegistryConfig(dir);
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
rmSync(dir, { recursive: true, force: true });
});

it.each([
{ names: ["my-block"], failed: ["my-block"], installed: [], json: true },
{
names: ["my-block", "deprecated-block"],
failed: ["my-block"],
installed: ["deprecated-block"],
json: true,
},
{ names: ["my-block"], failed: [], installed: ["my-block"], json: true },
{ names: ["my-block"], failed: ["my-block"], installed: [], json: false },
])("reports failed downloads and keeps successful installs: %j", async (scenario) => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: string | URL) => {
const url = new URL(input);
if (url.pathname.endsWith("/registry.json")) {
return Response.json({
...MANIFEST,
items: MANIFEST.items.filter((item) => scenario.names.includes(item.name)),
});
}
const name = scenario.names.find((item) => url.pathname.includes(`/blocks/${item}/`));
if (!name) return new Response("not found", { status: 404 });
if (url.pathname.endsWith("/registry-item.json")) {
return Response.json({ ...ITEM_BY_NAME[name], tags: ["test-tag"] });
}
if (scenario.failed.includes(name)) return new Response("not found", { status: 404 });
return new Response(FILE_BODIES[`${name}.html`]);
}),
);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const run = runCittyCommand(addCommand, {
rawArgs: ["test-tag", "--dir", dir, "--no-clipboard", ...(scenario.json ? ["--json"] : [])],
});
if (scenario.failed.length > 0) {
await expect(run).rejects.toMatchObject({ result: { exitCode: 1, presented: true } });
} else {
await expect(run).resolves.toBeDefined();
}

if (scenario.json) {
expect(log).toHaveBeenCalledTimes(1);
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({
ok: scenario.failed.length === 0,
tag: "test-tag",
installed: scenario.installed,
errors: scenario.failed.map((name) => ({
name,
error: expect.stringContaining("HTTP 404"),
})),
});
} else {
expect(log.mock.calls.flat().join(" ")).toContain("HTTP 404");
}
for (const name of scenario.names) {
expect(existsSync(join(dir, "compositions", `${name}.html`))).toBe(
scenario.installed.includes(name),
);
}
});
});
19 changes: 14 additions & 5 deletions packages/cli/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,18 @@ export function buildSnippet(
return "";
}

/** `add <tag> --json` output: each item's warnings, prefixed with its name. */
export function tagAddJson(tag: string, results: RunAddResult[]) {
/** `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: true,
ok: errors.length === 0,
tag,
installed: results.map((r) => r.name),
warnings: results.flatMap((r) => r.warnings.map((w) => `${r.name}: ${w}`)),
errors,
};
}

Expand Down Expand Up @@ -584,6 +589,7 @@ export default defineCommand({
}

const results: RunAddResult[] = [];
const errors: Array<{ name: string; error: string }> = [];
for (const item of items) {
try {
const result = await runAdd({
Expand All @@ -600,16 +606,19 @@ export default defineCommand({
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})`);
}
}

if (json) {
console.log(JSON.stringify(tagAddJson(args.name, results)));
console.log(JSON.stringify(tagAddJson(args.name, results, errors)));
} else {
console.log("");
console.log(`${c.success("✓")} Installed ${results.length}/${items.length} blocks`);
const summary = `Installed ${results.length}/${items.length} blocks`;
console.log(errors.length > 0 ? c.error(summary) : c.success(summary));
}
if (errors.length > 0) failCommand();
}
},
});
Loading