Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
634 changes: 355 additions & 279 deletions README.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
Expand Down
107 changes: 107 additions & 0 deletions docs/updating.md
Original file line number Diff line number Diff line change
@@ -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/<name>/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 <pkg>@latest` |
| `node_modules/` anywhere else | npm, local | `npm install <pkg>@latest` |
| `/.pnpm/`, `pnpm/global/`, `Library/pnpm/` | pnpm | `pnpm add -g <pkg>@latest` |
| `/.bun/` | bun | `bun add -g <pkg>@latest` |
| `/.yarn/`, `yarn/global/` | yarn 1.x | `yarn global add <pkg>@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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
47 changes: 42 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]): {
Expand All @@ -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<number> {
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 <command> --help Help for one command',
],
});
Expand All @@ -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: () => {} });
}
Expand Down Expand Up @@ -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.
Expand All @@ -122,4 +157,6 @@ if (isEntryPoint()) {
process.exitCode = output.failure(argv[0] ?? 'linchpin', error);
}
}

reportUpdates(output, argv);
}
9 changes: 8 additions & 1 deletion src/cli/commands/index.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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,
];
Loading