- storebound.workspace.json: app folders (globs), studios with accounts by credential profile, ignore list, the desktop's pairing overrides; ws init | list | status | check | validate | run | apps import | desktop export/import - credential profiles in ~/.storebound/credentials/<profile>.json or env, --account <profile> on any command - release status / release plan <version>, table or --json; MCP workspace_list, workspace_status, release_status, release_plan (read-only) - pairing rules (cli/lib/identity.ts), release lanes (cli/lib/train.ts) and the per-app status read (cli/lib/app-status.ts) shared with the desktop instead of copied - tests with sanitized fixtures, README, skills, CHANGELOG
200 lines
11 KiB
TypeScript
200 lines
11 KiB
TypeScript
// One app card per product, across studios and store accounts: the pairing rules the desktop app (apps/desktop) and
|
|
// the CLI workspace (`storebound ws`, cli/lib/workspace.ts) share. Pure, so tests pin every rule
|
|
// (apps/desktop/tests/identity.test.ts, tests/workspace.test.ts).
|
|
//
|
|
// Inputs: app folders (with the store ids of their config), the apps each store account can see (`apps list`), and
|
|
// the owner's overrides (manual pairs / unpairs, links of a store app to a folder, studio moves, the ignore list).
|
|
// Order of matching:
|
|
// 1. a folder takes the store apps its config names (explicit link > App Store app id > bundle id / package name),
|
|
// preferring the accounts mapped to the folder;
|
|
// 2. the remaining store apps pair iOS + Android: manual pairs, then the same bundle id and package name, then a
|
|
// close id (".ios" / ".android" suffixes, case, punctuation) or the same name; unpairs block an automatic pair;
|
|
// 3. what is left is a single-platform store-only app.
|
|
// A store-only app is read through a "store target" (store:ios:<account>:<app id>:<bundle>;android:<account>:<package>).
|
|
import type { ProjectSummary, StoreApp } from './desktop-protocol';
|
|
|
|
/** An app folder as both front ends know it (the desktop's Project carries more fields). */
|
|
export interface IdProject { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null }
|
|
/** A store account: an id (16 hex) and its studio. */
|
|
export interface IdAccount { id: string; studio?: string | null }
|
|
|
|
export interface Pair { ios: string; android: string }
|
|
export interface Overrides {
|
|
pairs: Pair[];
|
|
unpairs: Pair[];
|
|
links: { key: string; project: string }[];
|
|
appStudio: { key: string; studio: string }[];
|
|
ignored: string[];
|
|
}
|
|
export const NO_OVERRIDES: Overrides = { pairs: [], unpairs: [], links: [], appStudio: [], ignored: [] };
|
|
|
|
/** One app record of one store account. */
|
|
export interface StoreRef { key: string; account: string; store: 'ios' | 'android'; app: StoreApp }
|
|
|
|
export type Pairing = 'folder' | 'manual' | 'id' | 'close' | 'name' | 'single';
|
|
|
|
export interface UnifiedApp<P extends IdProject = IdProject> {
|
|
/** Route and list key: the project id, else the store target. */
|
|
key: string;
|
|
/** What reads use: the project id, else the store target ("store:..."). */
|
|
target: string;
|
|
name: string;
|
|
project: P | null;
|
|
ios: StoreRef | null;
|
|
android: StoreRef | null;
|
|
studio: string | null;
|
|
pairing: Pairing;
|
|
/** The keys an "ignore" puts on the list ("project:<id>" or "app:<store key>"). */
|
|
ignoreKeys: string[];
|
|
ignored: boolean;
|
|
/** Store icon (data URL) when the folder has none. */
|
|
storeIcon: string | null;
|
|
}
|
|
|
|
export const refKey = (account: string, a: StoreApp): string => `${a.store}:${account}:${a.id}`;
|
|
|
|
/** Store target of a store-only app. */
|
|
export function storeTarget(ios: StoreRef | null, android: StoreRef | null): string {
|
|
const parts: string[] = [];
|
|
if (ios) parts.push(`ios:${ios.account}:${ios.app.id}:${ios.app.bundleId}`);
|
|
if (android) parts.push(`android:${android.account}:${android.app.id}`);
|
|
return `store:${parts.join(';')}`;
|
|
}
|
|
|
|
/** "com.example.Puzzle.ios" -> "comexamplepuzzle": ids that differ only by a platform suffix, case or punctuation. */
|
|
export function normId(id: string): string {
|
|
return id.toLowerCase().replace(/[._-](ios|iphone|android|droid|app)$/g, '').replace(/[^a-z0-9]/g, '');
|
|
}
|
|
export const normName = (n: string): string => n.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]/g, '');
|
|
|
|
const samePair = (p: Pair, ios: string, android: string) => p.ios === ios && p.android === android;
|
|
|
|
export interface BuildInput<P extends IdProject = IdProject> {
|
|
projects: P[];
|
|
/** Store ids of each folder's config (from the offline portfolio read). */
|
|
summaryOf: (p: P) => ProjectSummary | null | undefined;
|
|
accounts: IdAccount[];
|
|
/** The last app list of each account. */
|
|
storeApps: { account: string; apps: StoreApp[] }[];
|
|
overrides: Overrides;
|
|
}
|
|
|
|
export function buildApps<P extends IdProject>(i: BuildInput<P>): UnifiedApp<P>[] {
|
|
const o = i.overrides;
|
|
const ignoredAccounts = new Set(o.ignored.filter(x => x.startsWith('account:')).map(x => x.slice(8)));
|
|
const accountStudio = new Map(i.accounts.map(a => [a.id, a.studio ?? null] as const));
|
|
const known = new Set(i.accounts.map(a => a.id));
|
|
// every store app of every (not ignored) account
|
|
const refs: StoreRef[] = [];
|
|
for (const s of i.storeApps) {
|
|
if (!known.has(s.account) || ignoredAccounts.has(s.account)) continue;
|
|
for (const app of s.apps) refs.push({ key: refKey(s.account, app), account: s.account, store: app.store, app });
|
|
}
|
|
const used = new Set<string>();
|
|
const take = (r: StoreRef | null | undefined): StoreRef | null => { if (!r || used.has(r.key)) return null; used.add(r.key); return r; };
|
|
const studioOverride = new Map(o.appStudio.map(x => [x.key, x.studio] as const));
|
|
const ignored = new Set(o.ignored);
|
|
const out: UnifiedApp<P>[] = [];
|
|
|
|
// 1. folders
|
|
for (const p of i.projects) {
|
|
const s = i.summaryOf(p);
|
|
const mine = (r: StoreRef) => r.account === (r.store === 'ios' ? p.appleAccount : p.playAccount);
|
|
const rank = (rs: StoreRef[]) => [...rs].sort((a, b) => Number(mine(b)) - Number(mine(a)));
|
|
const linked = o.links.filter(l => l.project === p.id).map(l => refs.find(r => r.key === l.key)).filter((r): r is StoreRef => !!r && !used.has(r.key));
|
|
let ios = take(linked.find(r => r.store === 'ios'));
|
|
let android = take(linked.find(r => r.store === 'android'));
|
|
if (!ios && s?.apple) {
|
|
const free = refs.filter(r => r.store === 'ios' && !used.has(r.key) && !o.links.some(l => l.key === r.key));
|
|
ios = take(rank(free.filter(r => s.apple!.appId && r.app.id === s.apple!.appId))[0] ?? rank(free.filter(r => r.app.bundleId === s.apple!.bundleId))[0]);
|
|
}
|
|
if (!android && s?.play) {
|
|
const free = refs.filter(r => r.store === 'android' && !used.has(r.key) && !o.links.some(l => l.key === r.key));
|
|
android = take(rank(free.filter(r => r.app.id === s.play!.packageName))[0]);
|
|
}
|
|
const studio = studioOverride.get(`project:${p.id}`)
|
|
?? (p.appleAccount && accountStudio.get(p.appleAccount)) ?? (p.playAccount && accountStudio.get(p.playAccount))
|
|
?? (ios && accountStudio.get(ios.account)) ?? (android && accountStudio.get(android.account)) ?? null;
|
|
out.push({
|
|
key: p.id, target: p.id, name: p.name, project: p, ios, android, studio, pairing: 'folder',
|
|
ignoreKeys: [`project:${p.id}`], ignored: ignored.has(`project:${p.id}`), storeIcon: ios?.app.icon ?? android?.app.icon ?? null,
|
|
});
|
|
}
|
|
|
|
// 2. pairs among the remaining store apps
|
|
const freeIos = () => refs.filter(r => r.store === 'ios' && !used.has(r.key));
|
|
const freeAndroid = () => refs.filter(r => r.store === 'android' && !used.has(r.key));
|
|
const blocked = (a: StoreRef, b: StoreRef) => o.unpairs.some(p => samePair(p, a.key, b.key));
|
|
const sameStudio = (a: StoreRef, b: StoreRef) => (accountStudio.get(a.account) ?? null) === (accountStudio.get(b.account) ?? null);
|
|
const pairs: [StoreRef, StoreRef, Pairing][] = [];
|
|
for (const m of o.pairs) {
|
|
const a = freeIos().find(r => r.key === m.ios), b = freeAndroid().find(r => r.key === m.android);
|
|
if (a && b) { take(a); take(b); pairs.push([a, b, 'manual']); }
|
|
}
|
|
const auto = (how: Pairing, match: (a: StoreRef, b: StoreRef) => boolean) => {
|
|
for (const a of freeIos()) {
|
|
const cands = freeAndroid().filter(b => !blocked(a, b) && match(a, b)).sort((x, y) => Number(sameStudio(a, y)) - Number(sameStudio(a, x)));
|
|
const b = cands[0];
|
|
if (b) { take(a); take(b); pairs.push([a, b, how]); }
|
|
}
|
|
};
|
|
auto('id', (a, b) => a.app.bundleId === b.app.id);
|
|
auto('close', (a, b) => normId(a.app.bundleId) !== '' && normId(a.app.bundleId) === normId(b.app.id));
|
|
auto('name', (a, b) => normName(a.app.name) !== '' && normName(a.app.name) === normName(b.app.name));
|
|
|
|
const storeOnly = (ios: StoreRef | null, android: StoreRef | null, pairing: Pairing) => {
|
|
const target = storeTarget(ios, android);
|
|
const keys = [ios, android].filter((r): r is StoreRef => !!r).map(r => r.key);
|
|
const studio = keys.map(k => studioOverride.get(k)).find(Boolean)
|
|
?? (ios && accountStudio.get(ios.account)) ?? (android && accountStudio.get(android.account)) ?? null;
|
|
out.push({
|
|
key: target, target, name: ios?.app.name ?? android?.app.name ?? '?', project: null, ios, android, studio, pairing,
|
|
ignoreKeys: keys.map(k => `app:${k}`), ignored: keys.some(k => ignored.has(`app:${k}`)), storeIcon: ios?.app.icon ?? android?.app.icon ?? null,
|
|
});
|
|
};
|
|
for (const [a, b, how] of pairs) storeOnly(a, b, how);
|
|
// 3. singles
|
|
for (const r of refs) if (!used.has(r.key)) { used.add(r.key); storeOnly(r.store === 'ios' ? r : null, r.store === 'android' ? r : null, 'single'); }
|
|
return out;
|
|
}
|
|
|
|
/** Candidates to pair a single-platform app with (the other store's single apps), closest first. */
|
|
export function pairCandidates<P extends IdProject>(app: UnifiedApp<P>, all: UnifiedApp<P>[]): UnifiedApp<P>[] {
|
|
if (app.project || (app.ios && app.android)) return [];
|
|
const want = app.ios ? 'android' : 'ios';
|
|
const me = (app.ios ?? app.android)!;
|
|
const score = (x: UnifiedApp<P>) => {
|
|
const r = (want === 'ios' ? x.ios : x.android)!;
|
|
return (normId(r.app.bundleId) === normId(me.app.bundleId) ? 4 : 0) + (normName(r.app.name) === normName(me.app.name) ? 2 : 0) + (x.studio === app.studio ? 1 : 0);
|
|
};
|
|
return all.filter(x => !x.project && !x.ignored && (want === 'ios' ? x.ios && !x.android : x.android && !x.ios)).sort((a, b) => score(b) - score(a) || a.name.localeCompare(b.name));
|
|
}
|
|
|
|
/** Overrides after pairing two single store apps by hand (drops an unpair of the same two). */
|
|
export function withPair(o: Overrides, ios: string, android: string): Overrides {
|
|
return { ...o, pairs: [...o.pairs.filter(p => p.ios !== ios && p.android !== android), { ios, android }], unpairs: o.unpairs.filter(p => !samePair(p, ios, android)) };
|
|
}
|
|
|
|
/** Overrides after splitting a paired app (keeps it apart from now on). */
|
|
export function withUnpair(o: Overrides, ios: string, android: string): Overrides {
|
|
return { ...o, pairs: o.pairs.filter(p => !samePair(p, ios, android)), unpairs: [...o.unpairs.filter(p => !samePair(p, ios, android)), { ios, android }] };
|
|
}
|
|
|
|
export function withIgnored(o: Overrides, keys: string[], on: boolean): Overrides {
|
|
const set = new Set(o.ignored);
|
|
for (const k of keys) { if (on) set.add(k); else set.delete(k); }
|
|
return { ...o, ignored: [...set] };
|
|
}
|
|
|
|
/** Moves an app to a studio (null: back to its accounts' studio). */
|
|
export function withStudio(o: Overrides, app: UnifiedApp, studio: string | null): Overrides {
|
|
const keys = app.project ? [`project:${app.project.id}`] : [app.ios?.key, app.android?.key].filter((k): k is string => !!k);
|
|
const rest = o.appStudio.filter(x => !keys.includes(x.key));
|
|
return { ...o, appStudio: studio ? [...rest, ...keys.map(key => ({ key, studio }))] : rest };
|
|
}
|
|
|
|
/** Links the store apps of a store-only app to a folder. */
|
|
export function withLink(o: Overrides, app: UnifiedApp, project: string): Overrides {
|
|
const keys = [app.ios?.key, app.android?.key].filter((k): k is string => !!k);
|
|
return { ...o, links: [...o.links.filter(l => !keys.includes(l.key)), ...keys.map(key => ({ key, project }))] };
|
|
}
|