From c7d2f1ff92041b82e91ea1be51c34f0e5e15a15d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 9 Sep 2026 09:55:54 +0000 Subject: [PATCH] feat(agenticjobs): the job board, as a command on every box The board is @profullstack/agenticjobs, the same one running at agenticjobs.work. It has had a CLI since 0.2.0 and it was reachable from exactly one place: a curl installer you had to remember the URL for. This makes it a command here like every other one, installed on first use. Everything is handed through untouched. Two flags are ours, spelled --self-* because every plain word is upstream's. The name is the interesting part. Upstream's own executable is also called `agenticjobs`, so this is the first wrapper in the repo whose name collides head-on with the package it launches. A global install would put two of them on PATH and the winner would come down to directory order; if ours won and it followed PATH it would exec itself. resolveRunner only ever accepts a copy that is not this wrapper, and the vendor prefix means the name exists once. Three plain words are intercepted, and only for the copy we installed. `update`, `uninstall` and `where` are upstream's, and upstream answers them out of a manifest.json that its own curl installer writes. A copy npm puts in our prefix has no manifest, so upstream replies "not installed by the installer" on a box where this command plainly did install it and works. Those three are answered here when the board that would run is ours, and handed straight through when it is not, so a board from upstream's installer keeps upstream's behaviour exactly. That case is already live on this box: upstream's installer writes a real shell script to ~/.local/bin/agenticjobs, which is the directory install-links.mjs links into, and it never takes over a real file even with --force. So a box that already ran their installer reports SKIP and keeps what it had. The README says so rather than leaving it to be discovered. Verified end to end with ~/.local/bin off PATH: first run installed 0.5.0 with pnpm and then ran the command, --self-where reported the prefix, `update` refreshed ours, `uninstall` without --yes changed nothing and with it removed only our prefix. Install goes through vendor-verify, so pnpm's release-age cooldown cannot report success while leaving the old version in place. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WxWrsbLuaSaZqQ7FFTrdVW --- README.md | 57 ++++++++ bin/agenticjobs.ts | 159 ++++++++++++++++++++++ src/agenticjobs.ts | 280 +++++++++++++++++++++++++++++++++++++++ src/registry.ts | 1 + test/agenticjobs.test.ts | 227 +++++++++++++++++++++++++++++++ 5 files changed, 724 insertions(+) create mode 100644 bin/agenticjobs.ts create mode 100644 src/agenticjobs.ts create mode 100644 test/agenticjobs.test.ts 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); + }); +});