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

65
cli/accounts.ts Normal file
View file

@ -0,0 +1,65 @@
// `storebound accounts`: the store accounts of this machine, shared by every project, the CLI and the desktop app
// (credential profiles in ~/.storebound/credentials; model: cli/lib/credentials.ts, cli/lib/accounts.ts). Values
// are never printed.
// storebound accounts [list] [--json] every profile: Apple key, Play account (masked), which
// one this folder uses and why
// storebound accounts import [--dir <project>] [--name <profile>] [--dry] [--yes]
// move the store keys of a project's .storebound/
// credentials.json into a profile (the same account as an
// existing profile = that profile); the keystore, review
// contact and demo account stay in the project
// storebound accounts use <profile> [--dir <project>] this project uses that profile ("account" in its
// .storebound/credentials.json)
import { resolve } from 'node:path';
import type { Args } from './lib/args';
import { applyImport, describeProfiles, planImport } from './lib/accounts';
import { activeProfile, profileDir } from './lib/credentials';
import { confirm, type Ctx } from './lib/ctx';
import { textTable } from './lib/table';
const tilde = (p: string) => (process.env.HOME && p.startsWith(process.env.HOME) ? '~' + p.slice(process.env.HOME.length) : p);
export async function run(args: Args): Promise<void> {
const sub = args._[0] ?? 'list';
const dir = resolve(args.str('dir', '.'));
if (sub === 'list') {
const rows = describeProfiles();
let active = null;
try { active = activeProfile(dir); } catch { /* unreadable project file */ }
if (args.has('json')) { console.log(JSON.stringify({ folder: tilde(profileDir()), profiles: rows, thisFolder: active }, null, 2)); return; }
if (!rows.length) {
console.log(`no store accounts on this machine yet (${tilde(profileDir())}).\n From a project that has its keys in .storebound/credentials.json: storebound accounts import\n Or add them in the desktop app (Accounts).`);
return;
}
console.log(textTable(['profile', 'App Store Connect', 'Google Play', 'vendor no.'], rows.map(r => [r.profile, r.error ? `unreadable: ${r.error}` : r.apple ? 'key' : '-', r.play ? r.playAccount ?? 'service account' : '-', r.vendor ? 'yes' : '-'])));
console.log(`\n${tilde(profileDir())}: shared by the CLI and the desktop app`);
console.log(active ? `this folder uses "${active.name}" (${{ env: '--account / STOREBOUND_PROFILE', config: 'account in the config', project: '"account" in .storebound/credentials.json', default: 'the default profile' }[active.from]})` : 'this folder uses no profile (several profiles and none named "default": storebound accounts use <profile>)');
return;
}
if (sub === 'import') {
const plan = planImport(dir, { name: args.get('name') });
const json = args.has('json');
const dry = args.has('dry');
if (json && dry) { console.log(JSON.stringify({ ...plan, writes: plan.writes.map(w => ({ path: tilde(w.path), what: w.what, action: w.action })), project: tilde(plan.project), projectFile: tilde(plan.projectFile) }, null, 2)); return; }
console.log(`${dry ? 'dry run: ' : ''}import the store accounts of ${tilde(plan.project)} into profile "${plan.profile}"${plan.reuse ? ' (the same account is already there)' : ' (new)'}`);
console.log(` stores: ${plan.stores.join(', ')}; moved out of the project file: ${plan.moved.join(', ')}`);
for (const w of plan.writes) console.log(` ${w.action === 'same' ? 'unchanged' : w.action.padEnd(9)} ${tilde(w.path)} (${w.what}${w.mode === 0o600 && w.action !== 'same' ? ', mode 600' : ''})`);
for (const n of plan.notes) console.log(` note: ${n}`);
console.log(' stays in the project: the Android upload keystore, the review contact and demo account, everything else');
if (dry) { console.log('\nnothing written. Run again without --dry to import.'); return; }
if (plan.writes.every(w => w.action === 'same')) { console.log('\nalready imported: nothing to change'); return; }
if (!(await confirm({ args } as unknown as Ctx, `Import into profile "${plan.profile}"?`))) return;
const r = applyImport(plan);
console.log(`\nimported: profile "${plan.profile}"${r.backup ? `; the old project file is kept as ${tilde(r.backup)}` : ''}`);
return;
}
if (sub === 'use') {
const name = args._[1];
if (!name) throw new Error('storebound accounts use <profile>');
const { useProfile } = await import('./lib/accounts');
const f = useProfile(dir, name);
console.log(`${tilde(f)}: "account": "${name}"`);
return;
}
throw new Error('usage: storebound accounts [list | import [--dir <project>] [--name <profile>] [--dry] [--yes] | use <profile>]');
}

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);
}

View file

