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
78 changes: 77 additions & 1 deletion packages/cli/src/commands/add.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { parseArgs as parseCittyArgs, type ArgsDef } from "citty";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { RegistryItem, RegistryManifest } from "@hyperframes/core";
import {
import addCommand, {
AddError,
buildSnippet,
describeInstallFailure,
formatExtraPositionalsError,
parseVariableValues,
remapTarget,
runAdd,
} from "./add.js";
import { trackRegistryItemAdded } from "../telemetry/events.js";
import { CliUsageError } from "../utils/commandResult.js";

// Assert the emitted payload rather than the transport: `shouldTrack()` is
// already false under test (dev mode / no PostHog key), so a real call would
Expand Down Expand Up @@ -260,6 +263,17 @@ describe("add command pure helpers", () => {
expect(buildSnippet(EXAMPLE_ITEM, "index.html")).toBe("");
});
});

describe("formatExtraPositionalsError", () => {
it("names every extra argument, singular wording for one", () => {
expect(formatExtraPositionalsError(["b"])).toContain("extra argument: b");
});

it("names every extra argument, plural wording for more than one", () => {
const msg = formatExtraPositionalsError(["b", "c"]);
expect(msg).toContain("extra arguments: b, c");
});
});
});

describe("runAdd (integration, mocked registry)", () => {
Expand Down Expand Up @@ -470,6 +484,68 @@ describe("variable values in the snippet", () => {
});
});

describe("add command run() — extra positional arguments", () => {
let dir: string;
let errorSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
dir = tmp();
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});

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

// citty binds only the first positional to `name`; `_` carries every
// positional token exactly as citty itself would populate it.
async function runCommand(args: Record<string, unknown>): Promise<void> {
await (addCommand.run as (ctx: { args: Record<string, unknown> }) => Promise<void>)({ args });
}

it("rejects `add <a> <b> <c>`, naming the dropped arguments and touching no registry", async () => {
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);

await expect(
runCommand({ name: "a", _: ["a", "b", "c"], dir, clipboard: true }),
).rejects.toThrow(CliUsageError);

// Failing fast means no partial install either: `a` never touches the
// registry, so an invalid multi-name invocation can't half-succeed and
// leave the project in a state that depends on install order.
expect(fetchSpy).not.toHaveBeenCalled();
expect(errorSpy.mock.calls.flat().join(" ")).toContain("b, c");
});

it("rejects extra positionals under real citty parsing, with a flag interleaved", async () => {
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);

// `add a --dir <dir> b`: exercises citty's own parser rather than a
// hand-built args object, proving `_` really does exclude the `--dir`
// flag and its value while still keeping both positional tokens.
const parsed = parseCittyArgs(["a", "--dir", dir, "b"], addCommand.args as ArgsDef);
expect(parsed._).toEqual(["a", "b"]);

await expect(runCommand(parsed as unknown as Record<string, unknown>)).rejects.toThrow(
CliUsageError,
);
expect(fetchSpy).not.toHaveBeenCalled();
});

it("does not fire on a normal single-item invocation", async () => {
mockFetch();
writeRegistryConfig(dir);

await expect(
runCommand({ name: "my-block", _: ["my-block"], dir, clipboard: false, json: true }),
).resolves.toBeUndefined();
});
});

describe("describeInstallFailure", () => {
it("explains a bare transport failure instead of echoing it", () => {
// What the user actually sees after copying a command off the catalog page.
Expand Down
24 changes: 23 additions & 1 deletion packages/cli/src/commands/add.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { failCommand } from "../utils/commandResult.js";
import { failCommand, failUsage } from "../utils/commandResult.js";
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";

Expand Down Expand Up @@ -380,6 +380,19 @@ export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
};
}

// ── Extra-positional-argument guard ─────────────────────────────────────────
// One item or tag per invocation is the contract — a tag is the bulk path.
// citty binds only the FIRST positional token to `name`; every token after it
// still lands in `args._` but was never read here, so `add a b c` behaved
// exactly like `add a` — same exit code, same output, `b` and `c` never
// installed and never mentioned.
export function formatExtraPositionalsError(extra: string[]): string {
return (
`add installs one item or tag per invocation. Got extra argument${extra.length === 1 ? "" : "s"}: ${extra.join(", ")}. ` +
"Run add once per item, or pass a single tag to install every item tagged with it."
);
}

// ── Command ─────────────────────────────────────────────────────────────────

export default defineCommand({
Expand Down Expand Up @@ -433,6 +446,15 @@ export default defineCommand({
const projectDir = resolve(args.dir ?? process.cwd());
const json = args.json === true;
const skipClipboard = args.clipboard === false;

const extraPositionals = args._.slice(1);
if (extraPositionals.length > 0) {
const msg = formatExtraPositionalsError(extraPositionals);
if (json) console.log(JSON.stringify({ ok: false, error: msg }));
else console.error(c.error(msg));
failUsage();
}

const hasConfigBefore = existsSync(projectConfigPath(projectDir));

// Try single item first. If it fails, check if the name matches a tag.
Expand Down
Loading