// `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 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; export async function fetchManifest(channel: 'stable' | 'beta', get: Fetch = fetch): Promise { 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 { 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 { 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: 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.' : ''); }