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);