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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/registry-item-setup.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions apps/docs/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,8 @@
"meta": {
"eve": {
"setup": {
"command": "eve",
"package": "eve",
"bin": "eve",
"args": ["integration", "setup", "slack"]
},
"requires": ">=0.27.8"
Expand Down Expand Up @@ -779,7 +780,8 @@
"meta": {
"eve": {
"setup": {
"command": "eve",
"package": "eve",
"bin": "eve",
"args": ["integration", "setup", "web"]
},
"requires": ">=0.27.8"
Expand Down
9 changes: 7 additions & 2 deletions apps/docs/scripts/validate-channel-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ interface RegistryItem {
meta?: {
eve?: {
setup?: {
command?: string;
package?: string;
bin?: string;
args?: string[];
};
};
Expand Down Expand Up @@ -94,7 +95,11 @@ for (const [index, item] of items.entries()) {
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?.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}.`,
);
Expand Down
2 changes: 2 additions & 0 deletions docs/install-integrations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`
Expand Down
4 changes: 2 additions & 2 deletions packages/eve/src/cli/commands/channels.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {});

Expand All @@ -64,7 +64,7 @@ describe("runChannelsAddCompatibilityCommand", () => {
);

expect(runAdd).toHaveBeenCalledWith(output, "/project", "channel/slack", {
setupArgs: ["--yes"],
yes: true,
});
});

Expand Down
2 changes: 1 addition & 1 deletion packages/eve/src/cli/commands/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
5 changes: 4 additions & 1 deletion packages/eve/src/cli/commands/integration-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down
16 changes: 12 additions & 4 deletions packages/eve/src/cli/commands/register-registry-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
164 changes: 164 additions & 0 deletions packages/eve/src/cli/commands/registry-setup-command.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
77 changes: 68 additions & 9 deletions packages/eve/src/cli/commands/registry-setup-command.ts
Original file line number Diff line number Diff line change
@@ -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",
Comment thread
OwenKephart marked this conversation as resolved.
});
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(
Expand Down
Loading
Loading