From 131d60543f8f16204bc81cd2baf2d6665c33185e Mon Sep 17 00:00:00 2001 From: Aaron Ware Date: Wed, 26 Aug 2026 10:37:09 -0400 Subject: [PATCH 1/3] feat(NO-TASK): Add version detection and self-update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two commands and a notifier, so a user finds out a new version exists without asking and without paying for the check. `linchpin version` reports what is installed, whether a newer version is published, and how this copy was installed. `linchpin update` runs the install command for *that* install method — derived from the path the process is running from, because handing a pnpm or bun install an `npm install -g` leaves two copies on the machine and which one answers depends on PATH order. The notice costs no latency: it is read from a 24-hour cache file, and a stale cache is refreshed by a detached process that outlives the command. It goes to stderr only, so `cd "$(linchpin wt switch)"` and `eval "$(linchpin shell-init)"` keep working, and it is suppressed in --json, --quiet, CI, and for agents — an agent asks `linchpin version --check --json` instead of paying tokens for a line it did not request. Two ways to ask, deliberately: `version --check` always exits 0 so it is safe in a prompt or status line, while `update --check` exits 3 when an update is pending so it can gate a job with nothing to parse. Also fixes a pre-existing gap the new commands made visible: the mode flags were only accepted *before* a subcommand, so `linchpin version --json` was an unknown-option error while `linchpin --json version` worked. They are now accepted at any depth, except on the `wt` passthrough which forwards them to the legacy dispatcher. Commands receive the Output renderer and the package manifest through CommandContext, so mode is still decided once per process. The suite never reaches the network — the checker is exercised against a local registry stub, and the shared fixture disables the notifier so no test can be perturbed by a real release. Co-Authored-By: Claude Opus 5 (1M context) --- src/cli.ts | 47 +++- src/cli/commands/index.ts | 9 +- src/cli/commands/update.ts | 161 +++++++++++++ src/cli/commands/version.ts | 99 ++++++++ src/cli/program.ts | 37 ++- src/cli/registry.ts | 11 + src/cli/update-notifier.ts | 113 +++++++++ src/core/exec.ts | 12 +- src/core/update.ts | 457 ++++++++++++++++++++++++++++++++++++ src/index.ts | 34 ++- src/version.ts | 18 +- test-utils/cli-fixture.js | 15 +- test/update.test.js | 353 ++++++++++++++++++++++++++++ 13 files changed, 1349 insertions(+), 17 deletions(-) create mode 100644 src/cli/commands/update.ts create mode 100644 src/cli/commands/version.ts create mode 100644 src/cli/update-notifier.ts create mode 100644 src/core/update.ts create mode 100644 test/update.test.js diff --git a/src/cli.ts b/src/cli.ts index d75b124..1dd5c1c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,7 +8,9 @@ import { COMMANDS } from './cli/commands/index.js'; import { EXIT_CODES } from './cli/errors.js'; import { Output, resolveOutputMode, type OutputMode } from './cli/output.js'; import { CommandError, assertNoControlCharacters, buildProgram } from './cli/program.js'; -import { readVersion } from './version.js'; +import { notifyAboutUpdates } from './cli/update-notifier.js'; +import { detectInstallation } from './core/update.js'; +import { readManifest } from './version.js'; /** Read the mode flags before Commander parses, so failures render correctly too. */ function readModeFlags(argv: readonly string[]): { @@ -31,18 +33,26 @@ function readModeFlags(argv: readonly string[]): { */ export async function run( argv: readonly string[], - options: { mode?: OutputMode } = {} + options: { mode?: OutputMode; output?: Output } = {} ): Promise { assertNoControlCharacters(argv); + const manifest = readManifest(); + const output = + options.output ?? new Output(options.mode ?? resolveOutputMode(readModeFlags(argv))); + const program = buildProgram(COMMANDS, { name: 'linchpin', - version: readVersion(), + version: manifest.version, + manifest, + output, description: "Linchpin's command line tool for WordPress and agent workflows", examples: [ 'linchpin wt ls List worktrees for this repo', 'linchpin wt switch feature/checkout Point the local site at a worktree', 'linchpin shell-init >> ~/.zshrc Install the directory-changing wrapper', + 'linchpin version --check Check whether a newer release exists', + 'linchpin update Install the latest version', 'linchpin --help Help for one command', ], }); @@ -55,7 +65,10 @@ export async function run( // writes its own plain-text usage errors, which would hand an agent // unparseable output at exactly the moment it asked for JSON — so silence it // and let the envelope carry the message instead. - const jsonMode = options.mode === 'json'; + // Read from the resolved renderer, not the raw option: the entry point hands + // in an Output it already built, and reading `options.mode` here would leave + // Commander free to write plain-text usage errors into a JSON stream. + const jsonMode = output.mode === 'json'; if (jsonMode) { program.configureOutput({ writeErr: () => {}, writeOut: () => {} }); } @@ -107,12 +120,34 @@ function isEntryPoint(): boolean { } } +/** + * Tell the user about a newer release, after their command has finished. + * + * Deliberately last: reading a cache file and spawning a detached refresh must + * never be able to affect the exit code or the output of the thing they ran, so + * every failure in here is swallowed. + */ +function reportUpdates(output: Output, argv: readonly string[]): void { + try { + const manifest = readManifest(); + + notifyAboutUpdates(output, { + current: manifest.version, + installation: detectInstallation(manifest.name), + entryPath: fileURLToPath(import.meta.url), + commandName: argv.find((argument) => !argument.startsWith('-')), + }); + } catch { + // An update notice is never worth failing a command over. + } +} + if (isEntryPoint()) { const argv = process.argv.slice(2); const output = new Output(resolveOutputMode(readModeFlags(argv))); try { - process.exitCode = await run(argv, { mode: output.mode }); + process.exitCode = await run(argv, { output }); } catch (error) { // Commander-originated failures arrive with an empty message because it has // already reported them; rendering again would duplicate the output. @@ -122,4 +157,6 @@ if (isEntryPoint()) { process.exitCode = output.failure(argv[0] ?? 'linchpin', error); } } + + reportUpdates(output, argv); } diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index c7bd2e7..ba67b24 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -1,6 +1,8 @@ import type { CommandDefinition } from '../registry.js'; import { shellInitCommand } from './shell-init.js'; +import { updateCommand } from './update.js'; +import { versionCommand } from './version.js'; import { wtCommand } from './wt.js'; /** @@ -10,4 +12,9 @@ import { wtCommand } from './wt.js'; * `linchpin schema` are generated from. Adding a command means adding a file * here and one entry — nothing else. */ -export const COMMANDS: readonly CommandDefinition[] = [wtCommand, shellInitCommand]; +export const COMMANDS: readonly CommandDefinition[] = [ + wtCommand, + shellInitCommand, + versionCommand, + updateCommand, +]; diff --git a/src/cli/commands/update.ts b/src/cli/commands/update.ts new file mode 100644 index 0000000..1903760 --- /dev/null +++ b/src/cli/commands/update.ts @@ -0,0 +1,161 @@ +import { z } from 'zod'; + +import { runCommand } from '../../core/exec.js'; +import { + detectInstallation, + formatCommand, + resolveUpdateStatus, + writeUpdateCache, +} from '../../core/update.js'; +import { EXIT_CODES, UserError } from '../errors.js'; +import { defineCommand } from '../registry.js'; + +/** + * `linchpin update` — install the newest published version. + * + * The install command is derived from where this copy is actually running from, + * so a pnpm or bun install is not handed an `npm install -g` that would leave + * two copies on the machine shadowing each other. + */ +export const updateCommand = defineCommand({ + meta: { + name: 'update', + summary: 'Update the CLI to the latest published version', + description: + 'Queries the npm registry, then runs the install command for however this\n' + + 'copy was installed. --check makes it read-only and exits 3 when an update\n' + + 'is pending, so it can gate a release or a CI job.', + group: 'utility', + examples: [ + 'linchpin update', + 'linchpin update --check', + 'linchpin update --dry-run', + ], + }, + effect: 'write', + args: z.object({ + check: z + .boolean() + .default(false) + .describe('Report whether an update is pending and exit 3 if so; install nothing'), + dryRun: z + .boolean() + .default(false) + .describe('Print the install command that would run, without running it'), + }), + handler: async (args, ctx) => { + const { output } = ctx; + const { name, version } = ctx.manifest; + const installation = detectInstallation(name); + + const status = await resolveUpdateStatus({ + packageName: name, + current: version, + refresh: true, + }); + + // Unlike `version`, this command cannot do its job without an answer: it + // would either install nothing or reinstall blind. + if (status.latest === undefined) { + throw new UserError(`Could not reach the npm registry: ${status.error ?? 'unknown error'}`, { + exitCode: EXIT_CODES.precondition, + code: 'registry_unreachable', + remedy: 'Check your network, or set LINCHPIN_REGISTRY if you publish through a mirror.', + }); + } + + // A reachable cache with an unreachable registry is still actionable — the + // package manager will report its own network failure if there is one — but + // acting on a day-old answer without saying so would be dishonest. The + // warning is human-only; a parser reads `checkError` out of the envelope. + if (status.error !== undefined && output.mode === 'human') { + output.warn(`Registry unreachable (${status.error}); using the last cached answer.`); + } + + const latest = status.latest; + const updateCommand = installation.command; + const rendered = updateCommand ? formatCommand(updateCommand) : null; + + if (!status.updateAvailable) { + output.result( + 'update', + { + current: version, + latest, + updateAvailable: false, + command: rendered, + checkError: status.error ?? null, + }, + { changed: false, human: `${name} ${version} is the latest version.` } + ); + return; + } + + // A pending update is a precondition failure on purpose: `--check` exists to + // gate something, and a gate that exits 0 gates nothing. + if (args.check) { + throw new UserError(`Update available: ${version} → ${latest}`, { + exitCode: EXIT_CODES.precondition, + code: 'update_available', + remedy: rendered === null ? installation.hint : `Run 'linchpin update' to install it.`, + }); + } + + if (updateCommand === undefined || rendered === null) { + throw new UserError(`Cannot update a ${installation.scope} install automatically`, { + exitCode: EXIT_CODES.precondition, + code: 'unsupported_install', + remedy: installation.hint, + }); + } + + if (args.dryRun) { + output.result( + 'update', + { + current: version, + latest, + updateAvailable: true, + command: rendered, + checkError: status.error ?? null, + }, + { changed: false, human: `Would run: ${rendered}` } + ); + return; + } + + output.info(`Updating ${name} ${version} → ${latest}`); + + const [binary, ...rest] = updateCommand; + const result = runCommand(binary ?? 'npm', rest, { + allowFailure: true, + // Silence for the length of an install reads as a hang, so a human watches + // the package manager directly. A parser gets the envelope instead. + inherit: output.mode === 'human', + }); + + if (!result.ok) { + throw new UserError(`Update failed: ${result.stderr || rendered}`, { + exitCode: EXIT_CODES.unexpected, + code: 'update_failed', + remedy: `Run it yourself to see the full output: ${rendered}`, + }); + } + + // Reset the check window so the notifier does not repeat a notice that has + // just been acted on. + writeUpdateCache({ checkedAt: Date.now(), latest, current: latest }); + + output.result( + 'update', + { + current: version, + latest, + updateAvailable: false, + command: rendered, + checkError: null, + }, + { changed: true, human: `Updated ${name} ${version} → ${latest}` } + ); + }, +}); diff --git a/src/cli/commands/version.ts b/src/cli/commands/version.ts new file mode 100644 index 0000000..87ad189 --- /dev/null +++ b/src/cli/commands/version.ts @@ -0,0 +1,99 @@ +import { z } from 'zod'; + +import { + detectInstallation, + formatAge, + formatCommand, + resolveUpdateStatus, + updateCachePath, +} from '../../core/update.js'; +import { defineCommand } from '../registry.js'; + +/** + * `linchpin version` — the installed version, plus whether a newer one exists. + * + * `-v/--version` prints the bare number and nothing else, because a script that + * parses it should keep working. This command is the human and agent form: one + * place that answers "what am I running, is it current, and how would I move". + * + * Always exits 0, including when the registry cannot be reached. Use + * `linchpin update --check` for the form that fails when an update is pending. + */ +export const versionCommand = defineCommand({ + meta: { + name: 'version', + summary: 'Print the installed version and whether a newer one is published', + description: + 'Reads the cached result of the last check. Pass --check to query the npm\n' + + 'registry now. Exits 0 either way, so it is safe in a prompt or a status line.', + group: 'utility', + examples: [ + 'linchpin version', + 'linchpin version --check', + 'linchpin version --check --json', + ], + }, + effect: 'read', + args: z.object({ + check: z + .boolean() + .default(false) + .describe('Query the npm registry now instead of reading the cached answer'), + }), + handler: async (args, ctx) => { + const { output } = ctx; + const { name, version } = ctx.manifest; + const installation = detectInstallation(name); + + const status = await resolveUpdateStatus({ + packageName: name, + current: version, + refresh: args.check, + cacheOnly: !args.check, + }); + + const updateCommand = installation.command ? formatCommand(installation.command) : null; + + const lines = [`${name} ${version}`]; + + if (status.updateAvailable && status.latest) { + lines.push(`Update available: ${version} → ${status.latest}`); + lines.push( + updateCommand === null + ? ` ${installation.hint}` + : ` Run: linchpin update (or: ${updateCommand})` + ); + } else if (status.latest !== undefined) { + const age = status.checkedAt === undefined ? '' : ` (checked ${formatAge(status.checkedAt)})`; + lines.push(`Up to date${age}`); + } else { + lines.push("Update state unknown. Run 'linchpin version --check' to ask the registry."); + } + + if (status.error !== undefined) { + lines.push(` Registry unreachable: ${status.error}`); + } + + output.result( + 'version', + { + name, + current: version, + latest: status.latest ?? null, + updateAvailable: status.updateAvailable, + checkedAt: status.checkedAt === undefined ? null : new Date(status.checkedAt).toISOString(), + source: status.source, + checkError: status.error ?? null, + install: { + manager: installation.manager, + scope: installation.scope, + path: installation.path, + updateCommand, + }, + cachePath: updateCachePath(), + node: process.versions.node, + }, + { human: lines.join('\n') } + ); + }, +}); diff --git a/src/cli/program.ts b/src/cli/program.ts index a203a19..bbb7293 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -1,4 +1,4 @@ -import { Command } from 'commander'; +import { Command, Option } from 'commander'; import { z } from 'zod'; import { @@ -7,6 +7,8 @@ import { type CommandDefinition, } from './registry.js'; import { EXIT_CODE_DESCRIPTIONS, EXIT_CODES, UserError } from './errors.js'; +import { Output } from './output.js'; +import type { Manifest } from '../version.js'; import { buildOption, deriveFields } from './schema-to-options.js'; /** @@ -26,6 +28,16 @@ export class CommandError extends UserError { } } +/** + * Flags the Output layer owns, which every command must still accept. + * + * They are resolved from the whole argv before Commander parses, so a command + * never reads them — but Commander rejects options it has not been told about, + * and `linchpin version --json` failing while `linchpin --json version` works is + * exactly the kind of positional trap an agent cannot be expected to learn. + */ +const MODE_FLAGS = ['--json', '--plain', '--quiet', '--no-input', '--no-color'] as const; + // ASCII control characters. Excluded from argv entirely: multi-line and binary // payloads travel by file path in this CLI (--message-file, --body-file), never // as arguments, so a control character in argv is either a mistake or an @@ -63,6 +75,14 @@ export interface BuildProgramOptions { readonly description?: string; /** Shown under the root `--help`, so usage is discoverable without docs. */ readonly examples?: readonly string[]; + /** + * The package identity handed to commands. Defaults to the binary name, which + * is only right when the two happen to match — `linchpin version` needs the + * *package* name to ask a registry about it. + */ + readonly manifest?: Manifest; + /** Renderer handed to every command, so mode is decided once per process. */ + readonly output?: Output; } /** Turn a Zod failure into one message an agent can act on without parsing a stack. */ @@ -113,6 +133,9 @@ export function buildProgram( ): Command { assertAllCommandsClassified(commands); + const manifest: Manifest = options.manifest ?? { name: options.name, version: options.version }; + const output = options.output ?? new Output(); + const program = new Command(options.name); program.version(options.version, '-v, --version', 'Print the version and exit'); if (options.description) program.description(options.description); @@ -182,6 +205,12 @@ export function buildProgram( command.passThroughOptions(true); } + // A passthrough is excluded deliberately: the legacy `wt` dispatcher reads + // `--json` out of its own argv, so claiming it here would swallow it. + if (!definition.meta.passthrough) { + for (const flag of MODE_FLAGS) command.addOption(new Option(flag).hideHelp()); + } + const fields = deriveFields(definition.args); for (const field of fields) { @@ -219,7 +248,11 @@ export function buildProgram( const parsed = definition.args.safeParse(raw); if (!parsed.success) throw formatValidationError(definition.meta.name, parsed.error); - const code = await definition.handler(parsed.data, { argv: process.argv.slice(2) }); + const code = await definition.handler(parsed.data, { + argv: process.argv.slice(2), + output, + manifest, + }); if (typeof code === 'number' && code !== 0) { throw new CommandError(`'${definition.meta.name}' exited with code ${code}`, code); } diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 0a0d3d0..ea79637 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -1,5 +1,8 @@ import { z } from 'zod'; +import type { Manifest } from '../version.js'; +import type { Output } from './output.js'; + /** * What a command does to the world. * @@ -70,6 +73,14 @@ export interface ArgMeta { export interface CommandContext { readonly argv: readonly string[]; + /** + * The single seam to the terminal. A handler renders through this rather than + * writing to a stream, which is what keeps the `--json` surface and the human + * one two renderings of one value instead of two code paths. + */ + readonly output: Output; + /** Name and version of the running package, read once at startup. */ + readonly manifest: Manifest; } export type CommandHandler = ( diff --git a/src/cli/update-notifier.ts b/src/cli/update-notifier.ts new file mode 100644 index 0000000..37dfd7e --- /dev/null +++ b/src/cli/update-notifier.ts @@ -0,0 +1,113 @@ +import { spawn } from 'node:child_process'; + +import { + isCacheFresh, + isUpdateAvailable, + readUpdateCache, + type Installation, +} from '../core/update.js'; +import { isAgent, isCI } from './interactive.js'; +import type { Output } from './output.js'; + +/** + * Telling someone a newer version exists, without ever being in the way. + * + * Two rules shape this file. The notice costs no latency — it is read from a + * cache file and refreshed by a process that outlives this one. And it never + * lands on stdout, so `cd "$(linchpin wt switch)"` keeps working and a `--json` + * envelope stays the only thing a parser sees. + */ + +/** Commands that report update state themselves; a second notice would be noise. */ +const SELF_REPORTING = new Set(['version', 'update']); + +/** Marks the detached refresh, so it cannot spawn a refresh of its own. */ +export const CHILD_ENV_FLAG = 'LINCHPIN_UPDATE_CHECK_CHILD'; + +function isEnabled(value: string | undefined): boolean { + return value !== undefined && value !== '' && value !== '0' && value !== 'false'; +} + +/** + * Whether this invocation may be told about a new version. + * + * Agents and CI are excluded on purpose. An agent parses what a command emits, + * and an unrequested line on stderr is a token cost it cannot act on — the + * structured answer is available on demand through `linchpin version --check + * --json` instead. + */ +export function notificationsAllowed(): boolean { + if (isEnabled(process.env.LINCHPIN_NO_UPDATE_NOTIFIER)) return false; + if (isEnabled(process.env.NO_UPDATE_NOTIFIER)) return false; + if (isEnabled(process.env[CHILD_ENV_FLAG])) return false; + return !isCI() && !isAgent(); +} + +export function renderUpdateNotice( + current: string, + latest: string, + installation: Installation +): string { + const lines = [`Update available: ${current} → ${latest}`]; + + lines.push( + installation.command + ? ' Run: linchpin update' + : ` ${installation.hint}` + ); + + return lines.join('\n'); +} + +/** + * Refresh the cache in a process that outlives this one. + * + * `detached` plus `unref()` plus ignored stdio is what keeps a piped caller from + * waiting on a registry round trip it never asked for. + */ +function spawnBackgroundCheck(entryPath: string): void { + try { + const child = spawn(process.execPath, [entryPath, 'version', '--check', '--quiet'], { + detached: true, + stdio: 'ignore', + env: { ...process.env, [CHILD_ENV_FLAG]: '1' }, + }); + + child.unref(); + } catch { + // A refusal to spawn is not the user's problem; the notice simply waits. + } +} + +/** + * Warn about a newer version, and top up the cache when it has gone stale. + * + * Call this *after* the command has run, so a failure in here can never affect + * the thing the user asked for. + */ +export function notifyAboutUpdates( + output: Output, + options: { + readonly current: string; + readonly installation: Installation; + readonly entryPath: string; + readonly commandName: string | undefined; + } +): void { + if (output.mode !== 'human') return; + if (!notificationsAllowed()) return; + if (options.commandName !== undefined && SELF_REPORTING.has(options.commandName)) return; + + // Nothing to upgrade, so nothing worth saying — a source checkout or an npx + // run would only get a notice it cannot act on. + if (options.installation.command === undefined) return; + + const cache = readUpdateCache(); + + if (isUpdateAvailable(options.current, cache?.latest)) { + // Non-null: isUpdateAvailable is false for an absent latest. + output.warn(renderUpdateNotice(options.current, cache?.latest ?? '', options.installation)); + } + + if (!isCacheFresh(cache)) spawnBackgroundCheck(options.entryPath); +} diff --git a/src/core/exec.ts b/src/core/exec.ts index e8c7692..cae71ff 100644 --- a/src/core/exec.ts +++ b/src/core/exec.ts @@ -11,6 +11,14 @@ export interface RunOptions { readonly allowFailure?: boolean; readonly cwd?: string; readonly env?: NodeJS.ProcessEnv; + /** + * Stream the child's output straight to the terminal instead of capturing it. + * + * For a long-running command whose progress is the point — a package manager + * installing an upgrade — silence for thirty seconds reads as a hang. The + * returned stdout and stderr are empty in this mode; nothing captured them. + */ + readonly inherit?: boolean; } /** @@ -32,7 +40,7 @@ export function runCommand( args: readonly string[], options: RunOptions = {} ): RunResult { - const { allowFailure = false, cwd, env } = options; + const { allowFailure = false, cwd, env, inherit = false } = options; let exitCode: number; let stdout: string; @@ -44,7 +52,7 @@ export function runCommand( nodeOptions: { // stdin ignored: nothing here is interactive, and inheriting it would // let a subprocess block on a stream nobody is attached to. - stdio: ['ignore', 'pipe', 'pipe'], + stdio: inherit ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe'], ...(cwd === undefined ? {} : { cwd }), ...(env === undefined ? {} : { env }), }, diff --git a/src/core/update.ts b/src/core/update.ts new file mode 100644 index 0000000..39fc470 --- /dev/null +++ b/src/core/update.ts @@ -0,0 +1,457 @@ +import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Version detection: is a newer release published, and what would install it? + * + * Nothing here writes to a stream or exits. `cli/update-notifier.ts` owns the + * policy (who gets told) and the rendering; this file owns the facts. + */ + +/** Default registry. Overridden by LINCHPIN_REGISTRY, then npm's own config. */ +export const REGISTRY_URL = 'https://registry.npmjs.org'; + +/** How long a registry answer is trusted before it is worth asking again. */ +export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; + +/** Short enough that a background refresh cannot outlive the shell that spawned it. */ +export const FETCH_TIMEOUT_MS = 3_000; + +export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'; + +/** + * Where this copy came from. `source` is a clone or `npm link`; `npx` is a + * one-off run that already fetched the version it was asked for. + */ +export type InstallScope = 'global' | 'local' | 'npx' | 'source'; + +export interface Installation { + readonly manager: PackageManager; + readonly scope: InstallScope; + /** argv that upgrades this install, or undefined when no single command can. */ + readonly command: readonly string[] | undefined; + /** What to tell a caller `command` cannot help. */ + readonly hint: string; + readonly path: string; +} + +export interface UpdateCache { + /** Epoch ms. */ + readonly checkedAt: number; + readonly latest: string; + /** The version that performed the check, kept for debugging a stale file. */ + readonly current: string; +} + +export type UpdateSource = 'registry' | 'cache' | 'none'; + +export interface UpdateStatus { + readonly current: string; + readonly latest: string | undefined; + readonly updateAvailable: boolean; + /** Epoch ms of the answer being reported, or undefined when there is none. */ + readonly checkedAt: number | undefined; + readonly source: UpdateSource; + /** Why the registry could not be reached, when that is why `latest` is absent. */ + readonly error: string | undefined; +} + +interface ParsedVersion { + readonly parts: readonly [number, number, number]; + readonly prerelease: readonly string[]; +} + +const VERSION_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/; + +function parseVersion(value: string): ParsedVersion | undefined { + const match = VERSION_PATTERN.exec(value.trim()); + if (!match) return undefined; + + return { + parts: [Number(match[1]), Number(match[2]), Number(match[3])], + prerelease: match[4] ? match[4].split('.') : [], + }; +} + +/** Compare two prerelease identifier lists per semver's precedence rules. */ +function comparePrerelease(a: readonly string[], b: readonly string[]): number { + // A release outranks any prerelease of the same numbers: 1.2.0 > 1.2.0-rc.1. + if (a.length === 0 && b.length === 0) return 0; + if (a.length === 0) return 1; + if (b.length === 0) return -1; + + for (let index = 0; index < Math.max(a.length, b.length); index += 1) { + const left = a[index]; + const right = b[index]; + + // A shorter set of identifiers has lower precedence. + if (left === undefined) return -1; + if (right === undefined) return 1; + + const leftNumeric = /^\d+$/.test(left); + const rightNumeric = /^\d+$/.test(right); + + if (leftNumeric && rightNumeric) { + if (Number(left) !== Number(right)) return Number(left) < Number(right) ? -1 : 1; + continue; + } + + // Numeric identifiers always have lower precedence than alphanumeric ones. + if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1; + if (left !== right) return left < right ? -1 : 1; + } + + return 0; +} + +/** + * -1, 0 or 1, the way `Array#sort` wants it. + * + * An unparseable version compares equal rather than greater. A registry that + * answers with something unexpected must never be read as "you are out of + * date" — that would nag every single invocation with no way to satisfy it. + */ +export function compareVersions(a: string, b: string): number { + const left = parseVersion(a); + const right = parseVersion(b); + if (!left || !right) return 0; + + for (let index = 0; index < 3; index += 1) { + const l = left.parts[index] ?? 0; + const r = right.parts[index] ?? 0; + if (l !== r) return l < r ? -1 : 1; + } + + return comparePrerelease(left.prerelease, right.prerelease); +} + +/** True when `latest` is a version worth moving to from `current`. */ +export function isUpdateAvailable(current: string, latest: string | undefined): boolean { + if (!latest) return false; + return compareVersions(latest, current) > 0; +} + +/** + * Cache location, XDG first so a user who has moved their cache is respected. + * + * Exposed through `linchpin version --json` as `cachePath`, so uninstall + * instructions can name the real directory rather than guess at it. + */ +export function cacheDirectory(): string { + const explicit = process.env.LINCHPIN_CACHE_DIR?.trim(); + if (explicit) return explicit; + + const xdg = process.env.XDG_CACHE_HOME?.trim(); + if (xdg) return join(xdg, 'linchpin'); + + if (process.platform === 'win32') { + const local = process.env.LOCALAPPDATA?.trim(); + if (local) return join(local, 'linchpin', 'cache'); + } + + return join(homedir(), '.cache', 'linchpin'); +} + +export function updateCachePath(): string { + return join(cacheDirectory(), 'update-check.json'); +} + +/** + * Read the last answer. Never throws: a corrupt or unreadable cache means "ask + * again", not "fail the command the user actually ran". + */ +export function readUpdateCache(path: string = updateCachePath()): UpdateCache | undefined { + try { + const parsed: unknown = JSON.parse(readFileSync(path, 'utf8')); + + if ( + typeof parsed !== 'object' || + parsed === null || + !('checkedAt' in parsed) || + !('latest' in parsed) || + typeof parsed.checkedAt !== 'number' || + typeof parsed.latest !== 'string' + ) { + return undefined; + } + + const current = + 'current' in parsed && typeof parsed.current === 'string' ? parsed.current : ''; + + return { checkedAt: parsed.checkedAt, latest: parsed.latest, current }; + } catch { + return undefined; + } +} + +/** Persist an answer. Returns whether it landed; a read-only home is not fatal. */ +export function writeUpdateCache(cache: UpdateCache, path: string = updateCachePath()): boolean { + try { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(cache, null, 2)}\n`, 'utf8'); + return true; + } catch { + return false; + } +} + +export function isCacheFresh( + cache: UpdateCache | undefined, + maxAgeMs: number = CHECK_INTERVAL_MS, + now: number = Date.now() +): boolean { + if (!cache) return false; + const age = now - cache.checkedAt; + // A checkedAt in the future means a clock change, not a fresh answer. + return age >= 0 && age < maxAgeMs; +} + +function registryBase(override?: string): string { + const candidate = + override ?? + process.env.LINCHPIN_REGISTRY?.trim() ?? + process.env.npm_config_registry?.trim() ?? + REGISTRY_URL; + + return candidate.replace(/\/+$/, ''); +} + +/** + * Ask the registry for the `latest` dist-tag. + * + * The dist-tags endpoint rather than the packument: one small JSON object + * instead of every version's metadata, which for a package with a long history + * is the difference between a kilobyte and a megabyte. + */ +export async function fetchLatestVersion(options: { + readonly packageName: string; + readonly registry?: string; + readonly timeoutMs?: number; +}): Promise { + const base = registryBase(options.registry); + const url = `${base}/-/package/${encodeURIComponent(options.packageName)}/dist-tags`; + + const response = await fetch(url, { + signal: AbortSignal.timeout(options.timeoutMs ?? FETCH_TIMEOUT_MS), + headers: { accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error( + `${options.packageName}: registry answered ${String(response.status)} ${response.statusText}` + ); + } + + const body: unknown = await response.json(); + + if ( + typeof body !== 'object' || + body === null || + !('latest' in body) || + typeof body.latest !== 'string' + ) { + throw new Error(`${options.packageName}: registry response has no "latest" dist-tag`); + } + + return body.latest; +} + +/** + * The current update picture, from the cache when it is fresh enough. + * + * `cacheOnly` is the notifier's path: it must add no latency to a command the + * user actually asked for, so it reports whatever is on disk — even stale — and + * leaves refreshing to a detached process. + */ +export async function resolveUpdateStatus(options: { + readonly packageName: string; + readonly current: string; + readonly refresh?: boolean; + readonly cacheOnly?: boolean; + readonly maxAgeMs?: number; + readonly registry?: string; + readonly timeoutMs?: number; + readonly cachePath?: string; +}): Promise { + const cachePath = options.cachePath ?? updateCachePath(); + const cache = readUpdateCache(cachePath); + + const fromCache = (source: UpdateSource): UpdateStatus => ({ + current: options.current, + latest: cache?.latest, + updateAvailable: isUpdateAvailable(options.current, cache?.latest), + checkedAt: cache?.checkedAt, + source: cache ? source : 'none', + error: undefined, + }); + + if (options.cacheOnly) return fromCache('cache'); + if (!options.refresh && isCacheFresh(cache, options.maxAgeMs)) return fromCache('cache'); + + try { + const latest = await fetchLatestVersion({ + packageName: options.packageName, + ...(options.registry === undefined ? {} : { registry: options.registry }), + ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), + }); + + const checkedAt = Date.now(); + writeUpdateCache({ checkedAt, latest, current: options.current }, cachePath); + + return { + current: options.current, + latest, + updateAvailable: isUpdateAvailable(options.current, latest), + checkedAt, + source: 'registry', + error: undefined, + }; + } catch (error) { + // A registry that cannot be reached is not a failed command. Report what is + // known, name why it is not newer, and let the caller decide. + const message = error instanceof Error ? error.message : String(error); + return { ...fromCache('cache'), error: message }; + } +} + +/** The path this process is actually running from, symlinks resolved. */ +export function currentInstallPath(): string { + const entry = process.argv[1]; + + if (entry !== undefined && entry !== '') { + try { + return realpathSync(entry); + } catch { + return entry; + } + } + + return fileURLToPath(import.meta.url); +} + +function containsSegment(path: string, fragment: string): boolean { + return path.includes(fragment.split('/').join(sep)); +} + +/** + * Work out how this copy was installed, and therefore what would update it. + * + * Read from the path the process is running from rather than from an env var, + * because `npm_config_user_agent` is only set when npm itself is the parent — + * which it is during `npm install`, and never when a user runs `linchpin`. + */ +export function detectInstallation( + packageName: string, + path: string = currentInstallPath() +): Installation { + const at = `${packageName}@latest`; + + // A one-off run already fetched what it was asked for; there is nothing local + // to upgrade, and telling someone to install globally would be a different + // decision than the one they made. + if (containsSegment(path, '/_npx/')) { + return { + manager: 'npm', + scope: 'npx', + command: undefined, + hint: `npx fetches a fresh copy each run. Install it for good with: npm install -g ${packageName}`, + path, + }; + } + + if (containsSegment(path, '/.bun/')) { + return { + manager: 'bun', + scope: 'global', + command: ['bun', 'add', '-g', at], + hint: '', + path, + }; + } + + if ( + containsSegment(path, '/.pnpm/') || + containsSegment(path, '/pnpm/global/') || + containsSegment(path, '/Library/pnpm/') || + containsSegment(path, '/.local/share/pnpm/') + ) { + return { + manager: 'pnpm', + scope: 'global', + command: ['pnpm', 'add', '-g', at], + hint: '', + path, + }; + } + + if ( + containsSegment(path, '/.yarn/') || + containsSegment(path, '/yarn/global/') || + containsSegment(path, '/.config/yarn/global/') + ) { + return { + manager: 'yarn', + scope: 'global', + command: ['yarn', 'global', 'add', at], + hint: 'Yarn 2+ has no global add — install with npm instead.', + path, + }; + } + + if (containsSegment(path, '/node_modules/')) { + // A global npm prefix always ends in `lib/node_modules` on macOS and Linux, + // and `npm/node_modules` under AppData on Windows. Anything else is a + // project-local dependency, which must not be upgraded with `-g`. + const global = + containsSegment(path, '/lib/node_modules/') || containsSegment(path, '/npm/node_modules/'); + + return { + manager: 'npm', + scope: global ? 'global' : 'local', + command: global ? ['npm', 'install', '-g', at] : ['npm', 'install', at], + hint: '', + path, + }; + } + + // Outside node_modules entirely: a clone, or `npm link` pointing the global + // bin at a working tree. Updating that means git, not a package manager. + return { + manager: 'npm', + scope: 'source', + command: undefined, + hint: 'Running from a source checkout. Update with: git pull && npm install && npm run build', + path, + }; +} + +/** An argv rendered as something a person can paste into a shell. */ +export function formatCommand(command: readonly string[]): string { + return command.join(' '); +} + +/** "just now", "3 hours ago" — enough precision to judge whether to re-check. */ +export function formatAge(checkedAt: number, now: number = Date.now()): string { + const seconds = Math.max(0, Math.round((now - checkedAt) / 1000)); + + if (seconds < 60) return 'just now'; + + const units: readonly [number, string][] = [ + [60, 'minute'], + [60, 'hour'], + [24, 'day'], + ]; + + let value = seconds; + let label = 'second'; + + for (const [factor, name] of units) { + if (value < factor) break; + value = Math.floor(value / factor); + label = name; + } + + return `${String(value)} ${label}${value === 1 ? '' : 's'} ago`; +} diff --git a/src/index.ts b/src/index.ts index 6fe9bcb..7abd7ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -127,4 +127,36 @@ export { type HookPhase, } from './core/hooks.js'; -export { readVersion } from './version.js'; +export { readManifest, readVersion, type Manifest } from './version.js'; + +export { + CHECK_INTERVAL_MS, + FETCH_TIMEOUT_MS, + REGISTRY_URL, + cacheDirectory, + compareVersions, + currentInstallPath, + detectInstallation, + fetchLatestVersion, + formatAge, + formatCommand, + isCacheFresh, + isUpdateAvailable, + readUpdateCache, + resolveUpdateStatus, + updateCachePath, + writeUpdateCache, + type InstallScope, + type Installation, + type PackageManager, + type UpdateCache, + type UpdateSource, + type UpdateStatus, +} from './core/update.js'; + +export { + CHILD_ENV_FLAG, + notificationsAllowed, + notifyAboutUpdates, + renderUpdateNotice, +} from './cli/update-notifier.js'; diff --git a/src/version.ts b/src/version.ts index 24f0c50..afe50ac 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,7 +1,12 @@ import { readFileSync } from 'node:fs'; +export interface Manifest { + readonly name: string; + readonly version: string; +} + /** - * The published version, read from package.json at runtime. + * The published name and version, read from package.json at runtime. * * Read rather than inlined at build time so the value cannot drift from the * manifest release-please owns. The bundled entry lives in `dist/`, one level @@ -11,7 +16,7 @@ import { readFileSync } from 'node:fs'; * Fixes the hardcoded '0.1.0' that shipped while the package was at 1.0.19 * (LINCHPIN-5366). */ -export function readVersion(): string { +export function readManifest(): Manifest { const manifestUrl = new URL('../package.json', import.meta.url); const raw = readFileSync(manifestUrl, 'utf8'); const parsed: unknown = JSON.parse(raw); @@ -25,5 +30,12 @@ export function readVersion(): string { throw new Error('package.json is missing a string "version" field'); } - return parsed.version; + const name = 'name' in parsed && typeof parsed.name === 'string' ? parsed.name : ''; + + return { name, version: parsed.version }; +} + +/** Just the version, for `--version` and anything that needs nothing else. */ +export function readVersion(): string { + return readManifest().version; } diff --git a/test-utils/cli-fixture.js b/test-utils/cli-fixture.js index a8dd4ca..ed40c55 100644 --- a/test-utils/cli-fixture.js +++ b/test-utils/cli-fixture.js @@ -4,9 +4,18 @@ const path = require('node:path'); const { execFileSync, spawnSync } = require('node:child_process'); const BIN_PATH = path.resolve(__dirname, '..', 'dist', 'cli.js'); -const CLEAN_ENV = Object.fromEntries( - Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')) -); + +// GIT_* is stripped so a caller's git environment cannot leak into a fixture +// repo. The update notifier is switched off for a different reason: a suite that +// reaches the npm registry fails offline, and its stderr notice would show up in +// tests asserting on stderr. `test/update.test.js` opts back in deliberately. +const CLEAN_ENV = { + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')) + ), + LINCHPIN_NO_UPDATE_NOTIFIER: '1', + LINCHPIN_CACHE_DIR: path.join(os.tmpdir(), 'linchpin-test-cache') +}; function makeTempDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'linchpin-cli-')); diff --git a/test/update.test.js b/test/update.test.js new file mode 100644 index 0000000..768cb69 --- /dev/null +++ b/test/update.test.js @@ -0,0 +1,353 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); +const { pathToFileURL } = require('node:url'); + +const ROOT = path.resolve(__dirname, '..'); +const DIST_CLI = path.join(ROOT, 'dist', 'cli.js'); +const LIB = pathToFileURL(path.join(ROOT, 'dist', 'index.js')).href; +const { version: packageVersion, name: packageName } = require('../package.json'); + +let lib; +test.before(async () => { + lib = await import(LIB); +}); + +/** + * A registry that answers only the dist-tags endpoint. + * + * Every test here goes through this rather than npmjs.org: a suite that reaches + * the real registry fails on a plane, and it would also make "is an update + * available" depend on whatever happens to be published that day. + */ +function startRegistry(latest) { + const requests = []; + + const server = http.createServer((request, response) => { + requests.push(request.url); + + if (!request.url.endsWith('/dist-tags')) { + response.writeHead(404).end('{}'); + return; + } + + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ latest })); + }); + + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + resolve({ + url: `http://127.0.0.1:${server.address().port}`, + requests, + close: () => new Promise((done) => server.close(done)), + }); + }); + }); +} + +function tempDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +/** + * A copy of the built CLI at a path that looks like a global npm install. + * + * Install detection reads the path the process is running from, and + * `realpathSync` defeats a symlink, so the files have to actually be there. + */ +function fakeGlobalInstall() { + const root = tempDir('linchpin-global-'); + const packageRoot = path.join(root, 'lib', 'node_modules', packageName); + + fs.mkdirSync(packageRoot, { recursive: true }); + fs.cpSync(path.join(ROOT, 'dist'), path.join(packageRoot, 'dist'), { recursive: true }); + fs.copyFileSync(path.join(ROOT, 'package.json'), path.join(packageRoot, 'package.json')); + + return { root, cli: path.join(packageRoot, 'dist', 'cli.js') }; +} + +/** + * Run the CLI with CI and AI_AGENT unset unless a case sets them itself. + * + * Both suppress the update notice, so a notifier test that inherits either one + * passes for the wrong reason — and the caller's env is applied *after* the + * scrub, or a case that sets AI_AGENT deliberately would have it removed again. + * + * Asynchronous on purpose: `spawnSync` blocks this process's event loop, so the + * registry stub living here could never answer the child's request and every + * check would fail on a three-second timeout. + */ +function run(cli, args, env = {}) { + const base = { ...process.env }; + delete base.CI; + delete base.AI_AGENT; + delete base.GITHUB_ACTIONS; + + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [cli, ...args], { + env: { ...base, ...env }, + timeout: 20_000, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => (stdout += chunk)); + child.stderr.on('data', (chunk) => (stderr += chunk)); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); + }); +} + +test('versions compare by precedence, prereleases included', () => { + const { compareVersions } = lib; + + assert.equal(compareVersions('1.2.3', '1.2.3'), 0); + assert.equal(compareVersions('1.2.3', '1.2.4'), -1); + assert.equal(compareVersions('1.10.0', '1.9.0'), 1); + assert.equal(compareVersions('2.0.0', '10.0.0'), -1, 'numeric, not lexical'); + assert.equal(compareVersions('v1.2.3', '1.2.3'), 0, 'a leading v is noise'); + + // A release outranks its own prereleases; prerelease identifiers compare + // numerically when they are numbers and lexically when they are not. + assert.equal(compareVersions('1.2.0', '1.2.0-rc.1'), 1); + assert.equal(compareVersions('1.2.0-rc.2', '1.2.0-rc.10'), -1); + assert.equal(compareVersions('1.2.0-alpha', '1.2.0-beta'), -1); +}); + +test('an unparseable version never reads as newer', () => { + const { compareVersions, isUpdateAvailable } = lib; + + // Otherwise a registry answering with something unexpected nags on every + // invocation, with no version that could ever satisfy it. + assert.equal(compareVersions('1.2.3', 'nightly'), 0); + assert.equal(isUpdateAvailable('1.2.3', 'nightly'), false); + assert.equal(isUpdateAvailable('1.2.3', undefined), false); + assert.equal(isUpdateAvailable('1.2.3', '1.3.0'), true); +}); + +test('the install method is read from the path, and drives the update command', () => { + const { detectInstallation } = lib; + + const cases = [ + ['/usr/local/lib/node_modules/@x/cli/dist/cli.js', 'npm', 'global', 'npm install -g @x/cli@latest'], + ['/Users/x/project/node_modules/@x/cli/dist/cli.js', 'npm', 'local', 'npm install @x/cli@latest'], + ['/Users/x/Library/pnpm/global/5/node_modules/@x/cli/dist/cli.js', 'pnpm', 'global', 'pnpm add -g @x/cli@latest'], + ['/Users/x/.bun/install/global/node_modules/@x/cli/dist/cli.js', 'bun', 'global', 'bun add -g @x/cli@latest'], + ['/Users/x/.config/yarn/global/node_modules/@x/cli/dist/cli.js', 'yarn', 'global', 'yarn global add @x/cli@latest'], + ]; + + for (const [modulePath, manager, scope, command] of cases) { + const installation = detectInstallation('@x/cli', modulePath); + assert.equal(installation.manager, manager, modulePath); + assert.equal(installation.scope, scope, modulePath); + assert.equal(lib.formatCommand(installation.command), command, modulePath); + } + + // Neither of these can be upgraded by a package manager, and both must say so + // rather than hand back an `npm install -g` that would install a second copy. + const npx = detectInstallation('@x/cli', '/Users/x/.npm/_npx/abc123/node_modules/@x/cli/dist/cli.js'); + assert.equal(npx.scope, 'npx'); + assert.equal(npx.command, undefined); + + const source = detectInstallation('@x/cli', '/Users/x/GitHub/cli/dist/cli.js'); + assert.equal(source.scope, 'source'); + assert.equal(source.command, undefined); + assert.match(source.hint, /git pull/); +}); + +test('a corrupt or missing cache means "ask again", not a thrown error', () => { + const { readUpdateCache, writeUpdateCache, isCacheFresh } = lib; + const dir = tempDir('linchpin-cache-'); + const file = path.join(dir, 'update-check.json'); + + assert.equal(readUpdateCache(file), undefined, 'missing file'); + + fs.writeFileSync(file, '{not json', 'utf8'); + assert.equal(readUpdateCache(file), undefined, 'corrupt file'); + + fs.writeFileSync(file, JSON.stringify({ latest: 5 }), 'utf8'); + assert.equal(readUpdateCache(file), undefined, 'wrong shape'); + + const now = Date.now(); + assert.equal(writeUpdateCache({ checkedAt: now, latest: '9.9.9', current: '1.0.0' }, file), true); + assert.equal(readUpdateCache(file).latest, '9.9.9'); + + assert.equal(isCacheFresh(readUpdateCache(file)), true); + assert.equal(isCacheFresh({ checkedAt: now - 48 * 3600 * 1000, latest: '9.9.9' }), false); + // A timestamp in the future is a clock change, not a fresh answer. + assert.equal(isCacheFresh({ checkedAt: now + 3600 * 1000, latest: '9.9.9' }), false); +}); + +test('the registry is asked for one dist-tag, not the whole packument', async (t) => { + const registry = await startRegistry('4.5.6'); + t.after(() => registry.close()); + + const latest = await lib.fetchLatestVersion({ packageName: '@x/cli', registry: registry.url }); + + assert.equal(latest, '4.5.6'); + assert.deepEqual(registry.requests, ['/-/package/%40x%2Fcli/dist-tags']); +}); + +test('an unreachable registry is reported, never thrown at the caller', async (t) => { + const registry = await startRegistry('4.5.6'); + const url = registry.url; + await registry.close(); + + const status = await lib.resolveUpdateStatus({ + packageName: '@x/cli', + current: '1.0.0', + refresh: true, + registry: url, + timeoutMs: 1_000, + cachePath: path.join(tempDir('linchpin-cache-'), 'update-check.json'), + }); + + assert.equal(status.latest, undefined); + assert.equal(status.updateAvailable, false); + assert.equal(status.source, 'none'); + assert.match(status.error, /fetch failed|ECONNREFUSED|terminated/i); +}); + +test('version --check reports the newer release and caches the answer', async (t) => { + const registry = await startRegistry('99.0.0'); + t.after(() => registry.close()); + const cacheDir = tempDir('linchpin-cache-'); + + const env = { LINCHPIN_REGISTRY: registry.url, LINCHPIN_CACHE_DIR: cacheDir }; + const result = await run(DIST_CLI, ['version', '--check'], env); + + assert.equal(result.code, 0, `informational, so always 0\nSTDERR:\n${result.stderr}`); + assert.match(result.stdout, new RegExp(`Update available: ${packageVersion} → 99\\.0\\.0`)); + + const cached = JSON.parse(fs.readFileSync(path.join(cacheDir, 'update-check.json'), 'utf8')); + assert.equal(cached.latest, '99.0.0'); + + // The JSON form is the agent's path to the same facts, and it carries the + // cache location so uninstall instructions do not have to guess at it. + const json = await run(DIST_CLI, ['version', '--json'], env); + const envelope = JSON.parse(json.stdout); + assert.equal(envelope.ok, true); + assert.equal(envelope.data.updateAvailable, true); + assert.equal(envelope.data.latest, '99.0.0'); + assert.equal(envelope.data.source, 'cache', 'without --check it must not re-ask'); + assert.equal(envelope.data.cachePath, path.join(cacheDir, 'update-check.json')); + assert.equal(json.stderr, '', 'stderr stays empty in json mode'); +}); + +test('update --check exits 3 when an update is pending, so it can gate CI', async (t) => { + const registry = await startRegistry('99.0.0'); + t.after(() => registry.close()); + + const result = await run(DIST_CLI, ['update', '--check'], { + LINCHPIN_REGISTRY: registry.url, + LINCHPIN_CACHE_DIR: tempDir('linchpin-cache-'), + }); + + assert.equal(result.code, 3); + assert.match(result.stderr, /Update available/); +}); + +test('update --check exits 0 when there is nothing to do', async (t) => { + const registry = await startRegistry(packageVersion); + t.after(() => registry.close()); + + const result = await run(DIST_CLI, ['update', '--check'], { + LINCHPIN_REGISTRY: registry.url, + LINCHPIN_CACHE_DIR: tempDir('linchpin-cache-'), + }); + + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /is the latest version/); +}); + +test('a source checkout is told to use git rather than a package manager', async (t) => { + const registry = await startRegistry('99.0.0'); + t.after(() => registry.close()); + + // DIST_CLI is a working tree, not an install — the case a contributor hits. + const result = await run(DIST_CLI, ['update'], { + LINCHPIN_REGISTRY: registry.url, + LINCHPIN_CACHE_DIR: tempDir('linchpin-cache-'), + }); + + assert.equal(result.code, 3); + assert.match(result.stderr, /Cannot update a source install/); + assert.match(result.stderr, /git pull/); +}); + +test('a global install resolves the real install command without running it', async (t) => { + const registry = await startRegistry('99.0.0'); + t.after(() => registry.close()); + const install = fakeGlobalInstall(); + + const result = await run(install.cli, ['update', '--dry-run'], { + LINCHPIN_REGISTRY: registry.url, + LINCHPIN_CACHE_DIR: tempDir('linchpin-cache-'), + }); + + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, new RegExp(`Would run: npm install -g ${packageName}@latest`)); +}); + +test('the update notice goes to stderr, and never to a machine reader', async (t) => { + const registry = await startRegistry('99.0.0'); + t.after(() => registry.close()); + + const install = fakeGlobalInstall(); + const cacheDir = tempDir('linchpin-cache-'); + + // A fresh cache, so the notice is available with no network call of its own + // and no detached refresh is spawned. + fs.writeFileSync( + path.join(cacheDir, 'update-check.json'), + JSON.stringify({ checkedAt: Date.now(), latest: '99.0.0', current: packageVersion }), + 'utf8' + ); + + const env = { LINCHPIN_REGISTRY: registry.url, LINCHPIN_CACHE_DIR: cacheDir }; + + const human = await run(install.cli, ['shell-init', '--shell', 'zsh'], env); + assert.equal(human.code, 0, human.stderr); + assert.match(human.stderr, /Update available: .* → 99\.0\.0/); + assert.match(human.stderr, /linchpin update/); + assert.doesNotMatch(human.stdout, /Update available/, 'stdout must stay usable for eval and cd'); + + // Anything parsing output must not be handed an unrequested line. + const json = await run(install.cli, ['shell-init', '--shell', 'zsh', '--json'], env); + assert.equal(json.stderr, ''); + + const quiet = await run(install.cli, ['shell-init', '--shell', 'zsh', '--quiet'], env); + assert.equal(quiet.stderr, ''); + + // Opting out is honoured, and so is the agent case: an agent gets the answer + // from `version --json` when it asks, never as unsolicited stderr. + const optedOut = await run(install.cli, ['shell-init', '--shell', 'zsh'], { + ...env, + LINCHPIN_NO_UPDATE_NOTIFIER: '1', + }); + assert.equal(optedOut.stderr, ''); + + const agent = await run(install.cli, ['shell-init', '--shell', 'zsh'], { + ...env, + AI_AGENT: 'claude-code_2-1-223_agent', + }); + assert.equal(agent.stderr, ''); + + const ci = await run(install.cli, ['shell-init', '--shell', 'zsh'], { ...env, CI: 'true' }); + assert.equal(ci.stderr, ''); +}); + +test('version and update are registered as read and write', async () => { + const byName = Object.fromEntries(lib.COMMANDS.map((command) => [command.meta.name, command])); + + assert.equal(byName.version.effect, 'read', 'version must be allowlistable without a prompt'); + assert.equal(byName.update.effect, 'write'); +}); From 73f44446155ffb24af9ad3a3f65035fc4d3f89c7 Mon Sep 17 00:00:00 2001 From: Aaron Ware Date: Wed, 26 Aug 2026 10:37:23 -0400 Subject: [PATCH 2/3] ci(NO-TASK): Verify npm credentials before publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every release from v1.1.0 to v1.1.3 failed at `npm publish` with npm error 404 Not Found - PUT https://registry.npmjs.org/@linchpinagency%2fcli which reads as if the package did not exist. It does exist: npm answers an *unauthorized* write with 404 rather than 403, so the message names the wrong problem. 1.1.1 reached npm only because it was published by hand from a workstation — it carries no provenance attestation, and no CI publish has ever succeeded. The cause is the credential: NPM_TOKEN was minted 2026-02-17, before this package first existed on npm (2026-08-08), and a granular npm token only ever covers the packages that existed when it was created. Prove the credential before spending a build on it, and if the publish still fails, say what a 404 after a passing preflight can only mean. Also write repository.url in the form npm was auto-correcting on every publish. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release-please.yml | 36 +++++++++++++++++++++++++++- package.json | 2 +- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index e0d9023..65b2c78 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -48,6 +48,29 @@ jobs: node-version: '22.12' registry-url: https://registry.npmjs.org + # Before spending a build on it. npm answers an *unauthorized* write with + # 404 rather than 403, so a bad credential surfaces as + # "E404 ... PUT https://registry.npmjs.org/@linchpinagency%2fcli - Not found" + # at the very end of the job, reading as if the package did not exist. + # Every release from v1.1.0 to v1.1.3 failed exactly that way while 1.1.1 + # was hand-published from a workstation to cover it. Prove the credential + # up front so the failure names itself. + - name: Verify npm credentials + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -z "$NODE_AUTH_TOKEN" ]; then + echo "::error::NPM_TOKEN is not set on this repository. Mint a token with write access to the whole @linchpinagency scope and add it as the NPM_TOKEN secret." + exit 1 + fi + + if ! whoami_output="$( npm whoami 2>&1 )"; then + echo "::error::NPM_TOKEN does not authenticate against the registry: ${whoami_output}. It is expired, revoked, or not an npm token." + exit 1 + fi + + echo "Authenticated to npm as ${whoami_output}" + # Required before anything runs. `npm test` triggers `pretest -> build`, # and the build needs devDependencies. Without this the job fails with # "tsdown: not found" — which is exactly how v1.1.0 came to be tagged and @@ -63,7 +86,18 @@ jobs: HUSKY: 0 - name: Publish to npm - run: npm publish --access public --provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} HUSKY: 0 + run: | + if npm publish --access public --provenance; then + exit 0 + fi + + # Identity was proven in the preflight, so a 404 here can only be a + # permissions problem. A granular npm token covers the packages that + # existed when it was minted and nothing added later — which is how a + # token created before this package's first publish can authenticate + # perfectly and still be unable to publish it. + echo "::error::npm publish failed. The token authenticates but may not be allowed to write @linchpinagency/cli — grant it the whole @linchpinagency scope, or move this job to npm trusted publishing (OIDC, no secret, needs npm >= 11.5.1). Re-run this job on the existing tag once fixed; no new release is required." + exit 1 diff --git a/package.json b/package.json index 36a0f30..735412b 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.1.3", "repository": { "type": "git", - "url": "https://github.com/linchpin/cli" + "url": "git+https://github.com/linchpin/cli.git" }, "description": "Linchpin's command line tool for WordPress and agent workflows — git worktree management, local environment setup, and deterministic verbs agents can call without approval prompts", "license": "GPL-2.0-only", From 65664f46bf95bc6a4b1be5ba4e52ed1a3148dedf Mon Sep 17 00:00:00 2001 From: Aaron Ware Date: Wed, 26 Aug 2026 10:37:24 -0400 Subject: [PATCH 3/3] docs(NO-TASK): Rewrite the README around the whole CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README opened as if `wt` were the product, so a reader could not tell that this is one tool carrying every repeatable piece of the WordPress workflow, with worktrees as one command group among several. It now leads with the command surface and each command's effect classification, and carries the three lifecycle walkthroughs it was missing: install (per package manager, plus the shell wrapper and a source checkout), update, and uninstall — including what deliberately *stays* behind, since `.linchpin.json`, hooks, worktrees and symlinks all outlive the CLI and a teammate still needs them. docs/updating.md covers the mechanism rather than the walkthrough: how the check is cached, who is told and why an agent is not, how the install method is detected, and why `version --check` and `update --check` exit differently. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 634 ++++++++++++++++++++++++++--------------------- docs/README.md | 4 + docs/updating.md | 107 ++++++++ 3 files changed, 466 insertions(+), 279 deletions(-) create mode 100644 docs/updating.md diff --git a/README.md b/README.md index 4f1756f..4b539c0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Linchpin CLI
- Git worktree tooling for WordPress plugin review workflows with Codex, Claude Code, Cursor, Conductor and other agents. + One command line tool for WordPress and agent workflows — git worktree management, local environment switching, and deterministic verbs agents can call without approval prompts. npm version @@ -27,290 +27,351 @@ -## What is this CLI? - -`linchpin wt` is a git worktree helper tuned for WordPress plugin development alongside Agent support to help easily swap Symlinks between your local environment and worktrees created by you or agents. - -It is designed for this setup: - -- Plugin repository in `~/Documents/GitHub/`. -- Multiple git worktrees created by Codex or other agents. -- A shared local WordPress environment (Studio, `wp-env`, or LocalWP). -- A plugin directory in that environment that should point to a specific worktree via symlink. - -### Why symlinks? Why isn’t the repo checked out directly in my environment? - -Your plugin repo is **not** checked out directly into Studio, LocalWP, or wp-env on purpose. The workflow relies on **symlinks** so you can **swap** which worktree (branch) the environment sees: +```bash +npm install -g @linchpinagency/cli +linchpin --help +``` -- The repo lives in its own directory (e.g. `~/Documents/GitHub/my-plugin`) with multiple [git worktrees](https://git-scm.com/docs/git-worktree) (e.g. `main`, `conductor/a`, `feature/b`). -- The WordPress environment has **one** plugin (or theme) slot (e.g. `~/Studio/mysite/wp-content/plugins/my-plugin`). That slot is a **symlink** pointing at one of the worktree paths. -- When you run `linchpin wt switch ` (or pick from the list), we repoint that symlink to the chosen worktree. This allows for our local environment to use an already checked out worktree with out any errors. +**Contents** · [What this is](#what-this-is) · [Command surface](#command-surface) · [Requirements](#requirements) · +[Install](#install) · [Set up a project](#set-up-a-project) · [Daily use](#daily-use) · +[Staying up to date](#staying-up-to-date) · [Uninstall](#uninstall) · [Configuration](#configuration) · +[Hooks](#hooks) · [Agents, output modes and exit codes](#agents-output-modes-and-exit-codes) · +[Troubleshooting](#troubleshooting) · [Development](#development) · [Releases](#releases) -So you keep a single WordPress install and switch which worktree it uses by changing the symlink target. +## What this is -## Should you use this? +`linchpin` is a single binary that carries every repeatable piece of Linchpin's WordPress +workflow. It is not a wrapper around one thing — commands are declared in a registry that +generates `--help`, flag parsing, effect classification and (soon) shell completions from one +definition each, so the surface grows without the tool getting harder to learn. -Use this CLI if you already like `git worktree` but need WordPress-specific environment switching. +Three properties hold across every command: -This project does **not** replace git worktrees. It adds a WordPress workflow layer on top of them: +**One local WordPress install, many branches.** A plugin or theme repo can have any number of +git worktrees, but a local WordPress install has exactly one directory slot for it. +`linchpin wt switch` repoints that slot's symlink at the worktree you want, so one install +serves every branch without copying files or re-checking-out. -- Store plugin/theme target paths per local environment (`Studio`, `LocalWP`, `wp-env`, custom). -- Repoint one plugin/theme symlink to a different worktree with one command. -- Add safety checks around symlink replacement and worktree deletion. -- Keep one local WordPress install while reviewing many branches/worktrees. +**Built to be driven by an agent.** Every command is classified `read`, `write` or +`destructive`, takes file paths rather than piped heredocs, emits a JSON envelope on request, +and uses documented exit codes. Claude Code, Codex, Cursor and Conductor can call it without +tripping approval prompts that cannot be permanently allowlisted. -You probably **do not** need this if: +**It never blocks on a prompt nobody can answer.** Interactivity is decided from whether a TTY +is attached, not from whether `CI` is set — because inside an agent `CI` is unset and no stream +is a TTY, which is exactly the combination that makes a naive wizard hang forever. -- You only need `git worktree add/list/remove`. -- You do not use a shared local WordPress environment. -- You are fine managing symlink paths and switching manually. +Deeper background lives in [`docs/`](docs/README.md): [worktrees and the symlink +swap](docs/worktrees.md), [configuration](docs/configuration.md), [hooks](docs/hooks.md), +and [agent integration](docs/agent-integration.md). -## `linchpin wt` vs plain `git worktree` +## Command surface -| Need | Plain `git worktree` | `linchpin wt` | -|---|---|---| -| Create/list/remove worktrees | Yes (`git worktree ...`) | Yes (wrapper commands: `new`, `ls`, `del`, `get`) | -| Switch which branch your WordPress site loads | Manual symlink edits | Built-in: `linchpin wt switch [branch] --env ` | -| Save WordPress environment paths for team use | No | Yes (`linchpin wt config init` + `.linchpin.json`) | -| Guardrails for WP plugin/theme symlink targets | No | Yes (blocks non-symlink target replacement unless `--force`) | -| Interactive worktree picker for switching | No | Yes (TTY picker + optional `fzf` for `cd`) | +```bash +linchpin --help # every command, grouped by topic +linchpin --help # flags, examples and description for one +``` -If your pain is "I can create worktrees, but switching my WordPress site between them is manual and error-prone," this tool is the fit. +| Command | What it does | Effect | +| --- | --- | --- | +| `wt ls` / `wt current` | List worktrees, or report the active one and its symlink | read | +| `wt switch [ref]` | Repoint the WordPress plugin/theme symlink at a worktree | write | +| `wt new` / `wt get` / `wt extract` | Create a worktree from a new branch, a remote branch, or the current one | write | +| `wt mv` / `wt del` / `wt gone` | Rename, remove, or prune worktrees whose remote branch is gone | destructive | +| `wt cd` / `wt home` | Print a worktree path for `cd "$(…)"` | read | +| `wt use` | Detach the base worktree onto the current worktree's commit | write | +| `wt copy ` / `wt link ` | Copy or symlink a file from the base worktree into this one | write | +| `wt config init` / `wt config show` | Create or inspect `.linchpin.json` | write / read | +| `wt invoke ` | Run a lifecycle hook by hand | write | +| `shell-init` | Emit the shell wrapper that lets `wt switch` change your directory | read | +| `version` | Print the installed version and whether a newer one is published | read | +| `update` | Install the latest published version | write | + +The effect column is what each subcommand does to the world — the classification skills use to +decide what an agent may run without asking. `wt` is still registered as a **single +`destructive` passthrough** to the legacy dispatcher, because the safe reading of a group +containing `del` is the most dangerous verb in it; the per-subcommand effects above land as each +one is ported. + +`linchpin repo ` — connecting a repository to the release infrastructure in one command — +is specified but **not yet built**. See [docs/repo-tasks.md](docs/repo-tasks.md). + +## Requirements + +- Node.js **22.12+** and npm (the `engines` floor CI tests against). +- `git` **2.37+**, for worktree support. +- A local WordPress environment: [Studio](https://developer.wordpress.com/studio/), `wp-env`, + or LocalWP. +- Your plugin, theme or `wp-content` repository cloned somewhere stable, e.g. + `~/Documents/GitHub/`. +- Optional: [`fzf`](https://github.com/junegunn/fzf), which turns the site and worktree pickers + into fuzzy finders. ## Install +Install it globally — this is a tool you run against many repositories, not a project +dependency. + ```bash -npm install -g @linchpinagency/cli +npm install -g @linchpinagency/cli # npm +pnpm add -g @linchpinagency/cli # pnpm +bun add -g @linchpinagency/cli # bun +yarn global add @linchpinagency/cli # yarn 1.x only; yarn 2+ has no global add ``` -For local development in this repository: +Verify the install, which is also the fastest way to confirm your `PATH` picked up your package +manager's global bin directory: ```bash -npm link +linchpin version +# @linchpinagency/cli 1.1.3 +# Up to date (checked just now) ``` -## Team setup guide +If your shell reports `command not found`, the global bin directory is missing from `PATH`. +`npm prefix -g` prints it; add `$(npm prefix -g)/bin` to your shell profile. -### 1. Prerequisites +### Install the shell wrapper (recommended) -- `git` 2.37+ (worktree support). -- Node.js `22.12+` and `npm`. -- Optional: `fzf` for interactive `linchpin wt cd`. -- A local WordPress environment (Studio, `wp-env`, or LocalWP). -- Your plugin repository cloned under `~/Documents/GitHub/`. - -### 2. Install CLI +A child process cannot change its parent shell's directory. Without the wrapper, `linchpin wt +switch` repoints the symlink but leaves your shell sitting in the **old** worktree. Add this to +`~/.zshrc`, `~/.bashrc` or `~/.config/fish/config.fish`: ```bash -npm install -g @linchpinagency/cli +eval "$(linchpin shell-init)" ``` -Confirm install: - -```bash -linchpin --help -linchpin wt help -``` +The shell is detected from `$SHELL`; force one with `linchpin shell-init --shell fish`. If you +would rather not add anything to your profile, wrap the command instead — +`cd "$(linchpin wt switch feature/x)"` — which works because path output goes to stdout while +everything informational goes to stderr. -### 3. Initialize project config +### Install from source -From the plugin or theme repo root (base worktree), run: +For contributing, or to run an unreleased branch: ```bash -linchpin wt config init +git clone https://github.com/linchpin/cli.git +cd cli +npm install +npm run build +npm link # puts this working tree on your PATH as `linchpin` ``` -When run in an interactive terminal, you're guided through: - -1. **Agents** – Which agent base path(s) you use (Conductor, Claude Code, Codex, and/or Custom Path). You can select **multiple agents** so worktrees are found whether you're under Codex, Conductor, or another path — this avoids detached-HEAD issues when switching between agents. If you pick more than one, you choose a **default agent** for new worktrees. -2. **Plugin, theme, or wp-content** – Whether this repo is a WordPress plugin, theme, or a full wp-content project (the entire wp-content folder is the repo). You can pre-select this with `--type `. -3. **Slug / symlink name** – For plugins and themes, the WordPress directory name (defaults to the repo directory name). For wp-content projects, the symlink name (defaults to `wp-content`) — useful when your repo has a client name instead of `wp-content`. -4. **Environment(s)** – For each environment: **Environment type** (Studio, LocalWP, wp-env, or Other), which sets the base folder; then for Studio/LocalWP you **pick a site** from that base (list or `fzf` if installed), or for wp-env you enter the WordPress root path; for Other you enter name and full path. -5. Choose the **default environment** for `linchpin wt switch`. - -This creates `.linchpin.json`. You can edit it later if paths or environments change. +`linchpin version` reports a source install and will tell you to use `git pull` rather than a +package manager. Undo it with `npm unlink -g @linchpinagency/cli`. -6. **Create initial symlink(s)** – If the target already exists as a real folder (not a symlink), you're prompted to **back it up** (rename with `.bkp` suffix), **delete** it (with confirmation), or **skip** that environment. +## Set up a project -If `.linchpin.json` already exists, the flow offers **Overwrite**, **Edit** (keep existing and add more environments), or **Cancel**. - -For scripts or CI (no TTY), use non-interactive mode so a default template is written without prompts: +Run this once per repository, from the **base worktree** (the original clone, not a worktree): ```bash -linchpin wt config init --type [--plugin-slug ] [--force] [--no-interactive] +cd ~/Documents/GitHub/my-plugin +linchpin wt config init ``` -Use `--type wp-content` when your repo represents an entire wp-content folder (common for client projects where the repo is named after the client, not `wp-content`). Use `--force` to overwrite an existing `.linchpin.json` without prompting. Use `--no-interactive` to skip prompts even when running in a terminal. - -### 4. Paths built by config init - -For Studio and LocalWP, paths are built from the environment type and the site you pick: - -- **Studio**: `~/Studio//wp-content/plugins|themes/` (or `~/Studio//wp-content` for wp-content projects) -- **LocalWP**: `~/Local Sites//app/public/wp-content/plugins|themes/` (or `…/wp-content` for wp-content projects) -- **wp-env**: You provide the WordPress root; the CLI appends `wp-content/plugins|themes/` (or `wp-content` for wp-content projects). +In a terminal you are walked through five questions, and the answers become `.linchpin.json`: + +1. **Agents** — which agent base path(s) you use (Conductor, Claude Code, Codex, or a custom + path). Pick **several** if you work under more than one, so worktrees are found wherever they + were created; this is what avoids detached-HEAD surprises when switching between agents. With + more than one, you also choose a default for new worktrees. +2. **Plugin, theme, or wp-content** — what this repo is. Pre-select it with + `--type `. Use `wp-content` when the repo *is* an entire wp-content + directory, which is common on client projects named after the client. +3. **Slug / symlink name** — the WordPress directory name, defaulting to the repo directory + name (or `wp-content` for a wp-content project). +4. **Environment(s)** — pick Studio, LocalWP, wp-env or Other. Studio and LocalWP list your + sites to choose from (`fzf` if installed); wp-env asks for the WordPress root; Other asks for + a name and a full path. +5. **Default environment** — which one `linchpin wt switch` uses when `--env` is omitted. + +Paths are then built for you: + +| Environment | Path built | +| --- | --- | +| Studio | `~/Studio//wp-content/plugins\|themes/` | +| LocalWP | `~/Local Sites//app/public/wp-content/plugins\|themes/` | +| wp-env | `/wp-content/plugins\|themes/` | -Use absolute paths in `.linchpin.json` if you edit by hand. `~` is supported. +Finally, if the target already exists as a **real directory** rather than a symlink, you are +asked to back it up (`.bkp` suffix), delete it, or skip that environment. Nothing is replaced +silently. -### 5. Create and switch worktrees +Re-running `config init` on a repo that already has `.linchpin.json` offers **Overwrite**, +**Edit** (keep what is there and add environments), or **Cancel**. -Create a worktree for a new branch: +For scripts, CI, or an agent, skip the prompts entirely: ```bash -linchpin wt new feature/my-change +linchpin wt config init --type plugin --plugin-slug my-plugin --no-interactive +linchpin wt config show # what the CLI actually resolved ``` -Or attach an existing remote branch: +## Daily use ```bash -linchpin wt get feature/existing-branch +linchpin wt new feature/checkout # new branch + worktree +linchpin wt get feature/existing # attach an existing remote branch +cd "$(linchpin wt switch feature/checkout --env studio)" ``` -Point your WordPress environment to that worktree: +That third line is the whole point: your one WordPress install now loads that worktree. Review +the branch, then move on: ```bash -linchpin wt switch feature/my-change --env studio +linchpin wt ls # every worktree for this repo +linchpin wt current --link --env studio # what the symlink points at right now +cd "$(linchpin wt switch)" # no argument in a TTY: pick from a list +linchpin wt del # clean up once the branch is merged ``` -### 6. Verify active target - -Check current worktree metadata: - -```bash -linchpin wt current --link --env studio -``` +With no argument and no TTY, `wt switch` uses the current worktree rather than prompting — +which is what lets an agent call it safely. -List all worktrees: +Guardrails, so a switch can't quietly eat your work: -```bash -linchpin wt ls -``` +- An existing **symlink** target is repointed. +- An existing **real directory** is refused unless you pass `--force`. +- `wt del` refuses a worktree with uncommitted changes or an unmerged branch unless forced. -### 7. Daily review workflow +## Staying up to date -1. Open or create a worktree for the branch under review. -2. Run `linchpin wt switch --env ` to repoint the plugin symlink. -3. Test the branch in the shared WordPress install. -4. Repeat for the next worktree/branch. -5. Clean up with `linchpin wt del` when the branch is merged. +The CLI knows what version it is and whether a newer one has been published. -### 8. Switch and cd in one step +### Being told about it -After `wt switch` repoints a symlink your shell is still in the **old** worktree. Wrap the command in `cd` to land in the new target automatically: +When a newer version exists, a notice is written to **stderr** after your command completes: -```bash -cd "$(linchpin wt switch feature/my-change)" -cd "$(linchpin wt switch)" # interactive picker -cd "$(linchpin wt switch --env localwp)" # specific environment +``` +Update available: 1.1.3 → 1.2.0 + Run: linchpin update ``` -When piped (wrapped in `$()`), informational output goes to stderr so you still see it, while stdout carries the symlink path for `cd`. +Four things make that notice safe to leave on: -**Optional: fully automatic with shell-init** +- **It costs nothing.** The version is read from a small cache file, never from the network, so + no command waits on a registry round trip. When the cache is more than 24 hours old a detached + background process refreshes it and exits; nothing blocks on it. +- **It never touches stdout.** `cd "$(linchpin wt switch)"` and `eval "$(linchpin shell-init)"` + keep working, and a `--json` envelope stays the only thing on stdout. +- **Machine readers never see it.** It is suppressed in `--json` and `--quiet` mode, in CI, and + when an agent is driving. An agent that wants the facts asks for them: + `linchpin version --check --json`. +- **It is one line, and you can turn it off.** Set `LINCHPIN_NO_UPDATE_NOTIFIER=1` (or the + conventional `NO_UPDATE_NOTIFIER=1`). -If you prefer `linchpin wt switch` to handle the `cd` for you every time, add this to your shell profile (`~/.zshrc`, `~/.bashrc`, or `~/.config/fish/config.fish`): +### Asking directly ```bash -eval "$(linchpin shell-init)" +linchpin --version # just the number, for scripts that parse it +linchpin version # version + cached update state + how it was installed +linchpin version --check # ask the registry now, then cache the answer +linchpin version --check --json ``` -This defines a thin wrapper that re-enters your current directory after a successful switch, so the shell picks up the repointed symlink. The shell is auto-detected from `$SHELL`. To force a specific shell: `eval "$(linchpin shell-init --shell zsh)"`. +`linchpin version` always exits **0**, including when the registry is unreachable — it is safe +in a shell prompt or a status line. The JSON form carries everything a bug report or an agent +needs: -### 9. Path helpers +```json +{ + "version": 1, "ok": true, "command": "version", + "data": { + "name": "@linchpinagency/cli", + "current": "1.1.3", + "latest": "1.2.0", + "updateAvailable": true, + "checkedAt": "2026-08-26T14:17:31.655Z", + "source": "registry", + "checkError": null, + "install": { + "manager": "npm", "scope": "global", + "path": "/opt/homebrew/lib/node_modules/@linchpinagency/cli/dist/cli.js", + "updateCommand": "npm install -g @linchpinagency/cli@latest" + }, + "cachePath": "/Users/you/.cache/linchpin/update-check.json", + "node": "24.14.1" + } +} +``` -Use command substitution for other path-returning commands: +### Updating ```bash -cd "$(linchpin wt cd)" -cd "$(linchpin wt home)" +linchpin update # install the latest published version +linchpin update --dry-run # print the command it would run, and stop +linchpin update --check # read-only; exits 3 if an update is pending ``` -### 10. Troubleshooting +`update` works out how *this* copy was installed — from the path it is running from, not a +guess — and runs the matching command, so a pnpm or bun install is never handed an +`npm install -g` that would leave two copies shadowing each other: -- `Missing .linchpin.json`: - Run `linchpin wt config init` in the base worktree (interactive prompts) or `linchpin wt config init --type plugin --plugin-slug --no-interactive` for a default file. -- `Environment '' is not configured`: - Add the environment key in `.linchpin.json`. -- `Target exists and is not a symlink`: - Use `linchpin wt switch ... --force` only if replacing the directory is intended. -- `Worktree has uncommitted changes` on delete: - Commit/stash first, or force with `linchpin wt del --force`. -- `fzf is not installed`: - Install `fzf` or pass a branch/path directly to `linchpin wt cd `. +| How it was installed | What `linchpin update` runs | +| --- | --- | +| npm, global | `npm install -g @linchpinagency/cli@latest` | +| npm, project-local | `npm install @linchpinagency/cli@latest` | +| pnpm | `pnpm add -g @linchpinagency/cli@latest` | +| bun | `bun add -g @linchpinagency/cli@latest` | +| yarn 1.x | `yarn global add @linchpinagency/cli@latest` | +| `npx` | Nothing — each run already fetches the latest | +| source checkout / `npm link` | Nothing. It tells you to `git pull && npm install && npm run build` | -## Command surface +`--check` exits **3** ("precondition not met") when an update is pending and **0** when there is +nothing to do, so it can gate a job without any parsing: ```bash -linchpin shell-init [--shell bash|zsh|fish] - -linchpin wt ls [--json] -linchpin wt current [--link] [--env ] -linchpin wt switch [worktree|branch] [--env ] [--force] [--dry-run] - # No argument in a TTY: interactive picker from available worktrees. Non-interactive: use current worktree. - # When piped, outputs the symlink target path for cd: cd "$(linchpin wt switch ...)" - -linchpin wt new [name] -linchpin wt get -linchpin wt extract -linchpin wt mv -linchpin wt del [-f|--force] -linchpin wt cd [branch|path] -linchpin wt home -linchpin wt use -linchpin wt gone -linchpin wt copy -linchpin wt link -linchpin wt invoke - -linchpin wt config init [--type ] [--plugin-slug ] [--force] [--no-interactive] -linchpin wt config show +linchpin update --check || echo "CLI is behind — releasing with an old toolchain" ``` -Shell usage notes: - -- `linchpin wt cd` and `linchpin wt home` return paths for command substitution. -- Use `cd "$(linchpin wt cd)"` and `cd "$(linchpin wt home)"`. -- `linchpin wt cd` uses `fzf` when no argument is provided. +### Environment variables -## Configuration +| Variable | Effect | +| --- | --- | +| `LINCHPIN_NO_UPDATE_NOTIFIER` / `NO_UPDATE_NOTIFIER` | Never print the update notice | +| `LINCHPIN_REGISTRY` | Registry to check, for a mirror or an air-gapped network. Falls back to `npm_config_registry`, then npmjs.org | +| `LINCHPIN_CACHE_DIR` | Where the update-check cache lives. Defaults to `$XDG_CACHE_HOME/linchpin`, then `~/.cache/linchpin` | +| `LINCHPIN_OUTPUT` | `json`, `quiet`, `human` — set the output mode once instead of per call | +| `NO_COLOR` / `FORCE_COLOR` | Standard colour control | -Create `.linchpin.json` in the base repository root. The easiest way is to run `linchpin wt config init` in a terminal and follow the prompts. You can also create or edit the file manually: +## Uninstall -Plugin/theme project: +Remove the binary with whichever package manager installed it — `linchpin version` names it +under `install.manager` if you are unsure: -```json -{ - "agent": "conductor", - "agentBasePath": "/Users/you/conductor", - "wordpress": { - "contentType": "plugin", - "pluginSlug": "my-plugin", - "defaultEnvironment": "studio", - "environments": { - "studio": "/Users/you/Sites/studio/wp-content/plugins/my-plugin", - "wp-env": "/Users/you/Documents/projects/site/.wp-env/.../plugins/my-plugin", - "localwp": "/Users/you/Local Sites/site/app/public/wp-content/plugins/my-plugin" - } - } -} +```bash +npm uninstall -g @linchpinagency/cli +pnpm remove -g @linchpinagency/cli +bun remove -g @linchpinagency/cli +yarn global remove @linchpinagency/cli +npm unlink -g @linchpinagency/cli # a source install made with npm link ``` -WP-content project (repo is the entire wp-content folder): +Then clean up the two things that live outside the package. First the update-check cache — +`linchpin version --json` reports its exact location as `cachePath`, and by default it is: -```json -{ - "agent": "conductor", - "wordpress": { - "contentType": "wp-content", - "defaultEnvironment": "localwp", - "environments": { - "localwp": "/Users/you/Local Sites/site/app/public/wp-content" - } - } -} +```bash +rm -rf ~/.cache/linchpin ``` -Multi-agent (Codex and Conductor, etc.) — we look for worktrees in all listed paths: +Second, delete the `eval "$(linchpin shell-init)"` line from your shell profile, or every new +shell will print `command not found`. + +Nothing else is left behind. In particular: + +- **`.linchpin.json` and `.linchpin/hooks/` are project files**, committed to the repository and + shared with your team. Uninstalling the CLI does not touch them, and it should not — a + teammate still needs them. +- **Your worktrees and symlinks are untouched.** They are plain git worktrees and plain + symlinks; the CLI only ever pointed them at each other. Remove worktrees with + `git worktree remove` (or `linchpin wt del` before uninstalling) and delete a symlinked plugin + slot with `rm` — you are deleting a link, not your code. + +## Configuration + +`.linchpin.json` lives in the base repository root. `linchpin wt config init` writes it; this is +what it writes. Full reference in [docs/configuration.md](docs/configuration.md). ```json { @@ -324,13 +385,14 @@ Multi-agent (Codex and Conductor, etc.) — we look for worktrees in all listed "pluginSlug": "my-plugin", "defaultEnvironment": "studio", "environments": { - "studio": "/Users/you/Sites/studio/wp-content/plugins/my-plugin" + "studio": "/Users/you/Studio/mysite/wp-content/plugins/my-plugin", + "localwp": "/Users/you/Local Sites/mysite/app/public/wp-content/plugins/my-plugin" } } } ``` -If the repo uses a custom symlink name (e.g. the repo is named after the client), add `"symlinkName"`: +A repo that is an entire wp-content directory, under a name that is not `wp-content`: ```json { @@ -345,57 +407,81 @@ If the repo uses a custom symlink name (e.g. the repo is named after the client) } ``` -Behavior notes: +Notes that save an afternoon: -- **Agent / base path**: You can use a single agent or **multiple agents**. Single-agent config uses `agent` (Conductor, Claude Code, Codex, or Custom Path) and optional `agentBasePath`. Multi-agent config uses `agents` (object of name → base path) and optional `defaultAgent`. Default base paths: Conductor `~/conductor`, Claude Code `~/Documents`, Codex `~/Documents/GitHub`. For Custom Path you’re prompted for a base path during `config init`. When you use multiple agents (e.g. Codex for some work and Conductor for another), we look for worktrees in all configured paths so the correct main repo is found and detached-HEAD issues are avoided. -- If `defaultEnvironment` is omitted, the first environment key is used. -- `~` is supported in configured paths. -- `linchpin wt switch` without a worktree argument: in an interactive terminal you get a **picker** of available worktrees; in non-interactive use it uses the current worktree. +- **One agent or many.** A single agent uses `agent` plus an optional `agentBasePath`; several + use `agents` (name → base path) plus an optional `defaultAgent`. Defaults: Conductor + `~/conductor`, Claude Code `~/Documents`, Codex `~/Documents/GitHub`. With several configured, + every path is searched, so the right base repo is found no matter which agent made the + worktree. +- `defaultEnvironment` may be omitted; the first environment key wins. +- `~` is expanded. Anything else should be absolute. ## Hooks -Hook files are sourced in a subshell when present: +Twelve lifecycle points let a project run its own build, cache flush or fixup around each +operation. A hook is a file at `.linchpin/hooks/`, **sourced** in a subshell with the +worktree as the working directory: + +`pre-switch` · `post-switch` · `pre-new` · `post-new` · `pre-get` · `post-get` · +`pre-extract` · `post-extract` · `pre-mv` · `post-mv` · `pre-del` · `post-del` -- `.linchpin/hooks/` +```bash +# .linchpin/hooks/post-switch — rebuild whatever the new branch needs +composer install +npm install && npm run build +``` -Supported lifecycle hooks: +`LINCHPIN_BRANCH` and `LINCHPIN_WORKTREE` are always set; switch hooks also get +`LINCHPIN_ENVIRONMENT`. Run one by hand with `linchpin wt invoke post-switch`. Details and the +full environment contract: [docs/hooks.md](docs/hooks.md). -- `pre-switch`, `post-switch` -- `pre-new`, `post-new` -- `pre-get`, `post-get` -- `pre-extract`, `post-extract` -- `pre-mv`, `post-mv` -- `pre-del`, `post-del` +## Agents, output modes and exit codes -Manual invocation: +Mode is decided once at startup: an explicit `--json` / `--plain` / `--quiet` flag, then +`LINCHPIN_OUTPUT`, then whether stdout is a TTY. Warnings and notices always go to stderr so +stdout stays parseable. ```bash -linchpin wt invoke pre-new -linchpin wt invoke post-switch +linchpin wt ls --json +linchpin version --check --json ``` -Hook environment variables include `LINCHPIN_BRANCH`, `LINCHPIN_WORKTREE`, and for switch hooks `LINCHPIN_ENVIRONMENT`. - -To run commands after switching worktrees (e.g. `composer install`, `npm run build`), create `.linchpin/hooks/post-switch`. The hook runs with the worktree as the current directory: +In `--json` mode stdout carries exactly one envelope and stderr stays empty — including on +failure, which is precisely when structured output matters most. `changed` distinguishes a real +mutation from a no-op. -```bash -#!/bin/bash -composer install -npm install && npm run build +```json +{"version":1,"ok":true,"command":"wt switch","changed":true,"data":{"branch":"feature-b"}} ``` -## Typical WordPress review flow +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Unexpected error | +| 2 | Validation or usage error | +| 3 | Precondition not met | +| 4 | Authentication required or rejected | +| 5 | Refused by a safety check | -1. Open a plugin worktree. -2. Run `cd "$(linchpin wt switch --env studio)"` to repoint the symlink and land in the new target. -3. Use your existing WordPress environment to review that branch. -4. Move to another worktree and switch again. +⚠️ **`CI` is unset inside Claude Code while no stream is a TTY.** Anything that gates prompting +on a CI check alone classifies an agent as interactive and blocks forever. The non-TTY check is +the safety net. More on why this shapes the whole design: +[docs/agent-integration.md](docs/agent-integration.md). -## Safety behavior +## Troubleshooting -- Existing symlink targets are repointed safely. -- Existing non-symlink targets are blocked unless `--force` is used. -- `linchpin wt del` blocks dirty or unmerged branches unless forced. +| Symptom | Fix | +| --- | --- | +| `command not found: linchpin` | The global bin dir is not on `PATH`. Add `$(npm prefix -g)/bin` | +| `Missing .linchpin.json` | Run `linchpin wt config init` in the **base** worktree, not a worktree | +| `Environment '' is not configured` | Add the key under `wordpress.environments`, or pass `--env` with one that exists | +| `Target exists and is not a symlink` | A real directory is in the plugin slot. Back it up, or pass `--force` if replacing it is intended | +| `Worktree has uncommitted changes` on delete | Commit or stash first, or `linchpin wt del --force` | +| `fzf is not installed` | Install `fzf`, or pass a branch or path directly: `linchpin wt cd ` | +| Your shell stays in the old worktree after a switch | Install the wrapper: `eval "$(linchpin shell-init)"`, or use `cd "$(linchpin wt switch …)"` | +| Update notice will not go away | You are on an older version. `linchpin update`, or silence it with `LINCHPIN_NO_UPDATE_NOTIFIER=1` | +| `Could not reach the npm registry` | Offline, or behind a mirror. Set `LINCHPIN_REGISTRY` | ## Development @@ -406,19 +492,21 @@ npm run build # tsdown -> dist/ npm test # builds first, then node --test ``` -The CLI is TypeScript and ESM, built with [tsdown](https://tsdown.dev). Every runtime -dependency lives in `devDependencies` and is bundled into `dist/`, so the published package -installs with **zero transitive dependencies**. Un-ported CommonJS still lives in `legacy/`, -which carries its own `package.json` declaring `"type": "commonjs"`; it is being drained into -`src/` command by command. +TypeScript and ESM, built with [tsdown](https://tsdown.dev). Every runtime dependency lives in +`devDependencies` and is bundled into `dist/`, so the published package installs with **zero +transitive dependencies**. Un-ported CommonJS still lives in `legacy/`, which carries its own +`package.json` declaring `"type": "commonjs"`; it is being drained into `src/` command by +command. -Husky enforces Conventional Commits on `commit-msg`: +Adding a command means adding one `defineCommand()` definition — flags come from its Zod schema, +help grouping from `meta.group`, examples from `meta.examples`, and its `effect` classification +from `read` / `write` / `destructive`. Nothing is hand-wired twice. -```bash -npm run prepare -``` +The test suite never reaches the network: the update checker is exercised against a local +registry stub, and the shared fixture sets `LINCHPIN_NO_UPDATE_NOTIFIER` so no test can be +perturbed by a real release. -Example commit format: +Husky enforces Conventional Commits on `commit-msg` (`npm run prepare` installs it): ```text feat(LINCHPIN-4850): add release automation @@ -426,16 +514,14 @@ feat(LINCHPIN-4850): add release automation ### Continuous integration -`.github/workflows/ci.yml` runs on every pull request and on pushes to `main`: typecheck, -build and tests across Node **22.12** (the `engines` floor) and **24**. +`.github/workflows/ci.yml` runs on every pull request and on pushes to `main`: typecheck, build +and tests across Node **22.12** (the `engines` floor) and **24**. It also gates on **agent-readiness** using -[`cli-agent-lint`](https://github.com/Camil-H/cli-agent-lint), which grades a CLI A–F across -34 checks covering flow safety, token efficiency, self-description, automation safety and -predictability. - -CI fails if the score drops below a recorded floor, and the floor rises whenever the score -does, so a gain can't be given back silently. +[`cli-agent-lint`](https://github.com/Camil-H/cli-agent-lint), which grades a CLI A–F across 34 +checks covering flow safety, token efficiency, self-description, automation safety and +predictability. CI fails if the score drops below a recorded floor, and the floor rises whenever +the score does, so a gain can't be given back silently. | Recorded | Score | Where | What moved | | --- | --- | --- | --- | @@ -445,44 +531,34 @@ does, so a gain can't be given back silently. ⚠️ **Record the number CI reports, not a local run.** SD-5 (skill / context files) passes on a workstation off an untracked, gitignored `.claude/` directory that doesn't exist in a clean -checkout, so local runs read roughly 1.7 points high. The first two rows above were measured -locally and are inflated for that reason; CI is the gate, so CI is the measurement. +checkout, so local runs read roughly 1.7 points high. -Still outstanding: shell completions and schema introspection (SD-3/SD-4), env-var auth -(FS-4, arrives with `linchpin task`), skill/context files (SD-5, arrives with the bundled -skills), and a `--timeout` flag (PV-1). +Still outstanding: shell completions and schema introspection (SD-3/SD-4), env-var auth (FS-4, +arrives with `linchpin task`), skill/context files (SD-5, arrives with the bundled skills), and +a `--timeout` flag (PV-1). One check stays a warning **on purpose**. SD-1 wants errors to be JSON on stderr by default; -this CLI is human-readable by default and structured only when asked (`--json`), matching -`gh` and `wrangler`. In `--json` mode stdout carries exactly one envelope and stderr stays -empty, including on failure. - -## Output modes and exit codes - -Mode is decided once at startup: an explicit `--json` / `--plain` / `--quiet` flag, then -`LINCHPIN_OUTPUT`, then whether stdout is a TTY. Warnings always go to stderr so stdout stays -parseable. - -⚠️ **`CI` is unset inside Claude Code while no stream is a TTY.** Anything that gates -prompting on a CI check alone classifies an agent as interactive and blocks forever. The -non-TTY check is the safety net. - -| Code | Meaning | -| --- | --- | -| 0 | Success | -| 1 | Unexpected error | -| 2 | Validation or usage error | -| 3 | Precondition not met | -| 4 | Authentication required or rejected | -| 5 | Refused by a safety check | +this CLI is human-readable by default and structured only when asked (`--json`), matching `gh` +and `wrangler`. ## Releases Releases are managed by `release-please` in GitHub Actions: -- Pushes to `main` run `.github/workflows/release-please.yml`. -- `release-please` opens/updates a release PR from conventional commits. -- When the release PR is merged, a GitHub release/tag is created. -- If a release is created, the workflow publishes `@linchpinagency/cli` to npm. +1. Pushes to `main` run `.github/workflows/release-please.yml`. +2. `release-please` opens or updates a release PR from the conventional commits since the last + release. +3. Merging that PR creates the GitHub release and tag. +4. The `publish-npm` job then builds, tests and publishes to npm with provenance. + +Step 4 authenticates with the `NPM_TOKEN` repository secret, and that token needs write access +to the **whole `@linchpinagency` scope** — a granular npm token only ever covers packages that +existed when it was created, so one minted before a package's first publish cannot publish it. +npm answers an unauthorized write with `404`, not `403`, so the symptom is a confusing +`E404 … PUT https://registry.npmjs.org/@linchpinagency%2fcli`, not a permission error. The job +verifies the credential before building so that failure names its own cause. + +A publish that failed for a credential reason needs no new release: fix the token and re-run the +`publish-npm` job on the existing tag. ![Linchpin an award winning digital agency building immersive, high performing web experiences](https://assets.linchpin.com/github/linchpin-github-repo-banner.jpg) diff --git a/docs/README.md b/docs/README.md index 0c84fbc..a4839b7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,8 +7,11 @@ tripping approval prompts. ```bash npm install -g @linchpinagency/cli linchpin --help +linchpin version # what you are running, and whether a newer one exists ``` +→ [Installing, updating and uninstalling](updating.md) + ## What it solves **One WordPress install, many branches.** A plugin or theme repo has many git worktrees, but a @@ -30,6 +33,7 @@ cache flush, or environment fixup around each worktree operation. | Page | What's in it | | --- | --- | +| [Installing, updating and uninstalling](updating.md) | How version detection works, who sees an update notice, install-method detection, and how to remove it cleanly | | [Worktrees and the symlink swap](worktrees.md) | The core mechanic, why symlinks rather than checkouts, and how this differs from plain `git worktree` | | [Configuration](configuration.md) | `.linchpin.json` and `.clickup.json` — what each file owns and what is optional | | [Hooks](hooks.md) | The 12 hook points, the environment contract, and why hooks are sourced rather than executed | diff --git a/docs/updating.md b/docs/updating.md new file mode 100644 index 0000000..a54ffd2 --- /dev/null +++ b/docs/updating.md @@ -0,0 +1,107 @@ +# Installing, updating and uninstalling + +The CLI knows what version it is, whether a newer one is published, and how this particular copy +was installed. This page is the mechanism; the [README](../README.md#staying-up-to-date) is the +walkthrough. + +## Install + +```bash +npm install -g @linchpinagency/cli # or pnpm add -g / bun add -g +linchpin version +``` + +Global, not a project dependency: it is a tool you point at many repositories. + +`linchpin shell-init` emits a shell function that re-enters your current directory after a +successful `wt switch`, because a child process cannot change its parent shell's directory. Add +`eval "$(linchpin shell-init)"` to your profile, or wrap each call as +`cd "$(linchpin wt switch …)"`. + +## Two ways to ask about the version + +| Command | Answers | Exit code | +| --- | --- | --- | +| `linchpin --version` | The bare number, nothing else | 0 | +| `linchpin version` | Version, cached update state, how it was installed | 0 | +| `linchpin version --check` | Same, after asking the registry | 0 — always | +| `linchpin update --check` | Whether an update is pending | **3** if pending, 0 if not | + +Two commands rather than one flag, because the two callers want opposite things. A person or an +agent asking "what am I running" must not be handed a failure for the answer "there is a newer +one" — that would make the informational path unusable in a prompt or a status line. A CI job +gating on staleness needs exactly that failure, with no output to parse. + +## How the check works + +**The registry is asked for one dist-tag.** `GET /-/package//dist-tags` returns +`{"latest":"1.2.0"}` — a few dozen bytes, rather than the full packument with every version's +metadata. Override the host with `LINCHPIN_REGISTRY`; it falls back to `npm_config_registry`, +then npmjs.org. + +**The answer is cached for 24 hours**, at `$XDG_CACHE_HOME/linchpin/update-check.json` or +`~/.cache/linchpin/update-check.json` (`LINCHPIN_CACHE_DIR` overrides, and +`linchpin version --json` reports the resolved path as `cachePath`). + +**A notice costs no latency.** The notifier reads the cache file and nothing else. If the cache +has gone stale it spawns a detached process to refresh it — `detached`, stdio ignored, +`unref()`ed — so the command you actually ran never waits on a network round trip. That child is +marked with `LINCHPIN_UPDATE_CHECK_CHILD`, so it cannot spawn a refresh of its own. + +**A corrupt cache means "ask again", not "fail".** Every read and write here is best-effort: a +read-only home directory or a truncated file must never break the command someone was running. + +**An unparseable version never reads as newer.** If a registry answers with something that is +not a semver, the comparison returns "equal" rather than "newer" — otherwise every invocation +would nag with no version that could ever satisfy it. + +## Who gets told + +The notice is written to **stderr**, after the command completes, and only when all of these +hold: + +| Condition | Why | +| --- | --- | +| Output mode is `human` | `--json` keeps stdout to one envelope and stderr empty; `--quiet` means quiet | +| Not CI | A build log is not a person | +| Not an agent (`AI_AGENT` is unset) | An unrequested line is a token cost an agent cannot act on. It asks instead: `linchpin version --check --json`. Read straight from the environment rather than through the async `@vercel/detect-agent` call, so no invocation pays a detection cost to answer a question that only *removes* output | +| `LINCHPIN_NO_UPDATE_NOTIFIER` / `NO_UPDATE_NOTIFIER` unset | The opt-out, including the conventional name other tools use | +| The command is not `version` or `update` | Both report update state themselves | +| This install *can* be updated | A source checkout or an `npx` run would only get advice it cannot take | + +stderr rather than stdout is load-bearing, not stylistic: `cd "$(linchpin wt switch)"` and +`eval "$(linchpin shell-init)"` both consume stdout, and a notice there would be executed. + +## Install-method detection + +`linchpin update` derives its command from the path the process is running from — resolved +through `realpathSync`, since npm installs the bin as a symlink. + +| Path contains | Manager | Update command | +| --- | --- | --- | +| `lib/node_modules/` or `npm/node_modules/` | npm, global | `npm install -g @latest` | +| `node_modules/` anywhere else | npm, local | `npm install @latest` | +| `/.pnpm/`, `pnpm/global/`, `Library/pnpm/` | pnpm | `pnpm add -g @latest` | +| `/.bun/` | bun | `bun add -g @latest` | +| `/.yarn/`, `yarn/global/` | yarn 1.x | `yarn global add @latest` | +| `/_npx/` | — | Nothing; npx already fetched the latest | +| No `node_modules` at all | — | A checkout or `npm link`: `git pull && npm install && npm run build` | + +Read from the path rather than from `npm_config_user_agent`, which is only set while npm itself +is the parent process — true during `npm install`, never when a user runs `linchpin`. + +Getting this wrong is not cosmetic. Handing a pnpm or bun install an `npm install -g` leaves two +copies on the machine, and which one answers depends on `PATH` order. + +## Uninstall + +```bash +npm uninstall -g @linchpinagency/cli # or pnpm remove -g / bun remove -g +rm -rf ~/.cache/linchpin # the update-check cache +``` + +Then remove the `eval "$(linchpin shell-init)"` line from your shell profile. + +`.linchpin.json` and `.linchpin/hooks/` stay: they are committed project files that a teammate +still needs. Worktrees and symlinks stay too — they are plain git worktrees and plain symlinks, +and the CLI only ever pointed them at each other.