storebound/cli/desktop.ts
CRT d2ce185441 One product per machine: the CLI and the desktop app share one version, the store accounts and the app folder list
- one version: package.json's version is the desktop app's too (desktopVersion removed); tools/desktop-version.ts
  keeps the plugin manifests and the app's copies equal, desktop-release publish refuses another version, release
  order app first then npm publish; the unpublished desktop 0.1.0 candidate is noted in the docs and CHANGELOG
- storebound desktop installs (per user, no admin: macOS DMG -> /Applications or ~/Applications, Windows NSIS /S,
  Linux AppImage + .desktop with the storebound:// handler), updates an older app, notes a newer one (downgrade only
  with --force), asks once ([Y/n], --yes, exit 3 without a terminal), opens on the folder; --dry / --json / --download;
  the install steps are pure plans (cli/lib/desktop-install.ts); the version's own manifest dl/<version>/latest.json
- credentials: one store, the CLI's credential profiles in ~/.storebound/credentials; the desktop writes and reads
  them (profiles.rs, files 0600), moves old keychain accounts into profiles on first start, shows profiles the CLI
  made; precedence env > named profile > project file > default profile; config `account`, "account" in the
  project's credentials.json; storebound accounts list | import (dedup by key id / service account e-mail, key files
  copied 0600, project file keeps keystore and review contact, backup, --dry, never prints values) | use
- app folders: one list ~/.storebound/projects.json shared by the desktop (registry.rs), storebound desktop,
  apps folders | add | remove, ws init and ws without a workspace file
- desktop: notice when a folder pins another storebound version; storebound-desktop --version
- tests: version drift, desktop decisions and per-OS plans, credential precedence and import, registry (TS + Rust),
  keychain migration and profile round trip (Rust), boot check with old keychain state and the new shared state
2026-10-13 06:17:37 +08:00

252 lines
15 KiB
TypeScript

