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:
parent
bba46b5c7f
commit
d2ce185441
60 changed files with 2443 additions and 246 deletions
207
cli/lib/accounts.ts
Normal file
207
cli/lib/accounts.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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)) };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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/) };
|
||||
|
|
|
|||
|
|
@ -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
186
cli/lib/desktop-install.ts
Normal 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
82
cli/lib/registry.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue