372 lines
24 KiB
TypeScript
372 lines
24 KiB
TypeScript
// The apps on a store account, and whether the account's key works. No storebound.config.ts needed: credentials come
|
|
// from the environment or .storebound/credentials.json of the current folder (the desktop app passes one account's
|
|
// key per call). Read-only.
|
|
// storebound apps list [--store apple|play] [--icons] [--json] every app the key can see (App Store Connect team /
|
|
// every Play developer account that invited the
|
|
// service account)
|
|
// storebound apps check [--store apple|play] [--json] auth OK / permission missing / key revoked, with the fix
|
|
// storebound apps folders [--json] the app folders of this machine (~/.storebound/projects.json,
|
|
// the same list as the desktop app's App folders)
|
|
// storebound apps add [<folder>] | apps remove <folder> add / remove a folder in that list (the folder itself stays)
|
|
// storebound apps add --store apple|play --app-id <id> [--account <profile>] [--name <n>] [--bundle-id <id>]
|
|
// storebound apps relink [--root <folder>]... [--dry] after a move: "folder not found" entries -> the same app here
|
|
// a store app with no project on this machine: the
|
|
// desktop shows it read-only ("no local project");
|
|
// apps remove apple:<id> | <entry id> takes it out
|
|
import type { Args } from './lib/args';
|
|
import { ascKey } from './lib/credentials';
|
|
import type { AccountCheck, AppsListResult, BrokerGrant } from './lib/desktop-protocol';
|
|
import { PLAY_SCOPE, playCredentials, playToken, REPORTING_SCOPE } from './lib/google-auth';
|
|
import { brokerConfig, brokerPlayInfo, BrokerRefused, BrokerUnavailable, BrokerUnknownAccount, enabledApisOf, grantsOf, type PlayNeed, playGaps } from './lib/broker';
|
|
import { errorBody } from './lib/http';
|
|
import { done, log, printJson, warn } from './lib/log';
|
|
import { appleCheck, type Bytes, dataUrl, type Get, isServiceDisabled, listAppleApps, listPlayApps, localPlayApps, MAX_ICON_BYTES, playCheck } from './lib/store-apps';
|
|
import { textTable } from './lib/table';
|
|
import { ascClient } from './apple/asc';
|
|
import { REPORTING_API } from './play/google';
|
|
import * as rt from './lib/rt';
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
import { basename, join, resolve } from 'node:path';
|
|
import { CONFIG_NAMES } from './lib/config';
|
|
import { activeProjects, activeStoreApps, addFolder, withIgnore, addStoreApp, applyRelink, configAppName, configFolders, type IgnoreStore, ignoredStoreIds, ignoredStores, isIgnored, readRegistry, registryFile, relinkPlan, removeFolder, removeStoreApp, setIgnore } from './lib/registry';
|
|
import { homedir } from 'node:os';
|
|
|
|
type Store = 'apple' | 'play';
|
|
|
|
function stores(args: Args, root: string): Store[] {
|
|
const s = args.get('store');
|
|
if (s === 'apple' || s === 'play') return [s];
|
|
if (s !== undefined) throw new Error('--store: apple or play');
|
|
// every store whose credentials are set (env, or .storebound/credentials.json in this folder)
|
|
const out: Store[] = [];
|
|
try { ascKey(root); out.push('apple'); } catch { /* not set */ }
|
|
if (playCredentials(root).source) out.push('play');
|
|
if (!out.length) throw new Error('no store credentials: set ASC_KEY_ID + ASC_ISSUER_ID + ASC_P8 and / or PLAY_SERVICE_ACCOUNT (env or .storebound/credentials.json), or a credential broker (~/.storebound/broker.json)');
|
|
return out;
|
|
}
|
|
|
|
const bytes: Bytes = async url => {
|
|
const r = await fetch(url);
|
|
if (!r.ok) return null;
|
|
const len = Number(r.headers.get('content-length') ?? 0);
|
|
if (len > MAX_ICON_BYTES) { await r.body?.cancel(); return null; }
|
|
return { type: (r.headers.get('content-type') ?? 'image/png').split(';')[0]!, data: new Uint8Array(await r.arrayBuffer()) };
|
|
};
|
|
|
|
async function appleGet(root: string): Promise<Get> {
|
|
const c = await ascClient(root, '');
|
|
return path => c.asc('GET', path);
|
|
}
|
|
|
|
function googleGet(token: () => Promise<string>, base: string): Get {
|
|
return async path => {
|
|
for (let attempt = 0; ; attempt++) {
|
|
const r = await fetch(path.startsWith('http') ? path : base + path, { headers: { Authorization: 'Bearer ' + (await token()) } });
|
|
const t = await r.text();
|
|
if ((r.status === 429 || r.status >= 500) && attempt < 3) { await rt.sleep(1500 * (attempt + 1)); continue; }
|
|
if (!r.ok) throw new Error(`GET ${path.replace(/^https:\/\/[^/]+/, '')} -> ${r.status} ${errorBody(t, 800)}`);
|
|
return t ? JSON.parse(t) : {};
|
|
}
|
|
};
|
|
}
|
|
|
|
/** Play icon of one package: an edit is opened, the default language's icon listed, and the edit deleted (never committed). */
|
|
function playIcon(root: string): (pkg: string) => Promise<string | null> {
|
|
const token = playToken(root, PLAY_SCOPE);
|
|
const API = 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications/';
|
|
const call = async (method: string, url: string) => {
|
|
const r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + (await token()) } });
|
|
const t = await r.text();
|
|
if (!r.ok) throw new Error(`${method} -> ${r.status} ${errorBody(t, 300)}`);
|
|
return t ? JSON.parse(t) : {};
|
|
};
|
|
return async pkg => {
|
|
const base = API + encodeURIComponent(pkg);
|
|
const edit = await call('POST', `${base}/edits`);
|
|
try {
|
|
const details = await call('GET', `${base}/edits/${edit.id}/details`);
|
|
const lang = details.defaultLanguage ?? 'en-US';
|
|
const imgs = await call('GET', `${base}/edits/${edit.id}/listings/${encodeURIComponent(lang)}/icon`);
|
|
const url = imgs.images?.[0]?.url;
|
|
if (typeof url !== 'string' || !/^https:\/\/[^/]+\.googleusercontent\.com\//.test(url)) return null;
|
|
const img = await bytes(`${url}=s128`);
|
|
return img && img.data.length <= MAX_ICON_BYTES ? dataUrl(img.type, img.data) : null;
|
|
} finally {
|
|
await call('DELETE', `${base}/edits/${edit.id}`).catch(() => {});
|
|
}
|
|
};
|
|
}
|
|
|
|
const principalOf = (root: string): string | null => playCredentials(root).principal;
|
|
|
|
export async function listApps(store: Store, root: string, icons: boolean): Promise<AppsListResult> {
|
|
const at = new Date().toISOString();
|
|
if (store === 'apple') {
|
|
const r = await listAppleApps(await appleGet(root), { icons, bytes });
|
|
return { store: 'ios', ...r, at };
|
|
}
|
|
const token = playToken(root, REPORTING_SCOPE);
|
|
try {
|
|
const r = await listPlayApps(googleGet(token, REPORTING_API), icons ? { icon: playIcon(root) } : {});
|
|
return { store: 'android', ...r, at };
|
|
} catch (e) {
|
|
// the Reporting API is off in the service account's Cloud project: a clear finding with the exact link, and the
|
|
// Play apps this machine knows (app folders' configs, store-only entries) instead of failing the whole list
|
|
if (!isServiceDisabled(e)) throw e;
|
|
const c = playCheck(token.principal(), { error: e });
|
|
const known = knownPlayPackages(root);
|
|
return { store: 'android', apps: localPlayApps(known), at,
|
|
warnings: [`${c.message} Fix: ${c.fix}`, known.length ? `listed ${known.length} Play app(s) from this machine's app folders and store-only entries instead (not read from Google Play)` : 'no Play app folders on this machine to list instead (storebound apps add)'] };
|
|
}
|
|
}
|
|
|
|
/** Play package names this machine knows without the Reporting API: the config of `root`, every app folder of
|
|
* ~/.storebound/projects.json (read as text, never run) and the store-only Play entries. */
|
|
export function knownPlayPackages(root: string): { packageName: string; name: string; source: string }[] {
|
|
const out: { packageName: string; name: string; source: string }[] = [];
|
|
const reg = readRegistry();
|
|
const fromFolder = (dir: string, name: string) => {
|
|
for (const n of CONFIG_NAMES) {
|
|
const f = join(dir, n);
|
|
if (!existsSync(f)) continue;
|
|
try {
|
|
const text = readFileSync(f, 'utf8');
|
|
const pkg = /\bpackageName\s*["']?\s*:\s*["']([A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+)["']/.exec(text)?.[1];
|
|
const appName = /\bapp\s*["']?\s*:\s*\{[^}]*?\bname\s*["']?\s*:\s*["']([^"'\n]+)["']/.exec(text)?.[1];
|
|
if (pkg && !out.some(o => o.packageName === pkg)) out.push({ packageName: pkg, name: appName ?? name, source: dir });
|
|
} catch { /* unreadable: skipped */ }
|
|
return;
|
|
}
|
|
};
|
|
fromFolder(root, '');
|
|
for (const p of reg.projects) fromFolder(p.path, p.name);
|
|
for (const a of reg.storeApps ?? []) if (a.store === 'play' && !out.some(o => o.packageName === a.appId)) out.push({ packageName: a.appId, name: a.name, source: 'store-only entry' });
|
|
return out;
|
|
}
|
|
|
|
export async function checkAccount(store: Store, root: string): Promise<AccountCheck> {
|
|
if (store === 'apple') {
|
|
try {
|
|
// one app is enough to prove the key; the team's app count is meta.paging.total
|
|
const page = await (await appleGet(root))('/v1/apps?fields[apps]=bundleId&limit=1');
|
|
return appleCheck({ apps: Number(page.meta?.paging?.total ?? (page.data ?? []).length) });
|
|
} catch (e) { return appleCheck({ error: e }); }
|
|
}
|
|
let principal: string | null = null;
|
|
let source: AccountCheck['source'] = null;
|
|
try {
|
|
const creds = playCredentials(root);
|
|
principal = creds.principal;
|
|
source = creds.source;
|
|
if (creds.broker?.error) throw new BrokerRefused(creds.broker.error, 0);
|
|
if (source === 'broker') {
|
|
// proves the broker: one androidpublisher token (it may fall back to the local key, then source says "file")
|
|
const t = playToken(root, PLAY_SCOPE);
|
|
await t();
|
|
source = t.source();
|
|
principal = t.principal() ?? principal;
|
|
}
|
|
let apps: number | null = null;
|
|
let reporting: ReturnType<typeof playToken> | null = null;
|
|
try { reporting = playToken(root, REPORTING_SCOPE); } catch (e) { if (source !== 'broker') throw e; }
|
|
if (reporting) apps = ((await googleGet(reporting, REPORTING_API)('/apps:search?pageSize=1000')).apps ?? []).length;
|
|
const c = apps === null
|
|
? { store: 'android' as const, status: 'ok' as const, principal, apps: null, message: `The credential broker hands out Play tokens for ${principal ?? 'the service account'} (the app count needs the Reporting API scope, which the broker does not hand out).`, fix: null, docs: null }
|
|
: playCheck(principal, { apps });
|
|
return { ...c, source, ...(await brokerGrants(root, principal)) };
|
|
} catch (e) {
|
|
if (e instanceof BrokerRefused || e instanceof BrokerUnavailable || e instanceof BrokerUnknownAccount)
|
|
return { store: 'android', status: 'error', principal, apps: null, source, message: (e as Error).message, fix: 'Check the credential broker: ~/.storebound/broker.json (url, apiKeyFile) and the scopes of the API key (google:read, google:token).', docs: null };
|
|
return { ...playCheck(principal, { error: e }), source };
|
|
}
|
|
}
|
|
|
|
const ALL_NEEDS: PlayNeed[] = ['upload', 'listing', 'iap', 'declarations', 'reviews'];
|
|
|
|
/** With a credential broker: the Play permissions and enabled APIs it lists for the Play apps this machine knows (at
|
|
* most 25, 4 reads at a time), for the account check (CLI and the desktop's Accounts view). */
|
|
async function brokerGrants(root: string, principal: string | null): Promise<{ grants?: BrokerGrant[] }> {
|
|
let cfg;
|
|
try { cfg = brokerConfig(root); } catch { return {}; }
|
|
if (!cfg) return {};
|
|
const pkgs = knownPlayPackages(root).slice(0, 25).map(k => k.packageName);
|
|
const out: (BrokerGrant | null)[] = new Array(pkgs.length).fill(null);
|
|
let next = 0;
|
|
await Promise.all(Array.from({ length: Math.min(4, pkgs.length) }, async () => {
|
|
while (next < pkgs.length) {
|
|
const i = next++, pkg = pkgs[i]!;
|
|
const r = await brokerPlayInfo(cfg, pkg);
|
|
if (r.kind === 'ok') {
|
|
if (principal && r.info.account && r.info.account !== principal) continue;
|
|
out[i] = { packageName: pkg, registered: true, grants: grantsOf(r.info), enabledApis: enabledApisOf(r.info), missing: playGaps(r.info, ALL_NEEDS).map(g => g.name) };
|
|
} else if (r.kind === 'not-registered') out[i] = { packageName: pkg, registered: false, grants: null, enabledApis: null, missing: [] };
|
|
}
|
|
}));
|
|
return { grants: out.filter((g): g is BrokerGrant => !!g) };
|
|
}
|
|
|
|
const tilde = (p: string) => (process.env.HOME && p.startsWith(process.env.HOME) ? '~' + p.slice(process.env.HOME.length) : p);
|
|
|
|
/** apps folders / add / remove: the machine's app folder list (cli/lib/registry.ts). */
|
|
async function folders(sub: string, args: Args): Promise<void> {
|
|
// `apps folders add [<dir>]` / `apps folders remove <dir>`: the same as `apps add` / `apps remove` (this form used
|
|
// to fall through to the list and add nothing, without a word)
|
|
if (sub === 'folders' && (args._[1] === 'add' || args._[1] === 'remove')) { sub = args._[1]; args._.splice(0, 1); }
|
|
if (sub === 'folders' && args._[1]) throw new Error(`storebound apps folders ${args._[1]}: unknown; to add a folder: storebound apps add [<folder>] (or apps folders add [<folder>]), to list: storebound apps folders`);
|
|
if (sub === 'folders') {
|
|
const r = readRegistry();
|
|
const ignoredCount = r.projects.filter(p => ignoredStores(p).length).length + (r.storeApps ?? []).filter(a => isIgnored(a)).length;
|
|
if (args.has('json')) {
|
|
printJson({ file: registryFile(), projects: r.projects.map(p => ({ ...p, missing: !existsSync(p.path), ignored: ignoredStores(p), allIgnored: isIgnored(p) })), storeApps: r.storeApps ?? [] });
|
|
return;
|
|
}
|
|
const liveStore = activeStoreApps(r);
|
|
if (liveStore.length) {
|
|
console.log(textTable(['store app', 'store', 'app id', 'profile', ''], liveStore.map(a => [a.name, a.store, a.appId, a.account ?? '(default)', 'no local project'])));
|
|
console.log('');
|
|
}
|
|
const live = activeProjects(r);
|
|
if (!r.projects.length && !liveStore.length) { console.log(`no app folders yet (${tilde(registryFile())}): storebound apps add, in an app folder (or storebound desktop there)`); return; }
|
|
if (live.length) console.log(textTable(['app', 'folder', ''], live.map(p => [p.name, tilde(p.path), [existsSync(p.path) ? '' : 'folder not found', ignoredStores(p).length ? `${ignoredStores(p).join('+')} ignored` : ''].filter(Boolean).join(', ')])));
|
|
if (ignoredCount) {
|
|
console.log(`
|
|
ignored (${ignoredCount}; storebound apps unignore <name|id> [--store apple|play] brings them back):`);
|
|
console.log(textTable(['app', 'where', 'ignored'], [
|
|
...r.projects.filter(p => isIgnored(p)).map(p => [p.name, tilde(p.path), 'apple+play']),
|
|
...(r.storeApps ?? []).filter(a => isIgnored(a)).map(a => [a.name, `${a.store}:${a.appId}`, a.store]),
|
|
]));
|
|
}
|
|
const missing = r.projects.filter(p => !existsSync(p.path)).length;
|
|
if (missing) console.log(`\n${missing} folder(s) not found (moved to another computer or folder?): storebound apps relink --dry finds them by path and app name`);
|
|
console.log(`
|
|
${tilde(registryFile())}: the same list as the desktop app's App folders`);
|
|
return;
|
|
}
|
|
const store = args.get('store');
|
|
if (sub === 'add' && store !== undefined) {
|
|
if (store !== 'apple' && store !== 'play') throw new Error('--store: apple or play');
|
|
const appId = args.get('app-id');
|
|
if (!appId) throw new Error('storebound apps add --store apple|play --app-id <id> [--account <profile>] [--name <name>]');
|
|
let name = args.get('name'), bundleId = args.get('bundle-id') ?? null;
|
|
// Apple: the name and bundle id from App Store Connect when the profile's key can read them (read-only; offline is fine)
|
|
if (store === 'apple' && (!name || !bundleId) && /^\d{6,12}$/.test(appId)) {
|
|
try {
|
|
const a = (await (await appleGet(process.cwd()))(`/v1/apps/${appId}?fields[apps]=name,bundleId`)).data;
|
|
name ||= a?.attributes?.name; bundleId ||= a?.attributes?.bundleId ?? null;
|
|
} catch { /* no key or no access: the entry keeps what was given */ }
|
|
}
|
|
const r = addStoreApp({ store, appId, account: args.get('account') ?? (process.env.STOREBOUND_PROFILE || null), name, bundleId });
|
|
log('apps', r.added ? `added ${store} app ${r.app.appId} (${r.app.name}) with no local project${r.app.account ? `, profile ${r.app.account}` : ''}: the desktop shows it read-only (status, reviews, analytics)`
|
|
: r.changed ? `updated ${store} app ${r.app.appId}` : `${store} app ${r.app.appId} is already in the list`);
|
|
return;
|
|
}
|
|
if (sub === 'remove' && args._[1] && (/^(apple|play):/.test(args._[1]) || (readRegistry().storeApps ?? []).some(a => a.id === args._[1]))) {
|
|
const removed = removeStoreApp(args._[1]);
|
|
log('apps', removed ? `removed ${removed.store} app ${removed.appId} from the list` : `${args._[1]} is not in the list`);
|
|
return;
|
|
}
|
|
const dir = resolve(args._[1] ?? '.');
|
|
if (sub === 'add') {
|
|
if (!existsSync(dir)) throw new Error(`${dir}: no such folder`);
|
|
const cfg = CONFIG_NAMES.find(n => existsSync(join(dir, n)));
|
|
if (!cfg) throw new Error(`${dir}: not added, no storebound config here (${CONFIG_NAMES.join(', ')}); run storebound init in it, then this again`);
|
|
const r = addFolder(dir);
|
|
log('apps', r.added ? `added ${tilde(r.project.path)} (it shows in the desktop app too)` : `${tilde(r.project.path)} is already in the list`);
|
|
const note = templateNote(join(dir, cfg));
|
|
if (note) warn('apps', note);
|
|
return;
|
|
}
|
|
if (!args._[1]) throw new Error('storebound apps remove <folder>');
|
|
const removed = removeFolder(args._[1]!.match(/^[0-9a-f]{16}$/) ? args._[1]! : dir);
|
|
log('apps', removed ? `removed ${tilde(removed.path)} from the list (the folder and its files stay)` : `${tilde(dir)} is not in the list`);
|
|
}
|
|
|
|
/** apps ignore / unignore <name|id|folder|store:app id> [--store apple|play]: dormant apps, whole or one store side. */
|
|
function ignoreCmd(on: boolean, args: Args): void {
|
|
const key = args._[1];
|
|
const store = args.get('store') as IgnoreStore | undefined;
|
|
if (!key) throw new Error(`storebound apps ${on ? 'ignore' : 'unignore'} <name|id|folder|apple:<id>|play:<package>> [--store apple|play]`);
|
|
if (store !== undefined && store !== 'apple' && store !== 'play') throw new Error('--store: apple or play');
|
|
const { entry, changed } = args.has('dry') ? (({ entry, changed }) => ({ entry, changed }))(withIgnore(readRegistry(), key, store, on)) : setIgnore(key, store, on);
|
|
if (args.has('dry')) { log('apps', `dry run: ${changed ? `would ${on ? 'ignore' : 'restore'} ${store ? `the ${store} side of ` : ''}${entry.name}` : `${entry.name}: nothing to change`}`); return; }
|
|
const now = ignoredStores(entry);
|
|
const where = store ? `the ${store} side of ` : '';
|
|
if (args.has('json')) { printJson({ name: entry.name, id: entry.id, ignored: now, changed }); return; }
|
|
log('apps', changed ? `${on ? 'ignoring' : 'restored'} ${where}${entry.name}${now.length ? ` (ignored now: ${now.join(', ')})` : ''}: watch, events and portfolio reads ${on ? 'skip' : 'read'} it. Storebound only: the store listing is untouched`
|
|
: `${entry.name} was already ${on ? 'ignored' : 'not ignored'}${store ? ` on ${store}` : ''}`);
|
|
}
|
|
|
|
/** apps relink: point "folder not found" entries at the same app on this machine (after a move); ids stay. */
|
|
function relink(args: Args): void {
|
|
const home = homedir();
|
|
const roots = args.list('root').length ? args.list('root').map(r => resolve(r))
|
|
: [join(home, 'Documents', 'Visual Studio Workspaces'), join(home, 'Documents'), join(home, 'Desktop'), join(home, 'Projects'), join(home, 'code')].filter(d => existsSync(d));
|
|
const plan = relinkPlan(readRegistry(), configFolders(roots), existsSync, configAppName);
|
|
if (args.has('json')) { printJson({ roots, plan, dry: args.has('dry') }); }
|
|
else if (!plan.length) log('apps', `nothing to relink (looked under ${roots.map(tilde).join(', ')}; --root <folder> to look elsewhere)`);
|
|
else for (const s of plan) log('apps', `${s.name}: ${s.from} -> ${tilde(s.to)}`);
|
|
if (!plan.length || args.has('dry')) { if (plan.length) done('apps', `dry run: ${plan.length} folder(s) would be relinked`); return; }
|
|
applyRelink(plan);
|
|
done('apps', `${plan.length} folder(s) relinked (the desktop app picks them up on its next start)`);
|
|
}
|
|
|
|
export async function run(args: Args): Promise<void> {
|
|
const sub = args._[0];
|
|
const root = process.cwd();
|
|
const json = args.has('json');
|
|
if (sub === 'relink') return relink(args);
|
|
if (sub === 'folders' || sub === 'add' || sub === 'remove') return folders(sub, args);
|
|
if (sub === 'ignore' || sub === 'unignore') return ignoreCmd(sub === 'ignore', args);
|
|
if (sub === 'list') {
|
|
const out: AppsListResult[] = [];
|
|
for (const s of stores(args, root)) {
|
|
try { out.push(await listApps(s, root, args.has('icons'))); } catch (e) {
|
|
// say what is wrong and how to fix it (the same words as `apps check`), not the raw API error
|
|
const c = s === 'apple' ? appleCheck({ error: e }) : playCheck(principalOf(root), { error: e });
|
|
const why = `${c.message}${c.fix ? ` Fix: ${c.fix}` : ''}`;
|
|
if (args.get('store')) throw new Error(why);
|
|
warn('apps', `${s}: ${why}`);
|
|
}
|
|
}
|
|
// ignored apps (registry: whole app or this store's side) are listed apart, not with the live ones; --all keeps them in
|
|
const ign = ignoredStoreIds(readRegistry());
|
|
const sideOf = (r: AppsListResult) => (r.store === 'ios' ? ign.apple : ign.play);
|
|
const isIgn = (r: AppsListResult, a: { id: string; bundleId: string }) => sideOf(r).has(a.id) || sideOf(r).has(a.bundleId);
|
|
if (json) {
|
|
const marked = out.map(r => ({ ...r, apps: r.apps.map(a => (isIgn(r, a) ? { ...a, ignored: true } : a)) }));
|
|
printJson(args.get('store') ? marked[0] : marked);
|
|
return;
|
|
}
|
|
for (const r of out) {
|
|
const mine = r.apps.filter(a => !isIgn(r, a)), hidden = r.apps.filter(a => isIgn(r, a));
|
|
const rows = (l: typeof r.apps) => l.map(a => [a.name, a.id, a.bundleId, a.platforms.join(' '), a.latest ? `${a.latest.version} ${a.latest.state}` : '']);
|
|
log('apps', `${r.store === 'ios' ? 'App Store Connect' : 'Google Play'}: ${mine.length} apps${hidden.length ? ` (+${hidden.length} ignored)` : ''}`);
|
|
console.log(textTable(['name', 'id', 'bundle / package', 'platforms', 'newest'], rows(mine)));
|
|
if (hidden.length) { console.log(`ignored (storebound apps unignore):`); console.log(textTable(['name', 'id', 'bundle / package', 'platforms', 'newest'], rows(hidden))); }
|
|
for (const w of r.warnings) warn('apps', w);
|
|
}
|
|
return;
|
|
}
|
|
if (sub === 'check') {
|
|
const out: AccountCheck[] = [];
|
|
for (const s of stores(args, root)) out.push(await checkAccount(s, root));
|
|
if (json) { printJson(args.get('store') ? out[0] : out); return; }
|
|
for (const c of out) {
|
|
log('apps', `${c.store === 'ios' ? 'App Store Connect' : 'Google Play'}: ${c.status} · ${c.message}`);
|
|
if (c.fix) console.log(` fix: ${c.fix}`);
|
|
if (c.docs) console.log(` ${c.docs}`);
|
|
}
|
|
if (out.some(c => c.status !== 'ok')) process.exitCode = 1;
|
|
return;
|
|
}
|
|
console.error('usage: storebound apps list [--store apple|play] [--icons] [--json] | apps check [--store apple|play] [--json] | apps folders [--json] | apps ignore|unignore <name|id> [--store apple|play] | apps add [<folder>] | apps add --store apple|play --app-id <id> [--account <profile>] | apps remove <folder> | apps remove apple:<id>');
|
|
process.exit(2);
|
|
}
|
|
|
|
/** A note when the config still holds the template's placeholders (the folder is added anyway: it is the owner's). */
|
|
export function templateNote(file: string): string | null {
|
|
let t = '';
|
|
try { t = readFileSync(file, 'utf8'); } catch { return null; }
|
|
const left = [/['"]Example App['"]/.test(t) && "app.name is still the template's 'Example App'", /com\.example\./.test(t) && 'the bundle id / package is still com.example.*'].filter(Boolean);
|
|
return left.length ? `${left.join('; ')}: set your app's values in ${basename(file)} (storebound check lists the rest)` : null;
|
|
}
|