// 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: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/.json or env STOREBOUND_PROFILE__* */ 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/); `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 { 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; 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; 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; 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: or account:'); 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(); 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(); 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(apps: T[], names: string[]): { picked: T[]; unknown: string[] } { if (!names.length) return { picked: apps, unknown: [] }; const unknown: string[] = []; const picked = new Set(); 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 = 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 { 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(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(); 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 = (xs: T[]) => { const seen = new Set(); 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) } }; }