Skip to content

Commit f202747

Browse files
ralyodioclaude
andauthored
qc update: self-update to the latest release (qc 0.6.3) (#300)
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 <noreply@anthropic.com>
1 parent a0cdf8b commit f202747

4 files changed

Lines changed: 126 additions & 1 deletion

File tree

‎packages/qryptchat/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@profullstack/qryptchat",
3-
"version": "0.6.2",
3+
"version": "0.6.3",
44
"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.",
55
"type": "module",
66
"bin": {

‎src/cli/commands.js‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Usage:
2020
qc agent join <link> join as an AI agent from an invite (makes its own keys) [--name --username]
2121
qc login [--oob] sign in through your browser; --oob to paste a code (SSH)
2222
qc logout forget this terminal's session and keys
23+
qc update [--check] update qc to the latest release (--check only says whether there is one)
2324
qc whoami who this terminal is signed in as
2425
qc profile [--emoji 🔭] [--pronouns she/her] [--website URL] [--bio TEXT]
2526
show or set your public profile (an empty value clears it);
@@ -252,6 +253,12 @@ export async function main(argv, { version = '0.0.0' } = {}) {
252253
);
253254
return;
254255
}
256+
case 'update':
257+
case 'upgrade': {
258+
const { update } = await import('./update.js');
259+
const r = await update({ current: version, check: Boolean(flags.check) });
260+
return out(r.updated ? ok(r.line) : info(r.line));
261+
}
255262
case 'mcp': {
256263
const { serveMcp } = await import('./mcp.js');
257264
return serveMcp(await client(flags), { version });

‎src/cli/update.js‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* `qc update`: compare this qc with the npm registry and reinstall it with the
3+
* package manager that put it there (judged from where the qc binary lives).
4+
*/
5+
import { spawn } from 'node:child_process';
6+
import { realpathSync } from 'node:fs';
7+
8+
export const PACKAGE = '@profullstack/qryptchat';
9+
10+
/** -1, 0 or 1, comparing x.y.z versions (a pre-release sorts before its release). */
11+
export function compareVersions(a, b) {
12+
const parse = (v) => {
13+
const [core, pre = ''] = String(v).replace(/^v/, '').split('-');
14+
return { nums: core.split('.').map((n) => Number.parseInt(n, 10) || 0), pre };
15+
};
16+
const x = parse(a);
17+
const y = parse(b);
18+
for (let i = 0; i < 3; i++) {
19+
const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0);
20+
if (d) return Math.sign(d);
21+
}
22+
if (x.pre === y.pre) return 0;
23+
if (!x.pre) return 1;
24+
if (!y.pre) return -1;
25+
return x.pre < y.pre ? -1 : 1;
26+
}
27+
28+
/**
29+
* The install command for where qc lives, or { npx: true } when it runs from
30+
* the npx cache (`npx @profullstack/qryptchat@latest` already fetches the newest).
31+
*/
32+
export function installCommand(binPath = '') {
33+
const p = binPath.replaceAll('\\', '/');
34+
const spec = `${PACKAGE}@latest`;
35+
if (p.includes('/_npx/')) return { npx: true };
36+
if (p.includes('/.bun/')) return { cmd: 'bun', args: ['add', '-g', spec] };
37+
if (p.includes('/pnpm/')) return { cmd: 'pnpm', args: ['add', '-g', spec] };
38+
return { cmd: 'npm', args: ['install', '-g', spec] };
39+
}
40+
41+
export async function latestVersion(fetchImpl = fetch) {
42+
const res = await fetchImpl(`https://registry.npmjs.org/${PACKAGE}/latest`, { headers: { accept: 'application/json' } });
43+
if (!res.ok) throw new Error(`Could not reach the npm registry (HTTP ${res.status}).`);
44+
const { version } = await res.json();
45+
if (!version) throw new Error('The npm registry did not say which version is latest.');
46+
return version;
47+
}
48+
49+
function run(cmd, args) {
50+
return new Promise((resolve, reject) => {
51+
const child = spawn(cmd, args, { stdio: 'inherit', shell: process.platform === 'win32' });
52+
child.on('error', (err) => reject(new Error(`Could not run ${cmd}: ${err.message}`)));
53+
child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(' ')} exited with ${code}.`))));
54+
});
55+
}
56+
57+
/** Returns a line for the user; installs unless `check` is set. */
58+
export async function update({ current, check = false, binPath = process.argv[1], fetchImpl = fetch, runImpl = run }) {
59+
const latest = await latestVersion(fetchImpl);
60+
if (compareVersions(latest, current) <= 0) return { updated: false, line: `qc ${current} is the latest.` };
61+
let real = binPath || '';
62+
try {
63+
real = realpathSync(real);
64+
} catch {}
65+
const how = installCommand(real);
66+
if (how.npx) return { updated: false, line: `qc ${latest} is out (you have ${current}). Run: npx ${PACKAGE}@latest` };
67+
const command = `${how.cmd} ${how.args.join(' ')}`;
68+
if (check) return { updated: false, line: `qc ${latest} is out (you have ${current}). Update with: qc update (runs ${command})` };
69+
await runImpl(how.cmd, how.args);
70+
return { updated: true, line: `qc ${current} -> ${latest}.` };
71+
}

‎tests/cli/update.test.js‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { compareVersions, installCommand, update } from '../../src/cli/update.js';
3+
4+
const registry = (version) => async () => ({ ok: true, json: async () => ({ version }) });
5+
6+
describe('qc update', () => {
7+
it('compares versions numerically', () => {
8+
expect(compareVersions('0.6.10', '0.6.9')).toBe(1);
9+
expect(compareVersions('0.6.2', '0.6.2')).toBe(0);
10+
expect(compareVersions('0.7.0', '1.0.0')).toBe(-1);
11+
expect(compareVersions('1.0.0-beta', '1.0.0')).toBe(-1);
12+
});
13+
14+
it('reinstalls with the package manager that installed qc', () => {
15+
expect(installCommand('/home/a/.local/share/mise/installs/node/lts/lib/node_modules/@profullstack/qryptchat/bin/qc.js')).toEqual({
16+
cmd: 'npm',
17+
args: ['install', '-g', '@profullstack/qryptchat@latest'],
18+
});
19+
expect(installCommand('/home/a/.bun/install/global/node_modules/@profullstack/qryptchat/bin/qc.js').cmd).toBe('bun');
20+
expect(installCommand('/home/a/.local/share/pnpm/global/5/node_modules/@profullstack/qryptchat/bin/qc.js').cmd).toBe('pnpm');
21+
expect(installCommand('/home/a/.npm/_npx/abc/node_modules/@profullstack/qryptchat/bin/qc.js')).toEqual({ npx: true });
22+
});
23+
24+
it('does nothing when already current', async () => {
25+
const ran = [];
26+
const r = await update({ current: '0.6.2', fetchImpl: registry('0.6.2'), runImpl: async (...a) => ran.push(a) });
27+
expect(r).toEqual({ updated: false, line: 'qc 0.6.2 is the latest.' });
28+
expect(ran).toEqual([]);
29+
});
30+
31+
it('installs the newer version, or only reports it with --check', async () => {
32+
const ran = [];
33+
const runImpl = async (...a) => ran.push(a);
34+
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 });
35+
expect(checked.updated).toBe(false);
36+
expect(checked.line).toContain('npm install -g @profullstack/qryptchat@latest');
37+
expect(ran).toEqual([]);
38+
const done = await update({ current: '0.6.2', binPath: '/x/lib/node_modules/q/bin/qc.js', fetchImpl: registry('0.6.3'), runImpl });
39+
expect(done).toEqual({ updated: true, line: 'qc 0.6.2 -> 0.6.3.' });
40+
expect(ran).toEqual([['npm', ['install', '-g', '@profullstack/qryptchat@latest']]]);
41+
});
42+
43+
it('points npx users at @latest instead of installing', async () => {
44+
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 () => {} });
45+
expect(r.line).toBe('qc 0.6.3 is out (you have 0.6.1). Run: npx @profullstack/qryptchat@latest');
46+
});
47+
});

0 commit comments

Comments
 (0)