// `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 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; export async function fetchManifest(channel: 'stable' | 'beta', get: Fetch = fetch, url = MANIFEST[channel]): Promise { 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//latest.json) when that is * published, else the channel's newest. */ export async function targetManifest(cli: string, channel: 'stable' | 'beta', get: Fetch = fetch): Promise { 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 { 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; 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 { 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 { 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-'); 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'); }