From 79c8ca9d977d5d6a0ffdcfb307baa17aa7708ac3 Mon Sep 17 00:00:00 2001 From: Aaron Ware Date: Sun, 30 Aug 2026 00:17:22 -0400 Subject: [PATCH] feat(NO-TASK): Announce a new release at shell startup The existing notifier only speaks after someone runs a command, so the person most likely to be out of date -- the one who has not opened the CLI in a fortnight -- is exactly the one it never reaches. Adds a snippet for the shell profile, emitted by `linchpin shell-init`, so a release is announced by opening a terminal instead. Two properties keep it safe to put in a profile. First, no Node runs on the startup path; the common case is `test` plus `cat` against a file rendered ahead of time, rather than starting the CLI to decide whether to say anything, which would put roughly 100ms in front of every new prompt to print nothing on all but a handful of them. Second, nothing runs in the foreground -- the refresh is detached and redirected to /dev/null, so a slow or unreachable registry cannot hold up a prompt. Nothing in it is terminal-specific. The notice file is kept in step after every command by syncNoticeFile, which skips a write that would not change the file, so the steady state is one small read. A copy that cannot update itself, such as a source checkout or an npx run, neither writes nor clears it; the notice on the machine was written by the global install, and a linked working tree wiping it would silence a release for a shell that had nothing to do with that checkout. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 57 ++++++--- docs/README.md | 2 +- docs/updating.md | 77 ++++++++++-- src/cli/commands/shell-init.ts | 29 ++++- src/cli/commands/update.ts | 11 +- src/cli/commands/version.ts | 5 + src/cli/shell-notice.ts | 147 ++++++++++++++++++++++ src/cli/update-notifier.ts | 44 +++++++ src/core/update.ts | 61 +++++++++- src/index.ts | 12 ++ test/shell-init.test.js | 216 +++++++++++++++++++++++++++++++++ test/update.test.js | 78 ++++++++++++ 12 files changed, 707 insertions(+), 32 deletions(-) create mode 100644 src/cli/shell-notice.ts diff --git a/README.md b/README.md index 4b539c0..9baf8f0 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ linchpin --help # flags, examples and description for one | `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 | +| `shell-init` | Emit the shell wrapper that lets `wt switch` change your directory, and with `--notify` the shell-startup update notice | read | | `version` | Print the installed version and whether a newer one is published | read | | `update` | Install the latest published version | write | @@ -138,7 +138,7 @@ switch` repoints the symlink but leaves your shell sitting in the **old** worktr `~/.zshrc`, `~/.bashrc` or `~/.config/fish/config.fish`: ```bash -eval "$(linchpin shell-init)" +eval "$(linchpin shell-init --notify)" ``` The shell is detected from `$SHELL`; force one with `linchpin shell-init --shell fish`. If you @@ -146,6 +146,10 @@ 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. +`--notify` is the second half: it adds a block that tells you at shell startup when a newer +version has been published. Leave it off with a bare `linchpin shell-init` if you only want the +directory wrapper. See [Being told about a new version](#being-told-about-a-new-version). + ### Install from source For contributing, or to run an unreleased branch: @@ -239,27 +243,46 @@ Guardrails, so a switch can't quietly eat your work: The CLI knows what version it is and whether a newer one has been published. -### Being told about it +### Being told about a new version + +There are two places a newer version gets announced. Both print the same two lines, both to +**stderr**, and both are off for machine readers. -When a newer version exists, a notice is written to **stderr** after your command completes: +**After a command you ran.** Automatic, nothing to install: ``` Update available: 1.1.3 → 1.2.0 Run: linchpin update ``` -Four things make that notice safe to leave on: +**When you open a terminal.** Opt in once, by adding `--notify` to the `shell-init` line in your +profile: + +```bash +eval "$(linchpin shell-init --notify)" +``` + +The second one exists because the first only reaches people who are already running the CLI — +the teammate who has not opened it in a fortnight is exactly the one who is out of date and +never hears about it. It is not tied to any particular terminal: Ghostty, Terminal.app, iTerm, +VS Code and the terminal inside Herd all just start your shell. + +Four things make both safe to leave on: -- **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)"` +- **They cost nothing.** The version is read from a small cache file, never from the network, so + no command and no shell startup 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. The + startup block is a `test` and a `cat` — about 6ms, against ~35ms if it had to start Node. +- **They never touch 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: +- **Machine readers never see them.** Suppressed in `--json` and `--quiet` mode, in CI, when an + agent is driving, and — for the startup block — in any non-interactive shell, so a script that + sources your profile stays clean. 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`). +- **They are two lines, and you can turn them off.** Set `LINCHPIN_NO_UPDATE_NOTIFIER=1` (or the + conventional `NO_UPDATE_NOTIFIER=1`); both honour it. The notice disappears on its own once + you update, however you update — `linchpin update` clears it, and so does the next command you + run after a manual `npm install -g`. ### Asking directly @@ -329,9 +352,9 @@ linchpin update --check || echo "CLI is behind — releasing with an old toolcha | Variable | Effect | | --- | --- | -| `LINCHPIN_NO_UPDATE_NOTIFIER` / `NO_UPDATE_NOTIFIER` | Never print the update notice | +| `LINCHPIN_NO_UPDATE_NOTIFIER` / `NO_UPDATE_NOTIFIER` | Never print the update notice, after a command or at shell startup. `0` and `false` mean *not* set | | `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_CACHE_DIR` | Where the update-check cache and the pre-rendered startup notice live. Defaults to `$XDG_CACHE_HOME/linchpin`, then `~/.cache/linchpin`. Set at runtime it also overrides the path baked into the `shell-init --notify` block | | `LINCHPIN_OUTPUT` | `json`, `quiet`, `human` — set the output mode once instead of per call | | `NO_COLOR` / `FORCE_COLOR` | Standard colour control | @@ -355,8 +378,8 @@ Then clean up the two things that live outside the package. First the update-che rm -rf ~/.cache/linchpin ``` -Second, delete the `eval "$(linchpin shell-init)"` line from your shell profile, or every new -shell will print `command not found`. +Second, delete the `eval "$(linchpin shell-init --notify)"` line from your shell profile, or +every new shell will print `command not found`. Nothing else is left behind. In particular: diff --git a/docs/README.md b/docs/README.md index a4839b7..0403f53 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,7 +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 | +| [Installing, updating and uninstalling](updating.md) | How version detection works, who sees an update notice after a command and at shell startup, 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 index a54ffd2..6e1a938 100644 --- a/docs/updating.md +++ b/docs/updating.md @@ -15,8 +15,9 @@ 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 …)"`. +`eval "$(linchpin shell-init --notify)"` to your profile, or wrap each call as +`cd "$(linchpin wt switch …)"`. `--notify` adds the [shell-startup +notice](#the-shell-startup-notice). ## Two ways to ask about the version @@ -41,7 +42,9 @@ 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`). +`linchpin version --json` reports the resolved path as `cachePath`). Beside it sits +`update-notice.txt`, the finished text a shell should print — see below for why it is a second +file rather than a second read of the first. **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, @@ -55,10 +58,13 @@ read-only home directory or a truncated file must never break the command someon 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 +## Who gets told, after a command -The notice is written to **stderr**, after the command completes, and only when all of these -hold: +There are two surfaces. This section is the notice printed after a command you ran; the next one +is the notice printed when a shell starts. Both write to **stderr**, and both honour the same +opt-out. + +The in-command notice is written after the command completes, and only when all of these hold: | Condition | Why | | --- | --- | @@ -72,6 +78,59 @@ hold: 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. +## The shell-startup notice + +The notifier above only speaks after someone runs a command, which means it never reaches the +person most likely to be out of date: the one who has not opened the CLI in a fortnight. +`linchpin shell-init --notify` emits a second block for a shell profile, so a release is +announced by opening a terminal. + +```bash +linchpin shell-init --notify >> ~/.zshrc # or eval "$(linchpin shell-init --notify)" +``` + +Nothing in it is terminal-specific. Ghostty, Terminal.app, iTerm, VS Code and the terminal +inside Herd all start the user's shell, and the shell is what reads this. + +**No Node on the startup path.** This is why `update-notice.txt` exists as its own file. The +cache is an *answer* that still has to be interpreted — compare two versions by semver +precedence, work out which package manager installed this copy — and doing that at startup means +starting Node in front of the first prompt: ~35ms, several times a day, to print nothing on all +but a handful of shells. The notice file holds the finished two lines instead, so the common +path is a `test` and a `cat`, measured at about 6ms per shell. + +**Only the CLI writes it.** Every `linchpin version` and `linchpin update` syncs the file to +what it just learned, so it is written when a release appears and removed the moment it stops +being one. `linchpin update` clears it on success, and any human command clears it after a +manual `npm install -g` — otherwise every new terminal would keep advertising an update that was +already installed. An identical write is skipped rather than performed, so a file a shell may be +reading is not churned on every command. + +**A copy that cannot update itself stays out of it.** A source checkout or an `npx` run neither +writes nor clears: the notice on that machine belongs to the global install, and an `npm link`ed +working tree wiping it would silence a release for shells that have nothing to do with the +checkout. + +**The refresh is the shell's job when nothing else runs the CLI.** If `update-check.json` is +missing or more than a day old, the block spawns `linchpin version --check --quiet` detached, +with its stdio on `/dev/null`, and returns immediately. That is what keeps a machine that never +runs the CLI from going stale forever. + +The block declines to say anything when any of these hold: + +| Condition | Why | +| --- | --- | +| The shell is not interactive | A script that sources a profile is not a person. `case $- in *i*)` in POSIX shells, `status is-interactive` in fish | +| `LINCHPIN_NO_UPDATE_NOTIFIER` / `NO_UPDATE_NOTIFIER` is set | The same opt-out the in-command notifier uses, read the same way: `0` and `false` count as unset | +| `linchpin` is not on `PATH` | Uninstalled. Advice that cannot be taken, and nothing to spawn | +| `update-notice.txt` is absent | The "nothing to say" state | + +The cache directory is baked into the emitted block as a shell literal — single-quoted, with +embedded apostrophes escaped, since it is a path from `$HOME` going into a file that is sourced +without review. Resolving XDG in shell would be a second implementation of `cacheDirectory()` +free to drift from the first. A `LINCHPIN_CACHE_DIR` set at runtime still wins; move your cache +any other way and re-run `shell-init`. + ## Install-method detection `linchpin update` derives its command from the path the process is running from — resolved @@ -97,10 +156,12 @@ copies on the machine, and which one answers depends on `PATH` order. ```bash npm uninstall -g @linchpinagency/cli # or pnpm remove -g / bun remove -g -rm -rf ~/.cache/linchpin # the update-check cache +rm -rf ~/.cache/linchpin # the update-check cache and the notice file ``` -Then remove the `eval "$(linchpin shell-init)"` line from your shell profile. +Then remove the `eval "$(linchpin shell-init --notify)"` line from your shell profile. Left +behind, the notice block is harmless — it checks for `linchpin` on `PATH` and returns — but the +`eval` around it will report `command not found` on every new shell. `.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, diff --git a/src/cli/commands/shell-init.ts b/src/cli/commands/shell-init.ts index bab17c5..e1c3475 100644 --- a/src/cli/commands/shell-init.ts +++ b/src/cli/commands/shell-init.ts @@ -1,8 +1,9 @@ import { z } from 'zod'; import { defineCommand } from '../registry.js'; +import { noticeSnippet } from '../shell-notice.js'; // Transitional: ported in LINCHPIN-5372. -import { runShellInit } from '../../../legacy/commands/shell-init.js'; +import { detectShell, runShellInit } from '../../../legacy/commands/shell-init.js'; /** * Prints a shell function to stdout for the user to eval or source. It writes @@ -15,12 +16,19 @@ export const shellInitCommand = defineCommand({ summary: 'Output the shell wrapper that makes wt switch change directory', description: 'A child process cannot change its parent shell\'s directory, so `wt cd` and\n' + - '`wt switch` need a shell function wrapper. Add the output to your shell rc file.', + '`wt switch` need a shell function wrapper. Add the output to your shell rc file.\n' + + '\n' + + '--notify adds a second block that prints a short notice when a newer version\n' + + 'has been published, so a release reaches someone who has not run the CLI\n' + + 'lately. It reads a pre-rendered cache file rather than starting the CLI —\n' + + 'about 6ms per shell against ~35ms for a Node start — and refreshes that file\n' + + 'in a detached process at most once a day.', group: 'utility', examples: [ 'linchpin shell-init >> ~/.zshrc', + 'linchpin shell-init --notify >> ~/.zshrc', 'linchpin shell-init --shell fish', - 'eval "$(linchpin shell-init)"', + 'eval "$(linchpin shell-init --notify)"', ], }, effect: 'read', @@ -29,9 +37,22 @@ export const shellInitCommand = defineCommand({ .enum(['bash', 'zsh', 'fish']) .optional() .describe('Shell to emit a wrapper for. Detected from $SHELL when omitted.'), + notify: z + .boolean() + .default(false) + .describe('Also print a notice at shell startup when a newer version is published'), }), handler: async (args) => { const argv = args.shell ? ['--shell', args.shell] : []; - return runShellInit(argv); + const code = runShellInit(argv); + + // Written straight to stdout rather than through `output`, matching the + // wrapper above it: this command's whole contract is that its stdout is + // shell source, so an envelope or a suppressed mode would be a bug here. + if (args.notify) { + process.stdout.write(`\n${noticeSnippet(detectShell(argv))}`); + } + + return code; }, }); diff --git a/src/cli/commands/update.ts b/src/cli/commands/update.ts index 1903760..2a9cea6 100644 --- a/src/cli/commands/update.ts +++ b/src/cli/commands/update.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { runCommand } from '../../core/exec.js'; import { + clearUpdateNotice, detectInstallation, formatCommand, resolveUpdateStatus, @@ -9,6 +10,7 @@ import { } from '../../core/update.js'; import { EXIT_CODES, UserError } from '../errors.js'; import { defineCommand } from '../registry.js'; +import { syncNoticeFile } from '../update-notifier.js'; /** * `linchpin update` — install the newest published version. @@ -73,6 +75,11 @@ export const updateCommand = defineCommand({ } const latest = status.latest; + + // Before any of the early returns below: --check and --dry-run are both + // legitimate ways to learn a release exists, and a shell should say the same. + syncNoticeFile({ current: version, latest, installation }); + const updateCommand = installation.command; const rendered = updateCommand ? formatCommand(updateCommand) : null; @@ -143,8 +150,10 @@ export const updateCommand = defineCommand({ } // Reset the check window so the notifier does not repeat a notice that has - // just been acted on. + // just been acted on, and take down the shell-startup notice with it — + // otherwise every new terminal keeps advertising an update already installed. writeUpdateCache({ checkedAt: Date.now(), latest, current: latest }); + clearUpdateNotice(); output.result( 'update', diff --git a/src/cli/commands/version.ts b/src/cli/commands/version.ts index 87ad189..78067a7 100644 --- a/src/cli/commands/version.ts +++ b/src/cli/commands/version.ts @@ -8,6 +8,7 @@ import { updateCachePath, } from '../../core/update.js'; import { defineCommand } from '../registry.js'; +import { syncNoticeFile } from '../update-notifier.js'; /** * `linchpin version` — the installed version, plus whether a newer one exists. @@ -52,6 +53,10 @@ export const versionCommand = defineCommand({ cacheOnly: !args.check, }); + // This command is what the background refresh runs, so it is the natural + // place to keep the shell-startup notice in step with the registry. + syncNoticeFile({ current: version, latest: status.latest, installation }); + const updateCommand = installation.command ? formatCommand(installation.command) : null; const lines = [`${name} ${version}`]; diff --git a/src/cli/shell-notice.ts b/src/cli/shell-notice.ts new file mode 100644 index 0000000..dfcc758 --- /dev/null +++ b/src/cli/shell-notice.ts @@ -0,0 +1,147 @@ +import { cacheDirectory } from '../core/update.js'; +import { CHILD_ENV_FLAG } from './update-notifier.js'; + +/** + * The shell-startup half of update notification. + * + * The notifier in `update-notifier.ts` only speaks after someone runs a + * command, which means the person most likely to be out of date — the one who + * has not opened the CLI in a fortnight — is exactly the person it never + * reaches. This emits a snippet for their shell profile instead, so a release + * is announced by opening a terminal. + * + * Two properties make it safe to put in a profile: + * + * **No Node on the startup path.** The common case is `test` plus `cat` against + * a pre-rendered file. Starting the CLI to decide whether to say anything would + * put ~100ms in front of every new prompt, several times a day, to print + * nothing on all but a handful of them. + * + * **Nothing runs in the foreground.** The refresh is detached and redirected to + * /dev/null, so a slow or unreachable registry can never hold up a prompt. + * + * Nothing here is terminal-specific: Ghostty, Terminal.app, iTerm, VS Code and + * the terminal inside Herd all just start the user's shell. + */ + +/** Roughly a day, in the minutes `find -mmin` counts. Matches CHECK_INTERVAL_MS. */ +const STALE_AFTER_MINUTES = 24 * 60; + +export interface NoticeSnippetOptions { + /** + * Cache directory baked into the snippet, so the shell does not have to + * reimplement XDG resolution and drift from `cacheDirectory()`. + */ + readonly cacheDir?: string; +} + +/** + * Single-quote a path for a shell literal. + * + * A home directory can contain an apostrophe, and the emitted line goes + * straight into a profile that is sourced without review. + */ +function quote(value: string): string { + return `'${value.split("'").join(`'\\''`)}'`; +} + +export function posixNoticeSnippet(options: NoticeSnippetOptions = {}): string { + const dir = quote(options.cacheDir ?? cacheDirectory()); + + return [ + '# linchpin: report a newer published version when a shell starts.', + '__linchpin_update_notice() {', + ' # Interactive shells only. A script that sources this profile is not a person.', + ' case $- in', + ' *i*) ;;', + ' *) return 0 ;;', + ' esac', + '', + ' # Same opt-outs the in-command notifier honours, including "0" and "false".', + ' local __linchpin_off', + ' for __linchpin_off in "${LINCHPIN_NO_UPDATE_NOTIFIER:-}" "${NO_UPDATE_NOTIFIER:-}"; do', + ' case "$__linchpin_off" in', + ' "" | 0 | false) ;;', + ' *) return 0 ;;', + ' esac', + ' done', + '', + ' # Uninstalled: say nothing rather than advise an update that cannot happen.', + ' command -v linchpin >/dev/null 2>&1 || return 0', + '', + // Assigned in two steps rather than with `${VAR:-default}`: the default is a + // single-quoted literal, and inside the double quotes of an assignment those + // quotes would land in the path itself. + ` local __linchpin_dir=${dir}`, + ' [ -n "${LINCHPIN_CACHE_DIR:-}" ] && __linchpin_dir="$LINCHPIN_CACHE_DIR"', + ' local __linchpin_notice="$__linchpin_dir/update-notice.txt"', + ' local __linchpin_cache="$__linchpin_dir/update-check.json"', + '', + ' # stderr, so a profile whose stdout is captured stays clean.', + ' [ -r "$__linchpin_notice" ] && cat "$__linchpin_notice" >&2', + '', + ' # Refresh in the background when the answer is missing or a day old, so a', + ' # machine that never runs the CLI still learns that a release happened.', + ' if [ ! -f "$__linchpin_cache" ] ||', + ` [ -n "$(find "$__linchpin_cache" -mmin +${String(STALE_AFTER_MINUTES)} 2>/dev/null)" ]; then`, + ` (env ${CHILD_ENV_FLAG}=1 linchpin version --check --quiet >/dev/null 2>&1 &)`, + ' fi', + '', + ' return 0', + '}', + '__linchpin_update_notice', + '', + ].join('\n'); +} + +export function fishNoticeSnippet(options: NoticeSnippetOptions = {}): string { + const dir = quote(options.cacheDir ?? cacheDirectory()); + + return [ + '# linchpin: report a newer published version when a shell starts.', + 'function __linchpin_update_notice', + ' status is-interactive; or return 0', + '', + ' # Same opt-outs the in-command notifier honours, including "0" and "false".', + ' for __linchpin_off in "$LINCHPIN_NO_UPDATE_NOTIFIER" "$NO_UPDATE_NOTIFIER"', + ' switch "$__linchpin_off"', + " case '' 0 false", + " case '*'", + ' return 0', + ' end', + ' end', + '', + ' # Uninstalled: say nothing rather than advise an update that cannot happen.', + ' command -q linchpin; or return 0', + '', + ' set -l __linchpin_dir "$LINCHPIN_CACHE_DIR"', + ` test -n "$__linchpin_dir"; or set __linchpin_dir ${dir}`, + ' set -l __linchpin_notice "$__linchpin_dir/update-notice.txt"', + ' set -l __linchpin_cache "$__linchpin_dir/update-check.json"', + '', + ' # stderr, so a profile whose stdout is captured stays clean.', + ' if test -r "$__linchpin_notice"', + ' cat "$__linchpin_notice" >&2', + ' end', + '', + ' # Refresh in the background when the answer is missing or a day old, so a', + ' # machine that never runs the CLI still learns that a release happened.', + ` set -l __linchpin_stale (find "$__linchpin_cache" -mmin +${String(STALE_AFTER_MINUTES)} 2>/dev/null)`, + ' if not test -f "$__linchpin_cache"; or test -n "$__linchpin_stale"', + ` env ${CHILD_ENV_FLAG}=1 linchpin version --check --quiet >/dev/null 2>&1 &`, + ' disown 2>/dev/null', + ' end', + '', + ' return 0', + 'end', + '__linchpin_update_notice', + '', + ].join('\n'); +} + +export function noticeSnippet( + shell: 'bash' | 'zsh' | 'fish' | 'posix', + options: NoticeSnippetOptions = {} +): string { + return shell === 'fish' ? fishNoticeSnippet(options) : posixNoticeSnippet(options); +} diff --git a/src/cli/update-notifier.ts b/src/cli/update-notifier.ts index 37dfd7e..bcc6dd6 100644 --- a/src/cli/update-notifier.ts +++ b/src/cli/update-notifier.ts @@ -1,9 +1,11 @@ import { spawn } from 'node:child_process'; import { + clearUpdateNotice, isCacheFresh, isUpdateAvailable, readUpdateCache, + writeUpdateNotice, type Installation, } from '../core/update.js'; import { isAgent, isCI } from './interactive.js'; @@ -59,6 +61,39 @@ export function renderUpdateNotice( return lines.join('\n'); } +/** + * Keep the pre-rendered shell-startup notice in step with what we now know. + * + * Cheap enough to call after every command: `writeUpdateNotice` skips a write + * that would not change the file, so the steady state is one small read. + * + * A copy that cannot update itself — a source checkout, an `npx` run — neither + * writes nor clears. It has no standing to speak: the notice on this machine + * was written by the global install, and a `npm link`ed working tree wiping it + * would silence a release for a shell that had nothing to do with the checkout. + */ +export function syncNoticeFile(options: { + readonly current: string; + readonly latest: string | undefined; + readonly installation: Installation; +}): void { + try { + if (options.installation.command === undefined) return; + + if (!isUpdateAvailable(options.current, options.latest)) { + clearUpdateNotice(); + return; + } + + writeUpdateNotice( + // Non-null: isUpdateAvailable is false for an absent latest. + renderUpdateNotice(options.current, options.latest ?? '', options.installation) + ); + } catch { + // The notice is a convenience. Never let it affect the command that ran. + } +} + /** * Refresh the cache in a process that outlives this one. * @@ -109,5 +144,14 @@ export function notifyAboutUpdates( output.warn(renderUpdateNotice(options.current, cache?.latest ?? '', options.installation)); } + // Keeps the shell-startup notice honest for someone who updated with their + // package manager directly: the next command they run clears the file, rather + // than every new shell repeating a notice until the cache next refreshes. + syncNoticeFile({ + current: options.current, + latest: cache?.latest, + installation: options.installation, + }); + if (!isCacheFresh(cache)) spawnBackgroundCheck(options.entryPath); } diff --git a/src/core/update.ts b/src/core/update.ts index 39fc470..78cdd82 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -1,4 +1,4 @@ -import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; +import { mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -197,6 +197,65 @@ export function writeUpdateCache(cache: UpdateCache, path: string = updateCacheP } } +/** + * Where the pre-rendered shell-startup notice lives. + * + * A separate file from the cache on purpose. The cache is an answer that still + * has to be interpreted — compare two versions, work out the install command — + * and doing that at every shell startup would mean starting Node before the + * first prompt. This file holds the finished text, so the shell snippet emitted + * by `linchpin shell-init --notify` is a `test` and a `cat`. + */ +export function updateNoticePath(): string { + return join(cacheDirectory(), 'update-notice.txt'); +} + +/** + * Write the notice a shell should print, skipping the write when it would not + * change the file. + * + * Deliberately plain text, with no ANSI: the process that writes it is detached + * with its stdio ignored, so it has no terminal to detect colour support + * against, and the shell that eventually `cat`s it may be redirecting anywhere. + */ +export function writeUpdateNotice(text: string, path: string = updateNoticePath()): boolean { + const content = text.endsWith('\n') ? text : `${text}\n`; + + try { + if (readFileSync(path, 'utf8') === content) return true; + } catch { + // No readable file yet, which is the normal first-write case. + } + + try { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, 'utf8'); + return true; + } catch { + return false; + } +} + +/** Remove the notice. Absence is the "nothing to say" state, so this is a no-op when it is gone. */ +export function clearUpdateNotice(path: string = updateNoticePath()): boolean { + try { + rmSync(path, { force: true }); + return true; + } catch { + return false; + } +} + +/** The notice text a shell would print right now, or undefined when there is none. */ +export function readUpdateNotice(path: string = updateNoticePath()): string | undefined { + try { + const text = readFileSync(path, 'utf8'); + return text.trim() === '' ? undefined : text; + } catch { + return undefined; + } +} + export function isCacheFresh( cache: UpdateCache | undefined, maxAgeMs: number = CHECK_INTERVAL_MS, diff --git a/src/index.ts b/src/index.ts index 7abd7ba..4d295d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -134,6 +134,7 @@ export { FETCH_TIMEOUT_MS, REGISTRY_URL, cacheDirectory, + clearUpdateNotice, compareVersions, currentInstallPath, detectInstallation, @@ -143,9 +144,12 @@ export { isCacheFresh, isUpdateAvailable, readUpdateCache, + readUpdateNotice, resolveUpdateStatus, updateCachePath, + updateNoticePath, writeUpdateCache, + writeUpdateNotice, type InstallScope, type Installation, type PackageManager, @@ -159,4 +163,12 @@ export { notificationsAllowed, notifyAboutUpdates, renderUpdateNotice, + syncNoticeFile, } from './cli/update-notifier.js'; + +export { + fishNoticeSnippet, + noticeSnippet, + posixNoticeSnippet, + type NoticeSnippetOptions, +} from './cli/shell-notice.js'; diff --git a/test/shell-init.test.js b/test/shell-init.test.js index 19c0631..6348b61 100644 --- a/test/shell-init.test.js +++ b/test/shell-init.test.js @@ -75,3 +75,219 @@ test('fishWrapper contains the cd-after-switch guard', () => { assert.match(output, /"wt"/); assert.match(output, /"switch"/); }); + +// --- The shell-startup update notice (linchpin shell-init --notify) ---------- + +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { pathToFileURL } = require('node:url'); + +const LIB = pathToFileURL(path.join(__dirname, '..', 'dist', 'index.js')).href; + +let lib; +test.before(async () => { + lib = await import(LIB); +}); + +function scratch() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'linchpin-notice-')); +} + +/** + * A `linchpin` on PATH that records how it was called instead of doing work. + * + * The point of the snippet is that a shell startup does *not* start Node, so + * the stub is how a test can tell the difference between reading the cache and + * spawning a refresh. + */ +function stubBin(root) { + const bin = path.join(root, 'bin'); + const marker = path.join(root, 'calls.txt'); + + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync( + path.join(bin, 'linchpin'), + `#!/bin/sh\necho "$@" >> ${JSON.stringify(marker)}\n`, + 'utf8' + ); + fs.chmodSync(path.join(bin, 'linchpin'), 0o755); + + return { bin, marker }; +} + +/** Source a snippet in an interactive bash and return what each stream saw. */ +function sourceInBash(snippet, root, env = {}, { interactive = true } = {}) { + const file = path.join(root, 'snippet.sh'); + fs.writeFileSync(file, snippet, 'utf8'); + + const result = spawnSync('bash', [...(interactive ? ['-i'] : []), '-c', `source ${JSON.stringify(file)}`], { + encoding: 'utf8', + env: { ...process.env, ...env }, + }); + + return { + code: result.status ?? 0, + stdout: result.stdout ?? '', + // `bash -i` without a controlling terminal announces its lack of job + // control on stderr. That is bash talking, not the snippet. + stderr: (result.stderr ?? '') + .split('\n') + .filter((line) => !line.includes('no job control')) + .join('\n') + .trim(), + }; +} + +/** The detached refresh outlives the shell, so its marker has to be waited for. */ +function waitForMarker(marker, timeoutMs = 5_000) { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + try { + return fs.readFileSync(marker, 'utf8'); + } catch { + spawnSync(process.execPath, ['-e', 'setTimeout(() => {}, 50)']); + } + } + + return undefined; +} + +test('shell-init only emits the notice block when asked', () => { + const plain = runCli(os.tmpdir(), ['shell-init', '--shell', 'zsh']); + assert.equal(plain.code, 0, plain.stderr); + assert.doesNotMatch(plain.stdout, /__linchpin_update_notice/); + + const notify = runCli(os.tmpdir(), ['shell-init', '--shell', 'zsh', '--notify']); + assert.equal(notify.code, 0, notify.stderr); + // The wrapper is still there: --notify adds, it does not replace. + assert.match(notify.stdout, /linchpin\(\)/); + assert.match(notify.stdout, /__linchpin_update_notice/); + assert.match(notify.stdout, /update-notice\.txt/); + + const fish = runCli(os.tmpdir(), ['shell-init', '--shell', 'fish', '--notify']); + assert.equal(fish.code, 0, fish.stderr); + assert.match(fish.stdout, /function __linchpin_update_notice/); + assert.match(fish.stdout, /status is-interactive/); +}); + +test('the baked cache path is a shell literal, apostrophes included', () => { + const snippet = lib.posixNoticeSnippet({ cacheDir: "/Users/o'brien/.cache/linchpin" }); + + // Naively interpolated, the apostrophe would end the string and the rest of + // the line would be executed as shell. + assert.match(snippet, /'\/Users\/o'\\''brien\/\.cache\/linchpin'/); + + const check = spawnSync('bash', ['-n', '-c', snippet], { encoding: 'utf8' }); + assert.equal(check.status, 0, `emitted snippet must parse: ${check.stderr}`); +}); + +test('a shell startup prints the cached notice, and starts no CLI to do it', () => { + const root = scratch(); + const cache = path.join(root, 'cache'); + const { bin, marker } = stubBin(root); + + fs.mkdirSync(cache, { recursive: true }); + fs.writeFileSync( + path.join(cache, 'update-notice.txt'), + 'Update available: 1.0.0 → 9.9.9\n Run: linchpin update\n', + 'utf8' + ); + // Fresh, so nothing is due for a refresh. + fs.writeFileSync( + path.join(cache, 'update-check.json'), + JSON.stringify({ checkedAt: Date.now(), latest: '9.9.9', current: '1.0.0' }), + 'utf8' + ); + + const snippet = lib.posixNoticeSnippet({ cacheDir: cache }); + const env = { PATH: `${bin}:${process.env.PATH}`, LINCHPIN_CACHE_DIR: '' }; + + const shown = sourceInBash(snippet, root, env); + assert.equal(shown.code, 0); + assert.match(shown.stderr, /Update available: 1\.0\.0 → 9\.9\.9/); + // stdout is shell source and command substitution. A notice there gets eval'd. + assert.equal(shown.stdout, '', 'the notice must never reach stdout'); + assert.equal(fs.existsSync(marker), false, 'a fresh cache must not start the CLI'); + + // Non-interactive: a script that sources a profile is not a person to tell. + const script = sourceInBash(snippet, root, env, { interactive: false }); + assert.equal(script.stderr, ''); + assert.equal(script.stdout, ''); + + // Both opt-outs, and the "0 means not disabled" reading the notifier uses. + for (const key of ['LINCHPIN_NO_UPDATE_NOTIFIER', 'NO_UPDATE_NOTIFIER']) { + const off = sourceInBash(snippet, root, { ...env, [key]: '1' }); + assert.equal(off.stderr, '', `${key} must silence the notice`); + + const zero = sourceInBash(snippet, root, { ...env, [key]: '0' }); + assert.match(zero.stderr, /Update available/, `${key}=0 is not an opt-out`); + } + + // Nothing to say once the file is gone — that is how `linchpin update` and a + // manual `npm i -g` both take the notice down. + fs.rmSync(path.join(cache, 'update-notice.txt')); + const silent = sourceInBash(snippet, root, env); + assert.equal(silent.code, 0); + assert.equal(silent.stderr, ''); +}); + +test('a stale cache refreshes in the background, and an uninstalled CLI is left alone', () => { + const root = scratch(); + const cache = path.join(root, 'cache'); + const { bin, marker } = stubBin(root); + + fs.mkdirSync(cache, { recursive: true }); + fs.writeFileSync( + path.join(cache, 'update-check.json'), + JSON.stringify({ checkedAt: 1, latest: '9.9.9', current: '1.0.0' }), + 'utf8' + ); + // Two days old: past the day the snippet trusts an answer for. + const old = new Date(Date.now() - 48 * 60 * 60 * 1000); + fs.utimesSync(path.join(cache, 'update-check.json'), old, old); + + const snippet = lib.posixNoticeSnippet({ cacheDir: cache }); + + const refreshed = sourceInBash(snippet, root, { + PATH: `${bin}:${process.env.PATH}`, + LINCHPIN_CACHE_DIR: '', + }); + assert.equal(refreshed.code, 0); + assert.equal(refreshed.stdout, '', 'the refresh must not leak into stdout'); + assert.equal(refreshed.stderr, '', 'the refresh must not leak into stderr'); + + const calls = waitForMarker(marker); + assert.ok(calls, 'a stale cache must spawn a refresh'); + assert.match(calls, /version --check --quiet/); + + // With no linchpin on PATH there is nothing to advise and nothing to spawn. + fs.rmSync(marker); + const uninstalled = sourceInBash(snippet, root, { PATH: '/nonexistent', LINCHPIN_CACHE_DIR: '' }); + assert.equal(uninstalled.code, 0); + assert.equal(uninstalled.stderr, ''); + assert.equal(fs.existsSync(marker), false); +}); + +test('LINCHPIN_CACHE_DIR at runtime beats the path baked in at generation time', () => { + const root = scratch(); + const elsewhere = path.join(root, 'moved'); + const { bin } = stubBin(root); + + fs.mkdirSync(elsewhere, { recursive: true }); + fs.writeFileSync(path.join(elsewhere, 'update-notice.txt'), 'Update available: moved\n', 'utf8'); + fs.writeFileSync( + path.join(elsewhere, 'update-check.json'), + JSON.stringify({ checkedAt: Date.now(), latest: '9.9.9', current: '1.0.0' }), + 'utf8' + ); + + const snippet = lib.posixNoticeSnippet({ cacheDir: path.join(root, 'baked-in') }); + const result = sourceInBash(snippet, root, { + PATH: `${bin}:${process.env.PATH}`, + LINCHPIN_CACHE_DIR: elsewhere, + }); + + assert.match(result.stderr, /Update available: moved/); +}); diff --git a/test/update.test.js b/test/update.test.js index 768cb69..77e2d9c 100644 --- a/test/update.test.js +++ b/test/update.test.js @@ -351,3 +351,81 @@ test('version and update are registered as read and write', async () => { assert.equal(byName.version.effect, 'read', 'version must be allowlistable without a prompt'); assert.equal(byName.update.effect, 'write'); }); + +test('the notice file is written, refreshed and taken down by the CLI itself', async (t) => { + const registry = await startRegistry('99.0.0'); + t.after(() => registry.close()); + + const install = fakeGlobalInstall(); + const cacheDir = tempDir('linchpin-cache-'); + const noticePath = path.join(cacheDir, 'update-notice.txt'); + const env = { LINCHPIN_REGISTRY: registry.url, LINCHPIN_CACHE_DIR: cacheDir }; + + // `version --check` is what the shell snippet spawns in the background, so it + // is the thing that has to leave a notice behind for the next shell to print. + const checked = await run(install.cli, ['version', '--check', '--quiet'], env); + assert.equal(checked.code, 0, checked.stderr); + assert.equal(checked.stdout, '', '--quiet must stay quiet'); + + const notice = fs.readFileSync(noticePath, 'utf8'); + assert.match(notice, new RegExp(`Update available: ${packageVersion} . 99\\.0\\.0`)); + assert.match(notice, /Run: linchpin update/); + + // No ANSI: the process that writes this is detached with its stdio ignored, + // so it has no terminal to detect colour support against, and the shell that + // eventually cats it may be redirecting anywhere. + assert.doesNotMatch(notice, new RegExp(String.fromCharCode(27))); + + // An answer of "you are current" takes the notice down, so a shell stops + // announcing a release the moment it stops being one. + const current = await startRegistry(packageVersion); + t.after(() => current.close()); + + const uptodate = await run(install.cli, ['version', '--check', '--quiet'], { + ...env, + LINCHPIN_REGISTRY: current.url, + }); + assert.equal(uptodate.code, 0, uptodate.stderr); + assert.equal(fs.existsSync(noticePath), false, 'an up-to-date answer clears the notice'); +}); + +test('a source checkout neither writes nor clears the shared notice', async (t) => { + const registry = await startRegistry('99.0.0'); + t.after(() => registry.close()); + + const cacheDir = tempDir('linchpin-cache-'); + const noticePath = path.join(cacheDir, 'update-notice.txt'); + fs.writeFileSync(noticePath, 'Update available: written by the global install\n', 'utf8'); + + // DIST_CLI is a checkout, not an install: `detectInstallation` finds no + // node_modules and reports `source`. Wiping the file from here would silence + // a release for every shell on the machine, over a working tree that has + // nothing to do with the installed copy. + const result = await run(DIST_CLI, ['version', '--check', '--quiet'], { + LINCHPIN_REGISTRY: registry.url, + LINCHPIN_CACHE_DIR: cacheDir, + }); + + assert.equal(result.code, 0, result.stderr); + assert.match(fs.readFileSync(noticePath, 'utf8'), /written by the global install/); +}); + +test('an unchanged notice is not rewritten under a shell that may be reading it', () => { + const { writeUpdateNotice, readUpdateNotice, clearUpdateNotice } = lib; + const noticePath = path.join(tempDir('linchpin-cache-'), 'update-notice.txt'); + + assert.equal(readUpdateNotice(noticePath), undefined, 'absent means nothing to say'); + + assert.equal(writeUpdateNotice('Update available: 1.0.0 to 2.0.0', noticePath), true); + assert.equal(readUpdateNotice(noticePath), 'Update available: 1.0.0 to 2.0.0\n'); + + // Called after every human command, so a rewrite that changes nothing must + // not churn a file a shell may be reading. + const before = fs.statSync(noticePath).mtimeMs; + writeUpdateNotice('Update available: 1.0.0 to 2.0.0\n', noticePath); + assert.equal(fs.statSync(noticePath).mtimeMs, before, 'an identical write is skipped'); + + assert.equal(clearUpdateNotice(noticePath), true); + assert.equal(clearUpdateNotice(noticePath), true, 'clearing an absent notice is not a failure'); + assert.equal(readUpdateNotice(noticePath), undefined); +});