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

@ -1,5 +1,8 @@
// `storebound ws`: many apps and studios from one folder (storebound.workspace.json; model: cli/lib/workspace.ts).
// ws init [--dry] [--force] find the app folders below and write storebound.workspace.json
// (they join this machine's app folder list too)
// Without a storebound.workspace.json in this folder or above, ws works over this machine's app folders
// (~/.storebound/projects.json: `storebound apps folders`, the desktop app's list).
// ws list [--all] [--json] apps (folders + store-only apps from `ws apps import`), studios, accounts
// ws status [--offline] [--apps a,b] [--json] portfolio table: apps x stores (release trains, catalog, check), read
// in parallel (concurrency from the workspace, default 4)
@ -15,7 +18,7 @@
// Credentials: per account a credential profile (cli/lib/credentials.ts), handed to each app's child command as env.
// Nothing here prints a secret.
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { homedir, tmpdir } from 'node:os';
import { join, relative, resolve, sep } from 'node:path';
import type { Args } from './lib/args';
import { emptyStatus, fillAppStatus, lastLine, pool } from './lib/app-status';
@ -29,9 +32,10 @@ import { textTable } from './lib/table';
import { laneOf, laneText } from './lib/train';
import {
type Account, accountsOf, type AppFolder, cacheFile, type DesktopState, desktopStateFile, fromDesktopState, type LoadedWorkspace, loadWorkspace, mergeDesktopState,
overridesOf, readStoreApps, resolveApps, selectApps, type Store, type StoreAppsCache, studiosOf, toDesktopState, type Workspace, WORKSPACE_NAMES,
findWorkspace, overridesOf, readStoreApps, resolveApps, selectApps, type Store, type StoreAppsCache, studiosOf, toDesktopState, type Workspace, WORKSPACE_NAMES,
} from './lib/workspace';
import { FORBIDDEN_ARGS, TOOLS } from './mcp-tools';
import { addFolder, readRegistry, registryFile } from './lib/registry';
import { checkVirtual, VIRTUAL_READS, virtualDir } from './serve';
/** One app of the workspace: a folder or a store-only app, with the credentials env its commands get. */
@ -44,7 +48,9 @@ const USAGE = `usage: storebound ws <init | list | status | check | validate | r
/** Credentials env for one app: the apple account's ASC_* and the play account's PLAY_* from their profiles. */
export function appEnv(u: UnifiedApp<AppFolder>, accounts: Account[]): { env: Record<string, string>; problems: string[] } {
const env: Record<string, string> = { STOREBOUND_PROFILE: '' };
// with workspace accounts the child gets exactly their keys (no other profile); without, the app's own choice
// (config `account`, its .storebound "account", the machine's default profile) applies as in a terminal
const env: Record<string, string> = {};
const problems: string[] = [];
const profileOf = (store: Store): string | null => {
const fromFolder = store === 'apple' ? u.project?.appleProfile : u.project?.playProfile;
@ -62,6 +68,7 @@ export function appEnv(u: UnifiedApp<AppFolder>, accounts: Account[]): { env: Re
for (const store of ['apple', 'play'] as const) {
const p = profileOf(store);
if (!p) continue;
env.STOREBOUND_PROFILE = '';
try {
const all = profileEnv(p);
const keys = store === 'apple' ? ['ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8'] : ['PLAY_SERVICE_ACCOUNT'];
@ -146,8 +153,13 @@ const catalogCell = (s: AppStatus): string => {
return `${c.products + c.subscriptions} items${c.checked === 'live' ? (issues ? `, ${issues} mismatched` : ', in parity') : ''}`;
};
/** The workspace file in this folder or above; without one, the app folders of this machine (cli/lib/registry.ts:
* the desktop app's list too), each with its own account choice. */
async function loadAll(args: Args): Promise<LoadedWorkspace> {
return loadWorkspace(process.cwd(), args.get('workspace'));
if (args.get('workspace') || findWorkspace(process.cwd())) return loadWorkspace(process.cwd(), args.get('workspace'));
const reg = readRegistry();
if (!reg.projects.length) return loadWorkspace(process.cwd());
return { file: registryFile(), root: homedir(), ws: { apps: reg.projects.map(p => ({ path: p.path, ...(p.name ? { name: p.name } : {}) })) } };
}
function concurrencyOf(args: Args, ws: Workspace): number {
@ -220,6 +232,10 @@ async function init(args: Args): Promise<void> {
const giText = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
if (!/^\.storebound\/?$/m.test(giText)) writeFileSync(gi, giText + (giText && !giText.endsWith('\n') ? '\n' : '') + '.storebound/\n');
log('ws', `wrote ${file}: ${apps.length ? apps.join(', ') : 'no app folders found yet (add them under "apps")'}`);
// the folders also join this machine's list (storebound apps folders, the desktop app)
const found = resolveApps({ file, root, ws }).apps;
for (const f of found) addFolder(f.path);
if (found.length) log('ws', `${found.length} folder(s) in this machine's app list too (${registryFile().replace(homedir(), '~')})`);
log('ws', 'next: add studios and accounts by credential profile (~/.storebound/credentials/<profile>.json), then storebound ws list');
}