From acd4d5bb93ae2991a11da36e20287593d5399c94 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 12 Sep 2026 17:55:08 +0000 Subject: [PATCH 1/2] openmcp: the OpenMCP catalog as a command, installed on first use A launcher for @logicsrc/openmcp in the same shape as agenticjobs: everything is handed through to upstream, the first run installs it into a private prefix under ~/.local/share/cli-tools/vendor/openmcp so the name exists once on PATH, --self-update and --self-where are ours, and update, uninstall and where are answered here only for the copy we installed, since upstream reads those from the manifest its own curl installer writes. Node 24 is upstream's floor (node:sqlite); below it the command says so, offers the one-line installer that brings its own Node, and tries anyway. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Qh2dieNyPZ4Hx5g3XNJ1Eo --- README.md | 59 ++++++++- bin/openmcp.ts | 158 ++++++++++++++++++++++++ src/openmcp.ts | 278 +++++++++++++++++++++++++++++++++++++++++++ src/registry.ts | 1 + test/openmcp.test.ts | 219 ++++++++++++++++++++++++++++++++++ 5 files changed, 712 insertions(+), 3 deletions(-) create mode 100644 bin/openmcp.ts create mode 100644 src/openmcp.ts create mode 100644 test/openmcp.test.ts 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/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); + }); +}); From 8c756fb41cfe02df23fb4377975a7633338f89b4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 12 Sep 2026 17:59:49 +0000 Subject: [PATCH 2/2] chore(release): v0.32.0 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Qh2dieNyPZ4Hx5g3XNJ1Eo --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",