diff --git a/README.md b/README.md index 1af9e7d..8136532 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ TypeScript, installed as executables on `PATH`. | [`dl`](#dl) | Download a video, or just its audio, through yt-dlp | | [`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 | | [`shorten`](#shorten) | Mint a short link on the pit, and follow it from `/f/` | | [`sysupdate`](#sysupdate) | Update this box: apt lists, apt packages, snaps | @@ -66,6 +67,9 @@ 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 ## Install @@ -1293,6 +1297,59 @@ before every launch, which is fine for a one-shot and wrong for a dashboard you open twenty times a day. Upstream wants **Node 22.13+**; on an older one it says so and tries anyway, since that floor is theirs to move. +### `agenticjobs` + +The job board where the applying is done by agents, from the terminal: +[@profullstack/agenticjobs](https://www.npmjs.com/package/@profullstack/agenticjobs), +the same board that runs at [agenticjobs.work](https://agenticjobs.work). + +```sh +agenticjobs signup # an account, and this box signed in +agenticjobs search rust --remote # the board you are on +agenticjobs search go --network # every board in the directory +agenticjobs apply --resume cv.md +agenticjobs post job.md # then `publish ` to go live +agenticjobs tui # the full-screen client +agenticjobs mcp # stdio MCP server for the board +agenticjobs --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 +agenticjobs --self-update # reinstall the latest release +agenticjobs --self-where # which copy runs, and from where +``` + +**The first run installs it**, into +`~/.local/share/cli-tools/vendor/agenticjobs` rather than globally. That matters +more here than for the other wrapped packages, because upstream's own executable +is *also* called `agenticjobs`: a global install would put two of that name on +`PATH` and the winner would come down to the order of two directories, and if +ours won and it followed `PATH` it would exec itself. A private prefix means the +name exists once. `AGENTICJOBS_BIN` points at a copy you would rather run, and +`AGENTICJOBS_SPEC` pins a version. + +**Three plain words are intercepted, and only for the copy we installed.** +`update`, `uninstall` and `where` are upstream's, and upstream answers them from +a `manifest.json` that its own `curl` installer writes. A copy npm puts in our +prefix has no manifest, so upstream would answer "not installed by the +installer" on a box where this command plainly did install it and works. So +those three are answered here when the board that would run is ours, and handed +straight through when it is not: a board from upstream's installer, or one +pointed at with `AGENTICJOBS_BIN`, keeps upstream's behaviour exactly. + +That last case is not hypothetical. Upstream's installer writes a real shell +script to `~/.local/bin/agenticjobs`, which is the same directory this repo +links into, and `install-links.mjs` never takes over a real file even with +`--force`. On a box that already ran `curl -fsSL https://agenticjobs.work/install.sh | sh` +you will see `SKIP … is a real file` and keep the board you already had. Remove +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. + ### `shorten` Mints a short link on the Moshpit registry and prints it. `/f/` answers a diff --git a/bin/agenticjobs.ts b/bin/agenticjobs.ts new file mode 100644 index 0000000..ec7a1a9 --- /dev/null +++ b/bin/agenticjobs.ts @@ -0,0 +1,159 @@ +#!/usr/bin/env node +/** + * agenticjobs — search, apply, post and hire from the terminal. + * + * A launcher for the `@profullstack/agenticjobs` package, not a + * reimplementation of it. Everything you type is handed through untouched, so + * upstream's docs are the docs: + * + * agenticjobs signup make an account and sign this box in + * agenticjobs search rust --remote the current board + * agenticjobs search go --network every board in the directory + * agenticjobs apply --resume cv.md + * agenticjobs post job.md post one, then `publish ` + * agenticjobs tui the full-screen client + * agenticjobs mcp stdio MCP server for the board + * agenticjobs --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/agenticjobs.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/agenticjobs.ts'; + +const DESCRIBE: Record = { + env: 'AGENTICJOBS_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.AGENTICJOBS_SPEC || `${PACKAGE}@latest`; + process.stderr.write(`agenticjobs: ${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( + `agenticjobs: 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()}`, + '', + 'Your boards and tokens in ~/.config/agenticjobs are NOT touched.', + '', + 'Run it for real with: agenticjobs 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( + `agenticjobs: could not start ${file}\n agenticjobs --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/agenticjobs.ts b/src/agenticjobs.ts new file mode 100644 index 0000000..31a0743 --- /dev/null +++ b/src/agenticjobs.ts @@ -0,0 +1,280 @@ +/** + * agenticjobs — the job board where agents do the applying, on this box. + * + * The board is `@profullstack/agenticjobs`, published from profullstack/ + * agenticjobs and running at agenticjobs.work. Nothing here reimplements it; + * this is the part that has to exist so `agenticjobs` 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 codeburn: a + * search 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, and here it matters more than usual. The package's + * own executable is called `agenticjobs`, which is also the name of this + * wrapper, so a global install puts a second `agenticjobs` 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, which is the thing worth reading twice. + * `agenticjobs update`, `uninstall` and `where` all read a manifest.json that + * upstream's curl installer writes, 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. + * + * AGENTICJOBS_BIN run this executable instead: a checkout, or their installer + * AGENTICJOBS_SPEC what gets installed, when you want a pinned version + * + * Deliberately NOT set here: AGENTICJOBS_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 = '@profullstack/agenticjobs'; +export const EXECUTABLE = 'agenticjobs'; + +/** + * The floor the package declares. + * + * Higher than this repo's own floor of 22.18, so it is checked rather than + * assumed: the board's bin loads compiled ESM that uses newer built-ins, and + * the failure on older Node names a file inside node_modules and nothing else. + */ +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', 'agenticjobs'); +} + +/** 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 board client to run. + * + * The `ours` case is the whole reason this function exists. This wrapper is + * installed on PATH under the name `agenticjobs`, the same name the package's + * own bin uses, so "is agenticjobs 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 of the board, or a copy put + // there by upstream's own installer, is a deliberate act and not ours to + // second-guess. + const override = env.AGENTICJOBS_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 board put there by upstream's installer, or pointed at + * with AGENTICJOBS_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-agenticjobs', + version: '0.0.0', + private: true, + description: + 'Prefix owned by profullstack/cli-tools. Managed by the agenticjobs 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 board 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 board installed that way and no business of ours. + * + * Configuration is deliberately left alone. ~/.config/agenticjobs holds the + * boards you are signed in to and the tokens for them, and upstream keeps them + * across its own uninstall for the same reason: removing a program is not the + * same as saying you never want to log in again. + */ +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 [ + `agenticjobs: could not install ${PACKAGE}.`, + ` cd ${root} && npm install ${PACKAGE}@latest # by hand, to see the error`, + ' AGENTICJOBS_BIN=/path/to/agenticjobs agenticjobs # or point at a copy you have', + ].join('\n'); +} + +/** What to print when the Node running this is older than the board accepts. */ +export function nodeFloorMessage(version: string): string { + return [ + `agenticjobs: needs Node ${MIN_NODE} or newer, and this is ${version}.`, + ' mise use -g node@lts # then re-run', + 'Continuing anyway, so the failure below, if any, is that.', + ].join('\n'); +} diff --git a/src/registry.ts b/src/registry.ts index df9c600..19590cf 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -28,6 +28,7 @@ export interface Command { /** One-line summaries, so `cli-tools list` says what each command is for. */ const SUMMARIES: Record = { affiliate: 'Work through a list of programs you mean to sign up for', + agenticjobs: 'Search, apply, post and hire on an agent-friendly job board', argontv: 'The shared IPTV line: is it healthy, and is there room to sell another pass', 'ask-web': 'Answer a question from the live web, with its sources', 'blog-post': 'Publish to a plain-HTML blog without breaking the feed', diff --git a/test/agenticjobs.test.ts b/test/agenticjobs.test.ts new file mode 100644 index 0000000..42a6251 --- /dev/null +++ b/test/agenticjobs.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, existsSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + EXECUTABLE, + INSTALL_WORDS, + MIN_NODE, + PACKAGE, + installPlan, + managers, + meetsNodeFloor, + ownsInstallWord, + prepareVendorDir, + removeVendor, + resolveRunner, + vendorBin, + vendorRoot, +} from '../src/agenticjobs.ts'; + +describe('vendorRoot', () => { + it('follows XDG_DATA_HOME when it is set', () => { + expect(vendorRoot({ XDG_DATA_HOME: '/data' })).toBe('/data/cli-tools/vendor/agenticjobs'); + }); + + it('falls back to ~/.local/share', () => { + expect(vendorRoot({ HOME: '/home/x' })).toBe( + '/home/x/.local/share/cli-tools/vendor/agenticjobs', + ); + }); + + it('is not upstream’s own install directory', () => { + // Upstream's curl installer owns ~/.local/share/agenticjobs and writes a + // manifest 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/agenticjobs'); + }); +}); + +describe('vendorBin', () => { + it('has the same name as this wrapper, which is the whole hazard', () => { + // The package's bin is `agenticjobs` and so is our command. A global + // install would put two of them on PATH and the winner would depend on + // directory order; if ours won and we followed PATH we would exec + // ourselves. The private prefix means the name exists exactly once. + expect(EXECUTABLE).toBe('agenticjobs'); + expect(PACKAGE).toBe('@profullstack/agenticjobs'); + expect(vendorBin({ XDG_DATA_HOME: '/data' })).toBe( + '/data/cli-tools/vendor/agenticjobs/node_modules/.bin/agenticjobs', + ); + }); +}); + +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.5.0`).args.at(-1)).toBe(`${PACKAGE}@0.5.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.4.0')).toBe(true); + }); + + it('rejects the Node this repo itself floors at', () => { + // The board wants 24 and cli-tools only promises 22.18, so a box that runs + // every other command here can still be too old for this one. Checked + // before spawning, because the failure otherwise names a file inside + // node_modules and nothing that suggests the Node version. + 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: { AGENTICJOBS_BIN: '/opt/agenticjobs' }, + exists: () => true, + }); + expect(runner).toEqual({ kind: 'env', file: '/opt/agenticjobs' }); + }); + + it('prefers the vendored copy over anything on PATH', () => { + const runner = resolveRunner({ + env: { XDG_DATA_HOME: '/data' }, + exists: (path) => + path === '/data/cli-tools/vendor/agenticjobs/node_modules/.bin/agenticjobs', + onPathStatus: () => 'other', + onPathTarget: () => '/usr/bin/agenticjobs', + }); + expect(runner.kind).toBe('vendor'); + }); + + it('uses a copy on PATH that is not ours', () => { + // Upstream's own installer, or a global npm install. A deliberate act, and + // not ours to second-guess. + const runner = resolveRunner({ + env: {}, + exists: () => false, + onPathStatus: () => 'other', + onPathTarget: () => '/home/x/.local/bin/agenticjobs', + }); + expect(runner).toEqual({ kind: 'path', file: '/home/x/.local/bin/agenticjobs' }); + }); + + it('refuses to follow our own wrapper back to itself', () => { + // Both are called `agenticjobs`, so "is it on PATH" answers yes on every + // box where this command is installed. Following that answer is a fork bomb. + const runner = resolveRunner({ + env: {}, + exists: () => false, + onPathStatus: () => 'ours', + onPathTarget: () => '/home/x/.local/bin/agenticjobs', + }); + 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', () => { + // Upstream reads these from a manifest.json its curl installer writes. A + // copy npm put in our prefix has none, so upstream would answer "not + // installed by the installer" on a box where this command installed it. + for (const word of INSTALL_WORDS) { + expect(ownsInstallWord('vendor', word)).toBe(true); + expect(ownsInstallWord('missing', word)).toBe(true); + } + }); + + it('leaves them alone for a board 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', 'search')).toBe(false); + expect(ownsInstallWord('vendor', 'serve')).toBe(false); + expect(ownsInstallWord('vendor', 'migrate')).toBe(false); + expect(ownsInstallWord('vendor', undefined)).toBe(false); + }); +}); + +describe('prepareVendorDir', () => { + it('writes the manifest both package managers insist on', () => { + const root = mkdtempSync(join(tmpdir(), 'agenticjobs-vendor-')); + try { + prepareVendorDir(root); + const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + expect(manifest.private).toBe(true); + expect(manifest.name).toContain('agenticjobs'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('leaves an existing manifest alone', () => { + const root = mkdtempSync(join(tmpdir(), 'agenticjobs-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(), 'agenticjobs-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', 'agenticjobs'); + 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(), 'agenticjobs-not-here') })).toBe(false); + }); +});