// `storebound desktop`: the CLI and the desktop app are one product with one version number. This command makes the
// app on this machine that version and opens it on the current folder.
// storebound desktop the app has this CLI's version: open it (in an app folder: on that folder, which
// also joins this machine's app folder list, ~/.storebound/projects.json)
// not installed: download it for this system, check its size, SHA-256 and minisign
// signature, install it for this user (no admin rights), open it. Asks once in a
// terminal ([Y/n]); --yes skips the question; without a terminal and --yes it prints
// the command and exits 3
// an older app: the same download and install as an update; a newer app: opened as
// it is, with a note (a downgrade only with --force)
// storebound desktop --dir <folder> the same for another folder; --no-folder: just open the app
// storebound desktop --download only save the installer for this system into ~/Downloads (checked as above)
// --dry (print the plan, change nothing), --json (the decision and plan as JSON, change nothing), --channel beta,
// --format deb (Linux, download only), --force (allow a downgrade), --yes
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { homedir, tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import pkg from '../package.json' with { type: 'json' };
import type { Args } from './lib/args';
import { CONFIG_NAMES } from './lib/config';
import { DECLINED_EXIT } from './lib/ctx';
import { BUNDLE_ID, DOWNLOAD_LABEL, type Download, type DownloadKey, downloadsDir, downloadsFor, findInstalled, type Installed, MANIFEST, type Manifest, manifestProblems, openCommand, openFolderLink, PAGE, PUBLIC_KEY, versionManifest } from './lib/desktop-dist';
import { consent, type Decision, decide, installPlan, isRunning, type Plan, question, runningCheck } from './lib/desktop-install';
import { verify } from './lib/minisign';
import { parsePlist } from './lib/plist';
import { addFolder } from './lib/registry';
import * as rt from './lib/rt';
type Fetch = (url: string) => Promise<Response>;
export async function fetchManifest(channel: 'stable' | 'beta', get: Fetch = fetch, url = MANIFEST[channel]): Promise<Manifest> {
const r = await get(url);
if (!r.ok) throw new Error(`${url}: HTTP ${r.status}`);
const m = await r.json() as unknown;
const problems = manifestProblems(m);
if (problems.length) throw new Error(`latest.json: ${problems.join('; ')}`);
return m as Manifest;
}
/** The release for this CLI: its own version's manifest (storebound.dev/desktop/dl/<version>/latest.json) when that is
* published, else the channel's newest. */
export async function targetManifest(cli: string, channel: 'stable' | 'beta', get: Fetch = fetch): Promise<Manifest> {
try {
const own = await fetchManifest(channel, get, versionManifest(cli));
if (own.version === cli) return own;
} catch { /* not published (yet): the channel's newest */ }
return fetchManifest(channel, get);
}
/** Picks the installer for this system: the first of downloadsFor() the release has (or the asked format). */
export function pick(m: Manifest, keys: DownloadKey[], format?: string): [DownloadKey, Download] | null {
const want = format ? keys.filter(k => k.endsWith(`-${format}`)) : keys;
for (const k of want) { const d = m.downloads?.[k]; if (d) return [k, d]; }
return null;
}
/** Downloads one installer and keeps it only when its size, SHA-256 and minisign signature are right. */
export async function download(d: Download, dir: string, get: Fetch = fetch, publicKey = PUBLIC_KEY): Promise<string> {
const r = await get(d.url);
if (!r.ok) throw new Error(`${d.url}: HTTP ${r.status}`);
const data = new Uint8Array(await r.arrayBuffer());
if (data.length !== d.size) throw new Error(`${d.file}: ${data.length} bytes, latest.json says ${d.size}; not kept`);
const sha = createHash('sha256').update(data).digest('hex');
if (sha !== d.sha256) throw new Error(`${d.file}: SHA-256 ${sha} does not match latest.json; not kept`);
const v = verify(data, d.signature, publicKey);
if (!v.ok) throw new Error(`${d.file}: signature check failed (${v.reason}); not kept`);
mkdirSync(dir, { recursive: true });
const out = join(dir, d.file);
const tmp = `${out}.part`;
writeFileSync(tmp, data);
renameSync(tmp, out);
return out;
}
const VERSION_RE = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/;
/** The version of an installed app: Info.plist on macOS, `storebound-desktop --version` elsewhere (15 s at most). */
export function appVersion(platform: NodeJS.Platform, app: string): string | null {
try {
if (platform === 'darwin') {
const p = parsePlist(readFileSync(join(app, 'Contents', 'Info.plist'))) as Record<string, unknown>;
return typeof p.CFBundleShortVersionString === 'string' ? p.CFBundleShortVersionString : null;
}
const r = rt.spawnSync([app, '--version'], { timeout: 15_000 });
return r.success ? VERSION_RE.exec(r.stdout.toString())?.[1] ?? null : null;
} catch { return null; }
}
/** Runs a plan step by step; `always` steps (unmount) run after a failure too. Every command has a timeout. */
export function runPlan(plan: Plan, platform: NodeJS.Platform, say: (s: string) => void): void {
let failed: Error | null = null;
for (const s of plan.steps) {
const always = 'run' in s && s.always;
if (failed && !always) continue;
try {
if ('run' in s) {
const r = rt.spawnSync(s.run, { timeout: s.timeoutMs });
if (r.signalCode) throw new Error(`${s.what}: no answer within ${s.timeoutMs / 1000} s (${s.run[0]})`);
if (!r.success) {
const why = `${s.what}: ${s.run[0]} exited ${r.exitCode}: ${r.stderr.toString().trim().split('\n').slice(-3).join(' / ')}`;
if (s.optional || always) { say(` (skipped: ${why})`); continue; }
throw new Error(why);
}
} else if ('mkdir' in s) mkdirSync(s.mkdir, { recursive: true });
else if ('copy' in s) { copyFileSync(s.copy.from, s.copy.to); if (s.copy.mode) chmodSync(s.copy.to, s.copy.mode); }
else if ('rename' in s) { rmSync(s.rename.to, { recursive: true, force: true }); renameSync(s.rename.from, s.rename.to); }
else if ('remove' in s) rmSync(s.remove, { recursive: true, force: true });
else if ('write' in s) { writeFileSync(s.write.path, s.write.content); if (s.write.mode) chmodSync(s.write.path, s.write.mode); }
else if ('expectVersion' in s) {
const v = appVersion(platform, s.expectVersion.app);
if (v && v !== s.expectVersion.version) throw new Error(`the installed app is ${v}, expected ${s.expectVersion.version}`);
if (!v) say(` (could not read the new app's version; going on)`);
}
if (!always) say(` ${s.what}`);
} catch (e) { if (!always) failed = e as Error; }
}
if (failed) {
// a staged copy that never made it into place is removed; the old app stays where it was
for (const s of plan.steps) if ('rename' in s && s.rename.from.includes('.new') && existsSync(s.rename.from)) rmSync(s.rename.from, { recursive: true, force: true });
throw failed;
}
}
function canWrite(dir: string): boolean {
try { const probe = join(dir, `.storebound-probe-${process.pid}`); writeFileSync(probe, ''); rmSync(probe); return true; } catch { return false; }
}
async function askYes(text: string): Promise<boolean> {
process.stdout.write(text);
for await (const line of console) return !/^n(o)?$/i.test(line.trim());
return false;
}
const tilde = (p: string) => (p.startsWith(homedir()) ? '~' + p.slice(homedir().length) : p);
export async function run(args: Args): Promise<void> {
const platform = process.platform;
const cli = pkg.version;
const channel = args.get('channel') === 'beta' ? 'beta' : 'stable';
const dir = resolve(args.str('dir', '.'));
const isApp = !args.has('no-folder') && CONFIG_NAMES.some(n => existsSync(join(dir, n)));
const link = isApp ? openFolderLink(dir) : null;
const json = args.has('json'), dry = args.has('dry') || json;
let installed: Installed | null = findInstalled(platform);
const installedVersion = installed ? appVersion(platform, installed.path) : null;
const keys = downloadsFor(platform, process.arch);
// the release: only when something has to be downloaded (opening the right version needs no network)
let manifest: Manifest | null = null;
let error = '';
if (args.has('download') || !installed || installedVersion !== cli) {
if (!keys.length) error = `there is no desktop build for ${platform} ${process.arch} yet`;
else try { manifest = await targetManifest(cli, channel); } catch (e) { error = /HTTP 404/.test((e as Error).message) ? 'no desktop release is published yet' : `could not read the release list: ${(e as Error).message}`; }
}
const chosen = manifest ? pick(manifest, keys, args.get('format') ?? (args.has('download') ? undefined : platform === 'linux' ? 'appimage' : undefined)) : null;
if (manifest && !chosen && !error) error = `version ${manifest.version} has no ${args.get('format') ?? 'installer'} for ${platform} ${process.arch} yet`;
if (args.has('download')) {
if (json) { console.log(JSON.stringify({ cli, installed, installedVersion, page: PAGE, version: manifest?.version ?? null, download: chosen ? { kind: chosen[0], ...chosen[1] } : null, error: error || null }, null, 2)); return; }
if (!chosen) throw new Error(`${error}; see ${PAGE}`);
const [kind, d] = chosen;
if (dry) { console.log(`dry run: would download ${d.file} (${(d.size / 1e6).toFixed(1)} MB, ${DOWNLOAD_LABEL[kind]}) into ${tilde(downloadsDir())} and check it`); return; }
console.log(`downloading ${d.file} (${(d.size / 1e6).toFixed(1)} MB, ${DOWNLOAD_LABEL[kind]})`);
const target = downloadsDir();
let out: string;
try { out = await download(d, target); } catch (e) { rmSync(join(target, `${d.file}.part`), { force: true }); throw e; }
console.log(`saved ${out}\n size, SHA-256 and signature checked (key of storebound desktop). Not installed (storebound desktop without --download installs it).`);
return;
}
const decision: Decision = decide({ installed, installedVersion, cli, available: chosen ? manifest!.version : null, unavailableReason: error || undefined, force: args.has('force') });
const changing = decision.action === 'install' || decision.action === 'update' || decision.action === 'downgrade';
const tmp = changing && !dry ? mkdtempSync(join(tmpdir(), 'storebound-desktop-')) : join(tmpdir(), 'storebound-desktop-<temp>');
const plan = changing && chosen ? installPlan({
platform, kind: chosen[0], file: join(tmp, chosen[1].file), version: manifest!.version, home: homedir(), existing: installed,
systemAppsWritable: platform === 'darwin' && canWrite('/Applications'), tmp, env: process.env, has: t => !!rt.which(t),
}) : null;
if (json) {
console.log(JSON.stringify({
cli, installed, installedVersion, decision, link, folder: isApp ? dir : null,
download: chosen && changing ? { kind: chosen[0], ...chosen[1] } : null,
plan: plan ? { target: plan.target, place: plan.place, manual: plan.manual ?? null, steps: plan.steps.map(s => s.what) } : null,
command: installed && !changing ? openCommand(platform, installed, link) : null,
}, null, 2));
return;
}
for (const n of decision.notes) console.log(`storebound desktop: ${n}`);
if (decision.action === 'unavailable') {
console.log(`storebound desktop is not installed on this machine, and it cannot be installed from here: ${error || decision.notes[0]}.\n Every download and its checksum: ${PAGE}`);
process.exitCode = 1;
return;
}
if (changing) {
const [, d] = chosen!;
if (plan!.manual) { console.log(`storebound desktop ${decision.to}: ${plan!.manual}`); process.exitCode = 1; return; }
if (dry) {
console.log(`dry run: ${decision.action} storebound desktop ${decision.from ? `${decision.from} -> ` : ''}${decision.to} (${Math.round(d.size / 1e6)} MB) in ${plan!.place}, then open it${link ? ` on ${tilde(dir)}` : ''}:`);
console.log(` download ${d.file}, check size, SHA-256 and signature`);
for (const s of plan!.steps) console.log(` ${s.what}`);
if (link) console.log(` add ${tilde(dir)} to this machine's app folders`);
console.log('nothing changed.');
return;
}
if (installed) {
const chk = runningCheck(platform);
let running = false;
try { const r = rt.spawnSync(chk, { timeout: 10_000 }); running = isRunning(platform, r.exitCode, r.stdout.toString()); } catch { /* no pgrep / tasklist: go on */ }
if (running) throw new Error(`storebound desktop ${decision.from} is running: quit it first, then run this again (or update it from the app: Settings > Updates)`);
}
const how = consent(args.has('yes'), !!process.stdin.isTTY);
if (how !== 'yes') {
if (how === 'declined') {
const again = ['storebound', 'desktop', ...process.argv.slice(3).filter(a => a !== '--yes'), '--yes'].join(' ');
console.log(`${question(decision, d.size, plan!.place).replace(/ \[Y\/n\] $/, '')}\n not a terminal: run \`${again}\` to confirm`);
console.error('declined: not a terminal and no --yes');
process.exitCode = DECLINED_EXIT;
rmSync(tmp, { recursive: true, force: true });
return;
}
if (!(await askYes(question(decision, d.size, plan!.place)))) {
console.error('declined: the owner answered no');
process.exitCode = DECLINED_EXIT;
rmSync(tmp, { recursive: true, force: true });
return;
}
}
try {
console.log(`downloading ${d.file} (${(d.size / 1e6).toFixed(1)} MB)`);
await download(d, tmp);
console.log(' size, SHA-256 and signature checked');
runPlan(plan!, platform, s => console.log(s));
} finally { rmSync(tmp, { recursive: true, force: true }); }
installed = findInstalled(platform) ?? { path: plan!.target, how: platform === 'darwin' ? 'bundle' : platform === 'win32' ? 'exe' : 'appimage' };
console.log(`storebound desktop ${decision.to} ${decision.action === 'install' ? 'installed' : 'updated'}: ${tilde(plan!.target)}`);
}
// open (the right version is there now)
if (dry) { console.log(`dry run: open storebound desktop ${installedVersion ?? ''}${link ? ` on ${tilde(dir)}` : ''}; nothing changed`); return; }
if (isApp) {
try { const r = addFolder(dir); if (r.added) console.log(`storebound desktop: ${tilde(dir)} added to this machine's app folders`); } catch (e) { console.log(`storebound desktop: the app folder list was not updated (${(e as Error).message})`); }
}
const fresh = changing && platform === 'darwin';
const cmd = fresh ? ['open', '-a', installed!.path, ...(link ? [link] : [])] : openCommand(platform, installed!, link);
const p = rt.spawn(cmd, { stdio: ['ignore', 'ignore', 'pipe'] });
if (platform === 'darwin') {
const code = await Promise.race([p.exited, rt.sleep(30_000).then(() => -1)]);
if (code !== 0) throw new Error(`${fresh ? 'open -a' : `open -b ${BUNDLE_ID}`} failed${code === -1 ? ' (no answer in 30 s)' : `: ${(await new Response(p.stderr).text()).trim()}`}`);
} else p.unref();
console.log(link ? `storebound desktop: opening ${tilde(dir)}` : 'storebound desktop: opened');
}