Onboarding live apps without owner questions for what the stores and the code prove: storebound onboard imports the live products and subscriptions with USD / base prices (catalog diff clean), contact values, the App Review contact and demo account (credentials.json, masked), sign-in for review from Apple's review detail and auth code, and the account deletion page from the app's site; App Store keywords count characters (live fields up to 265 bytes accepted); validate accepts the 6.5-inch iPhone set and Play's default-language graphics fallback; Play Reporting API off gives the project's enable link and apps list falls back to local apps; accounts default <profile>; store-only apps (apps add --store --app-id) shown read-only in the desktop (pitfalls 43-52)

This commit is contained in:
CRTkafa 2026-10-13 07:25:01 +08:00
parent d2ce185441
commit e5f148e534
55 changed files with 1648 additions and 158 deletions

View file

@ -8,21 +8,25 @@
// 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>]
// 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, playServiceAccount } from './lib/credentials';
import type { AccountCheck, AppsListResult } from './lib/desktop-protocol';
import { PLAY_SCOPE, REPORTING_SCOPE, tokenSource } from './lib/google-auth';
import { errorBody } from './lib/http';
import { log, printJson, warn } from './lib/log';
import { appleCheck, type Bytes, dataUrl, type Get, listAppleApps, listPlayApps, MAX_ICON_BYTES, playCheck } from './lib/store-apps';
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 } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { CONFIG_NAMES } from './lib/config';
import { addFolder, readRegistry, registryFile, removeFolder } from './lib/registry';
import { addFolder, addStoreApp, readRegistry, registryFile, removeFolder, removeStoreApp } from './lib/registry';
type Store = 'apple' | 'play';
@ -99,8 +103,42 @@ export async function listApps(store: Store, root: string, icons: boolean): Prom
return { store: 'ios', ...r, at };
}
const sa = playServiceAccount(root);
const r = await listPlayApps(googleGet(tokenSource(sa, REPORTING_SCOPE), REPORTING_API), icons ? { icon: playIcon(root) } : {});
return { store: 'android', ...r, at };
try {
const r = await listPlayApps(googleGet(tokenSource(sa, REPORTING_SCOPE), 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(sa.client_email, { 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> {
@ -123,16 +161,43 @@ export async function checkAccount(store: Store, root: string): Promise<AccountC
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). */
function folders(sub: string, args: Args): void {
async function folders(sub: string, args: Args): Promise<void> {
if (sub === 'folders') {
const r = readRegistry();
if (args.has('json')) { printJson({ file: registryFile(), projects: r.projects.map(p => ({ ...p, missing: !existsSync(p.path) })) }); return; }
if (args.has('json')) { printJson({ file: registryFile(), projects: r.projects.map(p => ({ ...p, missing: !existsSync(p.path) })), storeApps: r.storeApps ?? [] }); return; }
if (r.storeApps?.length) {
console.log(textTable(['store app', 'store', 'app id', 'profile', ''], r.storeApps.map(a => [a.name, a.store, a.appId, a.account ?? '(default)', 'no local project'])));
console.log('');
}
if (!r.projects.length) { console.log(`no app folders yet (${tilde(registryFile())}): storebound apps add, in an app folder (or storebound desktop there)`); return; }
console.log(textTable(['app', 'folder', ''], r.projects.map(p => [p.name, tilde(p.path), existsSync(p.path) ? '' : 'folder not found'])));
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 (!CONFIG_NAMES.some(n => existsSync(join(dir, n)))) throw new Error(`${dir}: no storebound config here (storebound init starts one)`);
@ -181,6 +246,6 @@ export async function run(args: Args): Promise<void> {
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 add [<folder>] | apps remove <folder>');
console.error('usage: storebound apps list [--store apple|play] [--icons] [--json] | apps check [--store apple|play] [--json] | apps folders [--json] | apps add [<folder>] | apps add --store apple|play --app-id <id> [--account <profile>] | apps remove <folder> | apps remove apple:<id>');
process.exit(2);
}