diff --git a/.changeset/registry-item-setup.md b/.changeset/registry-item-setup.md new file mode 100644 index 000000000..a8f87eca1 --- /dev/null +++ b/.changeset/registry-item-setup.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +`eve add` now asks before running an official registry item's setup and prints a resumable command when setup is skipped or cancelled. Run `eve add --skip-install` to launch setup later without reinstalling the item. diff --git a/apps/docs/registry.json b/apps/docs/registry.json index 5ecd24f75..dda92c36c 100644 --- a/apps/docs/registry.json +++ b/apps/docs/registry.json @@ -661,6 +661,8 @@ "eve": { "setup": { "command": "eve", + "package": "eve", + "bin": "eve", "args": ["integration", "setup", "slack"] }, "requires": ">=0.27.8" @@ -780,6 +782,8 @@ "eve": { "setup": { "command": "eve", + "package": "eve", + "bin": "eve", "args": ["integration", "setup", "web"] }, "requires": ">=0.27.8" diff --git a/apps/docs/scripts/validate-channel-registry.ts b/apps/docs/scripts/validate-channel-registry.ts index 73db8eed5..11c65402c 100644 --- a/apps/docs/scripts/validate-channel-registry.ts +++ b/apps/docs/scripts/validate-channel-registry.ts @@ -16,6 +16,8 @@ interface RegistryItem { eve?: { setup?: { command?: string; + package?: string; + bin?: string; args?: string[]; }; }; @@ -87,14 +89,31 @@ if (JSON.stringify(actualSlugs) !== JSON.stringify(expectedSlugs)) { } for (const [index, item] of items.entries()) { + const setup = item.meta?.eve?.setup; + if ( + setup !== undefined && + (setup.command === undefined || + setup.package === undefined || + setup.bin === undefined || + setup.args === undefined) + ) { + throw new Error( + `Registry item "${item.name}" setup must declare command, package, bin, and args during the migration.`, + ); + } + const entry = galleryEntries[index]; if (entry === undefined) throw new Error(`Unexpected channel registry item "${item.name}".`); const registrySlug = expectedSlugs[index]; if (entry.slug === "slack" || entry.slug === "eve") { - const setup = item.meta?.eve?.setup; const expectedArgs = ["integration", "setup", registrySlug]; - if (setup?.command !== "eve" || JSON.stringify(setup.args) !== JSON.stringify(expectedArgs)) { + if ( + setup?.command !== "eve" || + setup.package !== "eve" || + setup.bin !== "eve" || + JSON.stringify(setup.args) !== JSON.stringify(expectedArgs) + ) { throw new Error( `Registry item "${item.name}" must delegate setup to eve integration setup ${registrySlug}.`, ); diff --git a/apps/docs/scripts/validate-connection-registry.ts b/apps/docs/scripts/validate-connection-registry.ts index d74ae8cec..54e22531f 100644 --- a/apps/docs/scripts/validate-connection-registry.ts +++ b/apps/docs/scripts/validate-connection-registry.ts @@ -13,6 +13,16 @@ interface RegistryItem { dependencies?: string[]; envVars?: Record; files?: RegistryFile[]; + meta?: { + eve?: { + setup?: { + command?: string; + package?: string; + bin?: string; + args?: string[]; + }; + }; + }; } interface Registry { @@ -34,6 +44,19 @@ if (JSON.stringify(actualSlugs) !== JSON.stringify(expectedSlugs)) { } for (const item of items) { + const setup = item.meta?.eve?.setup; + if ( + setup !== undefined && + (setup.command === undefined || + setup.package === undefined || + setup.bin === undefined || + setup.args === undefined) + ) { + throw new Error( + `Registry item "${item.name}" setup must declare command, package, bin, and args during the migration.`, + ); + } + const slug = item.name.slice("connection/".length); const expectedPath = `registry/connections/${slug}.ts`; const expectedTarget = `agent/connections/${slug}.ts`; diff --git a/docs/install-integrations.mdx b/docs/install-integrations.mdx index 5f0d4cb85..a4fac1039 100644 --- a/docs/install-integrations.mdx +++ b/docs/install-integrations.mdx @@ -18,6 +18,8 @@ eve add instrumentation/braintrust Extensions may create a mount under `agent/extensions/`. Connections write their initial definition under `agent/connections/` and install `@vercel/connect` when required. Instrumentation providers write `agent/instrumentation.ts`; because an agent has one instrumentation file, compose multiple exporters there by hand. Configure generated files and required environment variables before running your agent. +When an official item has an interactive setup flow, eve asks whether to run it after installation. Run the printed `eve add --skip-install` command to resume a skipped or cancelled setup later. + ## Find an integration Browse the [Integrations directory](/integrations) to see the official integrations available from the eve registry. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 4fee40001..f99ca05e9 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -87,6 +87,7 @@ Commands for installing and discovering [shadcn registry](https://ui.shadcn.com/ ```bash eve add extension/agent-browser +eve add channel/slack --skip-install eve add https://example.com/r/my-extension.json --overwrite eve registry add @acme=https://example.com/r/{name}.json eve registry search browser @@ -95,6 +96,8 @@ eve registry view @acme/my-extension eve add @acme/my-extension ``` +`eve add` asks before running setup declared by an official item and prints the matching `eve add --skip-install` command when setup is skipped or cancelled. `--skip-install` reruns setup without reinstalling the item. + `eve registry add` records configured sources in `package.json#registries`. `eve registry list` and `search` aggregate the official catalog and all configured sources by default, or browse one supplied URL or namespace. Official and other universal items with explicit file targets do not require shadcn project configuration. ## `eve info` diff --git a/packages/eve/src/cli/commands/channels.integration.test.ts b/packages/eve/src/cli/commands/channels.integration.test.ts index cd99b81b7..b9e5dc295 100644 --- a/packages/eve/src/cli/commands/channels.integration.test.ts +++ b/packages/eve/src/cli/commands/channels.integration.test.ts @@ -52,7 +52,7 @@ describe("runChannelsAddCompatibilityCommand", () => { }); }); - it("forwards --yes to the hidden setup command", async () => { + it("forwards --yes to registry setup", async () => { const output = logger(); const runAdd = vi.fn(async () => {}); @@ -64,7 +64,7 @@ describe("runChannelsAddCompatibilityCommand", () => { ); expect(runAdd).toHaveBeenCalledWith(output, "/project", "channel/slack", { - setupArgs: ["--yes"], + yes: true, }); }); diff --git a/packages/eve/src/cli/commands/channels.ts b/packages/eve/src/cli/commands/channels.ts index 7d5abc874..008827e83 100644 --- a/packages/eve/src/cli/commands/channels.ts +++ b/packages/eve/src/cli/commands/channels.ts @@ -53,7 +53,7 @@ export async function runChannelsAddCompatibilityCommand( const runAdd = await dependencies.loadAddCommand(); const addOptions: AddCommandOptions = {}; if (args.options.force === true) addOptions.overwrite = true; - if (args.options.yes === true) addOptions.setupArgs = ["--yes"]; + if (args.options.yes === true) addOptions.yes = true; await runAdd(logger, appRoot, `channel/${kind}`, addOptions); return; } diff --git a/packages/eve/src/cli/commands/integration-setup.ts b/packages/eve/src/cli/commands/integration-setup.ts index 6a1d5627f..a8937cd89 100644 --- a/packages/eve/src/cli/commands/integration-setup.ts +++ b/packages/eve/src/cli/commands/integration-setup.ts @@ -82,7 +82,10 @@ export async function runIntegrationSetupCommand( skipDependencyMutation: true, deps: dependencies.addChannelsDeps, }); - if (result.kind === "cancelled") return; + if (result.kind === "cancelled") { + if (process.env.EVE_SETUP === "1") process.exitCode = 130; + return; + } let finalState = result.state; const addedVercelChannel = finalState.slackbotAttached || diff --git a/packages/eve/src/cli/commands/register-registry-commands.ts b/packages/eve/src/cli/commands/register-registry-commands.ts index 5c54ddbf4..213915aba 100644 --- a/packages/eve/src/cli/commands/register-registry-commands.ts +++ b/packages/eve/src/cli/commands/register-registry-commands.ts @@ -17,10 +17,18 @@ export function registerRegistryCommands(input: { .command("add ") .description("Install a registry item; relative paths use the official eve registry.") .option("-o, --overwrite", "Overwrite existing files.") - .action(async (item: string, options: { overwrite?: boolean }) => { - const { runAddCommand } = await import("./registry.js"); - await runAddCommand(logger, appRoot, item, options); - }); + .option("--skip-install", "Run the item's setup flow without installing it.") + .option("--skip-setup", "Skip the item's setup flow.") + .option("-y, --yes", "Run setup and accept its recommended defaults.") + .action( + async ( + item: string, + options: { skipInstall?: boolean; overwrite?: boolean; skipSetup?: boolean; yes?: boolean }, + ) => { + const { runAddCommand } = await import("./registry.js"); + await runAddCommand(logger, appRoot, item, options); + }, + ); const registry = program .command("registry") diff --git a/packages/eve/src/cli/commands/registry-setup-command.test.ts b/packages/eve/src/cli/commands/registry-setup-command.test.ts new file mode 100644 index 000000000..bb2c2c117 --- /dev/null +++ b/packages/eve/src/cli/commands/registry-setup-command.test.ts @@ -0,0 +1,164 @@ +import { EventEmitter } from "node:events"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { runRegistrySetupCommand } from "./registry-setup-command.js"; + +const { findPackageJSON, readFile, spawn } = vi.hoisted(() => ({ + findPackageJSON: vi.fn(), + readFile: vi.fn(), + spawn: vi.fn(), +})); + +vi.mock("node:fs/promises", () => ({ readFile })); +vi.mock("node:module", () => ({ findPackageJSON })); +vi.mock("node:child_process", () => ({ spawn })); + +function childThatCloses(code: number | null, signal: NodeJS.Signals | null = null) { + const child = new EventEmitter(); + setTimeout(() => child.emit("close", code, signal), 0); + return child; +} + +describe("runRegistrySetupCommand", () => { + beforeEach(() => { + vi.clearAllMocks(); + findPackageJSON.mockReturnValue("/project/node_modules/@acme/slack/package.json"); + readFile.mockResolvedValue( + JSON.stringify({ + name: "@acme/slack", + bin: { "acme-slack": "./dist/cli.js", other: "./dist/other.js" }, + }), + ); + }); + + it("runs a package's declared binary directly with Node", async () => { + spawn.mockReturnValue(childThatCloses(0)); + + await expect( + runRegistrySetupCommand( + "/project", + { package: "@acme/slack", bin: "acme-slack", args: ["setup"] }, + "channel/slack", + ), + ).resolves.toBe("completed"); + + expect(findPackageJSON).toHaveBeenCalledWith("@acme/slack", expect.any(URL)); + expect(spawn).toHaveBeenCalledWith( + process.execPath, + ["/project/node_modules/@acme/slack/dist/cli.js", "setup"], + expect.objectContaining({ + cwd: "/project", + env: expect.objectContaining({ EVE_SETUP: "1", EVE_SETUP_ITEM: "channel/slack" }), + stdio: "inherit", + }), + ); + }); + + it("runs the declared eve binary without a package-manager shim", async () => { + findPackageJSON.mockReturnValue("/project/node_modules/eve/package.json"); + readFile.mockResolvedValue(JSON.stringify({ name: "eve", bin: { eve: "./bin/eve.js" } })); + spawn.mockReturnValue(childThatCloses(0)); + + await runRegistrySetupCommand( + "/project", + { package: "eve", bin: "eve", args: ["integration", "setup", "slack"] }, + "channel/slack", + ); + + expect(spawn).toHaveBeenCalledWith( + process.execPath, + ["/project/node_modules/eve/bin/eve.js", "integration", "setup", "slack"], + expect.any(Object), + ); + }); + + it.each([ + [130, null], + [null, "SIGINT"], + ] as const)("maps exit %s and signal %s to cancellation", async (code, signal) => { + spawn.mockReturnValue(childThatCloses(code, signal)); + + await expect( + runRegistrySetupCommand( + "/project", + { package: "@acme/slack", bin: "acme-slack", args: ["setup"] }, + "channel/slack", + ), + ).resolves.toBe("cancelled"); + }); + + it("resolves string-form bin using the installed package's unscoped name", async () => { + readFile.mockResolvedValue( + JSON.stringify({ name: "@renamed/installed-slack", bin: "./dist/cli.js" }), + ); + spawn.mockReturnValue(childThatCloses(0)); + + await runRegistrySetupCommand( + "/project", + { package: "registry-package-alias", bin: "installed-slack", args: [] }, + "channel/slack", + ); + + expect(spawn).toHaveBeenCalledWith( + process.execPath, + ["/project/node_modules/@acme/slack/dist/cli.js"], + expect.any(Object), + ); + }); + + it("rejects a binary the installed package does not declare", async () => { + await expect( + runRegistrySetupCommand( + "/project", + { package: "@acme/slack", bin: "something-else", args: [] }, + "channel/slack", + ), + ).rejects.toThrow('Package "@acme/slack" does not declare a "something-else" binary.'); + expect(spawn).not.toHaveBeenCalled(); + }); + + it("rejects a declared binary outside the package directory", async () => { + readFile.mockResolvedValue( + JSON.stringify({ name: "@acme/slack", bin: { "acme-slack": "../escape.js" } }), + ); + + await expect( + runRegistrySetupCommand( + "/project", + { package: "@acme/slack", bin: "acme-slack", args: [] }, + "channel/slack", + ), + ).rejects.toThrow( + 'Package "@acme/slack" declares its "acme-slack" binary outside the package directory.', + ); + expect(spawn).not.toHaveBeenCalled(); + }); + + it("reports a package without binaries as not declaring the requested binary", async () => { + readFile.mockResolvedValue(JSON.stringify({ name: "@acme/slack" })); + + await expect( + runRegistrySetupCommand( + "/project", + { package: "@acme/slack", bin: "acme-slack", args: [] }, + "channel/slack", + ), + ).rejects.toThrow('Package "@acme/slack" does not declare a "acme-slack" binary.'); + }); + + it("does not download a missing setup package", async () => { + findPackageJSON.mockReturnValue(undefined); + + await expect( + runRegistrySetupCommand( + "/project", + { package: "@acme/slack", bin: "acme-slack", args: ["setup"] }, + "channel/slack", + ), + ).rejects.toThrow( + 'Setup package "@acme/slack" is not installed. Run `eve add channel/slack` first.', + ); + expect(spawn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eve/src/cli/commands/registry-setup-command.ts b/packages/eve/src/cli/commands/registry-setup-command.ts index 9ae95a795..33ef063d8 100644 --- a/packages/eve/src/cli/commands/registry-setup-command.ts +++ b/packages/eve/src/cli/commands/registry-setup-command.ts @@ -1,24 +1,83 @@ import { spawn } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { findPackageJSON } from "node:module"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { z } from "#compiled/zod/index.js"; export interface RegistrySetupCommand { - command: string; + package: string; + bin: string; args: string[]; } -/** Executes a trusted registry setup command in the consuming project. */ -export function runRegistrySetupCommand( +export type RegistrySetupCommandResult = "completed" | "cancelled"; + +const PackageJsonSchema = z.object({ + name: z.string().min(1), + bin: z.union([z.string(), z.record(z.string(), z.string())]).optional(), +}); + +type PackageJson = z.infer; + +function declaredBinPath(packageJson: PackageJson, binName: string): string | undefined { + if (typeof packageJson.bin === "string") { + const defaultBinName = packageJson.name.slice(packageJson.name.lastIndexOf("/") + 1); + return binName === defaultBinName ? packageJson.bin : undefined; + } + return packageJson.bin?.[binName]; +} + +async function resolveNodePackageBin(packageJsonPath: string, binName: string): Promise { + const packageRoot = dirname(packageJsonPath); + const packageJson = PackageJsonSchema.parse(JSON.parse(await readFile(packageJsonPath, "utf8"))); + const declaredPath = declaredBinPath(packageJson, binName); + if (declaredPath === undefined) { + throw new Error(`Package "${packageJson.name}" does not declare a "${binName}" binary.`); + } + + const executable = resolve(packageRoot, declaredPath); + const packageRelativePath = relative(packageRoot, executable); + if (packageRelativePath.startsWith("..") || isAbsolute(packageRelativePath)) { + throw new Error( + `Package "${packageJson.name}" declares its "${binName}" binary outside the package directory.`, + ); + } + return executable; +} + +/** Executes a trusted registry setup command from an installed package's declared Node binary. */ +export async function runRegistrySetupCommand( appRoot: string, setup: RegistrySetupCommand, -): Promise { - const command = setup.command; - const args = setup.args; + item: string, +): Promise { + const packageJsonPath = findPackageJSON( + setup.package, + pathToFileURL(resolve(appRoot, "package.json")), + ); + if (packageJsonPath === undefined) { + throw new Error( + `Setup package "${setup.package}" is not installed. Run \`eve add ${item}\` first.`, + ); + } + const executable = await resolveNodePackageBin(packageJsonPath, setup.bin); - return new Promise((resolve, reject) => { - const child = spawn(command, args, { cwd: appRoot, stdio: "inherit" }); + return new Promise((resolveResult, reject) => { + const child = spawn(process.execPath, [executable, ...setup.args], { + cwd: appRoot, + env: { ...process.env, EVE_SETUP: "1", EVE_SETUP_ITEM: item }, + stdio: "inherit", + }); child.once("error", reject); child.once("close", (code, signal) => { if (code === 0) { - resolve(); + resolveResult("completed"); + return; + } + if (code === 130 || signal === "SIGINT") { + resolveResult("cancelled"); return; } reject( diff --git a/packages/eve/src/cli/commands/registry.test.ts b/packages/eve/src/cli/commands/registry.test.ts index 872b14c24..91d781ecf 100644 --- a/packages/eve/src/cli/commands/registry.test.ts +++ b/packages/eve/src/cli/commands/registry.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createFakePrompter } from "#internal/testing/fake-prompter.js"; import { runAddCommand, runRegistryAddCommand, @@ -91,14 +92,14 @@ describe("registry commands", () => { "installs the official %s item before running its declared setup", async (kind) => { const logger = createLogger(); - const runSetupCommand = vi.fn(async () => {}); + const runSetupCommand = vi.fn(async () => "completed" as const); getRegistryItems.mockResolvedValue([ { name: `channel/${kind}`, type: "registry:item", meta: { eve: { - setup: { command: "eve", args: ["integration", "setup", kind] }, + setup: { package: "eve", bin: "eve", args: ["integration", "setup", kind] }, }, }, }, @@ -108,7 +109,7 @@ describe("registry commands", () => { logger, "/project", `channel/${kind}`, - { overwrite: true }, + { overwrite: true, yes: true }, { loadSetupCommandRunner: async () => runSetupCommand, }, @@ -118,16 +119,174 @@ describe("registry commands", () => { expect(addRegistryItems.mock.invocationCallOrder[0]).toBeLessThan( runSetupCommand.mock.invocationCallOrder[0]!, ); - expect(runSetupCommand).toHaveBeenCalledWith("/project", { - command: "eve", - args: ["integration", "setup", kind], - }); + expect(runSetupCommand).toHaveBeenCalledWith( + "/project", + { + package: "eve", + bin: "eve", + args: ["integration", "setup", kind, "--yes"], + }, + `channel/${kind}`, + ); }, ); + it("skips setup in non-interactive use and prints the resume command", async () => { + const logger = createLogger(); + const runSetup = vi.fn(async () => "completed" as const); + getRegistryItems.mockResolvedValue([ + { + meta: { eve: { setup: { package: "@acme/slack", bin: "eve-slack", args: ["setup"] } } }, + }, + ]); + + await runAddCommand( + logger, + "/project", + "channel/slack", + {}, + { + isInteractive: () => false, + loadSetupCommandRunner: async () => runSetup, + }, + ); + + expect(runSetup).not.toHaveBeenCalled(); + expect(logger.logs).toEqual([ + "Setup skipped. Run `eve add channel/slack --skip-install` when you're ready.", + ]); + }); + + it("asks before setup and prints the resume command when declined", async () => { + const logger = createLogger(); + const runSetup = vi.fn(async () => "completed" as const); + const fake = createFakePrompter({ single: () => "no" }); + getRegistryItems.mockResolvedValue([ + { + meta: { eve: { setup: { package: "@acme/slack", bin: "eve-slack", args: ["setup"] } } }, + }, + ]); + + await runAddCommand( + logger, + "/project", + "channel/slack", + {}, + { + createPrompter: () => fake.prompter, + isInteractive: () => true, + loadSetupCommandRunner: async () => runSetup, + }, + ); + + expect(fake.selectMessages).toEqual(["Set up channel/slack now?"]); + expect(runSetup).not.toHaveBeenCalled(); + expect(logger.logs).toEqual([ + "Setup skipped. Run `eve add channel/slack --skip-install` when you're ready.", + ]); + }); + + it("prints the resume command when the setup CLI cancels", async () => { + const logger = createLogger(); + const runSetup = vi.fn(async () => "cancelled" as const); + getRegistryItems.mockResolvedValue([ + { + meta: { eve: { setup: { package: "@acme/slack", bin: "eve-slack", args: ["setup"] } } }, + }, + ]); + + await runAddCommand( + logger, + "/project", + "channel/slack", + { yes: true }, + { + loadSetupCommandRunner: async () => runSetup, + }, + ); + + expect(logger.logs).toEqual([ + "Setup cancelled. Run `eve add channel/slack --skip-install` when you're ready.", + ]); + expect(process.exitCode).toBeUndefined(); + }); + + it("runs setup directly without installing the item", async () => { + const logger = createLogger(); + const runSetup = vi.fn(async () => "completed" as const); + getRegistryItems.mockResolvedValue([ + { + meta: { eve: { setup: { package: "@acme/slack", bin: "eve-slack", args: ["setup"] } } }, + }, + ]); + + await runAddCommand( + logger, + "/project", + "channel/slack", + { skipInstall: true, yes: true }, + { + loadSetupCommandRunner: async () => runSetup, + }, + ); + + expect(addRegistryItems).not.toHaveBeenCalled(); + expect(runSetup).toHaveBeenCalledWith( + "/project", + { package: "@acme/slack", bin: "eve-slack", args: ["setup", "--yes"] }, + "channel/slack", + ); + }); + + it("rejects --overwrite with --skip-install", async () => { + const logger = createLogger(); + + await runAddCommand(logger, "/project", "channel/slack", { + skipInstall: true, + overwrite: true, + }); + + expect(logger.errors).toEqual(["--overwrite cannot be used with --skip-install."]); + expect(getRegistryItems).not.toHaveBeenCalled(); + expect(addRegistryItems).not.toHaveBeenCalled(); + }); + + it("rejects --skip-setup with --skip-install", async () => { + const logger = createLogger(); + + await runAddCommand(logger, "/project", "channel/slack", { + skipInstall: true, + skipSetup: true, + }); + + expect(logger.errors).toEqual(["--skip-install cannot be used with --skip-setup."]); + expect(getRegistryItems).not.toHaveBeenCalled(); + expect(addRegistryItems).not.toHaveBeenCalled(); + }); + + it("rejects setup for third-party items", async () => { + const logger = createLogger(); + + await runAddCommand( + logger, + "/project", + "@acme/slack", + { skipInstall: true }, + { + loadSetupCommandRunner: vi.fn(), + }, + ); + + expect(logger.errors).toEqual([ + "Setup flows are currently supported only for official eve registry items.", + ]); + expect(getRegistryItems).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + it("does not infer setup from the item address", async () => { const logger = createLogger(); - const runSetupCommand = vi.fn(async () => {}); + const runSetupCommand = vi.fn(async () => "completed" as const); getRegistryItems.mockResolvedValue([{ name: "channel/web", type: "registry:item" }]); await runAddCommand( @@ -146,14 +305,14 @@ describe("registry commands", () => { it("does not execute setup metadata from a URL item", async () => { const logger = createLogger(); - const runSetupCommand = vi.fn(async () => {}); + const runSetupCommand = vi.fn(async () => "completed" as const); getRegistryItems.mockResolvedValue([ { name: "channel/web", type: "registry:item", meta: { eve: { - setup: { command: "eve", args: ["integration", "setup", "web"] }, + setup: { package: "eve", bin: "eve", args: ["integration", "setup", "web"] }, }, }, }, @@ -173,7 +332,7 @@ describe("registry commands", () => { expect(addRegistryItems).toHaveBeenCalledOnce(); }); - it("rejects invalid official setup metadata before installation", async () => { + it("accepts any declared package binary from trusted official metadata", async () => { const logger = createLogger(); getRegistryItems.mockResolvedValue([ { @@ -181,17 +340,16 @@ describe("registry commands", () => { type: "registry:item", meta: { eve: { - setup: { command: "sh", args: ["-c", "echo nope"] }, + setup: { package: "shell-package", bin: "sh", args: ["-c", "echo nope"] }, }, }, }, ]); - await runAddCommand(logger, "/project", "channel/unknown", {}); + await runAddCommand(logger, "/project", "channel/unknown", { skipSetup: true }); - expect(logger.errors).toHaveLength(1); - expect(addRegistryItems).not.toHaveBeenCalled(); - expect(process.exitCode).toBe(1); + expect(logger.errors).toEqual([]); + expect(addRegistryItems).toHaveBeenCalledOnce(); }); it("rejects an item that requires a newer eve before installation", async () => { @@ -203,7 +361,7 @@ describe("registry commands", () => { meta: { eve: { requires: ">=0.30.0", - setup: { command: "eve", args: ["integration", "setup", "web"] }, + setup: { package: "eve", bin: "eve", args: ["integration", "setup", "web"] }, }, }, }, diff --git a/packages/eve/src/cli/commands/registry.ts b/packages/eve/src/cli/commands/registry.ts index 1d0ccc239..1aa3d24c3 100644 --- a/packages/eve/src/cli/commands/registry.ts +++ b/packages/eve/src/cli/commands/registry.ts @@ -7,7 +7,9 @@ import { import semver from "#compiled/semver/index.js"; import { z } from "#compiled/zod/index.js"; import { resolveInstalledPackageInfo } from "#internal/application/package.js"; +import { createPrompter, type Prompter } from "#setup/prompter.js"; import { isEveProject } from "#setup/scaffold/index.js"; +import { WizardCancelledError } from "#setup/step.js"; import { NOT_AN_AGENT_MESSAGE } from "./preconditions.js"; import type { runRegistrySetupCommand } from "./registry-setup-command.js"; @@ -19,16 +21,26 @@ export interface RegistryCommandLogger { } export interface AddCommandOptions { + skipInstall?: boolean; overwrite?: boolean; - /** Arguments forwarded to a trusted setup command after installation. */ - setupArgs?: string[]; + skipSetup?: boolean; + yes?: boolean; } -export interface AddCommandDependencies { +type SetupCommandOptions = Pick; + +export interface RegistrySetupDependencies { loadSetupCommandRunner(): Promise; } +export interface AddCommandDependencies extends RegistrySetupDependencies { + createPrompter?: () => Prompter; + isInteractive?: () => boolean; +} + const defaultAddCommandDependencies: AddCommandDependencies = { + createPrompter, + isInteractive: () => process.stdin.isTTY === true && process.stdout.isTTY === true, loadSetupCommandRunner: async () => (await import("./registry-setup-command.js")).runRegistrySetupCommand, }; @@ -51,7 +63,11 @@ export async function installOfficialRegistryItem( options: AddCommandOptions = {}, ): Promise { const config = await readRegistryConfig(appRoot); - await addRegistryItems([itemAddress(item)], { ...options, config, cwd: appRoot }); + await addRegistryItems([itemAddress(item)], { + config, + cwd: appRoot, + overwrite: options.overwrite, + }); } const EveRegistryItemMetadataSchema = z.object({ @@ -62,8 +78,9 @@ const EveRegistryItemMetadataSchema = z.object({ requires: z.string().optional(), setup: z .object({ - command: z.literal("eve"), - args: z.tuple([z.literal("integration"), z.literal("setup"), z.string().min(1)]), + package: z.string().min(1), + bin: z.string().min(1), + args: z.array(z.string()).default([]), }) .optional(), }) @@ -76,6 +93,16 @@ function eveMetadataFromRegistryItem(item: unknown) { return EveRegistryItemMetadataSchema.parse(item).meta?.eve; } +function setupResumeCommand(item: string): string { + const argument = /^[\w@./:-]+$/.test(item) ? item : `'${item.replaceAll("'", `'\\''`)}'`; + return `eve add ${argument} --skip-install`; +} + +function setupReminder(item: string, outcome: "cancelled" | "skipped"): string { + const action = outcome === "cancelled" ? "Setup cancelled." : "Setup skipped."; + return `${action} Run \`${setupResumeCommand(item)}\` when you're ready.`; +} + function assertCompatibleEveVersion(requiredVersion: string | undefined): void { if (requiredVersion === undefined) return; const installedVersion = resolveInstalledPackageInfo().version; @@ -173,6 +200,28 @@ async function browseRegistryItems( if (errors.length > 0) process.exitCode = 1; } +async function runDeclaredSetup( + logger: RegistryCommandLogger, + appRoot: string, + item: string, + setup: NonNullable>["setup"], + options: SetupCommandOptions, + dependencies: RegistrySetupDependencies, +): Promise { + if (setup === undefined) return; + const runSetupCommand = await dependencies.loadSetupCommandRunner(); + try { + const result = await runSetupCommand( + appRoot, + { ...setup, args: [...setup.args, ...(options.yes ? ["--yes"] : [])] }, + item, + ); + if (result === "cancelled") logger.log(setupReminder(item, "cancelled")); + } catch (error) { + throw new Error(`${errorMessage(error)} Try again with \`${setupResumeCommand(item)}\`.`); + } +} + /** Installs an official, configured, or URL-addressed registry item. */ export async function runAddCommand( logger: RegistryCommandLogger, @@ -184,22 +233,67 @@ export async function runAddCommand( await runRegistryAction(logger, appRoot, async () => { const config = await readRegistryConfig(appRoot); const address = itemAddress(item); + if (options.skipInstall === true) { + if (options.overwrite === true) { + throw new Error("--overwrite cannot be used with --skip-install."); + } + if (options.skipSetup === true) { + throw new Error("--skip-install cannot be used with --skip-setup."); + } + if (!isOfficialItemAddress(address)) { + throw new Error( + "Setup flows are currently supported only for official eve registry items.", + ); + } + } const [registryItem] = await getRegistryItems([address], { config }); const eveMetadata = isOfficialItemAddress(address) ? eveMetadataFromRegistryItem(registryItem) : undefined; assertCompatibleEveVersion(eveMetadata?.requires); - const installOptions = { config, cwd: appRoot, overwrite: options.overwrite }; - await addRegistryItems([address], installOptions); + if (options.skipInstall === true) { + if (eveMetadata?.setup === undefined) { + throw new Error(`Registry item "${item}" does not declare a setup flow.`); + } + await runDeclaredSetup(logger, appRoot, item, eveMetadata.setup, options, dependencies); + return; + } + + await addRegistryItems([address], { config, cwd: appRoot, overwrite: options.overwrite }); + if (eveMetadata?.setup === undefined) return; - if (eveMetadata?.setup !== undefined) { - const runSetupCommand = await dependencies.loadSetupCommandRunner(); - await runSetupCommand(appRoot, { - ...eveMetadata.setup, - args: [...eveMetadata.setup.args, ...(options.setupArgs ?? [])], - }); + const isInteractive = + dependencies.isInteractive?.() ?? defaultAddCommandDependencies.isInteractive!(); + if (options.skipSetup === true || (!options.yes && !isInteractive)) { + logger.log(setupReminder(item, "skipped")); + return; } + + if (!options.yes) { + try { + const prompter = + dependencies.createPrompter?.() ?? defaultAddCommandDependencies.createPrompter!(); + const shouldRun = await prompter.select({ + message: `Set up ${item} now?`, + initialValue: "yes", + options: [ + { value: "yes", label: "Yes" }, + { value: "no", label: "No" }, + ], + }); + if (shouldRun === "no") { + logger.log(setupReminder(item, "skipped")); + return; + } + } catch (error) { + if (!(error instanceof WizardCancelledError)) throw error; + logger.log(setupReminder(item, "cancelled")); + return; + } + } + + await runDeclaredSetup(logger, appRoot, item, eveMetadata.setup, options, dependencies); }); } diff --git a/packages/eve/src/cli/run.test.ts b/packages/eve/src/cli/run.test.ts index 19c9c2261..0433cc7eb 100644 --- a/packages/eve/src/cli/run.test.ts +++ b/packages/eve/src/cli/run.test.ts @@ -53,10 +53,10 @@ describe("CLI command registration", () => { expect(help).toContain("link"); expect(help).toContain("deploy"); expect(help).toContain("registry"); - expect(help).not.toContain("setup"); + expect(help).not.toContain("setup [options] "); }); - it("keeps registry installation options minimal", async () => { + it("lists registry installation and setup options", async () => { const output: string[] = []; const logger = { error: (message: string) => output.push(message), @@ -67,7 +67,9 @@ describe("CLI command registration", () => { const help = output.join("\n"); expect(help).toContain("--overwrite"); - expect(help).not.toContain("--yes"); + expect(help).toContain("--skip-install"); + expect(help).toContain("--skip-setup"); + expect(help).toContain("--yes"); expect(help).not.toContain("--silent"); expect(help).not.toContain("--skip-fonts"); expect(help).not.toContain("--path");