mirror of
https://github.com/vercel/eve.git
synced 2026-09-20 05:35:39 +08:00
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:
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user