@ -12,8 +12,9 @@ export const PLAIN: Record<string, [() => Promise<Plain>, string]> = {
'install': [() => import('./install'), 'skills + MCP server for --agent claude|codex|cursor|copilot|windsurf|gemini|opencode'],
'mcp': [async () => ({ run: async () => (await import('./mcp')).serve() }), 'stdio MCP server wrapping these commands (for any MCP-capable AI tool)'],
'serve': [() => import('./serve'), '--stdio: JSON-RPC session for the storebound desktop app (dry runs; writes need a host confirm token)'],
'apps': [() => import('./apps'), 'list (every app the store keys can see: App Store Connect team, Play developer accounts; --icons) | check (key works / permission missing / revoked, with the fix); --store apple|play, --json; read-only'],
'desktop': [() => import('./desktop'), 'open the storebound desktop app (in an app folder: add or select it there); not installed: the download for this system, --download saves it to ~/Downloads after checking its signature (never installs)'],
'apps': [() => import('./apps'), 'list (every app the store keys can see: App Store Connect team, Play developer accounts; --icons) | check (key works / permission missing / revoked, with the fix); --store apple|play, --json; read-only | folders | add [<folder>] | remove <folder>: the app folders of this machine, the same list as the desktop app'],
'desktop': [() => import('./desktop'), 'open the storebound desktop app on this folder; not installed or another version: download it for this system (size, SHA-256, signature checked), install it for this user (asks once; --yes) and open it; --download only saves the installer, --dry / --json change nothing'],
'accounts': [() => import('./accounts'), 'the store accounts of this machine (credential profiles in ~/.storebound/credentials, shared with the desktop app): list | import (a project\'s store keys -> a profile; --dry first) | use <profile>'],
'doctor': [() => import('./doctor'), 'local tools for screenshots, previews, icons and captures (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install what is missing; --json'],
'scan': [() => import('./scan'), 'offline, read-only rejection-risk scan of the project itself (stack, SDKs, Info.plist, manifest, listings) with guideline, evidence file:line, fix and skill, plus the Data safety / App Privacy / ATT / privacy manifest proposal; --write puts the detected answers into the config; --json; exit 1 on blockers'],
'api': [() => import('./api'), 'every App Store Connect / Google Play API operation: list | coverage | apple|play <operationId|METHOD path> [--param k=v] [--body f.json] [--all-pages] (GET runs; writes dry unless --yes in a terminal; owner-only ones need the id typed)'],

View file

@ -11,6 +11,10 @@
"yes"
],
"commands": {
"accounts": [
"dir",
"name"
],
"ads": [
"ios",
"plist"
@ -376,8 +380,11 @@
"channel",
"dir",
"download",
"force",
"format",
"no-folder"
"no-folder",
"update-desktop-database",
"xdg-mime"
],
"doctor": [
"dir"
@ -928,6 +935,7 @@
"undo",
"unity",
"update-bigmac",
"update-desktop-database",
"upload",
"url",
"use-chrome-session",
@ -944,10 +952,16 @@
"with-automatic",
"with-version",
"workspace",
"write"
"write",
"xdg-mime"
]
},
"usage": {
"accounts": [
"storebound accounts [list] [--json] every profile: Apple key, Play account (masked), which",
"storebound accounts import [--dir <project>] [--name <profile>] [--dry] [--yes]",
"storebound accounts use <profile> [--dir <project>] this project uses that profile (\"account\" in its"
],
"ads": [
"storebound ads scan --ios ios/App [--plist path/to/Info.plist] [--dry] [--json]"
],
@ -1085,7 +1099,9 @@
],
"apps": [
"storebound apps list [--store apple|play] [--icons] [--json] every app the key can see (App Store Connect team /",
"storebound apps check [--store apple|play] [--json] auth OK / permission missing / key revoked, with the fix"
"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,",
"storebound apps add [<folder>] | apps remove <folder> add / remove a folder in that list (the folder itself stays)"
],
"aso": [
"storebound aso rank [--storefronts us,de=de-DE] [--keywords \"a,b\"] [--only-keywords] [--competitors 3] [--limit 200]",
@ -1116,9 +1132,9 @@
"storebound console verify play-app-record --url <url after Create app> [--write]"
],
"desktop": [
"storebound desktop open the app; in an app folder (storebound.config.*) it adds or selects that",
"storebound desktop the app has this CLI's version: open it (in an app folder: on that folder, which",
"storebound desktop --dir <folder> the same for another folder; --no-folder: just open the app",
"storebound desktop --download not installed: download the installer for this system into ~/Downloads,"
"storebound desktop --download only save the installer for this system into ~/Downloads (checked as above)"
],
"doctor": [
"storebound doctor [--dir .] [--json]"

View file

@ -1,31 +1,53 @@
// `storebound desktop`: the bridge from the terminal to the desktop app. The app is a separate signed download from
// storebound.dev (it updates itself and brings its own copy of this CLI); the npm package never contains it.
// storebound desktop open the app; in an app folder (storebound.config.*) it adds or selects that
// folder (storebound://open?path=..., the app asks before adding)
// `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 <folder> the same for another folder; --no-folder: just open the app
// storebound desktop --download not installed: download the installer for this system into ~/Downloads,
// check its SHA-256 and signature, and print where it is. It never installs.
// --channel beta, --format deb (Linux), --json (print what it would do, change nothing)
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
// 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 { BUNDLE_ID, DOWNLOAD_LABEL, type Download, type DownloadKey, downloadsDir, downloadsFor, findInstalled, MANIFEST, type Manifest, manifestProblems, openCommand, openFolderLink, PAGE, PUBLIC_KEY } from './lib/desktop-dist';
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<Response>;
export async function fetchManifest(channel: 'stable' | 'beta', get: Fetch = fetch): Promise<Manifest> {
const r = await get(MANIFEST[channel]);
if (!r.ok) throw new Error(`${MANIFEST[channel]}: HTTP ${r.status}`);
export async function fetchManifest(channel: 'stable' | 'beta', get: Fetch = fetch, url = MANIFEST[channel]): Promise<Manifest> {
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/<version>/latest.json) when that is
* published, else the channel's newest. */
export async function targetManifest(cli: string, channel: 'stable' | 'beta', get: Fetch = fetch): Promise<Manifest> {
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;
@ -51,58 +73,180 @@ export async function download(d: Download, dir: string, get: Fetch = fetch, pub
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<string, unknown>;
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<boolean> {
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<void> {
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 installed = findInstalled(platform);
if (installed && !args.has('download')) {
const link = isApp ? openFolderLink(dir) : null;
const cmd = openCommand(platform, installed, link);
if (args.has('json')) { console.log(JSON.stringify({ installed, link, command: cmd }, null, 2)); return; }
const p = rt.spawn(cmd, { stdio: ['ignore', 'ignore', 'pipe'] });
if (platform === 'darwin') {
const code = await p.exited;
if (code !== 0) throw new Error(`open -b ${BUNDLE_ID} failed: ${(await new Response(p.stderr).text()).trim()}`);
} else p.unref();
console.log(link ? `storebound desktop: opening ${dir} (the app asks before it adds a new folder)` : 'storebound desktop: opened');
return;
}
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 = '';
try { manifest = await fetchManifest(channel); } catch (e) { error = (e as Error).message; }
const chosen = manifest ? pick(manifest, keys, args.get('format')) : null;
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('json')) {
console.log(JSON.stringify({ installed, page: PAGE, version: manifest?.version ?? null, download: chosen ? { kind: chosen[0], ...chosen[1] } : null, error: error || null }, null, 2));
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;
}
if (!args.has('download')) {
console.log(installed ? 'storebound desktop is installed.' : 'storebound desktop is not installed on this machine.');
if (chosen) {
console.log(`\n ${DOWNLOAD_LABEL[chosen[0]]}, version ${manifest!.version}${chosen[1].codeSigned ? '' : ' (unsigned preview)'}:\n ${chosen[1].url}\n SHA-256 ${chosen[1].sha256}`);
} else if (!keys.length) {
console.log(`\n There is no desktop build for ${platform} ${process.arch} yet.`);
} else if (error) {
console.log(/HTTP 404/.test(error) ? '\n No desktop release is published yet.' : `\n (could not read the release list: ${error})`);
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-<temp>');
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;
}
console.log(`\n Every download and its checksum: ${PAGE}`);
if (chosen) console.log(' Or: storebound desktop --download (saves the installer in ~/Downloads after checking its signature; it does not install it)');
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)}`);
}
if (!manifest) throw new Error(`could not read the release list: ${error}`);
if (!chosen) throw new Error(keys.length ? `version ${manifest.version} has no ${args.get('format') ?? 'installer'} for ${platform} ${process.arch} yet; see ${PAGE}` : `no desktop build for ${platform} ${process.arch}; see ${PAGE}`);
const [kind, d] = chosen;
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 SHA-256 and signature checked (key of storebound desktop). Not installed: the owner opens it to install it on this machine.`);
if (!d.codeSigned) console.log(platform === 'darwin'
? ' This preview is not signed by Apple yet: after copying storebound to Applications, open it once, then System Settings > Privacy & Security > Open Anyway.'
: platform === 'win32' ? ' This preview is not code signed yet: Windows SmartScreen may ask; choose More info > Run anyway.' : '');
// 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');
}

207
cli/lib/accounts.ts Normal file
View file

@ -0,0 +1,207 @@
// The machine's store accounts: credential profiles in ~/.storebound/credentials (cli/lib/credentials.ts), shared by
// every project, the CLI and the desktop app. This module reads what a profile can reach (without values) and builds
// a profile from a project's .storebound/credentials.json (`storebound accounts import`): the store-account part
// (Apple team key, Play service account, vendor number) moves to the profile, the key files are copied into
// <credentials>/<name>/ (0600, folder 0700), and the project file keeps everything that belongs to the app (upload
// keystore, review contact, demo account) plus "account": "<name>". Same key id or service account e-mail as an
// existing profile = that profile (run it on every project; one account, one profile). Values are never printed.
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { isAbsolute, join, resolve } from 'node:path';
import { maskUser, PROFILE_KEYS, PROFILE_RE, profileDir, profileEnv, profileNames } from './credentials';
export type Kind = 'apple' | 'play';
/** The store keys that move to a profile (one developer account's), never the app's own ones. */
export const STORE_KEYS = PROFILE_KEYS;
export interface Identity { appleKeyId: string | null; playEmail: string | null; vendor: boolean }
const PEM = /BEGIN PRIVATE KEY/;
const isJsonText = (v: string) => /^\s*\{/.test(v);
const expand = (p: string, base: string) => { const x = p.replace(/^~(?=\/|\\)/, homedir()); return isAbsolute(x) ? x : resolve(base, x); };
/** The client_email of a service account (inline JSON or a file); null when unreadable. */
export function serviceAccountEmail(v: string, base: string): string | null {
try {
const text = isJsonText(v) ? v : readFileSync(expand(v, base), 'utf8');
const e = (JSON.parse(text) as { client_email?: unknown }).client_email;
return typeof e === 'string' && e ? e : null;
} catch { return null; }
}
/** What a profile can reach: the Apple key id and the Play service account e-mail (for matching, never printed). */
export function profileIdentity(name: string): Identity {
const env = profileEnv(name);
return { appleKeyId: env.ASC_KEY_ID ?? null, playEmail: env.PLAY_SERVICE_ACCOUNT ? serviceAccountEmail(env.PLAY_SERVICE_ACCOUNT, profileDir()) : null, vendor: !!env.ASC_VENDOR_NUMBER };
}
/** One line per profile for `storebound accounts`: stores, masked Play e-mail. No key ids, no values. */
export function describeProfiles(): { profile: string; apple: boolean; play: boolean; playAccount: string | null; vendor: boolean; error?: string }[] {
return profileNames().map(profile => {
try {
const id = profileIdentity(profile);
return { profile, apple: !!id.appleKeyId, play: !!id.playEmail, playAccount: id.playEmail ? maskUser(id.playEmail) : null, vendor: id.vendor };
} catch (e) { return { profile, apple: false, play: false, playAccount: null, vendor: false, error: (e as Error).message }; }
});
}
export interface FileWrite { path: string; what: string; action: 'create' | 'update' | 'same'; mode: number; content?: string; copyFrom?: string }
export interface ImportPlan {
project: string;
projectFile: string;
profile: string;
/** new profile, or the existing one with the same Apple key id / Play service account */
reuse: boolean;
stores: Kind[];
writes: FileWrite[];
/** keys removed from the project file (moved to the profile) */
moved: string[];
notes: string[];
}
type Json = Record<string, unknown>;
const readJson = (p: string): Json => JSON.parse(readFileSync(p, 'utf8')) as Json;
const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+|-+$/g, '').slice(0, 64);
/**
* The plan of `accounts import` for one project (reads only). Throws with the reason when there is nothing to move,
* when the project's Apple key and Play account belong to two different profiles, or when --name names a profile that
* already holds another account.
*/
export function planImport(project: string, o: { name?: string } = {}): ImportPlan {
const projectFile = join(project, '.storebound', 'credentials.json');
if (!existsSync(projectFile)) throw new Error(`${projectFile}: not found (nothing to import)`);
const file = readJson(projectFile);
const str = (k: string) => (typeof file[k] === 'string' && (file[k] as string).trim() ? (file[k] as string).trim() : undefined);
const notes: string[] = [];
const keyId = str('ASC_KEY_ID'), issuer = str('ASC_ISSUER_ID');
let p8 = str('ASC_P8');
if (keyId && !p8) {
const xcode = join(homedir(), '.appstoreconnect', 'private_keys', `AuthKey_${keyId}.p8`);
if (existsSync(xcode)) { p8 = xcode; notes.push('ASC_P8 from ~/.appstoreconnect/private_keys (copied into the profile)'); }
}
const sa = str('PLAY_SERVICE_ACCOUNT');
const playEmail = sa ? serviceAccountEmail(sa, project) : null;
if (sa && !playEmail) throw new Error('PLAY_SERVICE_ACCOUNT in the project file is not a readable service account key (path relative to the project, or the JSON)');
if (p8 && !PEM.test(p8) && !existsSync(expand(p8, project))) throw new Error('ASC_P8 in the project file points to a file that does not exist');
const apple = !!(keyId && issuer);
if ((keyId || issuer) && !apple) throw new Error('the project file has only half an App Store Connect key (ASC_KEY_ID and ASC_ISSUER_ID go together)');
if (!apple && !sa && typeof file.account === 'string' && profileNames().includes(file.account)) {
return { project, projectFile, profile: file.account, reuse: true, stores: [], writes: [], moved: [], notes: [`already imported: the project uses profile "${file.account}"`] };
}
if (!apple && !sa) throw new Error('the project file holds no store account (ASC_KEY_ID + ASC_ISSUER_ID, PLAY_SERVICE_ACCOUNT): nothing to import');
if (o.name !== undefined && !PROFILE_RE.test(o.name)) throw new Error(`--name ${o.name}: letters, digits, . _ - (max 64)`);
// the profile: the existing one with this account, else --name, else "default" (first profile) or a free name
const ids = new Map(profileNames().map(n => [n, (() => { try { return profileIdentity(n); } catch { return null; } })()] as const));
const hits = [...ids].filter(([, id]) => id && ((apple && id.appleKeyId === keyId) || (playEmail && id.playEmail === playEmail))).map(([n]) => n);
if (hits.length > 1) throw new Error(`the Apple key and the Play account of this project are in two profiles (${hits.join(', ')}): import with --name <one of them> after merging them, or keep them apart`);
let profile: string;
let reuse = false;
if (hits.length === 1) {
profile = hits[0]!;
reuse = true;
if (o.name && o.name !== profile) notes.push(`this account is already in profile "${profile}": --name ${o.name} ignored`);
} else if (o.name) {
profile = o.name;
reuse = ids.has(profile);
} else if (!ids.size) profile = 'default';
else {
const base = slug(project.split(/[\\/]/).filter(Boolean).pop() ?? 'account') || 'account';
profile = base;
for (let i = 2; ids.has(profile); i++) profile = `${base}-${i}`;
notes.push(`a second store account on this machine: profile "${profile}" (pick another with --name)`);
}
const current: Json = ids.has(profile) ? readJson(join(profileDir(), `${profile}.json`)) : {};
const have = ids.get(profile) ?? null;
if (have && apple && have.appleKeyId && have.appleKeyId !== keyId) throw new Error(`profile "${profile}" already has another App Store Connect key: use --name <new profile>`);
if (have && playEmail && have.playEmail && have.playEmail !== playEmail) throw new Error(`profile "${profile}" already has another Play service account: use --name <new profile>`);
const dir = profileDir();
const writes: FileWrite[] = [];
const next: Json = { ...current };
const keyFile = (rel: string, what: string, v: string, base: string) => {
const path = join(dir, rel);
const content = PEM.test(v) || isJsonText(v) ? (PEM.test(v) ? v.trim() + '\n' : v) : undefined;
const src = content === undefined ? expand(v, base) : undefined;
const bytes = content ?? readFileSync(src!, 'utf8');
const action = !existsSync(path) ? 'create' : readFileSync(path, 'utf8') === bytes ? 'same' : 'update';
writes.push({ path, what, action, mode: 0o600, ...(content !== undefined ? { content } : { copyFrom: src }) });
return rel.split('\\').join('/');
};
const moved: string[] = [];
const stores: Kind[] = [];
if (apple) {
stores.push('apple');
// a profile that already has this key keeps its own key file
if (!(have?.appleKeyId === keyId && typeof current.ASC_P8 === 'string')) {
if (p8) next.ASC_P8 = keyFile(join(profile, 'app-store-connect.p8'), 'App Store Connect key (.p8)', p8, project);
}
next.ASC_KEY_ID = keyId; next.ASC_ISSUER_ID = issuer;
moved.push('ASC_KEY_ID', 'ASC_ISSUER_ID', ...(str('ASC_P8') ? ['ASC_P8'] : []));
}
if (sa) {
stores.push('play');
if (!(have?.playEmail === playEmail && typeof current.PLAY_SERVICE_ACCOUNT === 'string')) next.PLAY_SERVICE_ACCOUNT = keyFile(join(profile, 'play-service-account.json'), 'Play service account key (JSON)', sa, project);
moved.push('PLAY_SERVICE_ACCOUNT');
}
const vendor = str('ASC_VENDOR_NUMBER');
if (vendor) {
if (typeof current.ASC_VENDOR_NUMBER === 'string' && current.ASC_VENDOR_NUMBER !== vendor) notes.push('ASC_VENDOR_NUMBER: the profile keeps its own; the project value stays in the project');
else { next.ASC_VENDOR_NUMBER = vendor; moved.push('ASC_VENDOR_NUMBER'); }
}
const profileText = JSON.stringify(next, null, 2) + '\n';
const profilePath = join(dir, `${profile}.json`);
writes.push({ path: profilePath, what: `profile ${profile}`, action: !existsSync(profilePath) ? 'create' : readFileSync(profilePath, 'utf8') === profileText ? 'same' : 'update', mode: 0o600, content: profileText });
// the project file: store keys out, "account" in; everything else (keystore, review contact, demo account) stays
const proj: Json = {};
for (const [k, v] of Object.entries(file)) if (!moved.includes(k)) proj[k] = v;
proj.account = profile;
const projText = JSON.stringify(proj, null, 2) + '\n';
writes.push({ path: projectFile, what: 'project credentials (store keys removed, "account" set; a backup is kept)', action: readFileSync(projectFile, 'utf8') === projText ? 'same' : 'update', mode: 0o600, content: projText });
return { project, projectFile, profile, reuse, stores, writes, moved, notes };
}
const atomic = (path: string, data: string | Buffer, mode: number) => {
const tmp = `${path}.tmp-${process.pid}`;
writeFileSync(tmp, data, { mode });
chmodSync(tmp, mode);
renameSync(tmp, path);
};
/** Runs a plan: key files and the profile first (0600, folders 0700), then reads the profile back, then the project
* file (its old version kept as credentials.json.bak-<time>, 0600). Safe to run again. */
export function applyImport(plan: ImportPlan, now = new Date()): { backup: string | null } {
const dir = profileDir();
mkdirSync(dir, { recursive: true, mode: 0o700 });
try { chmodSync(dir, 0o700); } catch { /* not ours to change (Windows) */ }
let backup: string | null = null;
for (const w of plan.writes) {
if (w.action === 'same') continue;
if (w.path === plan.projectFile) {
// the profile must answer before the project forgets its keys
const id = profileIdentity(plan.profile);
if ((plan.stores.includes('apple') && !id.appleKeyId) || (plan.stores.includes('play') && !id.playEmail)) throw new Error(`profile ${plan.profile} does not read back: the project file was not changed`);
backup = `${plan.projectFile}.bak-${now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`;
copyFileSync(plan.projectFile, backup);
chmodSync(backup, 0o600);
} else {
const parent = resolve(w.path, '..');
if (parent !== dir) { mkdirSync(parent, { recursive: true, mode: 0o700 }); try { chmodSync(parent, 0o700); } catch { /* Windows */ } }
}
atomic(w.path, w.copyFrom ? readFileSync(w.copyFrom) : w.content!, w.mode);
}
return { backup };
}
/** Points a project at a profile ("account" in its .storebound/credentials.json; the file is created when missing). */
export function useProfile(project: string, name: string): string {
if (!PROFILE_RE.test(name)) throw new Error(`${name}: letters, digits, . _ - (max 64)`);
if (!profileNames().includes(name)) throw new Error(`no profile "${name}" in ${profileDir()}: \`storebound accounts\` lists them`);
const file = join(project, '.storebound', 'credentials.json');
const cur: Json = existsSync(file) ? readJson(file) : {};
mkdirSync(join(project, '.storebound'), { recursive: true });
atomic(file, JSON.stringify({ ...cur, account: name }, null, 2) + '\n', 0o600);
return file;
}

View file

@ -3,6 +3,7 @@
import { existsSync } from 'node:fs';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import * as rt from './rt';
import { PROFILE_RE, setConfigAccount } from './credentials';
import { validateCapture, validateRecord } from '../shots/devices';
import { type AppleDataTypeId, type AppleTypeAnswer, validateAppPrivacyTypes } from './app-privacy';
import { type AppEventDef, type CustomProductPageDef, validateCpp, validateEvents } from './cpp-events';
@ -518,6 +519,10 @@ export interface ShipConfig {
website: string;
contactEmail: string;
};
/** The credential profile (~/.storebound/credentials/<name>.json: the Apple team key and Play service account,
* shared by every project and the desktop app) this app's store commands use. Default: the only profile on this
* machine, or the one named "default". `--account <name>` overrides it for one run. A name, never a secret. */
account?: string;
/** Accounts in the app: account deletion checks (Apple 5.1.1(v), Play Data safety delete-account URL). */
accounts?: AccountsConfig;
/** Sign-in for store reviewers (demo account; credentials live in .storebound/ only). */
@ -998,6 +1003,7 @@ export function validateConfig(c: unknown): string[] {
const e: string[] = [];
const cfg = c as Partial<ShipConfig>;
if (!cfg || typeof cfg !== 'object') return ['config must be an object'];
if (cfg.account !== undefined && (typeof cfg.account !== 'string' || !PROFILE_RE.test(cfg.account))) e.push('account: a credential profile name (letters, digits, . _ -), never a key');
const app = cfg.app;
if (!app) e.push('app: missing');
else {
@ -1187,6 +1193,7 @@ export async function loadConfig(explicit?: string, start = process.cwd()): Prom
const errors = validateConfig(config);
if (errors.length) throw new Error(`${path}:\n ` + errors.join('\n '));
const root = dirname(path);
setConfigAccount(root, config.account);
return { config, root, path, resolve: (p: string) => (isAbsolute(p) ? p : join(root, p)) };
}

View file

@ -1,17 +1,28 @@
// Credentials never live in storebound.config.ts or in git. Sources, first hit wins:
// Credentials never live in storebound.config.ts or in git. storebound is installed once per machine, and so are the
// store accounts: a credential profile in ~/.storebound/credentials/<name>.json (folder: STOREBOUND_CREDENTIALS_DIR)
// holds one Apple team key and / or one Play service account, shared by the CLI and the desktop app. Everything that
// belongs to one app stays in the project: <project>/.storebound/credentials.json (gitignored) has its Android upload
// keystore, the App Review contact and demo account, and may still hold store keys of its own.
//
// Which profile a project uses (first hit): --account <name> / env STOREBOUND_PROFILE (set but empty = none), the
// config's `account`, "account" in <project>/.storebound/credentials.json (written by `storebound accounts import`),
// else the default: the only profile on this machine, or the one named "default".
//
// One value, first hit wins:
// 1. environment: PLAY_SERVICE_ACCOUNT (path to the JSON key, or the JSON itself), ASC_KEY_ID, ASC_ISSUER_ID,
// ASC_P8 (path to AuthKey_<id>.p8, or the PEM itself)
// 2. <project>/.storebound/credentials.json (gitignored), same names:
// { "PLAY_SERVICE_ACCOUNT": "...", "ASC_KEY_ID": "...", "ASC_ISSUER_ID": "...", "ASC_P8": "...",
// ASC_P8 (path to AuthKey_<id>.p8, or the PEM itself), ASC_VENDOR_NUMBER, ANDROID_*, ...
// 2. the project's profile when it names one (--account, config, credentials.json "account"): store keys only
// 3. <project>/.storebound/credentials.json, same names:
// { "account": "<profile>", "ANDROID_KEYSTORE": "...", ...,
// "reviewContact": { "firstName": "", "lastName": "", "phone": "", "email": "" },
// "reviewDemoAccount": { "username": "", "password": "" } }
// The demo account for App Review can also come from env: REVIEW_DEMO_USER, REVIEW_DEMO_PASSWORD.
// 3. ASC_P8 only: ~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8 (where Xcode / altool keep it)
// With a credential profile (`--profile <name>` or STOREBOUND_PROFILE), the profile comes before all of these:
// ~/.storebound/credentials/<name>.json (same four names; relative paths are relative to that file; folder:
// STOREBOUND_CREDENTIALS_DIR) or env STOREBOUND_PROFILE_<NAME>_<KEY> (NAME upper case, other characters as _).
// Many studios / store accounts from one machine: one profile per account; storebound.workspace.json names them.
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
// 4. the default profile (none named): store keys only
// 5. ASC_P8 only: ~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8 (where Xcode / altool keep it)
// A profile file: { "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_P8", "PLAY_SERVICE_ACCOUNT", "ASC_VENDOR_NUMBER", "label"? };
// key paths relative to the credentials folder (`accounts import` puts the key files in <name>/, mode 0600), or env
// STOREBOUND_PROFILE_<NAME>_<KEY> (NAME upper case, other characters as _). storebound.workspace.json names profiles too.
import { chmodSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { isAbsolute, join, resolve } from 'node:path';
@ -60,6 +71,38 @@ export function profileEnv(profile: string): Record<string, string> {
return out;
}
/** The profiles on this machine (the *.json files of the credentials folder), sorted. */
export function profileNames(): string[] {
let names: string[] = [];
try { names = readdirSync(profileDir()).filter(n => n.endsWith('.json')).map(n => n.slice(0, -5)); } catch { /* no folder yet */ }
return names.filter(n => PROFILE_RE.test(n)).sort();
}
/** The default profile: the only one on this machine, else the one named "default"; null when that is not clear. */
export function defaultProfile(names = profileNames()): string | null {
return names.length === 1 ? names[0]! : names.includes('default') ? 'default' : null;
}
/** config.account per project root, set by loadConfig (config.ts) so credentials need not load the config. */
const configAccounts = new Map<string, string>();
export function setConfigAccount(root: string, name: string | undefined): void {
if (name) configAccounts.set(resolve(root), name); else configAccounts.delete(resolve(root));
}
export interface ActiveProfile { name: string; from: 'env' | 'config' | 'project' | 'default' }
/** The profile a project uses (see the header), or null. Throws only on an unreadable project credentials file. */
export function activeProfile(root: string): ActiveProfile | null {
const env = process.env.STOREBOUND_PROFILE;
if (env !== undefined) return env ? { name: env, from: 'env' } : null;
const cfg = configAccounts.get(resolve(root));
if (cfg) return { name: cfg, from: 'config' };
const own = fileStore(root).account;
if (typeof own === 'string' && own) return { name: own, from: 'project' };
const d = defaultProfile();
return d ? { name: d, from: 'default' } : null;
}
/** Which stores a profile can reach, and where it comes from. No values. */
export function profileStatus(profile: string): { profile: string; source: 'file' | 'env' | 'missing' | 'invalid'; apple: boolean; play: boolean; error?: string } {
try {
@ -98,23 +141,26 @@ export const CREDENTIAL_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUE
export function credentialStatus(root: string): { key: string; status: 'set' | 'missing' | 'placeholder' }[] {
let file: Store = {};
try { file = fileStore(root); } catch { /* unreadable: reported by the commands that read it */ }
const profile = process.env.STOREBOUND_PROFILE;
let prof: Record<string, string> = {};
try { if (profile) prof = profileEnv(profile); } catch { /* invalid profile */ }
try { const p = activeProfile(root); if (p) prof = profileEnv(p.name); } catch { /* invalid profile */ }
return CREDENTIAL_KEYS.map(key => {
const raw = [prof[key], process.env[key], typeof file[key] === 'string' ? file[key] as string : undefined].filter((x): x is string => !!x && !!x.trim());
return { key, status: raw.some(x => !isPlaceholder(x)) ? 'set' as const : raw.length ? 'placeholder' as const : 'missing' as const };
});
}
/** One value by the order in the header: env, a named profile, the project file, the default profile. */
function value(root: string, name: string): string | undefined {
const real = (v: unknown): v is string => typeof v === 'string' && !!v && !isPlaceholder(v);
const profile = process.env.STOREBOUND_PROFILE;
if (profile) { const v = profileEnv(profile)[name]; if (real(v)) return v; }
const env = process.env[name];
if (real(env)) return env;
const p = activeProfile(root);
const fromProfile = () => (p && (PROFILE_KEYS as readonly string[]).includes(name) ? profileEnv(p.name)[name] : undefined);
if (p && p.from !== 'default') { const v = fromProfile(); if (real(v)) return v; }
const v = fileStore(root)[name];
return real(v) ? v : undefined;
if (real(v)) return v;
if (p?.from === 'default') { const d = fromProfile(); if (real(d)) return d; }
return undefined;
}
/** A path or the inline content itself. */
@ -129,14 +175,14 @@ export function parseServiceAccount(text: string): ServiceAccount {
export function playServiceAccount(root: string): ServiceAccount {
const v = value(root, 'PLAY_SERVICE_ACCOUNT');
if (!v) throw new Error('PLAY_SERVICE_ACCOUNT is not set (env, or .storebound/credentials.json): path to the service account JSON key');
if (!v) throw new Error('PLAY_SERVICE_ACCOUNT is not set (env, the credential profile in ~/.storebound/credentials, or .storebound/credentials.json): path to the service account JSON key; `storebound accounts` lists the profiles');
return parseServiceAccount(inline(v, /^\s*\{/));
}
export function ascKey(root: string): AscKey {
const keyId = value(root, 'ASC_KEY_ID');
const issuerId = value(root, 'ASC_ISSUER_ID');
if (!keyId || !issuerId) throw new Error('ASC_KEY_ID and ASC_ISSUER_ID are not set (env, or .storebound/credentials.json)');
if (!keyId || !issuerId) throw new Error('ASC_KEY_ID and ASC_ISSUER_ID are not set (env, the credential profile in ~/.storebound/credentials, or .storebound/credentials.json); `storebound accounts` lists the profiles');
const p8 = value(root, 'ASC_P8') ?? join(homedir(), '.appstoreconnect/private_keys', `AuthKey_${keyId}.p8`);
if (!/BEGIN PRIVATE KEY/.test(p8) && !existsSync(p8.replace(/^~(?=\/)/, homedir()))) throw new Error(`App Store Connect key not found: ${p8} (set ASC_P8)`);
return { keyId, issuerId, pem: inline(p8, /BEGIN PRIVATE KEY/) };

View file

@ -1,6 +1,7 @@
// Where the storebound desktop app comes from and where it lives once installed. The app is a separate signed
// download from storebound.dev (DMG, Windows installer, AppImage / deb) that updates itself; the npm package stays
// the CLI, MCP server and skills and carries no app binaries. `storebound desktop` (cli/desktop.ts) uses this.
// 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
@ -16,6 +17,9 @@ 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';

186
cli/lib/desktop-install.ts Normal file
View file

@ -0,0 +1,186 @@
// What `storebound desktop` does on this machine, as data: the decision (open, install, update, downgrade) from the
// installed app's version and the CLI's (one product, one version number), and the install steps per OS as a plan
// that cli/desktop.ts runs and the tests read. Installs are per user and never need admin rights:
// macOS the .app from the DMG (mounted read-only, never shown in Finder) into /Applications when that is writable,
// else ~/Applications; an existing install is replaced in place. Nothing touches extended attributes: the
// installer was downloaded by this process (no quarantine flag), its SHA-256 and minisign signature are
// checked, and a release build is signed and notarized.
// Windows the NSIS installer, silent (/S), per user (%LOCALAPPDATA%\storebound), which also registers storebound://.
// Linux the AppImage into ~/.local/bin (or where the existing one is) plus ~/.local/share/applications/
// storebound.desktop with the storebound:// handler, registered when update-desktop-database / xdg-mime
// exist. A .deb install needs root, so it is printed, never run.
import { join } from 'node:path';
import type { DownloadKey, Installed } from './desktop-dist';
/** -1, 0, 1 like a comparator; a pre-release sorts before its release (0.8.0-beta.1 < 0.8.0). */
export function compareVersions(a: string, b: string): number {
const [ma, pa = ''] = a.split('-', 2) as [string, string?];
const [mb, pb = ''] = b.split('-', 2) as [string, string?];
const na = ma.split('.').map(Number), nb = mb.split('.').map(Number);
for (let i = 0; i < 3; i++) { const d = (na[i] ?? 0) - (nb[i] ?? 0); if (d) return d < 0 ? -1 : 1; }
if (pa === pb) return 0;
if (!pa) return 1;
if (!pb) return -1;
return pa < pb ? -1 : 1;
}
export type Action = 'open' | 'install' | 'update' | 'downgrade' | 'unavailable';
export interface Decision { action: Action; from: string | null; to: string | null; notes: string[] }
export interface DecideIn {
installed: Installed | null;
/** the installed app's version; null when it could not be read */
installedVersion: string | null;
cli: string;
/** the version storebound.dev offers for this system: the CLI's own when published, else the channel's newest */
available: string | null;
/** why nothing is available (no build for this system, no release yet, offline) */
unavailableReason?: string;
force: boolean;
}
/**
* The one rule: the app should have the CLI's version. Same: open. Missing: install. Older: update (to the CLI's
* version, or the newest published when the CLI's is not out yet). Newer: open and say so; a downgrade only with --force.
*/
export function decide(i: DecideIn): Decision {
const notes: string[] = [];
const offer = i.available;
if (offer && offer !== i.cli) notes.push(`storebound.dev has desktop ${offer} for this system, this CLI is ${i.cli}`);
if (!i.installed) {
if (!offer) return { action: 'unavailable', from: null, to: null, notes: [i.unavailableReason ?? 'no desktop build for this system'] };
return { action: 'install', from: null, to: offer, notes };
}
const have = i.installedVersion;
if (!have) return { action: 'open', from: null, to: null, notes: ['the installed app\'s version could not be read'] };
if (have === i.cli) return { action: 'open', from: have, to: null, notes: [] };
if (compareVersions(have, i.cli) < 0) {
if (offer && compareVersions(offer, have) > 0) return { action: 'update', from: have, to: offer, notes: [`the app is ${have}, this CLI is ${i.cli}`, ...notes] };
return { action: 'open', from: have, to: null, notes: [`the app is ${have}, this CLI is ${i.cli}: no newer app is published yet${i.unavailableReason ? ` (${i.unavailableReason})` : ''}`] };
}
// the app is newer than this CLI
if (i.force && offer && compareVersions(offer, have) < 0) return { action: 'downgrade', from: have, to: offer, notes: [`--force: replacing the newer app ${have} with ${offer}`] };
return { action: 'open', from: have, to: null, notes: [`the app (${have}) is newer than this CLI (${i.cli}): update the CLI (npx storebound@latest, or bun add -d storebound@latest in a project that pins it). Nothing was downgraded (--force installs ${offer ?? i.cli})`] };
}
/** One step of an install. `run` is an argv (never a shell); `always` steps run even after a failure (unmount). */
export type Step =
| { what: string; run: string[]; timeoutMs: number; always?: boolean; optional?: boolean }
| { what: string; mkdir: string }
| { what: string; copy: { from: string; to: string; mode?: number } }
| { what: string; rename: { from: string; to: string } }
| { what: string; remove: string }
| { what: string; write: { path: string; content: string; mode?: number } }
| { what: string; expectVersion: { app: string; version: string } };
export interface Plan {
/** where the app ends up (the .app, the .exe, the AppImage) */
target: string;
/** a short place for the question ("~/Applications") */
place: string;
steps: Step[];
/** the plan cannot run (a .deb needs root): what to tell the owner instead */
manual?: string;
}
export interface PlanIn {
platform: NodeJS.Platform;
kind: DownloadKey;
/** the verified installer on disk */
file: string;
version: string;
home: string;
/** the app already there (an update replaces it in place) */
existing: Installed | null;
/** /Applications is writable for this user (macOS) */
systemAppsWritable: boolean;
/** a private temp folder for the mount point / staging */
tmp: string;
env: Record<string, string | undefined>;
/** which optional Linux tools exist */
has: (tool: string) => boolean;
}
const tildeOf = (p: string, home: string) => (p.startsWith(home) ? '~' + p.slice(home.length) : p);
export function installPlan(i: PlanIn): Plan {
if (i.platform === 'darwin') {
const dir = i.existing?.how === 'bundle' ? join(i.existing.path, '..') : i.systemAppsWritable ? '/Applications' : join(i.home, 'Applications');
const target = join(dir, 'storebound.app');
const mnt = join(i.tmp, 'mnt');
const staged = join(dir, '.storebound.app.new');
const old = join(dir, '.storebound.app.old');
const steps: Step[] = [
{ what: `make ${tildeOf(dir, i.home)}`, mkdir: dir },
{ what: 'a private mount point', mkdir: mnt },
{ what: 'mount the disk image read-only (not shown in Finder)', run: ['hdiutil', 'attach', '-nobrowse', '-readonly', '-noautoopen', '-mountpoint', mnt, i.file], timeoutMs: 120_000 },
{ what: 'copy storebound.app', run: ['ditto', join(mnt, 'storebound.app'), staged], timeoutMs: 300_000 },
{ what: 'unmount the disk image', run: ['hdiutil', 'detach', mnt, '-quiet'], timeoutMs: 60_000, always: true },
{ what: `check the copied app is ${i.version}`, expectVersion: { app: staged, version: i.version } },
...(i.existing?.how === 'bundle' ? [{ what: 'move the old app aside', rename: { from: target, to: old } } as Step] : []),
{ what: `put storebound.app in ${tildeOf(dir, i.home)}`, rename: { from: staged, to: target } },
...(i.existing?.how === 'bundle' ? [{ what: 'remove the old app', remove: old } as Step] : []),
];
return { target, place: tildeOf(dir, i.home), steps };
}
if (i.platform === 'win32') {
const base = i.env.LOCALAPPDATA ?? join(i.home, 'AppData', 'Local');
const target = `${base}\\storebound\\storebound-desktop.exe`;
return {
target, place: '%LOCALAPPDATA%\\storebound',
steps: [
{ what: 'run the installer silently, for this user only (no admin rights)', run: [i.file, '/S'], timeoutMs: 300_000 },
{ what: `check the installed app is ${i.version}`, expectVersion: { app: target, version: i.version } },
],
};
}
// Linux
if (i.kind === 'linux-x86_64-deb' || i.existing?.how === 'deb') {
return { target: '/usr/bin/storebound-desktop', place: 'the system (.deb)', steps: [], manual: `a .deb installs system-wide and needs root: sudo apt install ${i.file}` };
}
const dir = i.existing?.how === 'appimage' ? join(i.existing.path, '..') : join(i.home, '.local', 'bin');
const target = i.existing?.how === 'appimage' ? i.existing.path : join(dir, 'storebound.AppImage');
const staged = join(dir, '.storebound.AppImage.new');
const apps = join(i.env.XDG_DATA_HOME ?? join(i.home, '.local', 'share'), 'applications');
const entry = [
'[Desktop Entry]', 'Type=Application', 'Name=storebound', 'Comment=Every game\'s releases on both stores in one window',
`Exec="${target}" %u`, 'Terminal=false', 'Categories=Development;', 'MimeType=x-scheme-handler/storebound;', '',
].join('\n');
const steps: Step[] = [
{ what: `make ${tildeOf(dir, i.home)}`, mkdir: dir },
{ what: 'copy the AppImage', copy: { from: i.file, to: staged, mode: 0o755 } },
{ what: `check the copied app is ${i.version}`, expectVersion: { app: staged, version: i.version } },
{ what: `put it at ${tildeOf(target, i.home)}`, rename: { from: staged, to: target } },
{ what: `make ${tildeOf(apps, i.home)}`, mkdir: apps },
{ what: 'menu entry and the storebound:// link handler', write: { path: join(apps, 'storebound.desktop'), content: entry, mode: 0o644 } },
];
if (i.has('update-desktop-database')) steps.push({ what: 'refresh the menu', run: ['update-desktop-database', apps], timeoutMs: 30_000, optional: true });
if (i.has('xdg-mime')) steps.push({ what: 'register storebound:// links', run: ['xdg-mime', 'default', 'storebound.desktop', 'x-scheme-handler/storebound'], timeoutMs: 30_000, optional: true });
return { target, place: tildeOf(dir, i.home), steps };
}
/** The command that tells whether the app is running (exit 0 = running), so an update never replaces a running app. */
export function runningCheck(platform: NodeJS.Platform): string[] {
if (platform === 'win32') return ['tasklist', '/FI', 'IMAGENAME eq storebound-desktop.exe', '/NH'];
return ['pgrep', '-x', 'storebound-desktop'];
}
/** Is the tasklist / pgrep result "running"? (tasklist exits 0 either way and prints "INFO: No tasks ..."). */
export function isRunning(platform: NodeJS.Platform, code: number | null, stdout: string): boolean {
if (platform === 'win32') return /storebound-desktop\.exe/i.test(stdout);
return code === 0;
}
/** The question in a terminal; the default is yes. */
export function question(d: Decision, sizeBytes: number, place: string): string {
const mb = `${Math.round(sizeBytes / 1e6)} MB`;
if (d.action === 'install') return `Install storebound desktop ${d.to} (${mb}) to ${place}? [Y/n] `;
if (d.action === 'update') return `Update storebound desktop ${d.from} to ${d.to} (${mb}) in ${place}? [Y/n] `;
return `Replace storebound desktop ${d.from} with the older ${d.to} (${mb}) in ${place}? [Y/n] `;
}
/** How an install / update gets its yes: --yes, the question in a terminal, or nobody to ask (exit 3, the command
* printed), like every other confirmation of storebound. */
export function consent(yes: boolean, tty: boolean): 'yes' | 'ask' | 'declined' {
return yes ? 'yes' : tty ? 'ask' : 'declined';
}

82
cli/lib/registry.ts Normal file
View file

@ -0,0 +1,82 @@
// The app folders of this machine: one list shared by the CLI and the desktop app, ~/.storebound/projects.json
// (STOREBOUND_REGISTRY names another file). `storebound desktop` in an app folder, `storebound apps add`, `ws init` and
// the desktop's App folders screen add to it; `storebound apps folders` and the desktop show it; `storebound ws ...`
// without a storebound.workspace.json works over it. The desktop keeps its per-folder extras (mapped accounts, saved
// reads) in its own state by the same id. Only paths and names: no keys, nothing from inside the folders.
// { "version": 1, "projects": [{ "id": "<16 hex>", "path": "/abs/folder", "name": "Star Dash", "added": "<iso>" }] }
// Written atomically (temp file, rename); a missing or unreadable file reads as empty (an unreadable one is kept).
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
export interface RegistryProject { id: string; path: string; name: string; added: string }
export interface Registry { version: 1; projects: RegistryProject[] }
export const registryFile = (env: Record<string, string | undefined> = process.env): string => env.STOREBOUND_REGISTRY || join(homedir(), '.storebound', 'projects.json');
/** The id of a folder the CLI adds: stable per absolute path (the same as `ws desktop export`'s project ids). */
export const folderId = (absPath: string): string => createHash('sha256').update(`project:${absPath}`).digest('hex').slice(0, 16);
const ID = /^[0-9a-f]{16}$/;
/** Parses the file's text; drops entries that are not well formed (an id, an absolute path). Pure. */
export function parseRegistry(text: string): Registry {
let raw: unknown;
try { raw = JSON.parse(text); } catch { return { version: 1, projects: [] }; }
const list = (raw as { projects?: unknown })?.projects;
const out: RegistryProject[] = [];
if (Array.isArray(list)) for (const p of list) {
const o = p as Partial<RegistryProject>;
if (typeof o?.path !== 'string' || !o.path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(o.path)) continue;
if (out.some(x => x.path === o.path)) continue;
out.push({ id: typeof o.id === 'string' && ID.test(o.id) && !out.some(x => x.id === o.id) ? o.id : folderId(o.path), path: o.path,
name: typeof o.name === 'string' && o.name.trim() ? o.name : basename(o.path), added: typeof o.added === 'string' ? o.added : '' });
}
return { version: 1, projects: out };
}
export function readRegistry(file = registryFile()): Registry {
if (!existsSync(file)) return { version: 1, projects: [] };
return parseRegistry(readFileSync(file, 'utf8'));
}
export function writeRegistry(r: Registry, file = registryFile()): void {
mkdirSync(dirname(file), { recursive: true });
if (existsSync(file)) {
// a file this version cannot read is kept once instead of being replaced silently
try { JSON.parse(readFileSync(file, 'utf8')); } catch { copyFileSync(file, `${file}.unreadable`); }
}
const tmp = `${file}.tmp-${process.pid}`;
writeFileSync(tmp, JSON.stringify({ version: 1, projects: r.projects }, null, 2) + '\n');
renameSync(tmp, file);
}
/** Adds a folder (by absolute path; the same path again only updates an empty name). Pure on the registry. */
export function withFolder(r: Registry, path: string, name: string | undefined, now: string): { registry: Registry; project: RegistryProject; added: boolean } {
const abs = resolve(path);
const have = r.projects.find(p => p.path === abs);
if (have) return { registry: r, project: have, added: false };
const project = { id: folderId(abs), path: abs, name: name?.trim() || basename(abs), added: now };
return { registry: { version: 1, projects: [...r.projects, project] }, project, added: true };
}
/** Removes a folder by path or id. Pure. */
export function withoutFolder(r: Registry, pathOrId: string): { registry: Registry; removed: RegistryProject | null } {
const abs = ID.test(pathOrId) ? '' : resolve(pathOrId);
const removed = r.projects.find(p => p.id === pathOrId || p.path === abs) ?? null;
return { registry: removed ? { version: 1, projects: r.projects.filter(p => p !== removed) } : r, removed };
}
/** Reads, adds and writes (only when it changed). */
export function addFolder(path: string, name?: string, file = registryFile()): { project: RegistryProject; added: boolean } {
const { registry, project, added } = withFolder(readRegistry(file), path, name, new Date().toISOString());
if (added) writeRegistry(registry, file);
return { project, added };
}
export function removeFolder(pathOrId: string, file = registryFile()): RegistryProject | null {
const { registry, removed } = withoutFolder(readRegistry(file), pathOrId);
if (removed) writeRegistry(registry, file);
return removed;
}

View file

@ -280,7 +280,7 @@ export function readStoreApps(root: string, accounts: Account[]): { account: str
// ---- The desktop's state (state.json in its data folder) <-> the workspace ----
export interface DesktopProject { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null; added: string }
export interface DesktopAccount { id: string; label: string; kind: Store; created: string; studio: string | null }
export interface DesktopAccount { id: string; label: string; kind: Store; created: string; studio: string | null; /** 0.8+: the credential profile that holds the key (shared with the CLI) */ profile?: string | null }
export interface DesktopStudio { id: string; name: string; color: string; logo: string | null; created: string }
export interface DesktopState {
version: number;
@ -315,7 +315,7 @@ export function toDesktopState(ws: Workspace, apps: AppFolder[], now = new Date(
return {
version: 2,
projects: apps.map(a => ({ id: idOf.get(a.id)!, path: a.path, name: a.name, playAccount: a.playAccount, appleAccount: a.appleAccount, added: now })),
accounts: accounts.map(a => ({ id: a.id, label: a.label ?? a.profile, kind: a.store, created: now, studio: a.studio })),
accounts: accounts.map(a => ({ id: a.id, label: a.label ?? a.profile, kind: a.store, created: now, studio: a.studio, profile: a.profile })),
studios: studiosOf(ws).map(s => ({ id: s.id, name: s.name, color: s.color, logo: null, created: now })),
overrides: {
pairs: o.pairs, unpairs: o.unpairs,
@ -336,13 +336,14 @@ function profileFor(a: DesktopAccount, taken: Set<string>): string {
}
/**
* Desktop state -> workspace. Folders under `root` become relative paths, others stay absolute. Each account becomes
* a credential profile name (the owner creates ~/.storebound/credentials/<profile>.json; the desktop's keychain is
* never read). Account ids are kept, so the overrides' store app keys stay valid.
* Desktop state -> workspace. Folders under `root` become relative paths, others stay absolute. Each account keeps
* its credential profile (0.8+: the app keeps its keys in ~/.storebound/credentials, shared with the CLI), or gets a
* profile name to create (accounts of older apps). Account ids are kept, so the overrides' store app keys stay valid.
*/
export function fromDesktopState(state: DesktopState, root: string): { ws: Workspace; profiles: { profile: string; store: Store; label: string }[] } {
const taken = new Set<string>();
const profile = new Map(state.accounts.map(a => [a.id, profileFor(a, taken)] as const));
// 0.8+ accounts name their credential profile (the app and the CLI share them); older ones get a name to create
const taken = new Set<string>(state.accounts.map(a => a.profile).filter((p): p is string => !!p && PROFILE_RE.test(p)));
const profile = new Map(state.accounts.map(a => [a.id, a.profile && PROFILE_RE.test(a.profile) ? a.profile : profileFor(a, taken)] as const));
const acc = (a: DesktopAccount): WsAccount => ({ store: a.kind, profile: profile.get(a.id)!, label: a.label, id: a.id });
const relOf = new Map(state.projects.map(p => {
const r = posix(relative(root, p.path));

View file

@ -450,7 +450,8 @@ export class Session {
/** apps.list / account.check: one account's credentials, no project folder. */
private async account(cmd: string[], params: Json, id: Id, list: boolean): Promise<unknown> {
for (const k of Object.keys(params)) if (!['credentials', 'args'].includes(k)) throw new RpcError(ERR.invalidParams, `unknown param: ${k}`);
const env = credentialsOf(params.credentials);
// exactly this account: no credential profile of the machine mixes in
const env: Record<string, string> = { ...credentialsOf(params.credentials), STOREBOUND_PROFILE: '' };
const play = !!env.PLAY_SERVICE_ACCOUNT, apple = !!(env.ASC_KEY_ID || env.ASC_ISSUER_ID || env.ASC_P8);
if (play === apple) throw new RpcError(ERR.invalidParams, 'credentials: exactly one store account (a Play service account, or an App Store Connect key)');
const args = (params.args ?? {}) as Json;

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');
}