storebound/cli/lib/desktop-dist.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

133 lines
7.6 KiB
TypeScript

// Where the storebound desktop app comes from and where it lives once installed. The app is a signed download from
// storebound.dev (DMG, Windows installer, AppImage / deb) with the same version number as the npm CLI; `storebound
// desktop` (cli/desktop.ts) installs, updates and opens it, and the app can update itself. The npm package carries no
// app binaries.
//
// storebound.dev/desktop/latest.json is Tauri's updater manifest (version, notes, pub_date, platforms: the update
// bundles with their minisign signatures) plus a `downloads` map of the installers people download by hand, each
// with its URL, SHA-256, size and minisign signature (the same key as the updater). Pure functions, tested in
// tests/desktop-dist.test.ts.
import { existsSync, readdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
export const BUNDLE_ID = 'dev.storebound.desktop';
export const PAGE = 'https://storebound.dev/#download';
export const MANIFEST: Record<'stable' | 'beta', string> = {
stable: 'https://storebound.dev/desktop/latest.json',
beta: 'https://storebound.dev/desktop/beta/latest.json',
};
/** The manifest of one version (written next to its files on publish): `storebound desktop` installs the CLI's own
* version from it, so the app and the CLI have one version number. */
export const versionManifest = (version: string): string => `https://storebound.dev/desktop/dl/${encodeURIComponent(version)}/latest.json`;
/** The updater public key of the desktop app (tauri.conf.json plugins.updater.pubkey; a test keeps them equal). */
export const PUBLIC_KEY = 'dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ0Rjc4QTc4RDlFRjM4MkEKUldRcU9PL1plSXIzUkJtb2x0L2Z4MTA4bTg5UU5FVW5kUlRGcnVjangwQ0JNb2VGYjcveGhydlcK';
export type DownloadKey = 'macos-universal-dmg' | 'windows-x86_64-nsis' | 'linux-x86_64-appimage' | 'linux-x86_64-deb';
export interface Download {
file: string;
url: string;
sha256: string;
size: number;
/** Tauri's form of the minisign signature (base64 of the .sig file). */
signature: string;
/** Code signing by the OS vendor's scheme (Developer ID + notarization, Authenticode); false = an unsigned preview. */
codeSigned: boolean;
}
export interface Manifest {
version: string;
notes?: string;
pub_date?: string;
platforms: Record<string, { signature: string; url: string }>;
downloads?: Partial<Record<DownloadKey, Download>>;
}
export const DOWNLOAD_LABEL: Record<DownloadKey, string> = {
'macos-universal-dmg': 'macOS 11+ (Apple silicon and Intel), .dmg',
'windows-x86_64-nsis': 'Windows 10/11 x64, installer (.exe, per user)',
'linux-x86_64-appimage': 'Linux x64, AppImage',
'linux-x86_64-deb': 'Linux x64 (Debian, Ubuntu), .deb',
};
/** The installers for this OS and CPU, best first; [] when there is no build for it yet. */
export function downloadsFor(platform: NodeJS.Platform, arch: string): DownloadKey[] {
if (platform === 'darwin' && (arch === 'arm64' || arch === 'x64')) return ['macos-universal-dmg'];
if (platform === 'win32' && arch === 'x64') return ['windows-x86_64-nsis'];
if (platform === 'linux' && arch === 'x64') return ['linux-x86_64-appimage', 'linux-x86_64-deb'];
return [];
}
const SHA256 = /^[0-9a-f]{64}$/;
const SAFE_FILE = /^[A-Za-z0-9._-]+$/;
/** Checks the shape of latest.json and that every URL is on storebound.dev over HTTPS. Returns the problems. */
export function manifestProblems(m: unknown): string[] {
const out: string[] = [];
const isObj = (x: unknown): x is Record<string, unknown> => !!x && typeof x === 'object' && !Array.isArray(x);
if (!isObj(m)) return ['latest.json is not an object'];
if (typeof m.version !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(m.version)) out.push('version is not a version number');
const ours = (u: unknown) => { try { const x = new URL(String(u)); return x.protocol === 'https:' && x.host === 'storebound.dev' && x.pathname.startsWith('/desktop/'); } catch { return false; } };
if (!isObj(m.platforms)) out.push('platforms is missing');
else for (const [k, p] of Object.entries(m.platforms)) {
if (!isObj(p) || typeof p.signature !== 'string' || !p.signature || !ours(p.url)) out.push(`platforms.${k}: needs a signature and a storebound.dev URL`);
}
if (m.downloads !== undefined) {
if (!isObj(m.downloads)) out.push('downloads is not an object');
else for (const [k, d] of Object.entries(m.downloads)) {
if (!(k in DOWNLOAD_LABEL)) { out.push(`downloads.${k}: unknown kind`); continue; }
if (!isObj(d)) { out.push(`downloads.${k}: not an object`); continue; }
if (typeof d.file !== 'string' || !SAFE_FILE.test(d.file)) out.push(`downloads.${k}: bad file name`);
if (!ours(d.url) || typeof d.file !== 'string' || !String(d.url).endsWith(`/${d.file}`)) out.push(`downloads.${k}: URL must be storebound.dev/desktop/.../<file>`);
if (typeof d.sha256 !== 'string' || !SHA256.test(d.sha256)) out.push(`downloads.${k}: sha256`);
if (typeof d.size !== 'number' || !(d.size > 0)) out.push(`downloads.${k}: size`);
if (typeof d.signature !== 'string' || !d.signature) out.push(`downloads.${k}: signature`);
if (typeof d.codeSigned !== 'boolean') out.push(`downloads.${k}: codeSigned`);
}
}
return out;
}
/** The deep link that makes the app add (after its own dialog) or select an app folder. */
export function openFolderLink(dir: string): string {
return `storebound://open?path=${encodeURIComponent(dir)}`;
}
export interface Installed { path: string; how: 'bundle' | 'exe' | 'appimage' | 'deb' }
/** Where the app is installed on this machine, or null. `exists` and `home` are injectable for tests. */
export function findInstalled(platform: NodeJS.Platform, env: Record<string, string | undefined> = process.env,
exists: (p: string) => boolean = existsSync, home = homedir(),
list: (dir: string) => string[] = d => { try { return readdirSync(d); } catch { return []; } }): Installed | null {
if (platform === 'darwin') {
for (const dir of ['/Applications', join(home, 'Applications')]) {
const app = join(dir, 'storebound.app');
if (exists(join(app, 'Contents/MacOS/storebound-desktop'))) return { path: app, how: 'bundle' };
}
return null;
}
if (platform === 'win32') {
// NSIS per-user install: %LOCALAPPDATA%\storebound\storebound-desktop.exe (all-users: %ProgramFiles%\storebound)
for (const base of [env.LOCALAPPDATA, env.ProgramFiles]) {
if (!base) continue;
const exe = `${base}\\storebound\\storebound-desktop.exe`;
if (exists(exe)) return { path: exe, how: 'exe' };
}
return null;
}
if (exists('/usr/bin/storebound-desktop')) return { path: '/usr/bin/storebound-desktop', how: 'deb' };
// an AppImage is wherever its owner put it: look in the usual folders (storebound_0.1.0_amd64.AppImage, renamed ones)
for (const dir of [join(home, 'Applications'), join(home, '.local/bin'), join(home, 'Downloads')]) {
const name = list(dir).filter(n => /^storebound(?:[-_][A-Za-z0-9._-]*)?\.AppImage$/i.test(n)).sort().at(-1);
if (name) return { path: join(dir, name), how: 'appimage' };
}
return null;
}
/** The command that opens the installed app (with a deep link, or plain). Never through a shell. */
export function openCommand(platform: NodeJS.Platform, installed: Installed, link: string | null): string[] {
if (platform === 'darwin') return link ? ['open', '-b', BUNDLE_ID, link] : ['open', '-b', BUNDLE_ID];
// Windows and Linux: the app takes the link as its argument; a running app gets it through single-instance
return link ? [installed.path, link] : [installed.path];
}
export const downloadsDir = (home = homedir()): string => join(home, 'Downloads');