feat(eve): add resumable registry setup with skip flags (#1304)

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
This commit is contained in:
OwenKephart
2026-07-29 12:37:26 -07:00
committed by GitHub
parent 5d25b16ad6
commit d257866e64
15 changed files with 598 additions and 54 deletions
+5
View File
@@ -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 <item> --skip-install` to launch setup later without reinstalling the item.
+4
View File
@@ -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"
+21 -2
View File
@@ -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}.`,
);
@@ -13,6 +13,16 @@ interface RegistryItem {
dependencies?: string[];
envVars?: Record<string, string>;
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`;
+2
View File
@@ -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 <item> --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.
+3
View File
@@ -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 <item> --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`
@@ -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,
});
});
+1 -1
View File
@@ -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;
}
@@ -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 ||
@@ -17,10 +17,18 @@ export function registerRegistryCommands(input: {
.command("add <item>")
.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")
@@ -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();
});
});
@@ -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<typeof PackageJsonSchema>;
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<string> {
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<void> {
const command = setup.command;
const args = setup.args;
item: string,
): Promise<RegistrySetupCommandResult> {
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<void>((resolve, reject) => {
const child = spawn(command, args, { cwd: appRoot, stdio: "inherit" });
return new Promise<RegistrySetupCommandResult>((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(
+175 -17
View File
@@ -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"] },
},
},
},
+109 -15
View File
@@ -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<AddCommandOptions, "yes">;
export interface RegistrySetupDependencies {
loadSetupCommandRunner(): Promise<typeof runRegistrySetupCommand>;
}
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<void> {
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<ReturnType<typeof eveMetadataFromRegistryItem>>["setup"],
options: SetupCommandOptions,
dependencies: RegistrySetupDependencies,
): Promise<void> {
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 (eveMetadata?.setup !== undefined) {
const runSetupCommand = await dependencies.loadSetupCommandRunner();
await runSetupCommand(appRoot, {
...eveMetadata.setup,
args: [...eveMetadata.setup.args, ...(options.setupArgs ?? [])],
});
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;
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);
});
}
+5 -3
View File
@@ -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] <item>");
});
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");