- cli/lib/rt.ts: the runtime layer on node: modules (spawn / spawnSync with Bun's defaults, exit codes and "Executable not found", which with PATHEXT, sleep, serve, Glob, hasher, gunzip, stdin, SQLite, packageRoot, config imports with a type-stripping fallback for Node 20); every Bun.* use in cli/ goes through it - tools/gen-embedded.ts -> cli/lib/embedded.ts: templates/, cli/shots/*.html and Play's Data safety form embedded (replaces Bun text imports; the bundle and the compiled sidecar carry them), checked fresh by bun run ci - package.json: bin dist/storebound.mjs (Node ESM bundle, tools/build-node.ts, prepack), engines node >=20 - bun run ci: node bundle + node smoke (real node: --version, help, check same as Bun, scan, release plan --help, init, MCP handshake + tool call); Forgejo CI sets up Node 20; tests/node-compat.test.ts guards cli/ - install writes npx MCP commands when run with Node; bin/storebound falls back to Node - docs: npx storebound init (Node 20+) or bunx storebound init
108 lines
6.7 KiB
TypeScript
108 lines
6.7 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';
|
|
import * as rt from './lib/rt';
|
|
|
|
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 = rt.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: the owner opens it to install it on this machine.`);
|
|
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.' : '');
|
|
}
|