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:
parent
d2ce185441
commit
e5f148e534
55 changed files with 1648 additions and 158 deletions
81
cli/apps.ts
81
cli/apps.ts
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue