storebound/tools/desktop-version.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

81 lines
4.2 KiB
TypeScript

#!/usr/bin/env bun
// One version for storebound: the npm CLI and the desktop app are one product and are released together (0.8.0 was
// the first release with one number). The authority is `version` in the root package.json; it is copied into the
// agent plugin manifests (written by tools/gen-adapters.ts), apps/desktop/package.json, src-tauri/tauri.conf.json,
// src-tauri/Cargo.toml and the app's entry in src-tauri/Cargo.lock. `storebound desktop` installs, opens or updates the
// app of exactly this version.
// bun tools/desktop-version.ts check: exit 1 when any copy drifted (CI runs this)
// bun tools/desktop-version.ts --set 0.8.0 write the new version into package.json and every copy
// bun tools/desktop-version.ts --print print the version
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
export const ROOT = join(import.meta.dir, '..');
const TAURI = 'apps/desktop/src-tauri';
export const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
export interface Copy { file: string; read(text: string): string | null; write(text: string, v: string): string }
/** The first `"version": "..."` of a JSON file (top level in package.json and the manifests, the plugin entry of
* marketplace.json). */
const jsonVersion: Pick<Copy, 'read' | 'write'> = {
read: t => /"version"\s*:\s*"([^"]*)"/.exec(t)?.[1] ?? null,
write: (t, v) => t.replace(/("version"\s*:\s*)"[^"]*"/, `$1"${v}"`),
};
/** The authority first, then every copy. */
export const COPIES: Copy[] = [
{ file: 'package.json', ...jsonVersion },
{ file: '.claude-plugin/plugin.json', ...jsonVersion },
{ file: '.claude-plugin/marketplace.json', ...jsonVersion },
{ file: '.codex-plugin/plugin.json', ...jsonVersion },
{ file: 'gemini-extension.json', ...jsonVersion },
{ file: 'apps/desktop/package.json', ...jsonVersion },
{ file: `${TAURI}/tauri.conf.json`, ...jsonVersion },
{
file: `${TAURI}/Cargo.toml`,
// the [package] table's version (the first `version =` line of the file, before any dependency table)
read: t => /^\[package\][^[]*?^version\s*=\s*"([^"]+)"/ms.exec(t)?.[1] ?? null,
write: (t, v) => t.replace(/^(\[package\][^[]*?^version\s*=\s*)"[^"]+"/ms, `$1"${v}"`),
},
{
file: `${TAURI}/Cargo.lock`,
read: t => /^name = "storebound-desktop"\nversion = "([^"]+)"/m.exec(t)?.[1] ?? null,
write: (t, v) => t.replace(/^(name = "storebound-desktop"\nversion = )"[^"]+"/m, `$1"${v}"`),
},
];
export interface Drift { file: string; found: string | null }
export function versions(root = ROOT): { version: string | null; drift: Drift[] } {
const [authority, ...copies] = COPIES.map(c => ({ file: c.file, found: c.read(readFileSync(join(root, c.file), 'utf8')) }));
const version = authority!.found;
return { version, drift: copies.filter(c => c.found !== version) };
}
export function setVersion(v: string, root = ROOT): void {
if (!SEMVER.test(v)) throw new Error(`not a version: ${v}`);
for (const c of COPIES) {
const p = join(root, c.file);
const before = readFileSync(p, 'utf8');
const after = c.write(before, v);
if (c.read(after) !== v) throw new Error(`could not set the version in ${c.file}`);
if (after !== before) writeFileSync(p, after);
}
}
if (import.meta.main) {
const argv = process.argv.slice(2);
const i = argv.indexOf('--set');
if (i >= 0) {
setVersion(argv[i + 1] ?? '');
console.log(`version ${argv[i + 1]} written to ${COPIES.map(c => c.file).join(', ')}\nnext: a CHANGELOG section for it; the npm package and the desktop app are released together (docs/desktop-release.md)`);
process.exit(0);
}
const { version, drift } = versions();
if (argv.includes('--print')) { console.log(version ?? ''); process.exit(version ? 0 : 1); }
if (!version || !SEMVER.test(version)) { console.error('version: package.json has no valid "version"'); process.exit(1); }
for (const d of drift) console.error(`version drift: ${d.file} has ${d.found ?? 'no version'}, package.json version is ${version}`);
if (drift.length) { console.error('fix: bun tools/desktop-version.ts --set ' + version); process.exit(1); }
console.log(`version ${version}: the CLI, the plugin manifests and the desktop app (${COPIES.length - 1} copies) match`);
}