storebound/cli/desktop.ts

107 lines
6.6 KiB
TypeScript

// `storebound desktop`: the bridge from the terminal to the desktop app. The app is a separate signed download from
// storebound.dev (it updates itself and brings its own copy of this CLI); the npm package never contains it.
// storebound desktop open the app; in an app folder (storebound.config.*) it adds or selects that
// folder (storebound://open?path=..., the app asks before adding)
// storebound desktop --dir <folder> the same for another folder; --no-folder: just open the app
// storebound desktop --download not installed: download the installer for this system into ~/Downloads,
// check its SHA-256 and signature, and print where it is. It never installs.
// --channel beta, --format deb (Linux), --json (print what it would do, change nothing)
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join, resolve } from 'node:path';
import type { Args } from './lib/args';
import { CONFIG_NAMES } from './lib/config';
import { BUNDLE_ID, DOWNLOAD_LABEL, type Download, type DownloadKey, downloadsDir, downloadsFor, findInstalled, MANIFEST, type Manifest, manifestProblems, openCommand, openFolderLink, PAGE, PUBLIC_KEY } from './lib/desktop-dist';
import { verify } from './lib/minisign';
type Fetch = (url: string) => Promise<Response>;
export async function fetchManifest(channel: 'stable' | 'beta', get: Fetch = fetch): Promise<Manifest> {
const r = await get(MANIFEST[channel]);
if (!r.ok) throw new Error(`${MANIFEST[channel]}: 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;
}
/** 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;
}
export async function run(args: Args): Promise<void> {
const platform = process.platform;
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 installed = findInstalled(platform);
if (installed && !args.has('download')) {
const link = isApp ? openFolderLink(dir) : null;
const cmd = openCommand(platform, installed, link);
if (args.has('json')) { console.log(JSON.stringify({ installed, link, command: cmd }, null, 2)); return; }
const p = Bun.spawn(cmd, { stdio: ['ignore', 'ignore', 'pipe'] });
if (platform === 'darwin') {
const code = await p.exited;
if (code !== 0) throw new Error(`open -b ${BUNDLE_ID} failed: ${(await new Response(p.stderr).text()).trim()}`);
} else p.unref();
console.log(link ? `storebound desktop: opening ${dir} (the app asks before it adds a new folder)` : 'storebound desktop: opened');
return;
}
const keys = downloadsFor(platform, process.arch);
let manifest: Manifest | null = null;
let error = '';
try { manifest = await fetchManifest(channel); } catch (e) { error = (e as Error).message; }
const chosen = manifest ? pick(manifest, keys, args.get('format')) : null;
if (args.has('json')) {
console.log(JSON.stringify({ installed, page: PAGE, version: manifest?.version ?? null, download: chosen ? { kind: chosen[0], ...chosen[1] } : null, error: error || null }, null, 2));
return;
}
if (!args.has('download')) {
console.log(installed ? 'storebound desktop is installed.' : 'storebound desktop is not installed on this machine.');
if (chosen) {
console.log(`\n ${DOWNLOAD_LABEL[chosen[0]]}, version ${manifest!.version}${chosen[1].codeSigned ? '' : ' (unsigned preview)'}:\n ${chosen[1].url}\n SHA-256 ${chosen[1].sha256}`);
} else if (!keys.length) {
console.log(`\n There is no desktop build for ${platform} ${process.arch} yet.`);
} else if (error) {
console.log(/HTTP 404/.test(error) ? '\n No desktop release is published yet.' : `\n (could not read the release list: ${error})`);
}
console.log(`\n Every download and its checksum: ${PAGE}`);
if (chosen) console.log(' Or: storebound desktop --download (saves the installer in ~/Downloads after checking its signature; it does not install it)');
return;
}
if (!manifest) throw new Error(`could not read the release list: ${error}`);
if (!chosen) throw new Error(keys.length ? `version ${manifest.version} has no ${args.get('format') ?? 'installer'} for ${platform} ${process.arch} yet; see ${PAGE}` : `no desktop build for ${platform} ${process.arch}; see ${PAGE}`);
const [kind, d] = chosen;
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 SHA-256 and signature checked (key of storebound desktop). Not installed: open it yourself to install.`);
if (!d.codeSigned) console.log(platform === 'darwin'
? ' This preview is not signed by Apple yet: after copying storebound to Applications, open it once, then System Settings > Privacy & Security > Open Anyway.'
: platform === 'win32' ? ' This preview is not code signed yet: Windows SmartScreen may ask; choose More info > Run anyway.' : '');
}