diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a0088bf..e0f6b75 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -116,6 +116,14 @@ npm install -g @reply-team/reply-cli # newest internal build (@latest) npm install -g @reply-team/reply-cli@0.3.0 # a specific build ``` +`reply install` works on an internal build too, and keeps you on the internal +channel: it reads the package name it is running as, so it will never move you +between the two. Because the internal package lives on GitHub Packages, the +registry line and the `read:packages` token above have to be in place — the +command reminds you of both if the update fails. What it compares against is the +newest release of any kind, pre-releases included, which is exactly the internal +stream; the public channel compares against the promoted release instead. + ## Releases Releases are automated with [semantic-release](https://semantic-release.gitbook.io/). diff --git a/README.md b/README.md index 726ca8c..3627fad 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ contacts, and the inbox are on the way. ## Installation -Requires [Node.js](https://nodejs.org) 20 or newer. Install globally from npm: +`reply` runs on [Node.js](https://nodejs.org) **20 or newer** — check yours with +`node --version`, and install or upgrade Node first if it is older. Then install +the CLI globally from npm: ```sh npm install -g reply-cli @@ -18,6 +20,41 @@ npm install -g reply-cli reply --version ``` +That is the whole installation. There is nothing else to run. + +## Staying up to date + +One command keeps the CLI current: + +```sh +reply install +``` + +It looks up the newest release, and when it can update your copy safely it runs +npm for you and reports the result: + +``` +✓ reply 0.4.0 → 0.5.0 installed +``` + +Already on the newest release, it says so and does nothing. Where the copy is +not ours to change — installed inside a project, run through `npx`, or built +from a checkout — it prints the exact command that fits your setup and leaves +everything alone. It exits non-zero whenever an update exists and was not +applied, so `reply install --dry-run` works as a check in CI. + +`reply --version` mentions a newer release when there is one: + +``` +0.4.0 +reply 0.4.0 → 0.5.0 available · run `reply install` +``` + +That check reads the public GitHub releases, is cached for a day, times out +after a second and a half, and stays silent when it fails. It never runs for any +other command, and never at all with `--json`, when output is piped, in CI, or +with `REPLY_NO_UPDATE_CHECK=1` set. + ## Usage ```sh @@ -199,6 +236,7 @@ Reply.io login (`reply auth login`) to actually do anything. | `REPLY_PROFILE` | Profile to use (same as `--profile`) | | `REPLY_TEAM_ID` | Team/workspace id sent as `X-TEAM-ID` (same as `--team-id`) | | `REPLY_CONFIG_DIR` | Config directory (default `~/.config/reply`; `%APPDATA%\reply` on Windows) | +| `REPLY_NO_UPDATE_CHECK` | Set to `1` to never check whether a newer release exists | ## Contributing diff --git a/src/__tests__/commands/install.test.ts b/src/__tests__/commands/install.test.ts new file mode 100644 index 0000000..95e541d --- /dev/null +++ b/src/__tests__/commands/install.test.ts @@ -0,0 +1,129 @@ +import {describe, it, expect, beforeEach, vi} from 'vitest'; +import path from 'path'; + +const mock_run_install = vi.hoisted(()=>vi.fn()); +vi.mock('../../selfupdate/install', ()=>({run_install: mock_run_install})); + +import {handle_install, install_command} from '../../commands/install'; +import type {Install_report} from '../../selfupdate/install'; +import {RuntimeError} from '../../utils/errors'; + +const MODULE_DIR = path.join(path.parse(process.cwd()).root, 'usr', 'lib', 'node_modules', 'reply-cli'); + +const report = (over: Partial = {}): Install_report=>({ + current: '0.4.0', + latest: '0.5.0', + up_to_date: false, + channel: 'public', + install: {kind: 'npm-global', package: 'reply-cli', path: MODULE_DIR}, + action: 'updated', + command: 'npm install -g reply-cli@latest', + note: `Installed globally with npm (${MODULE_DIR}).`, + ...over, +}); + +const capture = async(fn: ()=>unknown | Promise): Promise<{out: string; err: string}>=>{ + const out: string[] = []; + const err: string[] = []; + const log = console.log; + const error = console.error; + const write = process.stdout.write; + console.log = (...a: unknown[])=>{ out.push(a.join(' ')); }; + console.error = (...a: unknown[])=>{ err.push(a.join(' ')); }; + process.stdout.write = ((c: unknown): boolean=>{ out.push(String(c)); return true; }) as typeof process.stdout.write; + try { await fn(); } finally { console.log = log; console.error = error; process.stdout.write = write; } + const clean = (s: string[]): string=>s.join('\n').replace(/\x1b\[[0-9;]*m/g, '').trim(); + return {out: clean(out), err: clean(err)}; +}; + +beforeEach(()=>{ vi.clearAllMocks(); }); + +describe('handle_install', ()=>{ + it('keeps stdout clean and reports the update on stderr', async()=>{ + mock_run_install.mockResolvedValue(report()); + const {out, err} = await capture(()=>handle_install({})); + expect(out).toBe(''); + expect(err).toContain('reply 0.4.0 → 0.5.0 installed'); + }); + + it('says so when nothing needs doing, and exits zero', async()=>{ + mock_run_install.mockResolvedValue(report({action: 'current', up_to_date: true, latest: '0.4.0'})); + const {err} = await capture(()=>handle_install({})); + expect(err).toContain('reply 0.4.0 is the newest release'); + }); + + it('prints the exact command and fails when it cannot update', async()=>{ + mock_run_install.mockResolvedValue(report({ + action: 'manual', + install: {kind: 'npx', package: 'reply-cli', path: MODULE_DIR}, + command: 'npx reply-cli@latest', + note: 'Running through npx, which resolves the newest published version on each run.', + })); + let thrown: unknown; + const {err} = await capture(async()=>{ + await handle_install({}).catch((e: unknown)=>{thrown = e;}); + }); + expect(err).toContain('npx reply-cli@latest'); + expect(err).toContain('Running through npx'); + expect(thrown).toBeInstanceOf(RuntimeError); + expect(thrown).toMatchObject({exit_code: 1, code: 'update.manual'}); + }); + + it('reports why npm failed, shows what npm said, and offers the elevated command', async()=>{ + mock_run_install.mockResolvedValue(report({ + action: 'failed', + detail: 'npm exited with code 243 (permission denied)', + command: 'sudo npm install -g reply-cli@latest', + npm_output: 'npm error code EACCES\nnpm error syscall mkdir', + })); + let thrown: unknown; + const {err} = await capture(async()=>{ + await handle_install({}).catch((e: unknown)=>{thrown = e;}); + }); + expect(err).toContain('Could not update automatically: npm exited with code 243 (permission denied).'); + expect(err).toContain('npm error syscall mkdir'); + expect(err).toContain('sudo npm install -g reply-cli@latest'); + expect(thrown).toMatchObject({code: 'update.npm_failed'}); + }); + + it('does not narrate progress under --json', async()=>{ + mock_run_install.mockResolvedValue(report()); + await capture(()=>handle_install({json: true})); + expect(mock_run_install.mock.calls[0][1]).toEqual({progress: undefined}); + }); + + it('marks a dry run as having changed nothing, and still exits 1', async()=>{ + mock_run_install.mockResolvedValue(report({action: 'manual'})); + let thrown: unknown; + const {err} = await capture(async()=>{ + await handle_install({dryRun: true}).catch((e: unknown)=>{thrown = e;}); + }); + expect(mock_run_install.mock.calls[0][0]).toEqual({dry_run: true}); + expect(err).toContain('--dry-run'); + expect(thrown).toBeInstanceOf(RuntimeError); + }); + + it('puts the report on stdout under --json and no prose anywhere', async()=>{ + mock_run_install.mockResolvedValue(report()); + const {out, err} = await capture(()=>handle_install({json: true})); + expect(JSON.parse(out)).toMatchObject({action: 'updated', current: '0.4.0', latest: '0.5.0'}); + expect(err).toBe(''); + }); + + it('indents the report under --pretty', async()=>{ + mock_run_install.mockResolvedValue(report()); + const {out} = await capture(()=>handle_install({pretty: true})); + expect(out).toContain('\n "action": "updated"'); + }); +}); + +describe('the install command surface', ()=>{ + it('answers to update as well, so muscle memory works', ()=>{ + expect(install_command.name()).toBe('install'); + expect(install_command.aliases()).toContain('update'); + }); + + it('offers --dry-run', ()=>{ + expect(install_command.options.map(o=>o.long)).toContain('--dry-run'); + }); +}); diff --git a/src/__tests__/selfupdate/cache.test.ts b/src/__tests__/selfupdate/cache.test.ts new file mode 100644 index 0000000..2fa99f1 --- /dev/null +++ b/src/__tests__/selfupdate/cache.test.ts @@ -0,0 +1,87 @@ +import {describe, it, expect} from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import {update_check_file} from '../../config'; +import { + cache_is_fresh, + read_check_cache, + write_check_cache, + type Check_cache, +} from '../../selfupdate/cache'; + +// Every test gets its own config dir, so nothing reads or writes the real one. +const sandbox = (): Record=> + ({REPLY_CONFIG_DIR: fs.mkdtempSync(path.join(os.tmpdir(), 'reply-update-cache-'))}); + +const at = (iso: string): Date=>new Date(iso); +const NOW = at('2026-08-01T12:00:00.000Z'); +const entry = (over: Partial = {}): Check_cache=> + ({version: 1, channel: 'public', latest: '0.5.0', checked_at: NOW.toISOString(), ...over}); + +describe('the update-check cache', ()=>{ + it('returns what was written', ()=>{ + const env = sandbox(); + write_check_cache(entry(), env); + expect(read_check_cache(env)).toEqual(entry()); + }); + + it('reports nothing when the file has never been written', ()=>{ + expect(read_check_cache(sandbox())).toBeUndefined(); + }); + + it('treats a corrupt file as never checked instead of throwing', ()=>{ + const env = sandbox(); + fs.writeFileSync(update_check_file(env), '{ not json', 'utf8'); + expect(read_check_cache(env)).toBeUndefined(); + }); + + it('treats an unexpected shape as never checked', ()=>{ + const env = sandbox(); + fs.writeFileSync(update_check_file(env), '[]', 'utf8'); + expect(read_check_cache(env)).toBeUndefined(); + }); + + it('leaves no temporary file behind', ()=>{ + const env = sandbox(); + write_check_cache(entry(), env); + const left = fs.readdirSync(env.REPLY_CONFIG_DIR); + expect(left).toEqual(['update-check.json']); + }); + + it('creates the config directory when it does not exist yet', ()=>{ + const env = {REPLY_CONFIG_DIR: path.join(os.tmpdir(), `reply-cache-new-${process.pid}-${Math.trunc(NOW.getTime())}`)}; + fs.rmSync(env.REPLY_CONFIG_DIR, {recursive: true, force: true}); + write_check_cache(entry(), env); + expect(read_check_cache(env)?.latest).toBe('0.5.0'); + fs.rmSync(env.REPLY_CONFIG_DIR, {recursive: true, force: true}); + }); +}); + +describe('cache_is_fresh', ()=>{ + it('is fresh inside the success window and stale past it', ()=>{ + expect(cache_is_fresh(entry(), 'public', at('2026-08-02T11:00:00.000Z'))).toBe(true); + expect(cache_is_fresh(entry(), 'public', at('2026-08-02T13:00:00.000Z'))).toBe(false); + }); + + it('backs off for an hour after a failure, not a day', ()=>{ + const failed = entry({checked_at: undefined, failed_at: NOW.toISOString()}); + expect(cache_is_fresh(failed, 'public', at('2026-08-01T12:30:00.000Z'))).toBe(true); + expect(cache_is_fresh(failed, 'public', at('2026-08-01T14:00:00.000Z'))).toBe(false); + }); + + it('uses the failure window even when a last known version is kept', ()=>{ + const failed = entry({failed_at: NOW.toISOString()}); + expect(cache_is_fresh(failed, 'public', at('2026-08-01T14:00:00.000Z'))).toBe(false); + }); + + it('is stale when the cached channel is not the one being asked about', ()=>{ + expect(cache_is_fresh(entry(), 'internal', NOW)).toBe(false); + }); + + it('is stale with no entry, no timestamp, or an unparseable one', ()=>{ + expect(cache_is_fresh(undefined, 'public', NOW)).toBe(false); + expect(cache_is_fresh(entry({checked_at: undefined}), 'public', NOW)).toBe(false); + expect(cache_is_fresh(entry({checked_at: 'yesterday'}), 'public', NOW)).toBe(false); + }); +}); diff --git a/src/__tests__/selfupdate/detect.test.ts b/src/__tests__/selfupdate/detect.test.ts new file mode 100644 index 0000000..7f8586c --- /dev/null +++ b/src/__tests__/selfupdate/detect.test.ts @@ -0,0 +1,118 @@ +import {describe, it, expect} from 'vitest'; +import path from 'path'; +import {how_installed} from '../../selfupdate/detect'; + +// Absolute paths built for the platform the test is running on: a literal with +// separators would classify differently on Windows than on Linux. +const ROOT = path.parse(process.cwd()).root; +const at = (...parts: string[]): string=>path.join(ROOT, ...parts); +const pkg = (name: string, version = '0.4.0')=>()=>({name, version}); + +describe('how_installed', ()=>{ + it('calls an install outside the working directory global', ()=>{ + const info = how_installed({ + module_dir: at('usr', 'lib', 'node_modules', 'reply-cli'), + cwd: at('home', 'artem', 'work'), + read_package: pkg('reply-cli'), + }); + expect(info.kind).toBe('npm-global'); + expect(info.channel).toBe('public'); + expect(info.version).toBe('0.4.0'); + }); + + it('calls an install under the working directory local', ()=>{ + const cwd = at('home', 'artem', 'app'); + const info = how_installed({ + module_dir: path.join(cwd, 'node_modules', 'reply-cli'), + cwd, + read_package: pkg('reply-cli'), + }); + expect(info.kind).toBe('npm-local'); + }); + + it('calls it local when the owning project is an ancestor of the working directory', ()=>{ + // node resolves upward, so a copy in /home/artem/app/node_modules is + // this project's whether you stand in app or in app/src. + const project = at('home', 'artem', 'app'); + const info = how_installed({ + module_dir: path.join(project, 'node_modules', 'reply-cli'), + cwd: path.join(project, 'src', 'deep'), + read_package: pkg('reply-cli'), + }); + expect(info.kind).toBe('npm-local'); + }); + + it('still calls a version-manager install global from the home directory', ()=>{ + // The regression that matters: ~/.nvm/... is under $HOME, so asking + // whether the module sits under cwd would call this project-local and + // refuse to update the one install we can actually drive. + const home = at('home', 'artem'); + const info = how_installed({ + module_dir: path.join(home, '.nvm', 'versions', 'node', 'v22.17.1', 'lib', 'node_modules', 'reply-cli'), + cwd: home, + read_package: pkg('reply-cli'), + }); + expect(info.kind).toBe('npm-global'); + }); + + it('attributes a nested copy to the project that owns the tree', ()=>{ + const project = at('home', 'artem', 'app'); + const info = how_installed({ + module_dir: path.join(project, 'node_modules', 'some-tool', 'node_modules', 'reply-cli'), + cwd: project, + read_package: pkg('reply-cli'), + }); + expect(info.kind).toBe('npm-local'); + }); + + it('recognises npx before node_modules, since an npx cache has both', ()=>{ + const info = how_installed({ + module_dir: at('home', 'artem', '.npm', '_npx', 'a1b2', 'node_modules', 'reply-cli'), + cwd: at('home', 'artem'), + read_package: pkg('reply-cli'), + }); + expect(info.kind).toBe('npx'); + }); + + it('reads the internal channel off the scoped package name', ()=>{ + const info = how_installed({ + module_dir: at('src', 'reply-cli'), + cwd: at('src'), + read_package: pkg('@reply-team/reply-cli', '0.0.0-development'), + }); + expect(info.kind).toBe('source'); + expect(info.channel).toBe('internal'); + expect(info.version).toBe('0.0.0-development'); + }); + + it('refuses to classify a package it does not recognise', ()=>{ + const info = how_installed({ + module_dir: at('usr', 'lib', 'node_modules', 'some-fork'), + cwd: at('home'), + read_package: pkg('some-fork-of-reply'), + }); + expect(info.kind).toBe('unknown'); + expect(info.package_name).toBe('some-fork-of-reply'); + }); + + it('survives an unreadable package.json', ()=>{ + const info = how_installed({ + module_dir: at('opt', 'somewhere'), + cwd: at('opt'), + read_package: ()=>undefined, + }); + expect(info).toMatchObject({kind: 'unknown', package_name: '', version: '0.0.0'}); + }); + + it('reports the module directory it judged', ()=>{ + const dir = at('usr', 'lib', 'node_modules', 'reply-cli'); + expect(how_installed({module_dir: dir, cwd: at('home'), read_package: pkg('reply-cli')}).module_dir) + .toBe(dir); + }); + + it('reads this very checkout when given no directory', ()=>{ + const info = how_installed(); + expect(info.package_name).toBe('@reply-team/reply-cli'); + expect(info.module_dir).toBe(path.resolve(__dirname, '..', '..', '..')); + }); +}); diff --git a/src/__tests__/selfupdate/install.test.ts b/src/__tests__/selfupdate/install.test.ts new file mode 100644 index 0000000..796d4c4 --- /dev/null +++ b/src/__tests__/selfupdate/install.test.ts @@ -0,0 +1,191 @@ +import {describe, it, expect} from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import {read_check_cache} from '../../selfupdate/cache'; +import {run_install} from '../../selfupdate/install'; +import type {Install_deps} from '../../selfupdate/install'; +import type {Npm_outcome} from '../../selfupdate/npm'; +import type {Install_info, Release} from '../../selfupdate/types'; + +const sandbox = (): Record=> + ({REPLY_CONFIG_DIR: fs.mkdtempSync(path.join(os.tmpdir(), 'reply-install-'))}); + +const MODULE_DIR = path.join(path.parse(process.cwd()).root, 'usr', 'lib', 'node_modules', 'reply-cli'); + +const installed = (over: Partial = {}): Install_info=>({ + kind: 'npm-global', + channel: 'public', + package_name: 'reply-cli', + version: '0.4.0', + module_dir: MODULE_DIR, + ...over, +}); + +const released = (version: string): Release=> + ({version, tag: `v${version}`, url: 'https://example.invalid', prerelease: false}); + +const npm_outcome = (over: Partial = {}): Npm_outcome=> + ({ok: true, code: 0, output_tail: '', permission_denied: false, npm_missing: false, ...over}); + +const deps = (over: Partial = {}): Install_deps=>({ + install: installed(), + release: async()=>released('0.5.0'), + run_npm: async()=>npm_outcome(), + env: sandbox(), + now: ()=>new Date('2026-08-01T12:00:00.000Z'), + platform: 'linux', + ...over, +}); + +describe('run_install', ()=>{ + it('updates a global npm install and reports old -> new', async()=>{ + const asked: string[] = []; + const report = await run_install({}, deps({run_npm: async(pkg)=>{asked.push(pkg); return npm_outcome();}})); + expect(asked).toEqual(['reply-cli']); + expect(report).toMatchObject({ + action: 'updated', + current: '0.4.0', + latest: '0.5.0', + up_to_date: false, + command: 'npm install -g reply-cli@latest', + }); + }); + + it('does nothing when the newest release is already installed', async()=>{ + let spawned = false; + const report = await run_install({}, deps({ + release: async()=>released('0.4.0'), + run_npm: async()=>{spawned = true; return npm_outcome();}, + })); + expect(report.action).toBe('current'); + expect(report.up_to_date).toBe(true); + expect(spawned).toBe(false); + }); + + it('never suggests a downgrade when the installed build is ahead', async()=>{ + const report = await run_install({}, deps({ + install: installed({version: '0.6.0'}), + release: async()=>released('0.5.0'), + })); + expect(report.action).toBe('current'); + }); + + it('asks the channel the installed package belongs to', async()=>{ + const asked: string[] = []; + await run_install({}, deps({ + install: installed({channel: 'internal', package_name: '@reply-team/reply-cli'}), + release: async(channel)=>{asked.push(channel); return released('0.5.0');}, + })); + expect(asked).toEqual(['internal']); + }); + + it('reports without spawning anything under --dry-run', async()=>{ + let spawned = false; + const report = await run_install({dry_run: true}, deps({ + run_npm: async()=>{spawned = true; return npm_outcome();}, + })); + expect(report.action).toBe('manual'); + expect(spawned).toBe(false); + expect(report.command).toBe('npm install -g reply-cli@latest'); + }); + + it.each(['npm-local', 'npx', 'source', 'unknown'] as const)( + 'never spawns npm for a %s install', async kind=>{ + let spawned = false; + const report = await run_install({}, deps({ + install: installed({kind}), + run_npm: async()=>{spawned = true; return npm_outcome();}, + })); + expect(spawned).toBe(false); + expect(report.action).toBe('manual'); + expect(report.command).toBeTruthy(); + }); + + it('reports a failed npm run and keeps the reason', async()=>{ + const report = await run_install({}, deps({ + run_npm: async()=>npm_outcome({ok: false, code: 1, output_tail: 'npm error code E404'}), + })); + expect(report.action).toBe('failed'); + expect(report.detail).toBe('npm exited with code 1'); + expect(report.command).toBe('npm install -g reply-cli@latest'); + expect(report.npm_output).toBe('npm error code E404'); + }); + + it('carries npm output only when npm failed', async()=>{ + const ok = await run_install({}, deps({ + run_npm: async()=>npm_outcome({output_tail: 'added 1 package'}), + })); + expect(ok.npm_output).toBeUndefined(); + }); + + it('announces the npm run, which buffers for as long as it takes', async()=>{ + const said: string[] = []; + await run_install({}, deps({progress: m=>said.push(m)})); + expect(said).toEqual(['0.4.0 → 0.5.0, updating with npm…']); + }); + + it('says nothing before a run it will not make', async()=>{ + const said: string[] = []; + await run_install({dry_run: true}, deps({progress: m=>said.push(m)})); + await run_install({}, deps({install: installed({kind: 'npx'}), progress: m=>said.push(m)})); + await run_install({}, deps({release: async()=>released('0.4.0'), progress: m=>said.push(m)})); + expect(said).toEqual([]); + }); + + it('escalates to sudo on a permission failure, but not on Windows', async()=>{ + const denied = npm_outcome({ok: false, code: 243, permission_denied: true, output_tail: 'EACCES'}); + const posix = await run_install({}, deps({run_npm: async()=>denied})); + expect(posix.command).toBe('sudo npm install -g reply-cli@latest'); + expect(posix.detail).toContain('permission denied'); + + // No sudo to prepend on Windows, so the remedy has to be in the words. + const windows = await run_install({}, deps({platform: 'win32', run_npm: async()=>denied})); + expect(windows.command).toBe('npm install -g reply-cli@latest'); + expect(windows.detail).toContain('elevated terminal'); + expect(posix.detail).not.toContain('elevated terminal'); + }); + + it('says plainly when npm itself is missing', async()=>{ + const report = await run_install({}, deps({ + run_npm: async()=>npm_outcome({ok: false, code: 1, npm_missing: true, output_tail: 'spawn npm ENOENT'}), + })); + expect(report.detail).toBe('npm is not on PATH'); + }); + + it('caches the result so the version hint need not ask again', async()=>{ + const env = sandbox(); + await run_install({}, deps({env})); + expect(read_check_cache(env)).toEqual({ + version: 1, + channel: 'public', + latest: '0.5.0', + checked_at: '2026-08-01T12:00:00.000Z', + }); + }); + + it('still updates when the cache cannot be written', async()=>{ + // A regular file where the config directory should be: mkdir fails, + // and the update must not fail with it. + const blocked = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'reply-install-blocked-')), 'in-the-way'); + fs.writeFileSync(blocked, 'not a directory', 'utf8'); + const env = {REPLY_CONFIG_DIR: blocked}; + expect(()=>fs.mkdirSync(path.join(blocked, 'x'), {recursive: true})).toThrow(); + + const report = await run_install({}, deps({env})); + expect(report.action).toBe('updated'); + expect(read_check_cache(env)).toBeUndefined(); + }); + + it('lets a lookup failure surface: an explicit command must not fail silently', async()=>{ + await expect(run_install({}, deps({ + release: async()=>{throw new Error('offline');}, + }))).rejects.toThrow('offline'); + }); + + it('carries the install it judged, for --json consumers', async()=>{ + const report = await run_install({}, deps()); + expect(report.install).toEqual({kind: 'npm-global', package: 'reply-cli', path: MODULE_DIR}); + expect(report.note).toContain(MODULE_DIR); + }); +}); diff --git a/src/__tests__/selfupdate/notice.test.ts b/src/__tests__/selfupdate/notice.test.ts new file mode 100644 index 0000000..3952373 --- /dev/null +++ b/src/__tests__/selfupdate/notice.test.ts @@ -0,0 +1,128 @@ +import {describe, it, expect} from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import {read_check_cache, write_check_cache} from '../../selfupdate/cache'; +import {update_notice} from '../../selfupdate/notice'; +import type {Notice_deps} from '../../selfupdate/notice'; +import type {Install_info, Release} from '../../selfupdate/types'; + +const sandbox = (): Record=> + ({REPLY_CONFIG_DIR: fs.mkdtempSync(path.join(os.tmpdir(), 'reply-notice-'))}); + +const NOW = new Date('2026-08-01T12:00:00.000Z'); + +const installed = (over: Partial = {}): Install_info=>({ + kind: 'npm-global', + channel: 'public', + package_name: 'reply-cli', + version: '0.4.0', + module_dir: path.join(path.parse(process.cwd()).root, 'usr', 'lib', 'node_modules', 'reply-cli'), + ...over, +}); + +// Any test that expects no request gets this: it fails loudly if reached. +const forbidden = async(): Promise=>{ + throw new Error('the update check must not run here'); +}; + +const deps = (over: Partial = {}): Notice_deps=>({ + tty: true, + env: sandbox(), + now: ()=>NOW, + install: installed(), + release: async()=>({version: '0.5.0', tag: 'v0.5.0', url: 'https://example.invalid', prerelease: false}), + ...over, +}); + +describe('update_notice suppression', ()=>{ + const cases: Array<[string, Partial]> = [ + ['--json is in play', {json: true}], + ['--quiet is in play', {quiet: true}], + ['stderr is not a terminal', {tty: false}], + ['CI is set', {env: {...sandbox(), CI: '1'}}], + ['GITHUB_ACTIONS is set', {env: {...sandbox(), GITHUB_ACTIONS: 'true'}}], + ['the user switched it off', {env: {...sandbox(), REPLY_NO_UPDATE_CHECK: '1'}}], + ['this is a source checkout', {install: installed({kind: 'source', version: '0.0.0-development'})}], + ]; + + it.each(cases)('says nothing and makes no request when %s', async(_name, over)=>{ + expect(await update_notice(deps({release: forbidden, ...over}))).toBeUndefined(); + }); + + it('is not fooled by CI=false or CI=0', async()=>{ + const env = {...sandbox(), CI: 'false'}; + expect(await update_notice(deps({env}))).toBe('reply 0.4.0 → 0.5.0 available · run `reply install`'); + }); +}); + +describe('update_notice', ()=>{ + it('reports a newer release in one line', async()=>{ + expect(await update_notice(deps())) + .toBe('reply 0.4.0 → 0.5.0 available · run `reply install`'); + }); + + it('says nothing when the installed version is the newest', async()=>{ + expect(await update_notice(deps({ + release: async()=>({version: '0.4.0', tag: 'v0.4.0', url: 'u', prerelease: false}), + }))).toBeUndefined(); + }); + + it('answers from a fresh cache without asking GitHub', async()=>{ + const env = sandbox(); + write_check_cache({version: 1, channel: 'public', latest: '0.6.0', checked_at: NOW.toISOString()}, env); + expect(await update_notice(deps({env, release: forbidden, now: ()=>new Date('2026-08-01T20:00:00.000Z')}))) + .toBe('reply 0.4.0 → 0.6.0 available · run `reply install`'); + }); + + it('asks again once the cache has gone stale, and stores the answer', async()=>{ + const env = sandbox(); + write_check_cache({version: 1, channel: 'public', latest: '0.4.0', checked_at: NOW.toISOString()}, env); + const hint = await update_notice(deps({env, now: ()=>new Date('2026-08-03T12:00:00.000Z')})); + expect(hint).toBe('reply 0.4.0 → 0.5.0 available · run `reply install`'); + expect(read_check_cache(env)?.latest).toBe('0.5.0'); + }); + + it('ignores a cache written for the other channel', async()=>{ + const env = sandbox(); + write_check_cache({version: 1, channel: 'internal', latest: '9.9.9', checked_at: NOW.toISOString()}, env); + expect(await update_notice(deps({env}))).toBe('reply 0.4.0 → 0.5.0 available · run `reply install`'); + }); + + it('stays silent when the check fails, and records the failure', async()=>{ + const env = sandbox(); + const hint = await update_notice(deps({env, release: async()=>{throw new Error('offline');}})); + expect(hint).toBeUndefined(); + expect(read_check_cache(env)?.failed_at).toBe(NOW.toISOString()); + }); + + it('does not retry inside the failure backoff', async()=>{ + const env = sandbox(); + write_check_cache({version: 1, channel: 'public', failed_at: NOW.toISOString()}, env); + expect(await update_notice(deps({ + env, + release: forbidden, + now: ()=>new Date('2026-08-01T12:30:00.000Z'), + }))).toBeUndefined(); + }); + + it('keeps the last known version when a later check fails', async()=>{ + const env = sandbox(); + write_check_cache({version: 1, channel: 'public', latest: '0.5.0', checked_at: NOW.toISOString()}, env); + await update_notice(deps({ + env, + release: async()=>{throw new Error('offline');}, + now: ()=>new Date('2026-08-03T12:00:00.000Z'), + })); + expect(read_check_cache(env)).toMatchObject({latest: '0.5.0', failed_at: '2026-08-03T12:00:00.000Z'}); + }); + + it('never throws, even when the cache directory cannot be written', async()=>{ + const blocked = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'reply-notice-blocked-')), 'in-the-way'); + fs.writeFileSync(blocked, 'not a directory', 'utf8'); + expect(await update_notice(deps({ + env: {REPLY_CONFIG_DIR: blocked}, + release: async()=>{throw new Error('offline');}, + }))).toBeUndefined(); + }); +}); diff --git a/src/__tests__/selfupdate/npm.test.ts b/src/__tests__/selfupdate/npm.test.ts new file mode 100644 index 0000000..089399d --- /dev/null +++ b/src/__tests__/selfupdate/npm.test.ts @@ -0,0 +1,61 @@ +import {describe, it, expect} from 'vitest'; +import {run_npm_install, TAIL_BYTES} from '../../selfupdate/npm'; +import type {Npm_result, Npm_runner} from '../../selfupdate/npm'; +import {UsageError} from '../../utils/errors'; + +const runner = (result: Partial, seen?: string[][]): Npm_runner=>async args=>{ + seen?.push(args); + return {code: 0, stdout: '', stderr: '', ...result}; +}; + +describe('run_npm_install', ()=>{ + it('installs the latest of the given package globally', async()=>{ + const seen: string[][] = []; + const outcome = await run_npm_install('reply-cli', {run: runner({stdout: 'added 1 package'}, seen)}); + expect(seen).toEqual([['install', '-g', 'reply-cli@latest']]); + expect(outcome.ok).toBe(true); + expect(outcome.code).toBe(0); + }); + + it('quotes nothing and passes the scoped name through as one argument', async()=>{ + const seen: string[][] = []; + await run_npm_install('@reply-team/reply-cli', {run: runner({}, seen)}); + expect(seen[0][2]).toBe('@reply-team/reply-cli@latest'); + }); + + it('refuses to run npm for a package we do not publish', async()=>{ + await expect(run_npm_install('evil-package', {run: runner({})})).rejects.toThrow(UsageError); + await expect(run_npm_install('', {run: runner({})})).rejects.toMatchObject({exit_code: 2}); + }); + + it('reports a non-zero exit as a failure and keeps the output', async()=>{ + const outcome = await run_npm_install('reply-cli', { + run: runner({code: 1, stderr: 'npm error code E404'}), + }); + expect(outcome.ok).toBe(false); + expect(outcome.output_tail).toContain('E404'); + }); + + it('recognises a permission failure, which needs a different command', async()=>{ + const outcome = await run_npm_install('reply-cli', { + run: runner({code: 243, stderr: 'npm error code EACCES\nnpm error syscall mkdir'}), + }); + expect(outcome.permission_denied).toBe(true); + expect(outcome.npm_missing).toBe(false); + }); + + it('recognises npm not being on PATH', async()=>{ + const outcome = await run_npm_install('reply-cli', { + run: runner({code: 1, stderr: 'spawn npm ENOENT'}), + }); + expect(outcome.npm_missing).toBe(true); + }); + + it('keeps only the tail of a very long log', async()=>{ + const outcome = await run_npm_install('reply-cli', { + run: runner({code: 1, stdout: 'x'.repeat(TAIL_BYTES * 2), stderr: 'EACCES'}), + }); + expect(outcome.output_tail.length).toBe(TAIL_BYTES); + expect(outcome.output_tail.endsWith('EACCES')).toBe(true); + }); +}); diff --git a/src/__tests__/selfupdate/releases.test.ts b/src/__tests__/selfupdate/releases.test.ts new file mode 100644 index 0000000..522cf25 --- /dev/null +++ b/src/__tests__/selfupdate/releases.test.ts @@ -0,0 +1,90 @@ +import {describe, it, expect} from 'vitest'; +import {latest_release} from '../../selfupdate/releases'; +import type {Fetch_response} from '../../selfupdate/releases'; + +const ok = (body: unknown): Fetch_response=>({ok: true, status: 200, json: async()=>body}); +const fail = (status: number): Fetch_response=>({ok: false, status, json: async()=>({})}); +const release = (tag: string, prerelease = false)=>({ + tag_name: tag, + prerelease, + html_url: `https://github.com/reply-team/reply-cli/releases/tag/${tag}`, +}); + +describe('latest_release', ()=>{ + it('asks for the promoted release on the public channel', async()=>{ + const seen: string[] = []; + const found = await latest_release('public', { + fetch: async(url)=>{seen.push(url); return ok(release('v0.4.0'));}, + }); + expect(seen).toEqual(['https://api.github.com/repos/reply-team/reply-cli/releases/latest']); + expect(found).toEqual({ + version: '0.4.0', + tag: 'v0.4.0', + url: 'https://github.com/reply-team/reply-cli/releases/tag/v0.4.0', + prerelease: false, + }); + }); + + it('takes the newest release of any kind on the internal channel', async()=>{ + const seen: string[] = []; + const found = await latest_release('internal', { + fetch: async(url)=>{seen.push(url); return ok([release('v0.5.0', true), release('v0.4.0')]);}, + }); + expect(seen).toEqual(['https://api.github.com/repos/reply-team/reply-cli/releases?per_page=1']); + expect(found.version).toBe('0.5.0'); + expect(found.prerelease).toBe(true); + }); + + it('identifies itself, because GitHub rejects anonymous clients', async()=>{ + let sent: Record = {}; + await latest_release('public', { + fetch: async(_url, init)=>{sent = init?.headers ?? {}; return ok(release('v0.4.0'));}, + }); + expect(sent['User-Agent']).toMatch(/^reply-cli\//); + expect(sent.Accept).toBe('application/vnd.github+json'); + expect(sent['X-GitHub-Api-Version']).toBe('2022-11-28'); + }); + + it('aborts rather than hanging a command', async()=>{ + let signal: AbortSignal | undefined; + await latest_release('public', { + timeout_ms: 50, + fetch: async(_url, init)=>{signal = init?.signal; return ok(release('v0.4.0'));}, + }); + expect(signal).toBeInstanceOf(AbortSignal); + }); + + it('explains a rate limit rather than reporting a bare 403', async()=>{ + await expect(latest_release('public', {fetch: async()=>fail(403)})) + .rejects.toMatchObject({code: 'update.rate_limited', exit_code: 1}); + }); + + it('reports any other HTTP failure with its status', async()=>{ + await expect(latest_release('public', {fetch: async()=>fail(500)})) + .rejects.toMatchObject({code: 'update.http', detail: 'HTTP 500'}); + }); + + it('turns a transport failure into a runtime error', async()=>{ + await expect(latest_release('public', { + fetch: async()=>{throw new Error('getaddrinfo ENOTFOUND');}, + })).rejects.toMatchObject({code: 'update.unreachable', hint: 'getaddrinfo ENOTFOUND'}); + }); + + it('reports an empty release list instead of crashing on undefined', async()=>{ + await expect(latest_release('internal', {fetch: async()=>ok([])})) + .rejects.toMatchObject({code: 'update.no_release'}); + }); + + it('rejects a release whose tag is not a version', async()=>{ + await expect(latest_release('public', { + fetch: async()=>ok({tag_name: 'nightly', prerelease: false}), + })).rejects.toMatchObject({code: 'update.bad_release', detail: 'nightly'}); + }); + + it('falls back to the tag page when the release carries no url', async()=>{ + const found = await latest_release('public', { + fetch: async()=>ok({tag_name: 'v1.2.3', prerelease: false}), + }); + expect(found.url).toBe('https://github.com/reply-team/reply-cli/releases/tag/v1.2.3'); + }); +}); diff --git a/src/__tests__/selfupdate/routes.test.ts b/src/__tests__/selfupdate/routes.test.ts new file mode 100644 index 0000000..640d884 --- /dev/null +++ b/src/__tests__/selfupdate/routes.test.ts @@ -0,0 +1,67 @@ +import {describe, it, expect} from 'vitest'; +import path from 'path'; +import {REGISTRY_LINE, route_for} from '../../selfupdate/routes'; +import type {Install_info, Install_kind} from '../../selfupdate/types'; + +const DIR = path.join(path.parse(process.cwd()).root, 'usr', 'lib', 'node_modules', 'reply-cli'); + +const install = (over: Partial = {}): Install_info=>({ + kind: 'npm-global', + channel: 'public', + package_name: 'reply-cli', + version: '0.4.0', + module_dir: DIR, + ...over, +}); + +describe('route_for', ()=>{ + it('drives only a global npm install', ()=>{ + const route = route_for(install()); + expect(route.drivable).toBe(true); + expect(route.command).toBe('npm install -g reply-cli@latest'); + }); + + it('names the scoped package on the internal channel', ()=>{ + const route = route_for(install({channel: 'internal', package_name: '@reply-team/reply-cli'})); + expect(route.command).toBe('npm install -g @reply-team/reply-cli@latest'); + expect(route.note).toContain(REGISTRY_LINE); + expect(route.note).toContain('read:packages'); + }); + + it('leaves a project-local copy to its project', ()=>{ + const route = route_for(install({kind: 'npm-local'})); + expect(route.drivable).toBe(false); + expect(route.command).toBe('npm install reply-cli@latest'); + expect(route.note).toContain(DIR); + }); + + it('explains that npx has nothing installed to update', ()=>{ + const route = route_for(install({kind: 'npx'})); + expect(route.drivable).toBe(false); + expect(route.command).toBe('npx reply-cli@latest'); + expect(route.note).toMatch(/newest published version on each run/); + }); + + it('sends a source checkout to git', ()=>{ + const route = route_for(install({kind: 'source', channel: 'internal', package_name: '@reply-team/reply-cli'})); + expect(route.drivable).toBe(false); + expect(route.command).toBe('git pull && npm ci && npm run build'); + }); + + it('falls back to the public package when the name is unknown', ()=>{ + const route = route_for(install({kind: 'unknown', package_name: ''})); + expect(route.drivable).toBe(false); + expect(route.command).toBe('npm install -g reply-cli@latest'); + expect(route.note).toContain(DIR); + }); + + it('gives every kind a command and a note', ()=>{ + const kinds: Install_kind[] = ['npm-global', 'npm-local', 'npx', 'source', 'unknown']; + for (const kind of kinds) + { + const route = route_for(install({kind})); + expect(route.command, kind).toBeTruthy(); + expect(route.note, kind).toBeTruthy(); + } + }); +}); diff --git a/src/__tests__/selfupdate/semver.test.ts b/src/__tests__/selfupdate/semver.test.ts new file mode 100644 index 0000000..4323ce1 --- /dev/null +++ b/src/__tests__/selfupdate/semver.test.ts @@ -0,0 +1,60 @@ +import {describe, it, expect} from 'vitest'; +import {compare_versions, is_newer, parse_version} from '../../selfupdate/semver'; +import {RuntimeError} from '../../utils/errors'; + +describe('parse_version', ()=>{ + it('accepts a leading v, as release tags carry one', ()=>{ + expect(parse_version('v0.4.0')).toEqual({major: 0, minor: 4, patch: 0, pre: []}); + }); + + it('splits a pre-release into identifiers', ()=>{ + expect(parse_version('1.2.3-rc.2')?.pre).toEqual(['rc', '2']); + }); + + it('ignores build metadata, which semver excludes from precedence', ()=>{ + expect(parse_version('1.2.3+build.7')?.pre).toEqual([]); + }); + + it('returns undefined for anything that is not a version', ()=>{ + expect(parse_version('latest')).toBeUndefined(); + expect(parse_version('1.2')).toBeUndefined(); + expect(parse_version('')).toBeUndefined(); + }); +}); + +describe('compare_versions', ()=>{ + it.each([ + ['0.4.0', '0.5.0', -1], + ['0.5.0', '0.4.0', 1], + ['0.4.0', 'v0.4.0', 0], + ['1.0.0', '0.9.9', 1], + ['1.1.0', '1.0.9', 1], + ['1.0.0-rc.1', '1.0.0', -1], + ['1.0.0-rc.2', '1.0.0-rc.10', -1], + ['1.0.0-alpha', '1.0.0-beta', -1], + ['1.0.0-rc.1', '1.0.0-rc.1.1', -1], + ['1.0.0-1', '1.0.0-alpha', -1], + ])('%s vs %s -> %i', (a, b, expected)=>{ + expect(compare_versions(a as string, b as string)).toBe(expected); + }); + + it('refuses to compare something that is not a version', ()=>{ + expect(()=>compare_versions('0.4.0', 'nightly')).toThrow(RuntimeError); + }); +}); + +describe('is_newer', ()=>{ + it('treats a development build as older than any release', ()=>{ + expect(is_newer('0.5.0', '0.0.0-development')).toBe(true); + }); + + it('never claims an update when the installed copy is ahead', ()=>{ + expect(is_newer('0.4.0', '0.5.0')).toBe(false); + expect(is_newer('0.5.0', '0.5.0')).toBe(false); + }); + + it('fails closed when either side is unparseable', ()=>{ + expect(is_newer('0.5.0', 'unknown')).toBe(false); + expect(is_newer('main', '0.4.0')).toBe(false); + }); +}); diff --git a/src/commands/install.ts b/src/commands/install.ts new file mode 100644 index 0000000..ca47b48 --- /dev/null +++ b/src/commands/install.ts @@ -0,0 +1,107 @@ +import {Command} from 'commander'; +import {PROGRAM_NAME} from '../config'; +import {run_install} from '../selfupdate/install'; +import {RuntimeError} from '../utils/errors'; +import {info, print, success, warn, type Print_opts} from '../utils/output'; +import type {Install_report} from '../selfupdate/install'; + +type Install_cli_opts = { + dryRun?: boolean; + json?: boolean; + pretty?: boolean; +}; + +const wants_json = (o: Install_cli_opts): boolean=>Boolean(o.json || o.pretty); +const print_opts = (o: Install_cli_opts): Print_opts=>({json: o.json, pretty: o.pretty}); + +// npm's own output would be status, not data, so nothing here writes to stdout +// unless --json was asked for. +const human_report = (report: Install_report, dry_run: boolean): void=>{ + if (report.action === 'current') + { + success(`${PROGRAM_NAME} ${report.current} is the newest release`); + return; + } + if (report.action === 'updated') + { + success(`${PROGRAM_NAME} ${report.current} → ${report.latest} installed`); + return; + } + if (report.action === 'failed') + { + warn(`Could not update automatically: ${report.detail}.`); + if (report.npm_output) + { + info(' npm said:'); + for (const line of report.npm_output.split('\n')) + { + info(` ${line}`); + } + } + } + else + { + warn(`${PROGRAM_NAME} ${report.current} → ${report.latest} available` + + `${dry_run ? ' (nothing changed: --dry-run)' : ''}`); + info(` ${report.note}`); + } + info(' Run this instead:'); + info(''); + info(` ${report.command}`); +}; + +const handle_install = async(opts: Install_cli_opts): Promise=>{ + const dry_run = opts.dryRun === true; + if (!wants_json(opts)) + { + info(`Checking for a newer ${PROGRAM_NAME} release…`); + } + const report = await run_install({dry_run}, { + // Silent under --json, where the only output allowed is the report. + progress: wants_json(opts) ? undefined : (message: string)=>info(` ${message}`), + }); + + if (wants_json(opts)) + { + print(report, print_opts(opts)); + } + else + { + human_report(report, dry_run); + } + + // The user asked to be up to date and is not, so the exit code has to say + // so — a script that runs `install` must be able to tell. + if (report.action === 'manual' || report.action === 'failed') + { + throw new RuntimeError(`${PROGRAM_NAME} was not updated.`, { + code: report.action === 'failed' ? 'update.npm_failed' : 'update.manual', + detail: report.detail, + hint: report.command, + }); + } +}; + +const install_command = new Command('install') + // Accepted for muscle memory from other CLIs; `install` is the documented + // name, so users have one command to learn rather than two. + .alias('update') + .description('Update the CLI to the newest release, or say exactly how') + .option('--dry-run', 'Report what would happen and change nothing') + .addHelpText('after', ` +Runs npm for you when the CLI was installed globally with npm. Anything else — +a project-local copy, npx, a source checkout — is left untouched and reported +with the command that fits it. + +Exits 1 when an update exists and was not applied, so --dry-run works as a check. + +Examples: + ${PROGRAM_NAME} install + ${PROGRAM_NAME} install --dry-run + ${PROGRAM_NAME} install --json`) + .action(async function(this: Command) { + const o = this.optsWithGlobals(); + await handle_install({dryRun: o.dryRun, json: o.json, pretty: o.pretty}); + }); + +export {install_command, handle_install}; diff --git a/src/config.ts b/src/config.ts index 891355e..cfabc2a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -90,9 +90,14 @@ const config_file = (env: Env = process.env): string=> const skills_file = (env: Env = process.env): string=> path.join(config_dir(env), 'skills.json'); +// When we last asked whether a newer release exists (see selfupdate/cache.ts). +const update_check_file = (env: Env = process.env): string=> + path.join(config_dir(env), 'update-check.json'); + export { PROGRAM_NAME, APP_NAME, env_prefix, env_var, get_env, cli_version, user_agent, default_config_dir, config_dir, credentials_file, config_file, skills_file, + update_check_file, }; export type {Env}; diff --git a/src/index.ts b/src/index.ts index 907c27c..50862fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,8 +6,10 @@ import {profile_command} from './commands/profile'; import {team_command} from './commands/team'; import {api_command} from './commands/api'; import {skills_command} from './commands/skills'; +import {install_command} from './commands/install'; +import {update_notice} from './selfupdate/notice'; import {CliError} from './utils/errors'; -import {set_quiet} from './utils/output'; +import {info, set_quiet} from './utils/output'; // Route every command through commander's throwing mode so usage errors reach // our handler and map to exit code 2 (vs 1 for runtime/API failures). @@ -46,6 +48,7 @@ const build_program = (): Command=>{ program.addCommand(team_command); program.addCommand(api_command); program.addCommand(skills_command); + program.addCommand(install_command); program.addHelpText('after', ` Credential precedence: @@ -83,6 +86,12 @@ Raw API (agent/CI escape hatch) — docs: https://docs.reply.io/api-reference/in ${PROGRAM_NAME} api /v3/whoami --verbose # full req/resp to stderr (creds redacted) Prints {code, data}; exits non-zero on HTTP >= 400. +Keeping the CLI current: + ${PROGRAM_NAME} install # update to the newest release, or say how + ${PROGRAM_NAME} install --dry-run # report only; exits 1 when an update exists + ${PROGRAM_NAME} --version # mentions a newer release when there is one + ${PREFIX}_NO_UPDATE_CHECK=1 # never check for a newer release + Configuration (env vars): ${PREFIX}_API_KEY API key used as the bearer credential ${PREFIX}_PROFILE Profile to use (same as --profile) @@ -98,6 +107,7 @@ Examples: ${PROGRAM_NAME} api /v3/sequences ${PROGRAM_NAME} skills install ${PROGRAM_NAME} skills list --json + ${PROGRAM_NAME} install `); return program; @@ -112,7 +122,25 @@ const main = async(): Promise=>{ await program.parseAsync(process.argv); }; -void main().catch((error: unknown)=>{ +// --version is the only command allowed to check for a newer release. The +// version itself is already on stdout by the time we get here; the hint is +// status, so it follows on stderr — and only when update_notice allows it. +const version_hint = async(): Promise=>{ + try { + const hint = await update_notice({ + json: wants_json(), + quiet: process.argv.includes('-q') || process.argv.includes('--quiet'), + }); + if (hint) + { + info(hint); + } + } catch { + // A hint is never worth failing --version over. + } +}; + +void main().catch(async(error: unknown)=>{ if (error instanceof CommanderError) { // commander has already written help/usage text; help & version exit 0, @@ -120,6 +148,10 @@ void main().catch((error: unknown)=>{ const ok = error.code === 'commander.helpDisplayed' || error.code === 'commander.version' || error.code === 'commander.help'; + if (error.code === 'commander.version') + { + await version_hint(); + } process.exit(ok ? 0 : 2); } if (error instanceof CliError) diff --git a/src/selfupdate/cache.ts b/src/selfupdate/cache.ts new file mode 100644 index 0000000..eb12fae --- /dev/null +++ b/src/selfupdate/cache.ts @@ -0,0 +1,76 @@ +import fs from 'fs'; +import path from 'path'; +import {update_check_file} from '../config'; +import type {Env} from '../config'; +import type {Channel} from './types'; + +// Unlike the skills journal, a damaged file here is not an error: this caches +// the answer to one question. Anything unreadable means "never checked", which +// costs a single HTTP request and repairs itself on the next write. + +type Check_cache = { + version: 1; + channel: Channel; + latest?: string; + checked_at?: string; + // Set when the last attempt failed, so a machine that has been offline all + // day does not retry on every `--version`. + failed_at?: string; +}; + +const SUCCESS_TTL_MS = 24 * 60 * 60 * 1000; +const FAILURE_TTL_MS = 60 * 60 * 1000; + +const read_check_cache = (env?: Env): Check_cache | undefined=>{ + let raw: string; + try { + raw = fs.readFileSync(update_check_file(env), 'utf8'); + } catch { + return undefined; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + const doc = parsed as Check_cache; + if (!doc || typeof doc !== 'object' || Array.isArray(doc) + || (doc.channel !== 'public' && doc.channel !== 'internal')) + { + return undefined; + } + return doc; +}; + +const write_check_cache = (entry: Check_cache, env?: Env): void=>{ + const file = update_check_file(env); + fs.mkdirSync(path.dirname(file), {recursive: true, mode: 0o700}); + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(entry, null, 2) + '\n', 'utf8'); + fs.renameSync(tmp, file); +}; + +// A cache written for one channel says nothing about the other, so switching +// package (public <-> internal) invalidates it outright. +const cache_is_fresh = (entry: Check_cache | undefined, channel: Channel, now: Date): boolean=>{ + if (!entry || entry.channel !== channel) + { + return false; + } + const stamp = entry.failed_at ?? entry.checked_at; + if (!stamp) + { + return false; + } + const at = Date.parse(stamp); + if (Number.isNaN(at)) + { + return false; + } + const ttl = entry.failed_at ? FAILURE_TTL_MS : SUCCESS_TTL_MS; + return now.getTime() - at < ttl; +}; + +export {read_check_cache, write_check_cache, cache_is_fresh, SUCCESS_TTL_MS, FAILURE_TTL_MS}; +export type {Check_cache}; diff --git a/src/selfupdate/detect.ts b/src/selfupdate/detect.ts new file mode 100644 index 0000000..1cc6433 --- /dev/null +++ b/src/selfupdate/detect.ts @@ -0,0 +1,97 @@ +import fs from 'fs'; +import path from 'path'; +import type {Channel, Install_info, Install_kind} from './types'; + +// Which package this build is published as. The public one is unscoped on +// npmjs; the internal one lives on GitHub Packages and needs a token. +const PUBLIC_PACKAGE = 'reply-cli'; +const INTERNAL_PACKAGE = '@reply-team/reply-cli'; + +type Package_json = {name?: string; version?: string}; + +type Detect_deps = { + module_dir?: string; + cwd?: string; + read_package?: (dir: string)=>Package_json | undefined; +}; + +const read_package_json = (dir: string): Package_json | undefined=>{ + try { + return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) as Package_json; + } catch { + // Missing or malformed: the caller treats an unnamed package as an + // install layout we do not recognise, which is the safe answer. + return undefined; + } +}; + +// The directory holding dist/, resolved through symlinks: an npm global bin +// entry is a link, and the link's own path says nothing about the install. +// From dist/selfupdate/detect.js the package root is two levels up. +const default_module_dir = (): string=>{ + const dir = path.join(__dirname, '..', '..'); + try { + return fs.realpathSync(dir); + } catch { + return dir; + } +}; + +// Case sensitivity follows the platform, which is what path.relative already +// does — the same reason the skills journal compares paths this way. +const inside = (parent: string, child: string): boolean=>{ + const rel = path.relative(parent, child); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); +}; + +// The directory whose node_modules holds this package — its first one, so a +// transitive copy under a nested node_modules is still attributed to the +// project that owns the tree. +const owning_project = (module_dir: string, first_node_modules: number): string=>{ + const depth = module_dir.split(/[\\/]+/).length - first_node_modules; + return path.resolve(module_dir, ...Array(depth).fill('..')); +}; + +const classify = (module_dir: string, cwd: string): Install_kind=>{ + const parts = module_dir.split(/[\\/]+/); + // Checked before node_modules on purpose: an npx cache contains both. + if (parts.includes('_npx')) + { + return 'npx'; + } + const at = parts.indexOf('node_modules'); + if (at === -1) + { + return 'source'; + } + // Local means node resolves it by walking up from the working directory, + // so the owning project must BE the working directory or an ancestor of + // it. Asking only whether module_dir sits under cwd gets this backwards + // for a version-manager install — ~/.nvm/.../node_modules is under $HOME, + // and a user standing in $HOME would see their global install called + // project-local, with the update refused for a project that isn't there. + const project = owning_project(module_dir, at); + return project === cwd || inside(project, cwd) ? 'npm-local' : 'npm-global'; +}; + +const how_installed = (deps: Detect_deps = {}): Install_info=>{ + const module_dir = deps.module_dir ?? default_module_dir(); + const cwd = deps.cwd ?? process.cwd(); + const pkg = (deps.read_package ?? read_package_json)(module_dir); + const package_name = typeof pkg?.name === 'string' ? pkg.name : ''; + const version = typeof pkg?.version === 'string' && pkg.version ? pkg.version : '0.0.0'; + // An unrecognised package name outranks whatever the path suggests: a fork + // or a vendored copy is not something we should offer to replace. + const known = package_name === PUBLIC_PACKAGE || package_name === INTERNAL_PACKAGE; + const channel: Channel = package_name === INTERNAL_PACKAGE ? 'internal' : 'public'; + return { + kind: known ? classify(module_dir, cwd) : 'unknown', + channel, + package_name, + version, + module_dir, + }; +}; + +export {how_installed, PUBLIC_PACKAGE, INTERNAL_PACKAGE}; +export type {Detect_deps, Package_json}; diff --git a/src/selfupdate/install.ts b/src/selfupdate/install.ts new file mode 100644 index 0000000..5569e2c --- /dev/null +++ b/src/selfupdate/install.ts @@ -0,0 +1,131 @@ +import {write_check_cache} from './cache'; +import {how_installed} from './detect'; +import {latest_release} from './releases'; +import {run_npm_install} from './npm'; +import {route_for} from './routes'; +import {is_newer} from './semver'; +import type {Env} from '../config'; +import type {Npm_outcome} from './npm'; +import type {Channel, Install_info, Install_kind, Release} from './types'; + +// `reply install` is the one command a user has to remember: make sure what I +// have is current. It resolves the newest release of the channel this copy +// belongs to, updates when the install is ours to drive, and otherwise says +// exactly what to run — never touching an install it does not manage. + +type Install_action = 'updated' | 'current' | 'manual' | 'failed'; + +type Install_report = { + current: string; + latest: string; + up_to_date: boolean; + channel: Channel; + install: {kind: Install_kind; package: string; path: string}; + action: Install_action; + command: string; + note: string; + detail?: string; + // Present only on a failed run: the tail of what npm printed. + npm_output?: string; +}; + +type Install_deps = { + install?: Install_info; + release?: (channel: Channel)=>Promise; + run_npm?: (package_name: string)=>Promise; + env?: Env; + now?: ()=>Date; + platform?: NodeJS.Platform; + // Called once, just before npm is spawned. npm buffers for as long as it + // takes, and a silent half-minute reads as a hung command. + progress?: (message: string)=>void; +}; + +// Elevation differs per platform and is never done for the user: we print what +// they would have to run, and leave the decision with them. +const elevated = (command: string, platform: NodeJS.Platform): string=> + platform === 'win32' ? command : `sudo ${command}`; + +const failure_detail = (outcome: Npm_outcome, platform: NodeJS.Platform): string=>{ + if (outcome.npm_missing) + { + return 'npm is not on PATH'; + } + if (outcome.permission_denied) + { + // Windows has no sudo to prepend, so the command alone would leave the + // user with nothing to change on a second attempt. + const remedy = platform === 'win32' ? '; try an elevated terminal' : ''; + return `npm exited with code ${outcome.code} (permission denied${remedy})`; + } + return `npm exited with code ${outcome.code}`; +}; + +const run_install = async( + opts: {dry_run?: boolean} = {}, + deps: Install_deps = {}, +): Promise=>{ + const install = deps.install ?? how_installed(); + const platform = deps.platform ?? process.platform; + const release = await (deps.release ?? latest_release)(install.channel); + const now = (deps.now ?? (()=>new Date()))(); + try { + write_check_cache({ + version: 1, + channel: install.channel, + latest: release.version, + checked_at: now.toISOString(), + }, deps.env); + } catch { + // A cache we could not write costs one HTTP request later. It is never + // a reason to fail an update the user asked for. + } + + const route = route_for(install); + // Field order is part of what `--json` readers see, so it is built in one + // place rather than spread differently per branch. + const report = ( + action: Install_action, + command: string, + detail?: string, + npm_output?: string, + ): Install_report=>({ + current: install.version, + latest: release.version, + up_to_date: action === 'current', + action, + channel: install.channel, + install: {kind: install.kind, package: install.package_name, path: install.module_dir}, + command, + note: route.note, + ...(detail ? {detail} : {}), + ...(npm_output ? {npm_output} : {}), + }); + + if (!is_newer(release.version, install.version)) + { + return report('current', route.command); + } + if (opts.dry_run || !route.drivable) + { + return report('manual', route.command); + } + + deps.progress?.(`${install.version} → ${release.version}, updating with npm…`); + const outcome = await (deps.run_npm ?? (pkg=>run_npm_install(pkg)))(install.package_name); + if (outcome.ok) + { + return report('updated', route.command); + } + return report( + 'failed', + outcome.permission_denied ? elevated(route.command, platform) : route.command, + failure_detail(outcome, platform), + // npm's own words are what makes an unexpected failure diagnosable; + // carried only when it failed, so a normal run stays quiet. + outcome.output_tail || undefined, + ); +}; + +export {run_install}; +export type {Install_report, Install_action, Install_deps}; diff --git a/src/selfupdate/notice.ts b/src/selfupdate/notice.ts new file mode 100644 index 0000000..2ff217e --- /dev/null +++ b/src/selfupdate/notice.ts @@ -0,0 +1,87 @@ +import {PROGRAM_NAME} from '../config'; +import {cache_is_fresh, read_check_cache, write_check_cache} from './cache'; +import {how_installed} from './detect'; +import {latest_release} from './releases'; +import {is_newer} from './semver'; +import type {Env} from '../config'; +import type {Channel, Install_info, Release} from './types'; + +// The one place in the CLI that may touch the network without being asked, so +// it is also the one place with a hard rule: never throw, never run unless a +// human is looking at a terminal, and never cost more than the client's own +// timeout. Everything else in the CLI stays offline. + +type Notice_deps = { + json?: boolean; + quiet?: boolean; + tty?: boolean; + env?: Env; + now?: ()=>Date; + install?: Install_info; + release?: (channel: Channel)=>Promise; +}; + +const truthy = (value: string | undefined): boolean=> + value !== undefined && value !== '' && value !== '0' && value.toLowerCase() !== 'false'; + +const suppressed = (deps: Notice_deps, install: Install_info): boolean=>{ + const env = deps.env ?? process.env; + // A checkout sits on 0.0.0-development and belongs to whoever cuts the + // releases; telling them about one is noise. + return Boolean(deps.json) + || Boolean(deps.quiet) + || (deps.tty ?? process.stderr.isTTY) !== true + || truthy(env.CI) + || truthy(env.GITHUB_ACTIONS) + || truthy(env.REPLY_NO_UPDATE_CHECK) + || install.kind === 'source'; +}; + +const hint_for = (current: string, latest: string): string=> + `${PROGRAM_NAME} ${current} → ${latest} available · run \`${PROGRAM_NAME} install\``; + +const update_notice = async(deps: Notice_deps = {}): Promise=>{ + const install = deps.install ?? how_installed(); + if (suppressed(deps, install)) + { + return undefined; + } + const now = (deps.now ?? (()=>new Date()))(); + const cached = read_check_cache(deps.env); + if (cache_is_fresh(cached, install.channel, now)) + { + const latest = cached?.latest; + return latest && is_newer(latest, install.version) + ? hint_for(install.version, latest) + : undefined; + } + try { + const release = await (deps.release ?? latest_release)(install.channel); + write_check_cache({ + version: 1, + channel: install.channel, + latest: release.version, + checked_at: now.toISOString(), + }, deps.env); + return is_newer(release.version, install.version) + ? hint_for(install.version, release.version) + : undefined; + } catch { + // Offline, rate-limited, or slow: record the attempt so we back off, + // and say nothing. A hint is never worth a delay or an error. + try { + write_check_cache({ + version: 1, + channel: install.channel, + latest: cached?.latest, + failed_at: now.toISOString(), + }, deps.env); + } catch { + // Nothing left to do — a cache we cannot write only costs a retry. + } + return undefined; + } +}; + +export {update_notice, hint_for}; +export type {Notice_deps}; diff --git a/src/selfupdate/npm.ts b/src/selfupdate/npm.ts new file mode 100644 index 0000000..140dc9e --- /dev/null +++ b/src/selfupdate/npm.ts @@ -0,0 +1,72 @@ +import {execFile} from 'child_process'; +import {INTERNAL_PACKAGE, PUBLIC_PACKAGE} from './detect'; +import {UsageError} from '../utils/errors'; + +// Runs npm on the user's behalf so they never have to recall the command. +// Only ever invoked for a global npm install of a package we publish — see +// route_for() — which is also why passing the argument vector through a shell +// on Windows is safe: nothing here comes from user input. + +type Npm_result = {code: number; stdout: string; stderr: string}; + +// The single seam that keeps every test in this suite from spawning npm. +type Npm_runner = (args: string[])=>Promise; + +type Npm_outcome = { + ok: boolean; + code: number; + // Last 8 KB of npm's combined output — enough to classify the failure and + // to show the user why it failed, without holding a whole install log. + output_tail: string; + permission_denied: boolean; + npm_missing: boolean; +}; + +const TAIL_BYTES = 8000; + +// npm is a .cmd on Windows, which Node refuses to spawn directly since the +// CVE-2024-27980 fix — hence the shell there, and only there. +const default_npm_runner: Npm_runner = args=>new Promise(resolve=>{ + execFile('npm', args, { + encoding: 'utf8', + windowsHide: true, + shell: process.platform === 'win32', + maxBuffer: 10 * 1024 * 1024, + }, (error, stdout, stderr)=>{ + const code = error && typeof (error as {code?: unknown}).code === 'number' + ? (error as unknown as {code: number}).code + : (error ? 1 : 0); + const message = error ? `${error.message}\n` : ''; + resolve({code, stdout: stdout ?? '', stderr: `${message}${stderr ?? ''}`}); + }); +}); + +const tail_of = (result: Npm_result): string=> + `${result.stdout}\n${result.stderr}`.trim().slice(-TAIL_BYTES); + +const run_npm_install = async( + package_name: string, + deps: {run?: Npm_runner} = {}, +): Promise=>{ + if (package_name !== PUBLIC_PACKAGE && package_name !== INTERNAL_PACKAGE) + { + // Refusing here rather than in the caller keeps the guarantee local to + // the one function that can spawn a process. + throw new UsageError(`Refusing to run npm for an unrecognised package: ${package_name || '(none)'}`, { + code: 'update.unknown_package', + }); + } + const run = deps.run ?? default_npm_runner; + const result = await run(['install', '-g', `${package_name}@latest`]); + const output_tail = tail_of(result); + return { + ok: result.code === 0, + code: result.code, + output_tail, + permission_denied: /EACCES|EPERM|permission denied/i.test(output_tail), + npm_missing: /ENOENT|not recognized as an internal|command not found/i.test(output_tail), + }; +}; + +export {run_npm_install, default_npm_runner, TAIL_BYTES}; +export type {Npm_runner, Npm_result, Npm_outcome}; diff --git a/src/selfupdate/releases.ts b/src/selfupdate/releases.ts new file mode 100644 index 0000000..ce0ca0b --- /dev/null +++ b/src/selfupdate/releases.ts @@ -0,0 +1,119 @@ +import {user_agent} from '../config'; +import {RuntimeError} from '../utils/errors'; +import {parse_version} from './semver'; +import type {Channel, Release} from './types'; + +// The version of record is the git tag, not a registry: package.json in the +// repository is deliberately 0.0.0-development and semantic-release stamps the +// real version at publish time. Asking GitHub is also one code path for both +// channels, needs no token, and costs no npm subprocess. +// +// Safe for the public channel because the publish workflow flips a Release to +// "latest" only after `npm publish` succeeds — a hit here means npm has it. + +const REPO = 'reply-team/reply-cli'; +const API = 'https://api.github.com'; + +// Long enough for a cold TLS handshake, short enough that `reply --version` +// never feels like it hung. A check that cannot answer fast is discarded. +const TIMEOUT_MS = 1500; + +type Fetch_response = { + ok: boolean; + status: number; + json: ()=>Promise; +}; + +type Fetch_like = ( + url: string, + init?: {headers?: Record; signal?: AbortSignal}, +) => Promise; + +type Releases_deps = { + fetch?: Fetch_like; + timeout_ms?: number; +}; + +type Release_body = { + tag_name?: string; + prerelease?: boolean; + html_url?: string; +}; + +const headers = (): Record=>({ + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + // GitHub rejects API requests that do not identify themselves. + 'User-Agent': user_agent(), +}); + +// /releases/latest excludes pre-releases by design, which is exactly the +// promoted public build. The internal stream IS the pre-release stream, so it +// has to come off the unfiltered list. +const url_for = (channel: Channel): string=>channel === 'public' + ? `${API}/repos/${REPO}/releases/latest` + : `${API}/repos/${REPO}/releases?per_page=1`; + +const latest_release = async(channel: Channel, deps: Releases_deps = {}): Promise=>{ + const call = deps.fetch ?? (globalThis.fetch as unknown as Fetch_like); + const url = url_for(channel); + let response: Fetch_response; + try { + response = await call(url, { + headers: headers(), + signal: AbortSignal.timeout(deps.timeout_ms ?? TIMEOUT_MS), + }); + } catch (e) { + throw new RuntimeError('Could not reach GitHub to check for a newer release.', { + code: 'update.unreachable', + detail: url, + hint: (e as Error).message, + }); + } + if (!response.ok) + { + if (response.status === 403 || response.status === 429) + { + throw new RuntimeError('GitHub is rate-limiting the update check.', { + code: 'update.rate_limited', + detail: `HTTP ${response.status}`, + hint: 'Unauthenticated requests are capped at 60 an hour. Try again later.', + }); + } + throw new RuntimeError('GitHub did not return the release list.', { + code: 'update.http', + detail: `HTTP ${response.status}`, + hint: url, + }); + } + const body = await response.json(); + const found = (channel === 'public' + ? body + : (Array.isArray(body) ? body[0] : undefined)) as Release_body | undefined; + if (!found || typeof found !== 'object') + { + throw new RuntimeError('GitHub reported no releases for this channel.', { + code: 'update.no_release', + detail: url, + }); + } + const tag = typeof found.tag_name === 'string' ? found.tag_name : ''; + if (!parse_version(tag)) + { + throw new RuntimeError('The newest release is not tagged with a version.', { + code: 'update.bad_release', + detail: tag || '(no tag)', + }); + } + return { + version: tag.replace(/^v/, ''), + tag, + url: typeof found.html_url === 'string' && found.html_url + ? found.html_url + : `https://github.com/${REPO}/releases/tag/${tag}`, + prerelease: found.prerelease === true, + }; +}; + +export {latest_release, REPO, TIMEOUT_MS}; +export type {Fetch_like, Fetch_response, Releases_deps}; diff --git a/src/selfupdate/routes.ts b/src/selfupdate/routes.ts new file mode 100644 index 0000000..d763919 --- /dev/null +++ b/src/selfupdate/routes.ts @@ -0,0 +1,54 @@ +import {PROGRAM_NAME} from '../config'; +import {PUBLIC_PACKAGE} from './detect'; +import type {Install_info, Install_kind, Route} from './types'; + +// What `reply install` may do about each kind of install, and what it tells the +// user when the answer is "nothing". Only a global npm install is ours to +// drive; everything else belongs to a project, a cache, or a checkout, and +// touching it would be the corruption this command exists to avoid. + +// The internal package is not on npmjs, and this line is the piece people miss. +const REGISTRY_LINE = '@reply-team:registry=https://npm.pkg.github.com'; + +const internal_hint = (): string=> + `The internal package needs "${REGISTRY_LINE}" in your .npmrc and a token with read:packages.`; + +const route_for = (install: Install_info): Route=>{ + // An unrecognised copy still gets a useful command: the public package is + // what a stranger to this layout most likely wants. + const pkg = install.package_name || PUBLIC_PACKAGE; + const suffix = install.channel === 'internal' ? ` ${internal_hint()}` : ''; + const routes: Record = { + 'npm-global': { + drivable: true, + command: `npm install -g ${pkg}@latest`, + note: `Installed globally with npm (${install.module_dir}).${suffix}`, + }, + 'npm-local': { + drivable: false, + command: `npm install ${pkg}@latest`, + note: `Installed inside a project (${install.module_dir}), so it is that project's to update.` + + ` Run the command there.${suffix}`, + }, + npx: { + drivable: false, + command: `npx ${pkg}@latest`, + note: 'Running through npx, which resolves the newest published version on each run —' + + ' there is no installed copy to update.', + }, + source: { + drivable: false, + command: 'git pull && npm ci && npm run build', + note: `Running from a source checkout (${install.module_dir}).`, + }, + unknown: { + drivable: false, + command: `npm install -g ${pkg}@latest`, + note: `Could not tell how this copy was installed (${install.module_dir}),` + + ` so ${PROGRAM_NAME} will not change it.${suffix}`, + }, + }; + return routes[install.kind]; +}; + +export {route_for, REGISTRY_LINE}; diff --git a/src/selfupdate/semver.ts b/src/selfupdate/semver.ts new file mode 100644 index 0000000..c6150a2 --- /dev/null +++ b/src/selfupdate/semver.ts @@ -0,0 +1,99 @@ +import {RuntimeError} from '../utils/errors'; + +// Just enough semver to answer "is the published version newer than mine". +// A dependency for three comparisons would be the first runtime dependency +// added to this CLI since it shipped. + +type Version = {major: number; minor: number; patch: number; pre: string[]}; + +const PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/; + +// Accepts a leading `v` because release tags carry one, and drops build +// metadata, which semver excludes from precedence. +const parse_version = (raw: string): Version | undefined=>{ + const m = PATTERN.exec(String(raw).trim()); + if (!m) + { + return undefined; + } + return { + major: Number(m[1]), + minor: Number(m[2]), + patch: Number(m[3]), + pre: m[4] ? m[4].split('.') : [], + }; +}; + +const sign = (n: number): number=>(n === 0 ? 0 : n < 0 ? -1 : 1); + +// Semver precedence for pre-release identifiers: a release outranks its own +// pre-releases, numeric identifiers compare numerically and rank below +// alphanumeric ones, and when everything else ties the longer list wins. +const compare_pre = (a: string[], b: string[]): number=>{ + if (!a.length || !b.length) + { + return a.length === b.length ? 0 : (a.length ? -1 : 1); + } + for (let i = 0; i < Math.max(a.length, b.length); i++) + { + const x = a[i]; + const y = b[i]; + if (x === undefined) + { + return -1; + } + if (y === undefined) + { + return 1; + } + const x_numeric = /^\d+$/.test(x); + const y_numeric = /^\d+$/.test(y); + if (x_numeric && y_numeric) + { + const d = sign(Number(x) - Number(y)); + if (d) + { + return d; + } + continue; + } + if (x_numeric !== y_numeric) + { + return x_numeric ? -1 : 1; + } + if (x !== y) + { + return x < y ? -1 : 1; + } + } + return 0; +}; + +const compare_versions = (a: string, b: string): number=>{ + const left = parse_version(a); + const right = parse_version(b); + if (!left || !right) + { + throw new RuntimeError('Could not compare version strings.', { + code: 'update.bad_version', + detail: !left ? a : b, + }); + } + return sign(left.major - right.major) + || sign(left.minor - right.minor) + || sign(left.patch - right.patch) + || compare_pre(left.pre, right.pre); +}; + +// Fails closed: an unparseable version on either side means we say nothing, +// rather than pushing a user toward something we cannot reason about. +const is_newer = (candidate: string, current: string): boolean=>{ + if (!parse_version(candidate) || !parse_version(current)) + { + return false; + } + return compare_versions(candidate, current) > 0; +}; + +export {parse_version, compare_versions, is_newer}; +export type {Version}; diff --git a/src/selfupdate/types.ts b/src/selfupdate/types.ts new file mode 100644 index 0000000..6f26b72 --- /dev/null +++ b/src/selfupdate/types.ts @@ -0,0 +1,38 @@ +// How this copy of the CLI got onto the machine. Only `npm-global` is one we +// can safely drive ourselves; every other kind is managed by something else, +// so `install` reports the command that fits it and spawns nothing. +type Install_kind = 'npm-global' | 'npm-local' | 'npx' | 'source' | 'unknown'; + +// Which published stream this build belongs to. Read from the package name, +// never guessed: sending a public-channel user to GitHub Packages points them +// at a registry they cannot read. +type Channel = 'public' | 'internal'; + +type Install_info = { + kind: Install_kind; + channel: Channel; + package_name: string; + version: string; + // Real path of the directory holding dist/, resolved through symlinks. + module_dir: string; +}; + +// One published release, as the CLI cares about it. `version` is the tag +// without its leading v — the tag is the version of record, because the +// repository's package.json is deliberately 0.0.0-development. +type Release = { + version: string; + tag: string; + url: string; + prerelease: boolean; +}; + +// What can be done about an install, and what to tell the user otherwise. +// `drivable` is true only where running npm ourselves is safe. +type Route = { + drivable: boolean; + command: string; + note: string; +}; + +export type {Install_kind, Channel, Install_info, Release, Route};