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
This commit is contained in:
CRTkafa 2026-10-13 06:17:31 +08:00
parent bba46b5c7f
commit d2ce185441
60 changed files with 2443 additions and 246 deletions

View file

@ -5,6 +5,9 @@
// every Play developer account that invited the
// service account)
// storebound apps check [--store apple|play] [--json] auth OK / permission missing / key revoked, with the fix
// storebound apps folders [--json] the app folders of this machine (~/.storebound/projects.json,
// the same list as the desktop app's App folders)
// storebound apps add [<folder>] | apps remove <folder> add / remove a folder in that list (the folder itself stays)
import type { Args } from './lib/args';
import { ascKey, playServiceAccount } from './lib/credentials';
import type { AccountCheck, AppsListResult } from './lib/desktop-protocol';
@ -16,6 +19,10 @@ import { textTable } from './lib/table';
import { ascClient } from './apple/asc';
import { REPORTING_API } from './play/google';
import * as rt from './lib/rt';
import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { CONFIG_NAMES } from './lib/config';
import { addFolder, readRegistry, registryFile, removeFolder } from './lib/registry';
type Store = 'apple' | 'play';
@ -113,10 +120,36 @@ export async function checkAccount(store: Store, root: string): Promise<AccountC
} catch (e) { return playCheck(principal, { error: e }); }
}
const tilde = (p: string) => (process.env.HOME && p.startsWith(process.env.HOME) ? '~' + p.slice(process.env.HOME.length) : p);
/** apps folders / add / remove: the machine's app folder list (cli/lib/registry.ts). */
function folders(sub: string, args: Args): void {
if (sub === 'folders') {
const r = readRegistry();
if (args.has('json')) { printJson({ file: registryFile(), projects: r.projects.map(p => ({ ...p, missing: !existsSync(p.path) })) }); return; }
if (!r.projects.length) { console.log(`no app folders yet (${tilde(registryFile())}): storebound apps add, in an app folder (or storebound desktop there)`); return; }
console.log(textTable(['app', 'folder', ''], r.projects.map(p => [p.name, tilde(p.path), existsSync(p.path) ? '' : 'folder not found'])));
console.log(`
${tilde(registryFile())}: the same list as the desktop app's App folders`);
return;
}
const dir = resolve(args._[1] ?? '.');
if (sub === 'add') {
if (!CONFIG_NAMES.some(n => existsSync(join(dir, n)))) throw new Error(`${dir}: no storebound config here (storebound init starts one)`);
const r = addFolder(dir);
log('apps', r.added ? `added ${tilde(r.project.path)} (it shows in the desktop app too)` : `${tilde(r.project.path)} is already in the list`);
return;
}
if (!args._[1]) throw new Error('storebound apps remove <folder>');
const removed = removeFolder(args._[1]!.match(/^[0-9a-f]{16}$/) ? args._[1]! : dir);
log('apps', removed ? `removed ${tilde(removed.path)} from the list (the folder and its files stay)` : `${tilde(dir)} is not in the list`);
}
export async function run(args: Args): Promise<void> {
const sub = args._[0];
const root = process.cwd();
const json = args.has('json');
if (sub === 'folders' || sub === 'add' || sub === 'remove') return folders(sub, args);
if (sub === 'list') {
const out: AppsListResult[] = [];
for (const s of stores(args, root)) {
@ -148,6 +181,6 @@ export async function run(args: Args): Promise<void> {
if (out.some(c => c.status !== 'ok')) process.exitCode = 1;
return;
}
console.error('usage: storebound apps list [--store apple|play] [--icons] [--json] | apps check [--store apple|play] [--json]');
console.error('usage: storebound apps list [--store apple|play] [--icons] [--json] | apps check [--store apple|play] [--json] | apps folders [--json] | apps add [<folder>] | apps remove <folder>');
process.exit(2);
}