- 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
412 lines
26 KiB
TypeScript
412 lines
26 KiB
TypeScript
// The multi-app workspace: one storebound.workspace.json (or .ts) in a folder above many app folders. It names the
|
|
// app folders (globs allowed), the studios and their store accounts (by credential profile, never a secret), an
|
|
// ignore list and the owner's pairing overrides. It is the same model as the desktop app's state (studios, accounts,
|
|
// projects, overrides: apps/desktop/src-tauri/src/store.rs, workspace.rs), so `storebound ws desktop export | import`
|
|
// moves it both ways. Pairing of store apps with folders is cli/lib/identity.ts, shared with the desktop.
|
|
//
|
|
// {
|
|
// "apps": ["apps/*", { "path": "games/puzzle", "apple": "acme-apple", "play": "acme-play" }],
|
|
// "studios": [{ "name": "Acme Games", "color": "indigo", "apps": ["games/*"],
|
|
// "accounts": [{ "store": "apple", "profile": "acme-apple" }, { "store": "play", "profile": "acme-play" }] }],
|
|
// "accounts": [{ "store": "play", "profile": "solo-play" }],
|
|
// "ignore": ["apps/old-*", "app:ios:<account id>:<app id>", "account:solo-play"],
|
|
// "overrides": { "pairs": [], "unpairs": [], "links": [], "appStudio": [] },
|
|
// "concurrency": 4
|
|
// }
|
|
import { createHash } from 'node:crypto';
|
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
import { CONFIG_NAMES } from './config';
|
|
import { PROFILE_RE } from './credentials';
|
|
import type { StoreApp } from './desktop-protocol';
|
|
import { type IdProject, NO_OVERRIDES, type Overrides, type Pair } from './identity';
|
|
import * as rt from './rt';
|
|
|
|
export const WORKSPACE_NAMES = ['storebound.workspace.json', 'storebound.workspace.ts', 'storebound.workspace.js'];
|
|
/** Colours a studio can carry: the desktop's set (workspace.rs STUDIO_COLORS). */
|
|
export const STUDIO_COLORS = ['indigo', 'violet', 'rose', 'amber', 'emerald', 'teal', 'sky', 'slate'] as const;
|
|
export type StudioColor = (typeof STUDIO_COLORS)[number];
|
|
export type Store = 'apple' | 'play';
|
|
|
|
export interface WsAccount {
|
|
store: Store;
|
|
/** Credential profile: ~/.storebound/credentials/<profile>.json or env STOREBOUND_PROFILE_<NAME>_* */
|
|
profile: string;
|
|
label?: string;
|
|
/** The desktop's account id (16 hex) when it came from there; else derived from store + profile. */
|
|
id?: string;
|
|
/** play: the developer account id (console URL /developers/<id>); `console fill` opens that account directly. Not a secret. */
|
|
developerId?: string;
|
|
/** apple: the 10-character Team ID; `console fill` switches to that team. Not a secret. */
|
|
teamId?: string;
|
|
}
|
|
export interface WsStudio { id?: string; name: string; color?: StudioColor; accounts?: WsAccount[]; apps?: string[] }
|
|
export interface WsAppEntry { path: string; name?: string; apple?: string; play?: string }
|
|
/** Pairing overrides as the desktop keeps them; a project is named by its folder (relative to the workspace). */
|
|
export interface WsOverrides { pairs?: Pair[]; unpairs?: Pair[]; links?: { key: string; project: string }[]; appStudio?: { key: string; studio: string }[] }
|
|
export interface Workspace {
|
|
apps: (string | WsAppEntry)[];
|
|
studios?: WsStudio[];
|
|
accounts?: WsAccount[];
|
|
ignore?: string[];
|
|
overrides?: WsOverrides;
|
|
/** Apps read at the same time (1-8, default 4: the desktop's default). */
|
|
concurrency?: number;
|
|
}
|
|
|
|
/** For a storebound.workspace.ts: `export default defineWorkspace({ ... })`. */
|
|
export const defineWorkspace = (w: Workspace): Workspace => w;
|
|
|
|
export interface LoadedWorkspace { file: string; root: string; ws: Workspace }
|
|
|
|
/** Resolved account: the workspace entry with its id and studio. */
|
|
export interface Account extends WsAccount { id: string; studio: string | null }
|
|
export interface Studio { id: string; name: string; color: StudioColor }
|
|
/** One app folder of the workspace. `id` is its path relative to the workspace (forward slashes). */
|
|
export interface AppFolder extends IdProject { rel: string; ignored: boolean; studio: string | null; appleProfile: string | null; playProfile: string | null }
|
|
|
|
const hex16 = (s: string): string => createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
export const accountId = (a: WsAccount): string => (a.id && /^[0-9a-f]{16}$/.test(a.id) ? a.id : hex16(`${a.store}:${a.profile}`));
|
|
export const slug = (s: string): string => s.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 48) || 'studio';
|
|
export const studioId = (s: WsStudio): string => s.id || slug(s.name);
|
|
const posix = (p: string): string => p.split(sep).join('/');
|
|
|
|
/** Finds the workspace file in `from` or a folder above it. */
|
|
export function findWorkspace(from: string, explicit?: string): string | null {
|
|
if (explicit) {
|
|
const p = resolve(from, explicit);
|
|
if (existsSync(p) && statSync(p).isDirectory()) return WORKSPACE_NAMES.map(n => join(p, n)).find(existsSync) ?? null;
|
|
return existsSync(p) ? p : null;
|
|
}
|
|
let d = resolve(from);
|
|
for (;;) {
|
|
const hit = WORKSPACE_NAMES.map(n => join(d, n)).find(existsSync);
|
|
if (hit) return hit;
|
|
const up = resolve(d, '..');
|
|
if (up === d) return null;
|
|
d = up;
|
|
}
|
|
}
|
|
|
|
export async function loadWorkspace(from: string, explicit?: string): Promise<LoadedWorkspace> {
|
|
const file = findWorkspace(from, explicit);
|
|
if (!file) throw new Error(`no storebound.workspace.json in ${from} or above (storebound ws init creates one)`);
|
|
let raw: unknown;
|
|
if (file.endsWith('.json')) {
|
|
try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch (e) { throw new Error(`${file}: ${(e as Error).message}`); }
|
|
} else raw = ((await rt.importConfigModule(file)) as { default?: unknown }).default;
|
|
const problems = checkWorkspace(raw);
|
|
if (problems.length) throw new Error(`${file}:\n ${problems.join('\n ')}`);
|
|
return { file, root: resolve(file, '..'), ws: raw as Workspace };
|
|
}
|
|
|
|
const ACCOUNT_KEYS = ['store', 'profile', 'label', 'id', 'developerId', 'teamId'];
|
|
|
|
/** Problems of a workspace file (empty = fine). Secrets are refused: only profile names belong here. */
|
|
export function checkWorkspace(raw: unknown): string[] {
|
|
const e: string[] = [];
|
|
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return ['not an object'];
|
|
const w = raw as Record<string, unknown>;
|
|
const known = ['$schema', 'apps', 'studios', 'accounts', 'ignore', 'overrides', 'concurrency'];
|
|
for (const k of Object.keys(w)) if (!known.includes(k)) e.push(`${k}: unknown field (${known.slice(1).join(', ')})`);
|
|
if (!Array.isArray(w.apps)) e.push('apps: a list of app folders or globs ("apps/*")');
|
|
else w.apps.forEach((a, i) => {
|
|
if (typeof a === 'string') { if (!a.trim()) e.push(`apps[${i}]: empty`); return; }
|
|
if (typeof a !== 'object' || a === null || typeof (a as WsAppEntry).path !== 'string') { e.push(`apps[${i}]: a path or { path, name?, apple?, play? }`); return; }
|
|
for (const k of Object.keys(a)) if (!['path', 'name', 'apple', 'play'].includes(k)) e.push(`apps[${i}].${k}: unknown`);
|
|
for (const s of ['apple', 'play'] as const) { const p = (a as WsAppEntry)[s]; if (p !== undefined && (typeof p !== 'string' || !PROFILE_RE.test(p))) e.push(`apps[${i}].${s}: a credential profile name`); }
|
|
});
|
|
const account = (a: unknown, where: string) => {
|
|
if (typeof a !== 'object' || a === null) { e.push(`${where}: { store, profile }`); return; }
|
|
const o = a as Record<string, unknown>;
|
|
for (const k of Object.keys(o)) if (!ACCOUNT_KEYS.includes(k)) e.push(`${where}.${k}: unknown (secrets never go here: put them in the credential profile)`);
|
|
if (o.store !== 'apple' && o.store !== 'play') e.push(`${where}.store: apple or play`);
|
|
if (typeof o.profile !== 'string' || !PROFILE_RE.test(o.profile)) e.push(`${where}.profile: a credential profile name (letters, digits, . _ -)`);
|
|
if (o.id !== undefined && (typeof o.id !== 'string' || !/^[0-9a-f]{16}$/.test(o.id))) e.push(`${where}.id: 16 hex characters (the desktop's account id)`);
|
|
if (o.label !== undefined && (typeof o.label !== 'string' || o.label.length > 60)) e.push(`${where}.label: up to 60 characters`);
|
|
if (o.developerId !== undefined && (o.store !== 'play' || typeof o.developerId !== 'string' || !/^\d{1,25}$/.test(o.developerId))) e.push(`${where}.developerId: a play account's developer id (digits)`);
|
|
if (o.teamId !== undefined && (o.store !== 'apple' || typeof o.teamId !== 'string' || !/^[A-Z0-9]{10}$/.test(o.teamId))) e.push(`${where}.teamId: an apple account's 10-character Team ID`);
|
|
};
|
|
if (w.accounts !== undefined) { if (!Array.isArray(w.accounts)) e.push('accounts: a list'); else w.accounts.forEach((a, i) => account(a, `accounts[${i}]`)); }
|
|
if (w.studios !== undefined) {
|
|
if (!Array.isArray(w.studios)) e.push('studios: a list');
|
|
else w.studios.forEach((s, i) => {
|
|
if (typeof s !== 'object' || s === null) { e.push(`studios[${i}]: { name, color?, accounts?, apps? }`); return; }
|
|
const o = s as Record<string, unknown>;
|
|
for (const k of Object.keys(o)) if (!['id', 'name', 'color', 'accounts', 'apps'].includes(k)) e.push(`studios[${i}].${k}: unknown`);
|
|
if (typeof o.name !== 'string' || !o.name.trim() || o.name.length > 48) e.push(`studios[${i}].name: 1 to 48 characters`);
|
|
if (o.color !== undefined && !(STUDIO_COLORS as readonly unknown[]).includes(o.color)) e.push(`studios[${i}].color: one of ${STUDIO_COLORS.join(', ')}`);
|
|
if (o.accounts !== undefined) { if (!Array.isArray(o.accounts)) e.push(`studios[${i}].accounts: a list`); else o.accounts.forEach((a, j) => account(a, `studios[${i}].accounts[${j}]`)); }
|
|
if (o.apps !== undefined && (!Array.isArray(o.apps) || o.apps.some(x => typeof x !== 'string'))) e.push(`studios[${i}].apps: a list of folder globs`);
|
|
});
|
|
}
|
|
if (w.ignore !== undefined && (!Array.isArray(w.ignore) || w.ignore.some(x => typeof x !== 'string'))) e.push('ignore: a list of folder globs, app:<store app key> or account:<profile>');
|
|
if (w.concurrency !== undefined && !(Number.isInteger(w.concurrency) && (w.concurrency as number) >= 1 && (w.concurrency as number) <= 8)) e.push('concurrency: 1 to 8');
|
|
if (w.overrides !== undefined && (typeof w.overrides !== 'object' || w.overrides === null || Array.isArray(w.overrides))) e.push('overrides: { pairs, unpairs, links, appStudio }');
|
|
// duplicate profiles per store would make two accounts with one id
|
|
const all = [...((w.accounts as WsAccount[] | undefined) ?? []), ...(((w.studios as WsStudio[] | undefined) ?? []).flatMap(s => s?.accounts ?? []))];
|
|
const seen = new Set<string>();
|
|
for (const a of all) { const k = `${a?.store}:${a?.profile}`; if (seen.has(k)) e.push(`account ${k}: listed twice`); seen.add(k); }
|
|
return e;
|
|
}
|
|
|
|
/** Every account of the workspace (studio accounts and the loose ones) with ids and studios. */
|
|
export function accountsOf(ws: Workspace): Account[] {
|
|
const out: Account[] = [];
|
|
for (const s of ws.studios ?? []) for (const a of s.accounts ?? []) out.push({ ...a, id: accountId(a), studio: studioId(s) });
|
|
for (const a of ws.accounts ?? []) out.push({ ...a, id: accountId(a), studio: null });
|
|
return out;
|
|
}
|
|
|
|
export const studiosOf = (ws: Workspace): Studio[] =>
|
|
(ws.studios ?? []).map((s, i) => ({ id: studioId(s), name: s.name, color: s.color ?? STUDIO_COLORS[i % STUDIO_COLORS.length]! }));
|
|
|
|
const isGlob = (p: string): boolean => /[*?[{]/.test(p);
|
|
const matchAny = (globs: string[], rel: string): boolean => globs.some(g => new rt.Glob(g.replace(/\/+$/, '')).match(rel));
|
|
const hasConfig = (dir: string): boolean => CONFIG_NAMES.some(n => existsSync(join(dir, n)));
|
|
|
|
/**
|
|
* The app folders: globs expanded (folders with a storebound config only; node_modules and dot folders skipped), the
|
|
* ignore globs applied (kept with `ignored: true`, so `ws list --all` can show them), accounts and studio resolved:
|
|
* an entry's own profiles > the single account of that store in the studio whose `apps` globs match > the single
|
|
* account of that store in the workspace > none (the folder's own credentials, env or .storebound/).
|
|
*/
|
|
export function resolveApps(l: LoadedWorkspace): { apps: AppFolder[]; warnings: string[] } {
|
|
const { root, ws } = l;
|
|
const warnings: string[] = [];
|
|
const found = new Map<string, WsAppEntry>();
|
|
for (const a of ws.apps) {
|
|
const entry: WsAppEntry = typeof a === 'string' ? { path: a } : a;
|
|
const p = entry.path.replace(/^~(?=\/)/, homedir());
|
|
if (isGlob(p)) {
|
|
const base = isAbsolute(p) ? '/' : root;
|
|
const pattern = isAbsolute(p) ? p.slice(1) : p;
|
|
let hits = 0;
|
|
for (const m of new rt.Glob(pattern.replace(/\/+$/, '')).scanSync({ cwd: base, onlyFiles: false, dot: false })) {
|
|
if (/(^|\/)(node_modules|\.git)(\/|$)/.test(m)) continue;
|
|
const dir = resolve(base, m);
|
|
try { if (!statSync(dir).isDirectory()) continue; } catch { continue; }
|
|
if (!hasConfig(dir)) continue;
|
|
hits++;
|
|
if (!found.has(dir)) found.set(dir, { ...entry, path: dir });
|
|
}
|
|
if (!hits) warnings.push(`${entry.path}: no app folder (with a storebound config) matches`);
|
|
} else {
|
|
const dir = resolve(root, p);
|
|
if (!existsSync(dir)) { warnings.push(`${entry.path}: folder not found`); continue; }
|
|
if (!hasConfig(dir)) { warnings.push(`${entry.path}: no storebound.config.ts in it`); continue; }
|
|
found.set(dir, { ...found.get(dir), ...entry, path: dir });
|
|
}
|
|
}
|
|
const accounts = accountsOf(ws);
|
|
const byProfile = (store: Store, profile: string | undefined) => (profile ? accounts.find(a => a.store === store && a.profile === profile) : undefined);
|
|
const ignoreGlobs = (ws.ignore ?? []).filter(x => !/^(app|account|project):/.test(x));
|
|
const apps: AppFolder[] = [];
|
|
for (const [dir, entry] of found) {
|
|
const rel = posix(relative(root, dir)) || '.';
|
|
const studio = (ws.studios ?? []).find(s => matchAny(s.apps ?? [], rel)) ?? null;
|
|
const pick = (store: Store): WsAccount | null => {
|
|
const named = entry[store];
|
|
// a profile the workspace does not list is still usable (its id is derived the same way)
|
|
if (named) return byProfile(store, named) ?? { store, profile: named };
|
|
const inStudio = (studio?.accounts ?? []).filter(a => a.store === store);
|
|
if (inStudio.length === 1) return inStudio[0]!;
|
|
const all = accounts.filter(a => a.store === store);
|
|
return all.length === 1 ? all[0]! : null;
|
|
};
|
|
const apple = pick('apple'), play = pick('play');
|
|
apps.push({ id: rel, rel, path: dir, name: entry.name ?? basename(dir), appleAccount: apple ? accountId(apple) : null, playAccount: play ? accountId(play) : null,
|
|
appleProfile: apple?.profile ?? null, playProfile: play?.profile ?? null,
|
|
ignored: matchAny(ignoreGlobs, rel) || (ws.ignore ?? []).includes(`project:${rel}`), studio: studio ? studioId(studio) : null });
|
|
}
|
|
apps.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
return { apps, warnings };
|
|
}
|
|
|
|
/** Desktop-style overrides of the workspace: folder studios from `studios[].apps`, ignore keys with account ids. */
|
|
export function overridesOf(ws: Workspace, apps: AppFolder[]): Overrides {
|
|
const o = ws.overrides ?? {};
|
|
const accounts = accountsOf(ws);
|
|
const explicit = o.appStudio ?? [];
|
|
const fromGlobs = apps.filter(a => a.studio && !explicit.some(x => x.key === `project:${a.id}`)).map(a => ({ key: `project:${a.id}`, studio: a.studio! }));
|
|
const ignored: string[] = [];
|
|
for (const x of ws.ignore ?? []) {
|
|
if (x.startsWith('app:') || x.startsWith('project:')) ignored.push(x);
|
|
else if (x.startsWith('account:')) {
|
|
const p = x.slice(8);
|
|
const hit = accounts.filter(a => a.profile === p || a.id === p);
|
|
ignored.push(...(hit.length ? hit.map(a => `account:${a.id}`) : [x]));
|
|
}
|
|
}
|
|
for (const a of apps) if (a.ignored && !ignored.includes(`project:${a.id}`)) ignored.push(`project:${a.id}`);
|
|
return { ...NO_OVERRIDES, pairs: o.pairs ?? [], unpairs: o.unpairs ?? [], links: o.links ?? [], appStudio: [...explicit, ...fromGlobs], ignored };
|
|
}
|
|
|
|
/** Filters apps by `--apps a,b` (folder, folder name or app name, case-insensitive; globs allowed). */
|
|
export function selectApps<T extends { rel: string; name: string }>(apps: T[], names: string[]): { picked: T[]; unknown: string[] } {
|
|
if (!names.length) return { picked: apps, unknown: [] };
|
|
const unknown: string[] = [];
|
|
const picked = new Set<T>();
|
|
for (const n of names) {
|
|
const lc = n.toLowerCase();
|
|
const hits = apps.filter(a => a.rel.toLowerCase() === lc || basename(a.rel).toLowerCase() === lc || a.name.toLowerCase() === lc || (isGlob(n) && new rt.Glob(n).match(a.rel)));
|
|
if (!hits.length) unknown.push(n);
|
|
hits.forEach(h => picked.add(h));
|
|
}
|
|
return { picked: apps.filter(a => picked.has(a)), unknown };
|
|
}
|
|
|
|
// ---- Store app lists (ws apps import): a local cache, gitignored with the rest of .storebound/ ----
|
|
|
|
export interface StoreAppsCache { profile: string; store: Store; account: string; at: string; apps: StoreApp[] }
|
|
|
|
export const cacheDir = (root: string): string => join(root, '.storebound', 'workspace');
|
|
export const cacheFile = (root: string, a: { store: Store; profile: string }): string => join(cacheDir(root), `apps-${a.store}-${a.profile}.json`);
|
|
|
|
export function readStoreApps(root: string, accounts: Account[]): { account: string; apps: StoreApp[]; at: string }[] {
|
|
const out: { account: string; apps: StoreApp[]; at: string }[] = [];
|
|
for (const a of accounts) {
|
|
const f = cacheFile(root, a);
|
|
if (!existsSync(f)) continue;
|
|
try {
|
|
const c = JSON.parse(readFileSync(f, 'utf8')) as StoreAppsCache;
|
|
if (Array.isArray(c.apps)) out.push({ account: a.id, apps: c.apps, at: c.at });
|
|
} catch { /* a broken cache is a cache miss */ }
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---- 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; /** 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;
|
|
projects: DesktopProject[];
|
|
accounts: DesktopAccount[];
|
|
studios: DesktopStudio[];
|
|
overrides: Overrides;
|
|
settings?: unknown;
|
|
}
|
|
|
|
/** Where the desktop app keeps state.json (Tauri app data folder of dev.storebound.desktop). */
|
|
export function desktopStateFile(env: Record<string, string | undefined> = process.env, platform = process.platform): string {
|
|
if (env.STOREBOUND_DESKTOP_DATA) return join(env.STOREBOUND_DESKTOP_DATA, 'state.json');
|
|
const id = 'dev.storebound.desktop';
|
|
if (platform === 'darwin') return join(homedir(), 'Library', 'Application Support', id, 'state.json');
|
|
if (platform === 'win32') return join(env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), id, 'state.json');
|
|
return join(env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), id, 'state.json');
|
|
}
|
|
|
|
/** The desktop's project id of a folder: stable per absolute path (16 hex, like the desktop's own ids). */
|
|
export const desktopProjectId = (absPath: string): string => hex16(`project:${absPath}`);
|
|
|
|
/**
|
|
* Workspace -> desktop state (format 2). Accounts carry labels only: their keys go into the desktop's keychain from
|
|
* its Accounts screen (the desktop never reads the profiles). Project keys in the overrides become the desktop's ids.
|
|
*/
|
|
export function toDesktopState(ws: Workspace, apps: AppFolder[], now = new Date().toISOString()): DesktopState {
|
|
const accounts = accountsOf(ws);
|
|
const idOf = new Map(apps.map(a => [a.id, desktopProjectId(a.path)] as const));
|
|
const mapKey = (k: string) => (k.startsWith('project:') && idOf.has(k.slice(8)) ? `project:${idOf.get(k.slice(8))}` : k);
|
|
const o = overridesOf(ws, apps);
|
|
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, 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,
|
|
links: o.links.map(l => ({ key: l.key, project: idOf.get(l.project) ?? l.project })),
|
|
appStudio: o.appStudio.map(x => ({ key: mapKey(x.key), studio: x.studio })),
|
|
ignored: o.ignored.map(mapKey),
|
|
},
|
|
};
|
|
}
|
|
|
|
/** A profile name for a desktop account: its label as a slug, made unique per store. */
|
|
function profileFor(a: DesktopAccount, taken: Set<string>): string {
|
|
const base = (slug(a.label).slice(0, 56) || 'account');
|
|
let name = taken.has(base) ? `${base}-${a.kind}` : base;
|
|
for (let i = 2; taken.has(name); i++) name = `${base}-${a.kind}-${i}`;
|
|
taken.add(name);
|
|
return name;
|
|
}
|
|
|
|
/**
|
|
* 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 }[] } {
|
|
// 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));
|
|
return [p.id, !r || r.startsWith('..') || isAbsolute(r) ? p.path : r] as const;
|
|
}));
|
|
const proj = (k: string) => (k.startsWith('project:') && relOf.has(k.slice(8)) ? `project:${relOf.get(k.slice(8))}` : k);
|
|
const studios: WsStudio[] = state.studios.map(s => ({ id: s.id, name: s.name, ...((STUDIO_COLORS as readonly string[]).includes(s.color) ? { color: s.color as StudioColor } : {}),
|
|
accounts: state.accounts.filter(a => a.studio === s.id).map(acc) }));
|
|
const ignore: string[] = [];
|
|
for (const x of state.overrides?.ignored ?? []) {
|
|
if (x.startsWith('account:')) ignore.push(`account:${profile.get(x.slice(8)) ?? x.slice(8)}`);
|
|
else ignore.push(proj(x));
|
|
}
|
|
const ws: Workspace = {
|
|
apps: state.projects.map(p => {
|
|
const e: WsAppEntry = { path: relOf.get(p.id)! };
|
|
if (p.name && p.name !== basename(p.path)) e.name = p.name;
|
|
if (p.appleAccount && profile.has(p.appleAccount)) e.apple = profile.get(p.appleAccount)!;
|
|
if (p.playAccount && profile.has(p.playAccount)) e.play = profile.get(p.playAccount)!;
|
|
return Object.keys(e).length === 1 ? e.path : e;
|
|
}),
|
|
studios,
|
|
accounts: state.accounts.filter(a => !a.studio || !state.studios.some(s => s.id === a.studio)).map(acc),
|
|
ignore,
|
|
overrides: {
|
|
pairs: state.overrides?.pairs ?? [], unpairs: state.overrides?.unpairs ?? [],
|
|
links: (state.overrides?.links ?? []).map(l => ({ key: l.key, project: relOf.get(l.project) ?? l.project })),
|
|
appStudio: (state.overrides?.appStudio ?? []).map(x => ({ key: proj(x.key), studio: x.studio })),
|
|
},
|
|
};
|
|
return { ws, profiles: state.accounts.map(a => ({ profile: profile.get(a.id)!, store: a.kind, label: a.label })) };
|
|
}
|
|
|
|
/**
|
|
* Adds the workspace's desktop state to an existing desktop state: folders by path (an existing project keeps its id
|
|
* and accounts), accounts and studios by id (existing ones win), overrides as a union. Nothing is removed.
|
|
*/
|
|
export function mergeDesktopState(cur: DesktopState, add: DesktopState): { state: DesktopState; added: { projects: number; accounts: number; studios: number; overrides: number } } {
|
|
const idMap = new Map<string, string>();
|
|
const projects = [...cur.projects];
|
|
let np = 0;
|
|
for (const p of add.projects) {
|
|
const have = projects.find(x => x.path === p.path);
|
|
if (have) idMap.set(p.id, have.id);
|
|
else { projects.push(p); np++; }
|
|
}
|
|
const remap = (k: string) => (k.startsWith('project:') && idMap.has(k.slice(8)) ? `project:${idMap.get(k.slice(8))}` : k);
|
|
const accounts = [...cur.accounts];
|
|
let na = 0;
|
|
for (const a of add.accounts) if (!accounts.some(x => x.id === a.id)) { accounts.push(a); na++; }
|
|
const studios = [...cur.studios];
|
|
let ns = 0;
|
|
for (const s of add.studios) if (!studios.some(x => x.id === s.id)) { studios.push(s); ns++; }
|
|
const co = { ...NO_OVERRIDES, ...cur.overrides };
|
|
const ao = add.overrides;
|
|
const uniq = <T>(xs: T[]) => { const seen = new Set<string>(); return xs.filter(x => { const k = JSON.stringify(x); if (seen.has(k)) return false; seen.add(k); return true; }); };
|
|
const overrides: Overrides = {
|
|
pairs: uniq([...co.pairs, ...ao.pairs]),
|
|
unpairs: uniq([...co.unpairs, ...ao.unpairs]),
|
|
links: uniq([...co.links, ...ao.links.map(l => ({ key: l.key, project: idMap.get(l.project) ?? l.project }))]),
|
|
appStudio: uniq([...co.appStudio, ...ao.appStudio.map(x => ({ key: remap(x.key), studio: x.studio })).filter(x => !co.appStudio.some(y => y.key === x.key))]),
|
|
ignored: uniq([...co.ignored, ...ao.ignored.map(remap)]),
|
|
};
|
|
const count = (o: Overrides) => o.pairs.length + o.unpairs.length + o.links.length + o.appStudio.length + o.ignored.length;
|
|
return { state: { ...cur, version: Math.max(cur.version ?? 1, 2), projects, accounts, studios, overrides }, added: { projects: np, accounts: na, studios: ns, overrides: count(overrides) - count(co) } };
|
|
}
|