From bfe49ef30862429c5d27df490c046bdc359a5c93 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Wed, 5 Aug 2026 12:45:55 -0400 Subject: [PATCH] feat(installer): add --develop to install the develop build Each plugin repo now publishes every merge to a develop branch and only releases to main, but the installer could only reach main -- so there was no way to try a change before it shipped without hand-running four different plugin commands. `--develop` installs from the develop ref of our own distribution repos. Every agent CLI turned out to accept a ref inline, so this is one flag over one new internal option rather than a channel abstraction: Claude takes `owner/repo@ref` on `marketplace add`, Codex takes `--ref`, Grok takes `@ref` on its install source, and Cursor is a clone with `--branch`. Both builds provide the same skills, so installing one has to take the other out or two copies resolve at once. That runs through the existing cleanup hook and is symmetric -- installing develop removes the release build, including the copy from an agent's own official marketplace, and installing without the flag removes the develop build again, so the flag is not a one-way door. Deciding which build is installed is per-agent. Claude gets a distinct plugin id per channel because the two come from different marketplaces; Cursor is a checkout, so the branch is the answer. Codex and Grok install the same id from the same source either way, so the version string settles it -- the `-dev.` prerelease that dev-version.sh stamps is on every develop build and no release. `remove` deliberately takes no channel flag. It clears whichever build is present, so undoing a develop install does not depend on remembering how it was installed. --- packages/installer/README.md | 8 + .../installer/src/__tests__/harnesses.test.ts | 225 +++++++++++++++++- packages/installer/src/harnesses/channel.ts | 67 ++++++ packages/installer/src/harnesses/claude.ts | 157 +++++++++--- packages/installer/src/harnesses/codex.ts | 66 +++-- packages/installer/src/harnesses/cursor.ts | 57 ++++- packages/installer/src/harnesses/grok.ts | 48 ++-- packages/installer/src/harnesses/index.ts | 26 +- packages/installer/src/index.ts | 18 +- 9 files changed, 589 insertions(+), 83 deletions(-) create mode 100644 packages/installer/src/harnesses/channel.ts diff --git a/packages/installer/README.md b/packages/installer/README.md index 0ceef24d..d8f7e39e 100644 --- a/packages/installer/README.md +++ b/packages/installer/README.md @@ -31,6 +31,7 @@ Restart your AI tools afterward to load the plugin. npx @sentry/ai install # interactive — pick which agents to set up npx @sentry/ai install "Setup logging" # copy a custom prompt after installation npx @sentry/ai install --no-interactive # install into every detected agent +npx @sentry/ai install --develop # install the develop build instead of the release ``` When an instruction follows `install`, the installer offers to copy a prompt such as @@ -38,6 +39,13 @@ When an instruction follows `install`, the installer offers to copy a prompt suc Without an instruction, it offers the default get-started prompt. The non-interactive mode is intended for CI and unattended runs and skips this prompt. +`--develop` installs from the `develop` branch of each plugin repository, which is +rebuilt on every merge, rather than the released build. +Because both builds provide the same skills, it first removes any install of the +released plugin — including the one from the assistant’s official marketplace — so only +one copy resolves. Switching back is `install` without the flag, and a plain `remove` +takes out whichever build is present. + ## What it installs For each detected assistant, the installer runs that tool’s native plugin command: diff --git a/packages/installer/src/__tests__/harnesses.test.ts b/packages/installer/src/__tests__/harnesses.test.ts index f80ab25c..e1d108b1 100644 --- a/packages/installer/src/__tests__/harnesses.test.ts +++ b/packages/installer/src/__tests__/harnesses.test.ts @@ -16,13 +16,17 @@ const claudeList = (ids: string[]): ShellResult => ({ stdout: JSON.stringify(ids.map((id) => ({ id }))), }); -const codexList = (pluginIds: string[]): ShellResult => ({ +// Entries may carry a version, which is what distinguishes a develop build from +// a release for the harnesses whose plugin id is the same on both channels. +const codexList = (plugins: (string | { pluginId: string; version: string })[]): ShellResult => ({ ok: true, - stdout: JSON.stringify({ installed: pluginIds.map((pluginId) => ({ pluginId })) }), + stdout: JSON.stringify({ + installed: plugins.map((p) => (typeof p === "string" ? { pluginId: p } : p)), + }), }); const grokList = ( - plugins: { name: string; source: string; marketplace?: string | null }[], + plugins: { name: string; source: string; marketplace?: string | null; version?: string }[], ): ShellResult => ({ ok: true, stdout: JSON.stringify(plugins.map((p) => ({ marketplace: null, ...p }))), @@ -410,9 +414,15 @@ describe("cursor harness", () => { expect(await harness.detect()).toBe(false); }); - it("reports installed when the plugin directory exists", async () => { + it("reports installed when the checkout is on the stable branch", async () => { const target = "/home/user/.cursor/plugins/local/sentry"; - const harness = createCursor(fakeSystem({ homedir: "/home/user", existing: [target] })); + const harness = createCursor( + fakeSystem({ + homedir: "/home/user", + existing: [target], + run: () => ({ ok: true, stdout: "main\n" }), + }), + ); expect(await harness.isInstalled()).toBe(true); }); @@ -442,7 +452,7 @@ describe("cursor harness", () => { expect(outcome.kind).toBe("done"); expect(system.run).toHaveBeenCalledWith( - 'git clone https://github.com/getsentry/plugin-cursor.git "/home/user/.cursor/plugins/local/sentry"', + 'git clone --branch main https://github.com/getsentry/plugin-cursor.git "/home/user/.cursor/plugins/local/sentry"', ); }); @@ -473,3 +483,206 @@ describe("cursor harness", () => { expect(system.run).toHaveBeenCalledWith(`rmdir /s /q "${target}"`); }); }); + +// The `--develop` channel: every harness installs from the develop ref of our own +// distribution repo, and takes out whatever occupies the same slot on the other +// channel so only one copy of the skills resolves. +describe("develop channel", () => { + const DEVELOP = { ref: "develop" }; + const CURSOR_DIR = "/home/user/.cursor/plugins/local/sentry"; + + const onBranch = + (branch: string) => + (cmd: string): ShellResult => + cmd.includes("rev-parse --abbrev-ref") ? { ok: true, stdout: `${branch}\n` } : ok; + + it("claude adds our marketplace pinned to the ref and installs from it", async () => { + const system = fakeSystem({ run: () => ok }); + const outcome = await createClaude(system, DEVELOP).install(); + + expect(outcome.kind).toBe("done"); + expect(system.run).toHaveBeenCalledWith( + "claude plugin marketplace add getsentry/plugin-claude@develop", + ); + expect(system.run).toHaveBeenCalledWith( + "claude plugin install sentry@sentry-plugin-marketplace", + ); + }); + + it("claude removes the official plugin before a develop install", async () => { + const system = fakeSystem({ + run: (cmd) => (isList(cmd) ? claudeList(["sentry@claude-plugins-official"]) : ok), + }); + const cleaned = await createClaude(system, DEVELOP).cleanup?.(); + + expect(cleaned).toContain("sentry@claude-plugins-official"); + expect(system.run).toHaveBeenCalledWith( + "claude plugin uninstall sentry@claude-plugins-official", + ); + }); + + it("claude removes the develop plugin when going back to stable", async () => { + const system = fakeSystem({ + run: (cmd) => (isList(cmd) ? claudeList(["sentry@sentry-plugin-marketplace"]) : ok), + }); + const cleaned = await createClaude(system).cleanup?.(); + + expect(cleaned).toContain("sentry@sentry-plugin-marketplace"); + expect(system.run).toHaveBeenCalledWith( + "claude plugin uninstall sentry@sentry-plugin-marketplace", + ); + }); + + it("claude does not treat the stable install as a develop install", async () => { + const system = fakeSystem({ + run: (cmd) => (isList(cmd) ? claudeList(["sentry@claude-plugins-official"]) : ok), + }); + + expect(await createClaude(system, DEVELOP).isInstalled()).toBe(false); + expect(await createClaude(system).isInstalled()).toBe(true); + }); + + it("claude removes both channels under anyChannel", async () => { + const system = fakeSystem({ + run: (cmd) => + isList(cmd) + ? claudeList(["sentry@sentry-plugin-marketplace", "sentry@claude-plugins-official"]) + : ok, + }); + const outcome = await createClaude(system, { anyChannel: true }).remove(); + + expect(outcome.kind).toBe("done"); + expect(system.run).toHaveBeenCalledWith( + "claude plugin uninstall sentry@sentry-plugin-marketplace", + ); + expect(system.run).toHaveBeenCalledWith( + "claude plugin uninstall sentry@claude-plugins-official", + ); + }); + + it("codex re-points the marketplace at the ref", async () => { + const system = fakeSystem({ run: (cmd) => (isList(cmd) ? codexList([]) : ok) }); + const outcome = await createCodex(system, DEVELOP).install(); + + expect(outcome.kind).toBe("done"); + expect(system.run).toHaveBeenCalledWith( + "codex plugin marketplace remove sentry-plugin-marketplace", + ); + expect(system.run).toHaveBeenCalledWith( + "codex plugin marketplace add getsentry/plugin-codex --ref develop", + ); + }); + + it("codex tells the channels apart by version", async () => { + const develop = fakeSystem({ + run: (cmd) => + isList(cmd) + ? codexList([ + { pluginId: "sentry@sentry-plugin-marketplace", version: "1.2.1-dev.4.gabc" }, + ]) + : ok, + }); + const release = fakeSystem({ + run: (cmd) => + isList(cmd) + ? codexList([{ pluginId: "sentry@sentry-plugin-marketplace", version: "1.3.0" }]) + : ok, + }); + + expect(await createCodex(develop, DEVELOP).isInstalled()).toBe(true); + expect(await createCodex(develop).isInstalled()).toBe(false); + expect(await createCodex(release, DEVELOP).isInstalled()).toBe(false); + expect(await createCodex(release).isInstalled()).toBe(true); + }); + + it("codex re-points a stable run that finds a develop build", async () => { + const system = fakeSystem({ + run: (cmd) => + isList(cmd) + ? codexList([ + { pluginId: "sentry@sentry-plugin-marketplace", version: "1.2.1-dev.4.gabc" }, + ]) + : ok, + }); + await createCodex(system).install(); + + expect(system.run).toHaveBeenCalledWith( + "codex plugin marketplace remove sentry-plugin-marketplace", + ); + expect(system.run).toHaveBeenCalledWith("codex plugin marketplace add getsentry/plugin-codex"); + }); + + it("grok installs from the ref-pinned source", async () => { + const system = fakeSystem({ run: (cmd) => (isList(cmd) ? grokList([]) : ok) }); + const outcome = await createGrok(system, DEVELOP).install(); + + expect(outcome.kind).toBe("done"); + expect(system.run).toHaveBeenCalledWith( + "grok plugin install getsentry/plugin-grok@develop --trust", + ); + }); + + it("grok clears a release build out of its single sentry slot", async () => { + const system = fakeSystem({ + run: (cmd) => + isList(cmd) + ? grokList([ + { + name: "sentry", + source: "https://github.com/getsentry/plugin-grok", + version: "1.3.0", + }, + ]) + : ok, + }); + const cleaned = await createGrok(system, DEVELOP).cleanup?.(); + + expect(cleaned).toContain("1.3.0"); + expect(system.run).toHaveBeenCalledWith("grok plugin uninstall sentry"); + }); + + it("cursor clones the ref and reports the branch as the channel", async () => { + const system = fakeSystem({ homedir: "/home/user", run: onBranch("develop") }); + const outcome = await createCursor(system, DEVELOP).install(); + + expect(outcome.kind).toBe("done"); + expect(system.run).toHaveBeenCalledWith( + `git clone --branch develop https://github.com/getsentry/plugin-cursor.git "${CURSOR_DIR}"`, + ); + }); + + it("cursor discards a stable checkout when the develop ref is asked for", async () => { + const system = fakeSystem({ + homedir: "/home/user", + existing: [CURSOR_DIR], + run: onBranch("main"), + }); + const harness = createCursor(system, DEVELOP); + + expect(await harness.isInstalled()).toBe(false); + expect(await harness.cleanup?.()).toContain("main"); + expect(system.run).toHaveBeenCalledWith(`rm -rf "${CURSOR_DIR}"`); + }); + + it("cursor keeps a checkout already on the requested ref", async () => { + const system = fakeSystem({ + homedir: "/home/user", + existing: [CURSOR_DIR], + run: onBranch("develop"), + }); + const harness = createCursor(system, DEVELOP); + + expect(await harness.isInstalled()).toBe(true); + expect(await harness.cleanup?.()).toBeNull(); + }); + + it("cursor under anyChannel counts any branch as installed", async () => { + const system = fakeSystem({ + homedir: "/home/user", + existing: [CURSOR_DIR], + run: onBranch("develop"), + }); + + expect(await createCursor(system, { anyChannel: true }).isInstalled()).toBe(true); + }); +}); diff --git a/packages/installer/src/harnesses/channel.ts b/packages/installer/src/harnesses/channel.ts new file mode 100644 index 00000000..db6261c7 --- /dev/null +++ b/packages/installer/src/harnesses/channel.ts @@ -0,0 +1,67 @@ +/** + * Which build of the plugin a harness installs. + * + * `ref` is a git ref in the plugin's distribution repository. Omitting it + * installs the stable channel, which means the repository's default branch — and + * for Claude, the vendor's official marketplace rather than our repository at + * all. The CLI only exposes `--develop`, so in practice `ref` is either + * undefined or `"develop"`; it is modeled as a ref so pinning a release tag + * later needs no new plumbing. + */ +export interface HarnessOptions { + ref?: string; + /** + * Ignore the channel when deciding what counts as installed. `remove` sets it + * so it takes out whatever build is present instead of only the channel it was + * built for — a develop install has to be removable by a plain `remove`. + */ + anyChannel?: boolean; +} + +/** + * The branch each distribution repository publishes releases on. The stable + * channel tracks it by passing no ref at all, so this is only needed where a + * harness has to name the branch it expects to already be on. + */ +export const STABLE_BRANCH = "main"; + +/** + * Whether these options ask for a pre-release build. + * + * Anything other than the release branch counts, so `--develop` reads as + * pre-release while an explicit `main` reads as stable. A future release-tag + * pin would land on the wrong side of this and needs revisiting alongside + * {@link isDevelopVersion}. + */ +function wantsDevelop(options: HarnessOptions): boolean { + return options.ref !== undefined && options.ref !== STABLE_BRANCH; +} + +/** + * Whether an installed plugin's version string is a develop build. + * + * `scripts/dev-version.sh` stamps every develop build with a `-dev.` prerelease + * (`1.2.1-dev.14.gdeadbee`) and a release never carries one, so the version is + * the channel marker for the harnesses whose plugin id is identical on both + * channels. + */ +export function isDevelopVersion(version: string | null | undefined): boolean { + return (version ?? "").includes("-dev."); +} + +/** + * Whether an installed plugin belongs to the channel that was asked for. Used to + * decide whether an existing install counts as "already installed" or as the + * other channel's copy that has to be replaced. Always true under + * {@link HarnessOptions.anyChannel}. + */ +export function matchesChannel( + version: string | null | undefined, + options: HarnessOptions, +): boolean { + if (options.anyChannel) { + return true; + } + + return isDevelopVersion(version) === wantsDevelop(options); +} diff --git a/packages/installer/src/harnesses/claude.ts b/packages/installer/src/harnesses/claude.ts index 0bb01b9d..e791bc2f 100644 --- a/packages/installer/src/harnesses/claude.ts +++ b/packages/installer/src/harnesses/claude.ts @@ -1,13 +1,19 @@ -import { realSystem, type OutputSink, type SystemDeps } from "../system"; +import type { OutputSink, SystemDeps } from "../system"; import type { Harness, InstallOutcome } from "./types"; +import { type HarnessOptions } from "./channel"; import { detectOnPath, runCommand, runJson } from "./shell"; -const MARKETPLACE = "claude-plugins-official"; -const MARKETPLACE_SOURCE = "anthropics/claude-plugins-official"; -const PLUGIN_ID = `sentry@${MARKETPLACE}`; -const INSTALL_COMMAND = `claude plugin install ${PLUGIN_ID}`; -const UPDATE_COMMAND = `claude plugin update ${PLUGIN_ID}`; -const UNINSTALL_COMMAND = `claude plugin uninstall ${PLUGIN_ID}`; +// The stable channel installs from Anthropic's official catalog, which only ever +// lists the release. A ref-pinned channel therefore has to come from our own +// distribution repo instead, whose `.claude-plugin/marketplace.json` declares the +// marketplace name below. +const OFFICIAL_MARKETPLACE = "claude-plugins-official"; +const OFFICIAL_SOURCE = "anthropics/claude-plugins-official"; +const OFFICIAL_PLUGIN_ID = `sentry@${OFFICIAL_MARKETPLACE}`; + +const OUR_MARKETPLACE = "sentry-plugin-marketplace"; +const OUR_REPO = "getsentry/plugin-claude"; +const OUR_PLUGIN_ID = `sentry@${OUR_MARKETPLACE}`; // `claude plugin list --json` emits an array of installed plugins. We only care // about the marketplace-qualified id of each entry. @@ -21,58 +27,149 @@ interface ClaudeMarketplace { name?: string; } -async function isSentryInstalled(system: SystemDeps): Promise { +// Where a channel's plugin comes from. The two channels use different marketplace +// names, so both can stay registered at once and only the plugin installs +// conflict — which is what `cleanup` resolves. +interface Channel { + marketplace: string; + source: string; + pluginId: string; + conflictingPluginId: string; +} + +function channelFor(options: HarnessOptions): Channel { + if (options.ref === undefined) { + return { + marketplace: OFFICIAL_MARKETPLACE, + source: OFFICIAL_SOURCE, + pluginId: OFFICIAL_PLUGIN_ID, + conflictingPluginId: OUR_PLUGIN_ID, + }; + } + + // `owner/repo@ref` is how the GitHub shorthand pins a branch or tag. + return { + marketplace: OUR_MARKETPLACE, + source: `${OUR_REPO}@${options.ref}`, + pluginId: OUR_PLUGIN_ID, + conflictingPluginId: OFFICIAL_PLUGIN_ID, + }; +} + +async function installedIds(system: SystemDeps): Promise { const plugins = await runJson(system, "claude plugin list --json"); - return Array.isArray(plugins) && plugins.some((plugin) => plugin.id === PLUGIN_ID); + return Array.isArray(plugins) + ? plugins.map((plugin) => plugin.id).filter((id): id is string => id !== undefined) + : []; } -async function isMarketplaceRegistered(system: SystemDeps): Promise { +async function hasPlugin(system: SystemDeps, pluginId: string): Promise { + return (await installedIds(system)).includes(pluginId); +} + +async function isMarketplaceRegistered(system: SystemDeps, name: string): Promise { const list = await runJson(system, "claude plugin marketplace list --json"); - return Array.isArray(list) && list.some((entry) => entry.name === MARKETPLACE); + return Array.isArray(list) && list.some((entry) => entry.name === name); } -// A fresh CLI has no marketplaces registered, so register the official one if it -// is missing; otherwise refresh its index so the plugin resolves. Required by -// both install and update. -async function ensureMarketplace(system: SystemDeps, output?: OutputSink): Promise { - const registered = await isMarketplaceRegistered(system); +// A fresh CLI has no marketplaces registered, so register this channel's if it is +// missing; otherwise refresh its index so the plugin resolves. Required by both +// install and update. +// +// A ref-pinned channel always re-adds instead: `marketplace add` re-points an +// existing marketplace at a new source in place, and that is the only way to move +// an already-registered marketplace onto the requested ref. `marketplace update` +// would refresh it while leaving it on whatever ref it was added with. +async function ensureMarketplace( + system: SystemDeps, + channel: Channel, + options: HarnessOptions, + output?: OutputSink, +): Promise { + if (options.ref !== undefined) { + await runCommand(system, `claude plugin marketplace add ${channel.source}`, output); + return; + } + + const registered = await isMarketplaceRegistered(system, channel.marketplace); await runCommand( system, registered - ? `claude plugin marketplace update ${MARKETPLACE}` - : `claude plugin marketplace add ${MARKETPLACE_SOURCE}`, + ? `claude plugin marketplace update ${channel.marketplace}` + : `claude plugin marketplace add ${channel.source}`, output, ); } -export function createClaude(system: SystemDeps): Harness { +export function createClaude(system: SystemDeps, options: HarnessOptions = {}): Harness { + const channel = channelFor(options); + const installCommand = `claude plugin install ${channel.pluginId}`; + const updateCommand = `claude plugin update ${channel.pluginId}`; + + // Which of our plugin ids to take out. Normally just this channel's; under + // anyChannel every one that is present, so a plain `remove` clears a develop + // install too — and both at once on a machine that ended up with each. + const removableIds = async (): Promise => { + if (!options.anyChannel) { + return [channel.pluginId]; + } + + const installed = await installedIds(system); + return [OUR_PLUGIN_ID, OFFICIAL_PLUGIN_ID].filter((id) => installed.includes(id)); + }; + return { id: "claude", name: "Claude Code", detect: async () => detectOnPath(system, "claude"), - isInstalled: async () => isSentryInstalled(system), + isInstalled: async () => { + const installed = await installedIds(system); + return options.anyChannel + ? installed.includes(OUR_PLUGIN_ID) || installed.includes(OFFICIAL_PLUGIN_ID) + : installed.includes(channel.pluginId); + }, canInstall: async () => ({ ok: true }), + cleanup: async (output) => { + // Both channels install a plugin named `sentry`, so leaving the other one + // in place means two copies of the same skills resolving at once. Going to + // develop takes out the official marketplace's copy; coming back to stable + // takes out ours. + if (!(await hasPlugin(system, channel.conflictingPluginId))) { + return null; + } + + await runCommand(system, `claude plugin uninstall ${channel.conflictingPluginId}`, output); + return `Removed conflicting plugin ${channel.conflictingPluginId}`; + }, + install: async (output): Promise => { - await ensureMarketplace(system, output); - await runCommand(system, INSTALL_COMMAND, output); - return { kind: "done", command: INSTALL_COMMAND }; + await ensureMarketplace(system, channel, options, output); + await runCommand(system, installCommand, output); + return { kind: "done", command: installCommand }; }, update: async (output): Promise => { - await ensureMarketplace(system, output); - await runCommand(system, UPDATE_COMMAND, output); - return { kind: "done", command: UPDATE_COMMAND }; + await ensureMarketplace(system, channel, options, output); + await runCommand(system, updateCommand, output); + return { kind: "done", command: updateCommand }; }, remove: async (output): Promise => { - await runCommand(system, UNINSTALL_COMMAND, output); - return { kind: "done", command: UNINSTALL_COMMAND }; + // Fall back to this channel's id when nothing is listed, so the command + // still runs and its own error surfaces rather than silently doing nothing. + const ids = await removableIds(); + const targets = ids.length > 0 ? ids : [channel.pluginId]; + const commands = targets.map((id) => `claude plugin uninstall ${id}`); + + for (const command of commands) { + await runCommand(system, command, output); + } + + return { kind: "done", command: commands.join(" && ") }; }, }; } - -export const claude = createClaude(realSystem); diff --git a/packages/installer/src/harnesses/codex.ts b/packages/installer/src/harnesses/codex.ts index e541d8ec..cc32d5ec 100644 --- a/packages/installer/src/harnesses/codex.ts +++ b/packages/installer/src/harnesses/codex.ts @@ -1,5 +1,6 @@ -import { realSystem, type OutputSink, type SystemDeps } from "../system"; +import type { OutputSink, SystemDeps } from "../system"; import type { Harness, InstallOutcome } from "./types"; +import { isDevelopVersion, matchesChannel, type HarnessOptions } from "./channel"; import { detectOnPath, runCommand, runJson } from "./shell"; // TODO: Codex is the only agent we install from our OWN marketplace @@ -20,16 +21,20 @@ const UNINSTALL_COMMAND = `codex plugin remove ${PLUGIN_ID}`; const LEGACY_PLUGIN_ID = "sentry@openai-curated"; // `codex plugin list --json` wraps installed plugins under `installed`, each -// keyed by a marketplace-qualified pluginId. +// keyed by a marketplace-qualified pluginId and carrying the manifest version — +// which is what tells the two channels apart, since both install under the same +// pluginId from the same marketplace name. interface CodexPlugin { pluginId?: string; + version?: string; } interface CodexPluginList { installed?: CodexPlugin[]; } // `codex plugin marketplace list --json` wraps registered marketplaces under -// `marketplaces`, each with a `name`. +// `marketplaces`, each with a `name`. It does not report the ref a git source was +// added with, which is why the channel is read off the installed plugin instead. interface CodexMarketplaceList { marketplaces?: { name?: string }[]; } @@ -39,8 +44,8 @@ async function installedPlugins(system: SystemDeps): Promise { return data?.installed ?? []; } -async function hasPlugin(system: SystemDeps, pluginId: string): Promise { - return (await installedPlugins(system)).some((plugin) => plugin.pluginId === pluginId); +async function findPlugin(system: SystemDeps, pluginId: string): Promise { + return (await installedPlugins(system)).find((plugin) => plugin.pluginId === pluginId); } async function isMarketplaceRegistered(system: SystemDeps): Promise { @@ -51,7 +56,31 @@ async function isMarketplaceRegistered(system: SystemDeps): Promise { // The Sentry plugin lives in its own marketplace, not a Codex default, so // register it if missing; otherwise refresh its snapshot so it resolves. // Required by both install and update. -async function ensureMarketplace(system: SystemDeps, output?: OutputSink): Promise { +// +// Codex cannot re-point a registered marketplace at a different ref: `marketplace +// upgrade` only refreshes the snapshot it already has, and there is no way to read +// back which ref it was added with. So the source is removed and re-added whenever +// the ref has to change — always for a ref-pinned install, and for a stable +// install only when a develop build is what is currently installed, which is the +// one way a stable run can find the marketplace pinned somewhere else. The remove +// runs through `system.run` rather than `runCommand` because it is allowed to +// fail: nothing is registered yet on a first install. +async function ensureMarketplace( + system: SystemDeps, + options: HarnessOptions, + output?: OutputSink, +): Promise { + const installed = await findPlugin(system, PLUGIN_ID); + const repoint = options.ref !== undefined || isDevelopVersion(installed?.version); + + if (repoint) { + const source = + options.ref === undefined ? MARKETPLACE_SOURCE : `${MARKETPLACE_SOURCE} --ref ${options.ref}`; + await system.run(`codex plugin marketplace remove ${MARKETPLACE}`); + await runCommand(system, `codex plugin marketplace add ${source}`, output); + return; + } + const registered = await isMarketplaceRegistered(system); await runCommand( system, @@ -65,25 +94,34 @@ async function ensureMarketplace(system: SystemDeps, output?: OutputSink): Promi // Codex has no plugin update command; `add` is idempotent and re-points an // already-installed plugin at the refreshed snapshot, so install and update // share this single path. -async function addPlugin(system: SystemDeps, output?: OutputSink): Promise { - await ensureMarketplace(system, output); +async function addPlugin( + system: SystemDeps, + options: HarnessOptions, + output?: OutputSink, +): Promise { + await ensureMarketplace(system, options, output); await runCommand(system, INSTALL_COMMAND, output); return { kind: "done", command: INSTALL_COMMAND }; } -export function createCodex(system: SystemDeps): Harness { +export function createCodex(system: SystemDeps, options: HarnessOptions = {}): Harness { return { id: "codex", name: "Codex", detect: async () => detectOnPath(system, "codex"), - isInstalled: async () => hasPlugin(system, PLUGIN_ID), + // Both channels install the same pluginId, so presence alone would report the + // other channel's build as ours; the version is what settles it. + isInstalled: async () => { + const installed = await findPlugin(system, PLUGIN_ID); + return installed !== undefined && matchesChannel(installed.version, options); + }, canInstall: async () => ({ ok: true }), cleanup: async (output) => { - if (!(await hasPlugin(system, LEGACY_PLUGIN_ID))) { + if ((await findPlugin(system, LEGACY_PLUGIN_ID)) === undefined) { return null; } @@ -91,9 +129,9 @@ export function createCodex(system: SystemDeps): Harness { return `Removed conflicting plugin ${LEGACY_PLUGIN_ID}`; }, - install: async (output) => addPlugin(system, output), + install: async (output) => addPlugin(system, options, output), - update: async (output) => addPlugin(system, output), + update: async (output) => addPlugin(system, options, output), remove: async (output): Promise => { await runCommand(system, UNINSTALL_COMMAND, output); @@ -101,5 +139,3 @@ export function createCodex(system: SystemDeps): Harness { }, }; } - -export const codex = createCodex(realSystem); diff --git a/packages/installer/src/harnesses/cursor.ts b/packages/installer/src/harnesses/cursor.ts index 973df3b3..db6c55f7 100644 --- a/packages/installer/src/harnesses/cursor.ts +++ b/packages/installer/src/harnesses/cursor.ts @@ -1,6 +1,7 @@ import { join } from "node:path"; -import { realSystem, type OutputSink, type SystemDeps } from "../system"; +import type { OutputSink, SystemDeps } from "../system"; import type { Harness, InstallOutcome } from "./types"; +import { STABLE_BRANCH, type HarnessOptions } from "./channel"; import { detectOnPath, runCommand } from "./shell"; const PLUGIN_REPO = "https://github.com/getsentry/plugin-cursor.git"; @@ -24,7 +25,27 @@ function appLocations(system: SystemDeps): string[] { return []; } -export function createCursor(system: SystemDeps): Harness { +// Recursive directory delete, spelled for the platform. Windows has no `rm`. +function removeDirCommand(system: SystemDeps, dir: string): string { + return system.platform === "win32" ? `rmdir /s /q "${dir}"` : `rm -rf "${dir}"`; +} + +// Which branch the existing checkout is on, or null when there is no checkout to +// read. The plugin is a plain clone, so the branch *is* the installed channel. +async function checkedOutBranch(system: SystemDeps): Promise { + const dir = pluginDir(system); + + if (!system.exists(dir)) { + return null; + } + + const result = await system.run(`git -C "${dir}" rev-parse --abbrev-ref HEAD`); + return result.ok ? (result.stdout ?? "").trim() : null; +} + +export function createCursor(system: SystemDeps, options: HarnessOptions = {}): Harness { + const branch = options.ref ?? STABLE_BRANCH; + return { id: "cursor", name: "Cursor", @@ -37,16 +58,37 @@ export function createCursor(system: SystemDeps): Harness { return appLocations(system).some((path) => system.exists(path)); }, - isInstalled: async () => system.exists(pluginDir(system)), + // A checkout of the other channel is not this channel's install, so the + // branch has to match — otherwise switching channels would take the update + // path and just pull the branch it is already on. Under anyChannel the + // directory existing is enough, so `remove` clears whichever branch is there. + isInstalled: async () => + options.anyChannel + ? system.exists(pluginDir(system)) + : (await checkedOutBranch(system)) === branch, canInstall: async () => (await detectOnPath(system, "git")) ? { ok: true } : { ok: false, reason: "git is required to clone the Cursor plugin" }, + cleanup: async (output) => { + // The checkout lives at one fixed path, so switching channels means the + // existing clone has to go; `install` then re-clones on the right branch. + // A checkout already on this branch is left alone for `update` to pull. + const current = await checkedOutBranch(system); + + if (current === null || current === branch) { + return null; + } + + await runCommand(system, removeDirCommand(system, pluginDir(system)), output); + return `Removed the ${current} checkout`; + }, + install: async (output): Promise => { // Quote the target: Windows home paths routinely contain spaces. - const command = `git clone ${PLUGIN_REPO} "${pluginDir(system)}"`; + const command = `git clone --branch ${branch} ${PLUGIN_REPO} "${pluginDir(system)}"`; await runCommand(system, command, output); return { kind: "done", command }; }, @@ -59,13 +101,10 @@ export function createCursor(system: SystemDeps): Harness { remove: async (output): Promise => { // The plugin is just a checkout, so removal is a recursive directory - // delete. Windows has no `rm`, so use its native recursive remove. - const dir = pluginDir(system); - const command = system.platform === "win32" ? `rmdir /s /q "${dir}"` : `rm -rf "${dir}"`; + // delete. + const command = removeDirCommand(system, pluginDir(system)); await runCommand(system, command, output); return { kind: "done", command }; }, }; } - -export const cursor = createCursor(realSystem); diff --git a/packages/installer/src/harnesses/grok.ts b/packages/installer/src/harnesses/grok.ts index 1f48ca98..84fcd1f5 100644 --- a/packages/installer/src/harnesses/grok.ts +++ b/packages/installer/src/harnesses/grok.ts @@ -1,5 +1,6 @@ -import { realSystem, type OutputSink, type SystemDeps } from "../system"; +import type { SystemDeps } from "../system"; import type { Harness, InstallOutcome } from "./types"; +import { matchesChannel, type HarnessOptions } from "./channel"; import { detectOnPath, runCommand, runJson } from "./shell"; // Grok has no headless install-by-name; its marketplace install is TUI-only. So @@ -9,18 +10,19 @@ import { detectOnPath, runCommand, runJson } from "./shell"; // TODO: install sentry by name from the official "xAI Official" marketplace once // grok exposes a headless command for it (today that is TUI-only). const MARKETPLACE_SOURCE = "getsentry/plugin-grok"; -const INSTALL_COMMAND = `grok plugin install ${MARKETPLACE_SOURCE} --trust`; const UPDATE_COMMAND = "grok plugin update sentry"; const UNINSTALL_COMMAND = "grok plugin uninstall sentry"; // `grok plugin list --json` emits an array of plugins. The two ways sentry can // be installed both report our repo as `source`, so `marketplace` is what tells // them apart: a direct repo install (ours) has `marketplace: null`, while a -// marketplace install (e.g. "xAI Official") names that marketplace. +// marketplace install (e.g. "xAI Official") names that marketplace. `version` +// then tells our two channels apart, since both install from the same repo. interface GrokPlugin { name?: string; source?: string; marketplace?: string | null; + version?: string; } async function listPlugins(system: SystemDeps): Promise { @@ -37,41 +39,55 @@ function isOurs(plugin: GrokPlugin): boolean { ); } -export function createGrok(system: SystemDeps): Harness { +export function createGrok(system: SystemDeps, options: HarnessOptions = {}): Harness { + // `owner/repo@ref` is how grok's install source pins a branch or tag. + const source = + options.ref === undefined ? MARKETPLACE_SOURCE : `${MARKETPLACE_SOURCE}@${options.ref}`; + const installCommand = `grok plugin install ${source} --trust`; + + // Ours, on the channel we were asked for. Grok records the source without the + // ref, so the version is what distinguishes the channels. + const isThisChannel = (plugin: GrokPlugin): boolean => + isOurs(plugin) && matchesChannel(plugin.version, options); + return { id: "grok", name: "Grok", detect: async () => detectOnPath(system, "grok"), - isInstalled: async () => (await listPlugins(system)).some(isOurs), + isInstalled: async () => (await listPlugins(system)).some(isThisChannel), canInstall: async () => ({ ok: true }), cleanup: async (output) => { - // A sentry plugin installed from a marketplace (e.g. "xAI Official") - // shadows ours. Uninstall it so our direct-repo install is the one that - // resolves; ours (no marketplace) is left untouched. - const foreign = (await listPlugins(system)).find( - (plugin) => plugin.name === "sentry" && !isOurs(plugin), + // Grok has a single `sentry` slot, so anything in it that is not this + // channel's build has to come out: a marketplace install (e.g. "xAI + // Official") would shadow ours, and the other channel's build would make + // `grok plugin install` fail as already-installed. + const conflicting = (await listPlugins(system)).find( + (plugin) => plugin.name === "sentry" && !isThisChannel(plugin), ); - if (!foreign) { + if (!conflicting) { return null; } await runCommand(system, UNINSTALL_COMMAND, output); - const via = foreign.marketplace ? ` (installed via ${foreign.marketplace})` : ""; + const via = conflicting.marketplace + ? ` (installed via ${conflicting.marketplace})` + : ` (version ${conflicting.version ?? "unknown"})`; return `Removed conflicting sentry plugin${via}`; }, install: async (output): Promise => { - await runCommand(system, INSTALL_COMMAND, output); - return { kind: "done", command: INSTALL_COMMAND }; + await runCommand(system, installCommand, output); + return { kind: "done", command: installCommand }; }, // `grok plugin install` errors on an already-installed repo, so update in - // place instead of reinstalling. + // place instead of reinstalling. Only reached when the installed build is + // already on this channel, so the recorded source is the right one to pull. update: async (output): Promise => { await runCommand(system, UPDATE_COMMAND, output); return { kind: "done", command: UPDATE_COMMAND }; @@ -83,5 +99,3 @@ export function createGrok(system: SystemDeps): Harness { }, }; } - -export const grok = createGrok(realSystem); diff --git a/packages/installer/src/harnesses/index.ts b/packages/installer/src/harnesses/index.ts index 681f09b7..0d83f031 100644 --- a/packages/installer/src/harnesses/index.ts +++ b/packages/installer/src/harnesses/index.ts @@ -1,9 +1,25 @@ -import { claude, createClaude } from "./claude"; -import { codex, createCodex } from "./codex"; -import { cursor, createCursor } from "./cursor"; -import { grok, createGrok } from "./grok"; +import { realSystem } from "../system"; +import { createClaude } from "./claude"; +import { createCodex } from "./codex"; +import { createCursor } from "./cursor"; +import { createGrok } from "./grok"; +import type { HarnessOptions } from "./channel"; +import type { Harness } from "./types"; export type { Harness, InstallOutcome } from "./types"; +export type { HarnessOptions } from "./channel"; export { createClaude, createCodex, createCursor, createGrok }; -export const harnesses = [claude, codex, cursor, grok]; +/** + * Every harness, built for one channel. `install` passes the ref it was asked + * for; `remove` passes `anyChannel` so it acts on whatever is installed. No + * arguments builds the stable set. + */ +export function buildHarnesses(options: HarnessOptions = {}): Harness[] { + return [ + createClaude(realSystem, options), + createCodex(realSystem, options), + createCursor(realSystem, options), + createGrok(realSystem, options), + ]; +} diff --git a/packages/installer/src/index.ts b/packages/installer/src/index.ts index 9491e96d..421f398a 100644 --- a/packages/installer/src/index.ts +++ b/packages/installer/src/index.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { defineCommand, runMain } from "citty"; -import { harnesses } from "./harnesses"; +import { buildHarnesses } from "./harnesses"; import { captureAndFlush, initTelemetry } from "./instrument"; import { runInstaller, runRemover } from "./ui"; @@ -21,6 +21,10 @@ const { version, description } = JSON.parse( readFileSync(join(__dirname, "../package.json"), "utf8"), ) as { version: string; description: string }; +// The ref `--develop` resolves to, matching the branch each plugin repo deploys +// every merge to. +const DEVELOP_REF = "develop"; + // Both subcommands take the same agent-selection flags. const agentSelectionArgs = { // citty turns `--no-interactive` into `interactive: false` via its built-in @@ -56,10 +60,19 @@ const install = defineCommand({ description: "Instruction to include in the prompt copied after installation", required: false, }, + develop: { + type: "boolean", + description: + "Install the develop version of the plugins. Removes official marketplace installs", + default: false, + }, ...agentSelectionArgs, }, async run({ args }) { const interactive = args.interactive && !args.yes; + // The flag is the only channel the CLI exposes; the harnesses take a ref so + // pinning something else later needs no new surface here. + const harnesses = buildHarnesses({ ref: args.develop ? DEVELOP_REF : undefined }); try { const ok = await runInstaller(harnesses, { interactive, instruction: args.instruction }); process.exit(ok ? 0 : 1); @@ -80,6 +93,9 @@ const remove = defineCommand({ args: agentSelectionArgs, async run({ args }) { const interactive = args.interactive && !args.yes; + // No channel flag here on purpose: removal takes out whichever build is + // installed, so a develop install does not need to be remembered to undo. + const harnesses = buildHarnesses({ anyChannel: true }); try { const ok = await runRemover(harnesses, { interactive }); process.exit(ok ? 0 : 1);