From 9f29594768bf5af78427ef7b7b12057768a7615b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 7 Oct 2026 05:04:06 +0000 Subject: [PATCH] qc update: self-update to the latest release (qc 0.6.3) qc update asks the npm registry for the latest @profullstack/qryptchat and, when newer, reinstalls with the package manager that installed qc (npm, pnpm or bun, judged from the binary's real path). npx users are pointed at npx @profullstack/qryptchat@latest. --check only reports. Alias: upgrade. Co-Authored-By: Claude Opus 5.5 --- packages/qryptchat/package.json | 2 +- src/cli/commands.js | 7 ++++ src/cli/update.js | 71 +++++++++++++++++++++++++++++++++ tests/cli/update.test.js | 47 ++++++++++++++++++++++ 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 src/cli/update.js create mode 100644 tests/cli/update.test.js diff --git a/packages/qryptchat/package.json b/packages/qryptchat/package.json index ca6d73f1..583a501e 100644 --- a/packages/qryptchat/package.json +++ b/packages/qryptchat/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/qryptchat", - "version": "0.6.2", + "version": "0.6.3", "description": "qc: qrypt.chat in your terminal. A full-screen end-to-end encrypted chat client (ML-KEM-1024), plus a scriptable CLI and an MCP server.", "type": "module", "bin": { diff --git a/src/cli/commands.js b/src/cli/commands.js index 1e926bcb..178b9af8 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -20,6 +20,7 @@ Usage: qc agent join join as an AI agent from an invite (makes its own keys) [--name --username] qc login [--oob] sign in through your browser; --oob to paste a code (SSH) qc logout forget this terminal's session and keys + qc update [--check] update qc to the latest release (--check only says whether there is one) qc whoami who this terminal is signed in as qc profile [--emoji 🔭] [--pronouns she/her] [--website URL] [--bio TEXT] show or set your public profile (an empty value clears it); @@ -252,6 +253,12 @@ export async function main(argv, { version = '0.0.0' } = {}) { ); return; } + case 'update': + case 'upgrade': { + const { update } = await import('./update.js'); + const r = await update({ current: version, check: Boolean(flags.check) }); + return out(r.updated ? ok(r.line) : info(r.line)); + } case 'mcp': { const { serveMcp } = await import('./mcp.js'); return serveMcp(await client(flags), { version }); diff --git a/src/cli/update.js b/src/cli/update.js new file mode 100644 index 00000000..c0f99b8d --- /dev/null +++ b/src/cli/update.js @@ -0,0 +1,71 @@ +/** + * `qc update`: compare this qc with the npm registry and reinstall it with the + * package manager that put it there (judged from where the qc binary lives). + */ +import { spawn } from 'node:child_process'; +import { realpathSync } from 'node:fs'; + +export const PACKAGE = '@profullstack/qryptchat'; + +/** -1, 0 or 1, comparing x.y.z versions (a pre-release sorts before its release). */ +export function compareVersions(a, b) { + const parse = (v) => { + const [core, pre = ''] = String(v).replace(/^v/, '').split('-'); + return { nums: core.split('.').map((n) => Number.parseInt(n, 10) || 0), pre }; + }; + const x = parse(a); + const y = parse(b); + for (let i = 0; i < 3; i++) { + const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0); + if (d) return Math.sign(d); + } + if (x.pre === y.pre) return 0; + if (!x.pre) return 1; + if (!y.pre) return -1; + return x.pre < y.pre ? -1 : 1; +} + +/** + * The install command for where qc lives, or { npx: true } when it runs from + * the npx cache (`npx @profullstack/qryptchat@latest` already fetches the newest). + */ +export function installCommand(binPath = '') { + const p = binPath.replaceAll('\\', '/'); + const spec = `${PACKAGE}@latest`; + if (p.includes('/_npx/')) return { npx: true }; + if (p.includes('/.bun/')) return { cmd: 'bun', args: ['add', '-g', spec] }; + if (p.includes('/pnpm/')) return { cmd: 'pnpm', args: ['add', '-g', spec] }; + return { cmd: 'npm', args: ['install', '-g', spec] }; +} + +export async function latestVersion(fetchImpl = fetch) { + const res = await fetchImpl(`https://registry.npmjs.org/${PACKAGE}/latest`, { headers: { accept: 'application/json' } }); + if (!res.ok) throw new Error(`Could not reach the npm registry (HTTP ${res.status}).`); + const { version } = await res.json(); + if (!version) throw new Error('The npm registry did not say which version is latest.'); + return version; +} + +function run(cmd, args) { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: 'inherit', shell: process.platform === 'win32' }); + child.on('error', (err) => reject(new Error(`Could not run ${cmd}: ${err.message}`))); + child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(' ')} exited with ${code}.`)))); + }); +} + +/** Returns a line for the user; installs unless `check` is set. */ +export async function update({ current, check = false, binPath = process.argv[1], fetchImpl = fetch, runImpl = run }) { + const latest = await latestVersion(fetchImpl); + if (compareVersions(latest, current) <= 0) return { updated: false, line: `qc ${current} is the latest.` }; + let real = binPath || ''; + try { + real = realpathSync(real); + } catch {} + const how = installCommand(real); + if (how.npx) return { updated: false, line: `qc ${latest} is out (you have ${current}). Run: npx ${PACKAGE}@latest` }; + const command = `${how.cmd} ${how.args.join(' ')}`; + if (check) return { updated: false, line: `qc ${latest} is out (you have ${current}). Update with: qc update (runs ${command})` }; + await runImpl(how.cmd, how.args); + return { updated: true, line: `qc ${current} -> ${latest}.` }; +} diff --git a/tests/cli/update.test.js b/tests/cli/update.test.js new file mode 100644 index 00000000..44d34006 --- /dev/null +++ b/tests/cli/update.test.js @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { compareVersions, installCommand, update } from '../../src/cli/update.js'; + +const registry = (version) => async () => ({ ok: true, json: async () => ({ version }) }); + +describe('qc update', () => { + it('compares versions numerically', () => { + expect(compareVersions('0.6.10', '0.6.9')).toBe(1); + expect(compareVersions('0.6.2', '0.6.2')).toBe(0); + expect(compareVersions('0.7.0', '1.0.0')).toBe(-1); + expect(compareVersions('1.0.0-beta', '1.0.0')).toBe(-1); + }); + + it('reinstalls with the package manager that installed qc', () => { + expect(installCommand('/home/a/.local/share/mise/installs/node/lts/lib/node_modules/@profullstack/qryptchat/bin/qc.js')).toEqual({ + cmd: 'npm', + args: ['install', '-g', '@profullstack/qryptchat@latest'], + }); + expect(installCommand('/home/a/.bun/install/global/node_modules/@profullstack/qryptchat/bin/qc.js').cmd).toBe('bun'); + expect(installCommand('/home/a/.local/share/pnpm/global/5/node_modules/@profullstack/qryptchat/bin/qc.js').cmd).toBe('pnpm'); + expect(installCommand('/home/a/.npm/_npx/abc/node_modules/@profullstack/qryptchat/bin/qc.js')).toEqual({ npx: true }); + }); + + it('does nothing when already current', async () => { + const ran = []; + const r = await update({ current: '0.6.2', fetchImpl: registry('0.6.2'), runImpl: async (...a) => ran.push(a) }); + expect(r).toEqual({ updated: false, line: 'qc 0.6.2 is the latest.' }); + expect(ran).toEqual([]); + }); + + it('installs the newer version, or only reports it with --check', async () => { + const ran = []; + const runImpl = async (...a) => ran.push(a); + const checked = await update({ current: '0.6.2', check: true, binPath: '/x/lib/node_modules/q/bin/qc.js', fetchImpl: registry('0.6.3'), runImpl }); + expect(checked.updated).toBe(false); + expect(checked.line).toContain('npm install -g @profullstack/qryptchat@latest'); + expect(ran).toEqual([]); + const done = await update({ current: '0.6.2', binPath: '/x/lib/node_modules/q/bin/qc.js', fetchImpl: registry('0.6.3'), runImpl }); + expect(done).toEqual({ updated: true, line: 'qc 0.6.2 -> 0.6.3.' }); + expect(ran).toEqual([['npm', ['install', '-g', '@profullstack/qryptchat@latest']]]); + }); + + it('points npx users at @latest instead of installing', async () => { + const r = await update({ current: '0.6.1', binPath: '/h/.npm/_npx/1/node_modules/q/bin/qc.js', fetchImpl: registry('0.6.3'), runImpl: async () => {} }); + expect(r.line).toBe('qc 0.6.3 is out (you have 0.6.1). Run: npx @profullstack/qryptchat@latest'); + }); +});