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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/qryptchat/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
7 changes: 7 additions & 0 deletions src/cli/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Usage:
qc agent join <link> 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);
Expand Down Expand Up @@ -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 });
Expand Down
71 changes: 71 additions & 0 deletions src/cli/update.js
Original file line number Diff line number Diff line change
@@ -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}.` };
}
47 changes: 47 additions & 0 deletions tests/cli/update.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading