diff --git a/README.md b/README.md index 8136532..fdde74f 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ TypeScript, installed as executables on `PATH`. | [`torrent`](#torrent) | Make a torrent out of a directory, and get it seeded | | [`codeburn`](#codeburn) | See where your AI spend goes, by task, tool, model and project | | [`agenticjobs`](#agenticjobs) | Search, apply, post and hire on an agent-friendly job board | +| [`openmcp`](#openmcp) | The OpenMCP catalog of MCP relays: list, find a tool, call it, register your own | | [`shorten`](#shorten) | Mint a short link on the pit, and follow it from `/f/` | | [`sysupdate`](#sysupdate) | Update this box: apt lists, apt packages, snaps | @@ -67,9 +68,10 @@ One thing here is not a `PATH` command and does not need Node: - **Node 22.13+ and `pnpm` or `npm`** — `codeburn` only: it is somebody else's npm package, installed on first use, and upstream's engine floor is higher than this repo's -- **Node 24+ and `pnpm` or `npm`** — `agenticjobs` only, for the same reason: - the board is an npm package installed on first use, and it asks for a newer - Node than anything else here +- **Node 24+ and `pnpm` or `npm`** — `agenticjobs` and `openmcp` only, for the + same reason: each is an npm package installed on first use, and both ask for + a newer Node than anything else here (`openmcp` keeps its catalog in + `node:sqlite`) ## Install @@ -1350,6 +1352,57 @@ that script first if you want this wrapper to own the name. Upstream wants **Node 24+**, which is higher than this repo's own floor of 22.18; on an older one it says so and tries anyway, since that floor is theirs to move. +### `openmcp` + +The OpenMCP catalog of MCP relays, from the terminal: +[@logicsrc/openmcp](https://www.npmjs.com/package/@logicsrc/openmcp), the +reference client and server for the [OpenMCP](https://logicsrc.com/openmcp) +spec, pointed by default at the live catalog at +[openmcp.logicsrc.com](https://openmcp.logicsrc.com). + +```sh +openmcp relays # what the catalog lists, online and verified +openmcp find "fetch a page" # search every relay's tools +openmcp call obscura.openmcp.logicsrc.com fetch_page '{"url":"https://example.com"}' +openmcp add https://your.site # register a relay you operate (probed, then listed) +openmcp probe https://your.site # what a catalog would find, without one +openmcp serve # run a catalog of your own +openmcp --help # it is upstream's CLI: upstream's flags +``` + +Everything is handed through untouched, so upstream's docs are the docs. Two +flags are ours, spelled `--self-*` because every plain word belongs to them: + +```sh +openmcp --self-update # reinstall the latest release +openmcp --self-where # which copy runs, and from where +``` + +**The first run installs it**, into `~/.local/share/cli-tools/vendor/openmcp` +rather than globally, for the reason [`agenticjobs`](#agenticjobs) spells out: +upstream's executable is also called `openmcp`, and a private prefix means the +name exists once. `OPENMCP_BIN` points at a copy you would rather run, and +`OPENMCP_SPEC` pins a version. + +**Three plain words are intercepted, and only for the copy we installed.** +`update`, `uninstall` and `where` are upstream's, answered from the +`manifest.json` its own installer writes; a copy npm puts in our prefix has +none, so those three are answered here when the copy that would run is ours +and handed through when it is not. + +Upstream's own installer is one line and brings its own Node 24 when the box +has none: + +```sh +curl -fsSL https://openmcp.logicsrc.com/install.sh | sh +``` + +It writes a real script to `~/.local/bin/openmcp`, the directory this repo +links into, and `install-links.mjs` never takes over a real file even with +`--force`: on a box that ran it you will see `SKIP … is a real file` and keep +the copy you already had, which is the right outcome. Remove that script first +if you want this wrapper to own the name. + ### `shorten` Mints a short link on the Moshpit registry and prints it. `/f/` answers a diff --git a/bin/openmcp.ts b/bin/openmcp.ts new file mode 100644 index 0000000..3ae7c98 --- /dev/null +++ b/bin/openmcp.ts @@ -0,0 +1,158 @@ +#!/usr/bin/env node +/** + * openmcp — the OpenMCP catalog from the terminal. + * + * A launcher for the `@logicsrc/openmcp` package, not a reimplementation of + * it. Everything you type is handed through untouched, so upstream's docs are + * the docs: + * + * openmcp relays what openmcp.logicsrc.com lists + * openmcp find "fetch a page" search every relay's tools + * openmcp call obscura.openmcp.logicsrc.com fetch_page '{"url":"https://example.com"}' + * openmcp add https://your.site register a relay you operate + * openmcp probe https://your.site what a catalog would find, without one + * openmcp serve run a catalog of your own + * openmcp --help all of it + * + * What this adds is that it is a command rather than an incantation: installed + * on first use into a prefix of ours, refreshed when you ask, and never + * fighting a global install for a name they both want. src/openmcp.ts says why + * that last one is not hypothetical. + * + * Ours, and therefore NOT passed through: + * --self-update reinstall the latest release + * --self-where say which copy would run, and from where + * + * Both are spelled --self-* because every plain word is upstream's to use. + * + * The exception is `update`, `uninstall` and `where`, and only when the copy + * that would run is the one we installed. Those three read a manifest written + * by upstream's curl installer, which a copy npm put in our prefix does not + * have, so upstream would answer "not installed by the installer" on a box + * where this command installed it and works. + */ + +import { isMain } from '../src/is-main.ts'; +import { spawnInherit } from '../src/codeburn.ts'; +import { + MIN_NODE, + PACKAGE, + install, + installFailureMessage, + meetsNodeFloor, + nodeFloorMessage, + ownsInstallWord, + removeVendor, + resolveRunner, + vendorBin, + vendorRoot, +} from '../src/openmcp.ts'; + +const DESCRIBE: Record = { + env: 'OPENMCP_BIN', + vendor: 'installed by cli-tools', + path: 'already on PATH', + missing: 'not installed yet', +}; + +/** Install, and say what happened. Shared by first run and every refresh. */ +async function refresh(reason: string): Promise { + const spec = process.env.OPENMCP_SPEC || `${PACKAGE}@latest`; + process.stderr.write(`openmcp: ${reason} ${spec}\n`); + + const result = await install(spec); + if (!result.ok) { + process.stderr.write(`${installFailureMessage(vendorRoot())}\n`); + // An install that exited 0 and left the wrong version behind is the + // confusing case, so the reason goes out rather than just the failure. + if (result.note) process.stderr.write(` ${result.note}\n`); + return null; + } + + process.stderr.write( + `openmcp: installed ${result.version ?? ''} with ${result.manager}\n`.replace(' ', ' '), + ); + return vendorBin(); +} + +async function main(argv: string[]): Promise { + const runner = resolveRunner(); + + if (argv[0] === '--self-where') { + process.stdout.write( + [ + `${runner.file ?? '(none)'} ${DESCRIBE[runner.kind]}`, + `prefix: ${vendorRoot()}`, + `node: ${process.version}${meetsNodeFloor(process.version) ? '' : ` (below ${MIN_NODE})`}`, + '', + ].join('\n'), + ); + return 0; + } + + // `where` against a copy we installed. Answered with the same lines as + // --self-where, because the honest answer to "where is it" is our prefix. + if (ownsInstallWord(runner.kind, argv[0]) && argv[0] === 'where') { + return main(['--self-where']); + } + + if (ownsInstallWord(runner.kind, argv[0]) && argv[0] === 'uninstall') { + if (!argv.includes('--yes')) { + process.stdout.write( + [ + `This will remove the copy cli-tools installed:`, + ` ${vendorRoot()}`, + '', + 'A catalog database you ran `openmcp serve` against is NOT touched.', + '', + 'Run it for real with: openmcp uninstall --yes', + '', + ].join('\n'), + ); + return 0; + } + const removed = removeVendor(); + process.stdout.write( + removed ? `Removed ${vendorRoot()}\n` : `Nothing to remove at ${vendorRoot()}\n`, + ); + return 0; + } + + const refreshing = argv[0] === '--self-update' || ownsInstallWord(runner.kind, argv[0]); + const args = refreshing ? argv.slice(1) : argv; + + // The Node floor is a warning rather than a refusal. It is upstream's + // constraint, it may move, and being wrong about it should not be the thing + // that stops somebody using the tool. + if (!meetsNodeFloor(process.version)) { + process.stderr.write(`${nodeFloorMessage(process.version)}\n`); + } + + let file = runner.file; + + if (refreshing || runner.kind === 'missing') { + file = await refresh(refreshing ? 'updating' : 'first run, installing'); + if (file === null) return 1; + + // A bare `update` or `--self-update` is a maintenance run, not a launch. + if (refreshing && args.length === 0) return 0; + } + + if (file === null) { + process.stderr.write(`${installFailureMessage(vendorRoot())}\n`); + return 1; + } + + const code = await spawnInherit(file, args); + if (code === null) { + process.stderr.write( + `openmcp: could not start ${file}\n openmcp --self-update # reinstall it\n`, + ); + return 1; + } + return code; +} + +if (isMain(import.meta.url)) { + process.exit(await main(process.argv.slice(2))); +} diff --git a/package.json b/package.json index 79e84ec..a0d101b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/cli-tools", - "version": "0.31.0", + "version": "0.32.0", "private": true, "description": "Local command-line tools, in TypeScript, exposed on PATH.", "type": "module", diff --git a/src/openmcp.ts b/src/openmcp.ts new file mode 100644 index 0000000..674d5b4 --- /dev/null +++ b/src/openmcp.ts @@ -0,0 +1,278 @@ +/** + * openmcp — the OpenMCP catalog client and reference server, on this box. + * + * The package is `@logicsrc/openmcp`, published from logicsrc/openmcp and + * running at openmcp.logicsrc.com. Nothing here reimplements it; this is the + * part that has to exist so `openmcp` is a command on a server like every + * other one in this repo rather than a thing you remember to `npx`. + * + * INSTALLED rather than run through npx, for the same reason as agenticjobs + * and codeburn: a lookup is run many times a day and dlx hits the registry + * for metadata before it hands over on every one of them. + * + * Into a PRIVATE PREFIX. The package's own executable is called `openmcp`, + * which is also the name of this wrapper, so a global install puts a second + * `openmcp` on PATH and which one wins depends on the order of two + * directories. If theirs wins, nothing looks broken and this file never runs. + * If ours wins and we followed PATH, we would exec ourselves until the + * process table gave out. resolveRunner only ever accepts a copy that is NOT + * this wrapper. + * + * THE PACKAGE MANAGES ITS OWN INSTALL. `openmcp update`, `uninstall` and + * `where` all read a manifest.json that upstream's curl installer writes + * (`curl -fsSL https://openmcp.logicsrc.com/install.sh | sh`), and a copy put + * here by npm has no such file. Upstream answers "not installed by the + * installer" and exits, which on a box where cli-tools plainly did install it + * is the misleading kind of wrong. So those three words are answered here + * when the copy running is ours, and handed straight through when it is not. + * See ownsInstallWord. + * + * OPENMCP_BIN run this executable instead: a checkout, or their installer + * OPENMCP_SPEC what gets installed, when you want a pinned version + * + * Deliberately NOT set here: OPENMCP_HOME. That is upstream's variable for + * finding its own manifest, and pointing it at a prefix with no manifest in + * it would trade a clear message for a confusing one. + */ + +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { onPath, resolveCommand } from './registry.ts'; +import { spawnInherit } from './codeburn.ts'; +import { delivered, heldBackNote, installedVersion, wantedVersion } from './vendor-verify.ts'; + +/** The published package, and the executable it installs. */ +export const PACKAGE = '@logicsrc/openmcp'; +export const EXECUTABLE = 'openmcp'; + +/** + * The floor the package declares. + * + * Higher than this repo's own floor of 22.18, so it is checked rather than + * assumed: the catalog keeps its records in node:sqlite, which is Node 24, and + * the failure on older Node is a missing built-in module named nowhere useful. + */ +export const MIN_NODE = '24.0.0'; + +/** Where XDG says durable, non-config state goes. */ +export function dataHome(env: NodeJS.ProcessEnv = process.env): string { + return env.XDG_DATA_HOME || join(env.HOME ?? homedir(), '.local', 'share'); +} + +/** The private prefix: a directory whose entire job is to hold one package. */ +export function vendorRoot(env: NodeJS.ProcessEnv = process.env): string { + return join(dataHome(env), 'cli-tools', 'vendor', 'openmcp'); +} + +/** The installed executable, whether or not it exists yet. */ +export function vendorBin(env: NodeJS.ProcessEnv = process.env): string { + return join(vendorRoot(env), 'node_modules', '.bin', EXECUTABLE); +} + +export type PackageManager = 'pnpm' | 'npm'; + +export interface InstallPlan { + file: string; + args: string[]; +} + +/** + * How to install with each manager. + * + * `--ignore-workspace` is not decoration: pnpm walks up from the install + * directory looking for a workspace root, and ~/.local/share is inside + * somebody's home directory. + */ +export function installPlan(manager: PackageManager, spec = `${PACKAGE}@latest`): InstallPlan { + if (manager === 'pnpm') { + return { file: 'pnpm', args: ['add', '--ignore-workspace', '--reporter=silent', spec] }; + } + return { file: 'npm', args: ['install', '--no-audit', '--no-fund', '--silent', spec] }; +} + +/** The managers to try, in order. pnpm is the intent, npm is what a bare box has. */ +export function managers(env: NodeJS.ProcessEnv = process.env): PackageManager[] { + return onPath('pnpm', env) ? ['pnpm', 'npm'] : ['npm']; +} + +export type RunnerKind = 'env' | 'vendor' | 'path' | 'missing'; + +export interface Runner { + kind: RunnerKind; + file: string | null; +} + +export interface ResolveDeps { + env?: NodeJS.ProcessEnv; + exists?: (path: string) => boolean; + onPathStatus?: () => 'ours' | 'other' | 'missing'; + onPathTarget?: () => string | null; +} + +/** + * Which catalog client to run. + * + * The `ours` case is the whole reason this function exists. This wrapper is + * installed on PATH under the name `openmcp`, the same name the package's own + * bin uses, so "is openmcp on PATH" answers yes on every box where this + * command is installed. Acting on that answer is a fork bomb. + */ +export function resolveRunner(deps: ResolveDeps = {}): Runner { + const env = deps.env ?? process.env; + const exists = deps.exists ?? existsSync; + const status = deps.onPathStatus ?? (() => resolveCommand(EXECUTABLE, undefined, env).status); + const target = deps.onPathTarget ?? (() => resolveCommand(EXECUTABLE, undefined, env).target); + + // An explicit override wins outright: a checkout, or a copy put there by + // upstream's own installer, is a deliberate act and not ours to second-guess. + const override = env.OPENMCP_BIN; + if (override) return { kind: 'env', file: override }; + + const vendored = vendorBin(env); + if (exists(vendored)) return { kind: 'vendor', file: vendored }; + + // Only a copy that is NOT this wrapper counts. See above. + if (status() === 'other') return { kind: 'path', file: target() }; + + return { kind: 'missing', file: null }; +} + +/** + * The words about an install that only make sense against ours. + * + * `update`, `uninstall` and `where` are upstream's commands and upstream + * answers them from a manifest.json its curl installer writes. A copy npm put + * in our prefix has no manifest, so upstream would say "not installed by the + * installer" on a box where this command installed it and works. These three + * are therefore answered here, and ONLY when the copy that would run is the + * one we installed: a copy put there by upstream's installer, or pointed at + * with OPENMCP_BIN, keeps upstream's behaviour exactly. + */ +export const INSTALL_WORDS = new Set(['update', 'uninstall', 'where']); + +export function ownsInstallWord(kind: RunnerKind, word: string | undefined): boolean { + if (word === undefined || !INSTALL_WORDS.has(word)) return false; + return kind === 'vendor' || kind === 'missing'; +} + +/** Give the private prefix the package.json both managers insist on. */ +export function prepareVendorDir(root: string): void { + mkdirSync(root, { recursive: true }); + const manifest = join(root, 'package.json'); + if (existsSync(manifest)) return; + + writeFileSync( + manifest, + `${JSON.stringify( + { + name: 'cli-tools-vendor-openmcp', + version: '0.0.0', + private: true, + description: 'Prefix owned by profullstack/cli-tools. Managed by the openmcp command.', + }, + null, + 2, + )}\n`, + ); +} + +/** Is this Node new enough? Prerelease and build suffixes are dropped. */ +export function meetsNodeFloor(version: string, floor: string = MIN_NODE): boolean { + const parse = (v: string): number[] => + v + .replace(/^v/, '') + .split(/[-+]/)[0]! + .split('.') + .map((part) => Number.parseInt(part, 10) || 0); + + const got = parse(version); + const want = parse(floor); + + for (let i = 0; i < 3; i += 1) { + const a = got[i] ?? 0; + const b = want[i] ?? 0; + if (a !== b) return a > b; + } + return true; +} + +export interface InstallResult { + ok: boolean; + manager?: PackageManager; + code?: number | null; + /** What actually landed, when it could be read. */ + version?: string; + /** Why an install that exited 0 was not accepted. */ + note?: string; +} + +/** + * Install (or refresh) the catalog client in the private prefix. + * + * Exit 0 is not proof: pnpm's release-age cooldown installs the previous + * version and reports success, which is how an `update` run to pick up a fix + * can leave the fix uninstalled. src/vendor-verify.ts has the reproduction. + */ +export async function install( + spec: string = `${PACKAGE}@latest`, + env: NodeJS.ProcessEnv = process.env, + run: typeof spawnInherit = spawnInherit, +): Promise { + const root = vendorRoot(env); + prepareVendorDir(root); + + // Asked once rather than per manager. Null means the registry was + // unreachable, and an unverifiable install is allowed through: an offline + // box should still be able to reinstall what it already has. + const wanted = await wantedVersion(spec, PACKAGE); + let lastNote: string | undefined; + + for (const manager of managers(env)) { + const plan = installPlan(manager, spec); + const code = await run(plan.file, plan.args, root); + if (code !== 0) continue; + + const got = installedVersion(root, PACKAGE); + if (delivered(got, wanted)) return { ok: true, manager, code, ...(got ? { version: got } : {}) }; + lastNote = heldBackNote(manager, got, wanted); + } + + return { ok: false, ...(lastNote ? { note: lastNote } : {}) }; +} + +/** + * Remove the copy we installed. + * + * Only ever the prefix this command owns. Upstream's `uninstall` reads a + * manifest and removes the paths its own installer wrote, which is the right + * thing for a copy installed that way and no business of ours. A catalog's + * own database (`openmcp serve --db`) lives wherever it was pointed, never in + * this prefix, so removing the program removes no records. + */ +export function removeVendor(env: NodeJS.ProcessEnv = process.env): boolean { + const root = vendorRoot(env); + if (!existsSync(root)) return false; + rmSync(root, { recursive: true, force: true }); + return true; +} + +/** What to print when neither manager could install it. */ +export function installFailureMessage(root: string): string { + return [ + `openmcp: could not install ${PACKAGE}.`, + ` cd ${root} && npm install ${PACKAGE}@latest # by hand, to see the error`, + ' curl -fsSL https://openmcp.logicsrc.com/install.sh | sh # or upstream\'s installer, which brings its own Node', + ' OPENMCP_BIN=/path/to/openmcp openmcp # or point at a copy you have', + ].join('\n'); +} + +/** What to print when the Node running this is older than the package accepts. */ +export function nodeFloorMessage(version: string): string { + return [ + `openmcp: needs Node ${MIN_NODE} or newer, and this is ${version}.`, + ' mise use -g node@lts # then re-run', + ' curl -fsSL https://openmcp.logicsrc.com/install.sh | sh # or the installer, which fetches its own Node 24', + 'Continuing anyway, so the failure below, if any, is that.', + ].join('\n'); +} diff --git a/src/registry.ts b/src/registry.ts index 19590cf..9002fde 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -45,6 +45,7 @@ const SUMMARIES: Record = { genrewatch: 'What is coming out, and whether it exists at all', img: 'Resize, convert and inspect images, with sharp or ImageMagick', mail: 'The inbox from the terminal: read, search, reply, send, file, delete', + openmcp: 'The OpenMCP catalog of MCP relays: list, find a tool, call it, register your own', 'gh-prs': 'Every open PR across the owners you name', 'gh-prs-fix-all': 'Repair the open scan PRs that are broken because of us', 'gh-prs-merge': 'Squash-merge the PRs that are genuinely ready', diff --git a/test/openmcp.test.ts b/test/openmcp.test.ts new file mode 100644 index 0000000..d094e4b --- /dev/null +++ b/test/openmcp.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + EXECUTABLE, + INSTALL_WORDS, + MIN_NODE, + PACKAGE, + installFailureMessage, + installPlan, + managers, + meetsNodeFloor, + nodeFloorMessage, + ownsInstallWord, + prepareVendorDir, + removeVendor, + resolveRunner, + vendorBin, + vendorRoot, +} from '../src/openmcp.ts'; + +describe('vendorRoot', () => { + it('follows XDG_DATA_HOME when it is set', () => { + expect(vendorRoot({ XDG_DATA_HOME: '/data' })).toBe('/data/cli-tools/vendor/openmcp'); + }); + + it('falls back to ~/.local/share', () => { + expect(vendorRoot({ HOME: '/home/x' })).toBe('/home/x/.local/share/cli-tools/vendor/openmcp'); + }); + + it('is not upstream’s own install directory', () => { + // Upstream's curl installer owns ~/.local/share/openmcp and writes a + // manifest (and possibly a private Node) there. Ours is a sibling under + // cli-tools/vendor, so the two never manage each other's files. + expect(vendorRoot({ HOME: '/home/x' })).not.toBe('/home/x/.local/share/openmcp'); + }); +}); + +describe('vendorBin', () => { + it('has the same name as this wrapper, which is the whole hazard', () => { + expect(EXECUTABLE).toBe('openmcp'); + expect(PACKAGE).toBe('@logicsrc/openmcp'); + expect(vendorBin({ XDG_DATA_HOME: '/data' })).toBe( + '/data/cli-tools/vendor/openmcp/node_modules/.bin/openmcp', + ); + }); +}); + +describe('installPlan', () => { + it('keeps pnpm out of a workspace it happens to be standing in', () => { + const plan = installPlan('pnpm'); + expect(plan.file).toBe('pnpm'); + expect(plan.args).toContain('--ignore-workspace'); + expect(plan.args.at(-1)).toBe(`${PACKAGE}@latest`); + }); + + it('installs a pinned spec when given one', () => { + expect(installPlan('npm', `${PACKAGE}@0.3.0`).args.at(-1)).toBe(`${PACKAGE}@0.3.0`); + }); + + it('falls back to npm without pnpm flags', () => { + const plan = installPlan('npm'); + expect(plan.file).toBe('npm'); + expect(plan.args).not.toContain('--ignore-workspace'); + }); +}); + +describe('managers', () => { + it('tries npm alone when pnpm is not on PATH', () => { + expect(managers({ PATH: '/nowhere' })).toEqual(['npm']); + }); +}); + +describe('meetsNodeFloor', () => { + it('accepts the floor itself and anything above it', () => { + expect(meetsNodeFloor(MIN_NODE)).toBe(true); + expect(meetsNodeFloor('v24.18.1')).toBe(true); + }); + + it('rejects the Node this repo itself floors at', () => { + // node:sqlite is Node 24; on 22 the failure is a missing built-in module. + expect(meetsNodeFloor('v22.18.0')).toBe(false); + expect(meetsNodeFloor('v20.19.0')).toBe(false); + }); + + it('treats a prerelease as its release version', () => { + expect(meetsNodeFloor('v24.0.0-nightly20260101')).toBe(true); + }); +}); + +describe('resolveRunner', () => { + it('lets an explicit override win outright', () => { + const runner = resolveRunner({ env: { OPENMCP_BIN: '/opt/openmcp' }, exists: () => true }); + expect(runner).toEqual({ kind: 'env', file: '/opt/openmcp' }); + }); + + it('prefers the vendored copy over anything on PATH', () => { + const runner = resolveRunner({ + env: { XDG_DATA_HOME: '/data' }, + exists: (path) => path === '/data/cli-tools/vendor/openmcp/node_modules/.bin/openmcp', + onPathStatus: () => 'other', + onPathTarget: () => '/usr/bin/openmcp', + }); + expect(runner.kind).toBe('vendor'); + }); + + it('uses a copy on PATH that is not ours', () => { + // Upstream's curl installer writes a real script to ~/.local/bin/openmcp. + // A deliberate act, and not ours to second-guess. + const runner = resolveRunner({ + env: {}, + exists: () => false, + onPathStatus: () => 'other', + onPathTarget: () => '/home/x/.local/bin/openmcp', + }); + expect(runner).toEqual({ kind: 'path', file: '/home/x/.local/bin/openmcp' }); + }); + + it('refuses to follow our own wrapper back to itself', () => { + const runner = resolveRunner({ + env: {}, + exists: () => false, + onPathStatus: () => 'ours', + onPathTarget: () => '/home/x/.local/bin/openmcp', + }); + expect(runner).toEqual({ kind: 'missing', file: null }); + }); + + it('reports missing when there is nothing anywhere', () => { + const runner = resolveRunner({ env: {}, exists: () => false, onPathStatus: () => 'missing' }); + expect(runner.kind).toBe('missing'); + }); +}); + +describe('ownsInstallWord', () => { + it('answers update, uninstall and where for the copy we installed', () => { + for (const word of INSTALL_WORDS) { + expect(ownsInstallWord('vendor', word)).toBe(true); + expect(ownsInstallWord('missing', word)).toBe(true); + } + }); + + it('leaves them alone for a copy installed some other way', () => { + // A copy from upstream's installer HAS a manifest, and answering `update` + // ourselves would leave that copy stale while updating a different one. + for (const word of INSTALL_WORDS) { + expect(ownsInstallWord('path', word)).toBe(false); + expect(ownsInstallWord('env', word)).toBe(false); + } + }); + + it('claims no other word, however install-shaped', () => { + expect(ownsInstallWord('vendor', 'relays')).toBe(false); + expect(ownsInstallWord('vendor', 'serve')).toBe(false); + expect(ownsInstallWord('vendor', 'refresh')).toBe(false); + expect(ownsInstallWord('vendor', undefined)).toBe(false); + }); +}); + +describe('messages', () => { + it('offer upstream’s one-line installer as the way out', () => { + expect(installFailureMessage('/x')).toContain('curl -fsSL https://openmcp.logicsrc.com/install.sh | sh'); + expect(nodeFloorMessage('v22.18.0')).toContain('curl -fsSL https://openmcp.logicsrc.com/install.sh | sh'); + expect(nodeFloorMessage('v22.18.0')).toContain(MIN_NODE); + }); +}); + +describe('prepareVendorDir', () => { + it('writes the manifest both package managers insist on', () => { + const root = mkdtempSync(join(tmpdir(), 'openmcp-vendor-')); + try { + prepareVendorDir(root); + const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + expect(manifest.private).toBe(true); + expect(manifest.name).toContain('openmcp'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('leaves an existing manifest alone', () => { + const root = mkdtempSync(join(tmpdir(), 'openmcp-vendor-')); + try { + prepareVendorDir(root); + const before = readFileSync(join(root, 'package.json'), 'utf8'); + writeFileSync(join(root, 'package.json'), before.replace('0.0.0', '9.9.9')); + prepareVendorDir(root); + expect(readFileSync(join(root, 'package.json'), 'utf8')).toContain('9.9.9'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('removeVendor', () => { + it('removes only the prefix this command owns', () => { + const home = mkdtempSync(join(tmpdir(), 'openmcp-home-')); + try { + const env = { XDG_DATA_HOME: join(home, 'share') }; + const root = vendorRoot(env); + prepareVendorDir(root); + + // Upstream's own install directory is a sibling, and stays. + const theirs = join(home, 'share', 'openmcp'); + mkdirSync(theirs, { recursive: true }); + + expect(removeVendor(env)).toBe(true); + expect(existsSync(root)).toBe(false); + expect(existsSync(theirs)).toBe(true); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it('says so when there was nothing to remove', () => { + expect(removeVendor({ XDG_DATA_HOME: join(tmpdir(), 'openmcp-not-here') })).toBe(false); + }); +});