Open in Google Play Console deep-links the app: Play developer id per credential profile (accounts play-developer, desktop Accounts), shared playAppUrl builder for desktop, CLI and watch; developer chooser instead of the console root

This commit is contained in:
CRTkafa 2026-10-23 09:20:35 +08:00
parent 7c769c18f4
commit 4a74989a95
33 changed files with 311 additions and 40 deletions

View file

@ -10,13 +10,17 @@
// contact and demo account stay in the project
// storebound accounts use <profile> [--dir <project>] this project uses that profile ("account" in its
// .storebound/credentials.json)
// storebound accounts play-developer <profile> <id> | --clear
// the profile's Play developer account id (the digits in
// play.google.com/console/u/0/developers/<id>): Play
// Console links open the app's own page
// storebound accounts default <profile> | --clear the machine's default profile (a pointer file in the
// credentials folder). Order: env > a profile the project
// names > the project's own keys > this default > the only
// profile (or the one named "default")
import { resolve } from 'node:path';
import type { Args } from './lib/args';
import { applyImport, describeProfiles, planImport } from './lib/accounts';
import { applyImport, describeProfiles, planImport, setPlayDeveloperId } from './lib/accounts';
import { activeProfile, defaultPointer, defaultProfile, profileDir, setDefaultPointer } from './lib/credentials';
import { confirm, type Ctx } from './lib/ctx';
import { textTable } from './lib/table';
@ -38,7 +42,7 @@ export async function run(args: Args): Promise<void> {
console.log(`no store accounts on this machine yet (${tilde(profileDir())}).\n From a project that has its keys in .storebound/credentials.json: storebound accounts import\n Or add them in the desktop app (Accounts).`);
return;
}
console.log(textTable(['profile', 'App Store Connect', 'Google Play', 'vendor no.', 'default'], rows.map(r => [r.profile, r.error ? `unreadable: ${r.error}` : r.apple ? 'key' : '-', r.play ? r.playAccount ?? 'service account' : '-', r.vendor ? 'yes' : '-', r.profile === def ? (def === pointer ? 'yes (accounts default)' : 'yes') : ''])));
console.log(textTable(['profile', 'App Store Connect', 'Google Play', 'Play developer id', 'vendor no.', 'default'], rows.map(r => [r.profile, r.error ? `unreadable: ${r.error}` : r.apple ? 'key' : '-', r.play ? r.playAccount ?? 'service account' : '-', r.playDeveloperId ?? '-', r.vendor ? 'yes' : '-', r.profile === def ? (def === pointer ? 'yes (accounts default)' : 'yes') : ''])));
if (pointer && pointer !== def) console.log(`note: the default pointer names "${pointer}", which is not a profile here (storebound accounts default <profile>)`);
console.log(`\n${tilde(profileDir())}: shared by the CLI and the desktop app`);
console.log(active ? `this folder uses "${active.name}" (${{ env: '--account / STOREBOUND_PROFILE', config: 'account in the config', project: '"account" in .storebound/credentials.json', default: 'the default profile' }[active.from]})` : 'this folder uses no profile (several profiles and no default: storebound accounts default <profile>, or accounts use <profile> in the project)');
@ -69,6 +73,15 @@ export async function run(args: Args): Promise<void> {
console.log(`${tilde(f)}: "account": "${name}"`);
return;
}
if (sub === 'play-developer') {
const name = args._[1];
if (!name) throw new Error('storebound accounts play-developer <profile> <id> | --clear');
const id = args.has('clear') ? null : args._[2] ?? null;
if (!args.has('clear') && !id) throw new Error('storebound accounts play-developer <profile> <id>: the digits in play.google.com/console/u/0/developers/<id>');
const f = setPlayDeveloperId(name, id);
console.log(id ? `${tilde(f)}: PLAY_DEVELOPER_ID ${id} (Play Console links of this account's apps open the app's page)` : `${tilde(f)}: Play developer id removed`);
return;
}
if (sub === 'default') {
if (args.has('clear')) { setDefaultPointer(null); console.log('default profile pointer removed (default: the only profile, or the one named "default")'); return; }
const name = args._[1];
@ -77,5 +90,5 @@ export async function run(args: Args): Promise<void> {
console.log(`${tilde(f)}: default profile "${name}" (used by every folder that names no profile and has no keys of its own)`);
return;
}
throw new Error('usage: storebound accounts [list | import [--dir <project>] [--name <profile>] [--dry] [--yes] | use <profile> | default <profile> | default --clear]');
throw new Error('usage: storebound accounts [list | import [--dir <project>] [--name <profile>] [--dry] [--yes] | use <profile> | play-developer <profile> <id> | default <profile> | default --clear]');
}

View file

@ -16,7 +16,7 @@ export const PLAIN: Record<string, [() => Promise<Plain>, string]> = {
'serve': [() => import('./serve'), '--stdio: JSON-RPC session for the storebound desktop app (dry runs; writes need a host confirm token)'],
'apps': [() => import('./apps'), 'list (every app the store keys can see: App Store Connect team, Play developer accounts; --icons) | check (key works / permission missing / revoked, with the fix); --store apple|play, --json; read-only | folders | add [<folder>] | remove <folder>: the app folders of this machine, the same list as the desktop app | ignore|unignore <name|id> [--store apple|play]: leave a dormant app (or one store side) out of watch, portfolio reads and the desktop lists; storebound only, the store is untouched'],
'desktop': [() => import('./desktop'), 'open the storebound desktop app on this folder; not installed or another version: download it for this system (size, SHA-256, signature checked), install it for this user (asks once; --yes) and open it; --download only saves the installer, --dry / --json change nothing'],
'accounts': [() => import('./accounts'), 'the store accounts of this machine (credential profiles in ~/.storebound/credentials, shared with the desktop app): list | import (a project\'s store keys -> a profile; --dry first) | use <profile> | default <profile> (the machine default; --clear)'],
'accounts': [() => import('./accounts'), 'the store accounts of this machine (credential profiles in ~/.storebound/credentials, shared with the desktop app): list | import (a project\'s store keys -> a profile; --dry first) | use <profile> | play-developer <profile> <id> (Play Console deep links) | default <profile> (the machine default; --clear)'],
'settings': [() => import('./settings'), 'per-machine settings: bypass on | off | status (owner opt-in: the agent also presses the owner-only buttons; default off)'],
'aab': [() => import('./aab'), 'offline: an AAB file: packageName, versionCode, versionName, min / target SDK, sha256 (no config, no upload); --json'],
'doctor': [() => import('./doctor'), 'build and capture dependencies (JDK with the version the project needs, Android SDK, Xcode, CocoaPods, Node, Bun, ffmpeg, Chrome, adb, git, bundletool) and how to install what is missing; --install <tool> --yes; --json'],

View file

@ -14,6 +14,7 @@ import { maskUser, reviewContact, reviewDemoAccount } from './lib/credentials';
import { DATA_SAFETY_UPLOADED, declarations } from './lib/declarations';
import type { ProjectSummary } from './lib/desktop-protocol';
import { log, printJson } from './lib/log';
import { playIdsOf } from './lib/play-ids';
import { findIcon, iconDataUrl, listingCompleteness, listingText, renderImage } from './lib/project-files';
export function summarize(ctx: Ctx): ProjectSummary {
@ -24,7 +25,7 @@ export function summarize(ctx: Ctx): ProjectSummary {
config: path,
name: config.app.name,
languages: config.languages.map(l => l.id),
play: config.play ? { packageName: config.play.packageName } : null,
play: config.play ? { packageName: config.play.packageName, ...playIdsOf(config, root) } : null,
apple: config.apple ? { bundleId: config.apple.bundleId, appId: config.apple.appId ?? null } : null,
platforms: platformsOf(config),
products: config.products?.length ?? 0,

View file

@ -14,7 +14,7 @@ export type Kind = 'apple' | 'play';
/** The store keys that move to a profile (one developer account's), never the app's own ones. */
export const STORE_KEYS = PROFILE_KEYS;
export interface Identity { appleKeyId: string | null; playEmail: string | null; vendor: boolean }
export interface Identity { appleKeyId: string | null; playEmail: string | null; vendor: boolean; playDeveloperId: string | null }
const PEM = /BEGIN PRIVATE KEY/;
const isJsonText = (v: string) => /^\s*\{/.test(v);
@ -32,15 +32,15 @@ export function serviceAccountEmail(v: string, base: string): string | null {
/** What a profile can reach: the Apple key id and the Play service account e-mail (for matching, never printed). */
export function profileIdentity(name: string): Identity {
const env = profileEnv(name);
return { appleKeyId: env.ASC_KEY_ID ?? null, playEmail: env.PLAY_SERVICE_ACCOUNT ? serviceAccountEmail(env.PLAY_SERVICE_ACCOUNT, profileDir()) : null, vendor: !!env.ASC_VENDOR_NUMBER };
return { appleKeyId: env.ASC_KEY_ID ?? null, playEmail: env.PLAY_SERVICE_ACCOUNT ? serviceAccountEmail(env.PLAY_SERVICE_ACCOUNT, profileDir()) : null, vendor: !!env.ASC_VENDOR_NUMBER, playDeveloperId: env.PLAY_DEVELOPER_ID ?? null };
}
/** One line per profile for `storebound accounts`: stores, masked Play e-mail. No key ids, no values. */
export function describeProfiles(): { profile: string; apple: boolean; play: boolean; playAccount: string | null; vendor: boolean; error?: string }[] {
export function describeProfiles(): { profile: string; apple: boolean; play: boolean; playAccount: string | null; vendor: boolean; playDeveloperId?: string | null; error?: string }[] {
return profileNames().map(profile => {
try {
const id = profileIdentity(profile);
return { profile, apple: !!id.appleKeyId, play: !!id.playEmail, playAccount: id.playEmail ? maskUser(id.playEmail) : null, vendor: id.vendor };
return { profile, apple: !!id.appleKeyId, play: !!id.playEmail, playAccount: id.playEmail ? maskUser(id.playEmail) : null, vendor: id.vendor, playDeveloperId: id.playDeveloperId };
} catch (e) { return { profile, apple: false, play: false, playAccount: null, vendor: false, error: (e as Error).message }; }
});
}
@ -205,3 +205,18 @@ export function useProfile(project: string, name: string): string {
atomic(file, JSON.stringify({ ...cur, account: name }, null, 2) + '\n', 0o600);
return file;
}
/** Sets (or clears with null) the Play developer account id of a profile: an account id, not a secret, used for Play
* Console deep links (play-ids.ts). The same field the desktop's Accounts screen writes. Returns the profile file. */
export function setPlayDeveloperId(name: string, id: string | null): string {
if (!PROFILE_RE.test(name)) throw new Error(`${name}: letters, digits, . _ - (max 64)`);
if (!profileNames().includes(name)) throw new Error(`no profile "${name}" in ${profileDir()}: \`storebound accounts\` lists them`);
const v = id?.trim() ?? null;
if (v !== null && !/^\d{1,25}$/.test(v)) throw new Error('the Play developer id is the digits in play.google.com/console/u/0/developers/<id>/...');
const file = join(profileDir(), `${name}.json`);
const cur = readJson(file);
const next: Json = { ...cur };
if (v === null) delete next.PLAY_DEVELOPER_ID; else next.PLAY_DEVELOPER_ID = v;
atomic(file, JSON.stringify(next, null, 2) + '\n', 0o600);
return file;
}

View file

@ -6,6 +6,7 @@ import { existsSync } from 'node:fs';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import * as rt from './rt';
import { PROFILE_RE, setConfigAccount } from './credentials';
import { playIdsOf } from './play-ids';
import { validateCapture, validateRecord } from '../shots/devices';
import { featureBackgroundIssues, paletteIssues } from '../shots/palette';
import { type AppleDataTypeId, type AppleTypeAnswer, validateAppPrivacyTypes } from './app-privacy';
@ -1286,7 +1287,8 @@ export async function loadConfig(explicit?: string, start = process.cwd()): Prom
if (errors.length) throw new Error(`${path}:\n ` + errors.join('\n '));
const root = dirname(path);
setConfigAccount(root, config.account);
setConsoleIds(consoleIdsFromConfig(config));
const play = playIdsOf(config, root);
setConsoleIds({ ...consoleIdsFromConfig(config), playDeveloperId: play.developerId, playAppId: play.appId });
return { config, root, path, resolve: (p: string) => (isAbsolute(p) ? p : join(root, p)) };
}

View file

@ -9,6 +9,8 @@
export const ASC = 'https://appstoreconnect.apple.com';
export const PLAY_CONSOLE = 'https://play.google.com/console';
/** The developer account chooser: the fallback when no developer id is known (never the bare console root). */
export const PLAY_DEVELOPERS = `${PLAY_CONSOLE}/u/0/developers`;
export interface ConsoleIds { appleAppId?: string | null; playDeveloperId?: string | null; playAppId?: string | null }
@ -94,7 +96,7 @@ export function consoleUrl(page: ConsolePage, ids: ConsoleIds = current): string
return app ? `${ASC}/apps/${app}${p.path}` : `${ASC}/apps`;
}
const dev = id(ids.playDeveloperId ?? current.playDeveloperId), app = id(ids.playAppId ?? current.playAppId);
if (!dev) return PLAY_CONSOLE;
if (!dev) return PLAY_DEVELOPERS;
const base = `${PLAY_CONSOLE}/u/0/developers/${dev}`;
if (p.scope === 'developer') return `${base}/${p.path}`;
return app ? `${base}/app/${app}/${p.path}` : `${base}/app-list`;
@ -103,7 +105,22 @@ export function consoleUrl(page: ConsolePage, ids: ConsoleIds = current): string
/** True when the URL is the page itself (ids known), false when it is a fallback (app list, chooser). */
export function consoleUrlExact(page: ConsolePage, ids: ConsoleIds = current): boolean {
const u = consoleUrl(page, ids);
return u !== PLAY_CONSOLE && !(CONSOLE_PAGES[page].scope === 'app' && (u === `${ASC}/apps` || u.endsWith('/app-list')));
return u !== PLAY_DEVELOPERS && !(CONSOLE_PAGES[page].scope === 'app' && (u === `${ASC}/apps` || u.endsWith('/app-list')));
}
/**
* "Open in Google Play Console" for one app: its dashboard when the developer id and the console's app id are known,
* else that developer's app list (searchable, the package name finds the row), else the developer chooser. Every
* desktop / CLI / watch link to an app's Play page goes through here.
*/
export function playAppUrl(ids: { developerId?: string | null; appId?: string | null }): string {
return consoleUrl('play.dashboard', { playDeveloperId: ids.developerId ?? null, playAppId: ids.appId ?? null, appleAppId: null });
}
/** The first candidate that is a console id (digits); the order of the sources is in play-ids.ts. */
export function pickConsoleId(...candidates: (string | null | undefined)[]): string | null {
for (const c of candidates) if (c && /^\d{1,25}$/.test(c.trim())) return c.trim();
return null;
}
/** "Readable > path: https://..." (the path suffix, e.g. "> Copy Cloud Storage URI", is appended to the label). */

View file

@ -44,7 +44,7 @@ function fileStore(root: string): Store {
export function credentialValue(root: string, name: string): string | undefined { return value(root, name); }
/** The store credential names a profile holds (the same as the env names): per developer account, so the vendor number
* for reports too; an app's Android upload keystore stays in the project. */
export const PROFILE_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER'] as const;
export const PROFILE_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'PLAY_DEVELOPER_ID'] as const;
export const PROFILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
export const profileDir = (): string => process.env.STOREBOUND_CREDENTIALS_DIR || join(homedir(), '.storebound', 'credentials');

View file

@ -122,7 +122,8 @@ export interface ProjectSummary {
config: string;
name: string;
languages: string[];
play: { packageName: string } | null;
/** developerId / appId: the Play Console ids for deep links (play-ids.ts), null when unknown. */
play: { packageName: string; developerId?: string | null; appId?: string | null } | null;
apple: { bundleId: string; appId: string | null } | null;
/** app.platforms (default: from the apple / play sections). */
platforms: ('ios' | 'android')[];
@ -155,7 +156,7 @@ export interface ProjectSummary {
export interface AppIdentity {
id: string;
name: string;
platforms: { ios: { bundleId: string; ascAppId: string | null } | null; android: { packageName: string } | null };
platforms: { ios: { bundleId: string; ascAppId: string | null } | null; android: { packageName: string; developerId?: string | null; appId?: string | null } | null };
}
/** Where a version is on one store. */

43
cli/lib/play-ids.ts Normal file
View file

@ -0,0 +1,43 @@
// The Play Console ids an app's links use, from every place this machine keeps them (first one wins):
// developer id: play.developerId (config) > STOREBOUND_PLAY_DEVELOPER_ID (a workspace account, `ws`) >
// PLAY_DEVELOPER_ID of the credential profile (`storebound accounts play-developer`, the desktop's
// Accounts screen) > the id `console fill` remembered for the package (~/.storebound/console-accounts.json)
// app id: play.appId (config) > the remembered app id for the package
// Never a guess: an id that is not digits is skipped. The links themselves: console-links.ts (playAppUrl).
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { pickConsoleId } from './console-links';
import { credentialValue } from './credentials';
const consoleAccountsFile = (): string => process.env.STOREBOUND_CONSOLE_ACCOUNTS || join(homedir(), '.storebound', 'console-accounts.json');
/** What `console fill` remembered for a package ({ developerId, appId? }); {} when nothing or unreadable. */
export function rememberedPlay(pkg: string | null | undefined, file = consoleAccountsFile()): { developerId?: string; appId?: string } {
if (!pkg || !existsSync(file)) return {};
try {
const e = (JSON.parse(readFileSync(file, 'utf8')) as { play?: Record<string, { developerId?: unknown; appId?: unknown }> }).play?.[pkg];
return { developerId: typeof e?.developerId === 'string' ? e.developerId : undefined, appId: typeof e?.appId === 'string' ? e.appId : undefined };
} catch { return {}; }
}
export interface PlayIdSources {
config?: { developerId?: string | null; appId?: string | null; packageName?: string | null } | null;
workspace?: string | null;
profile?: string | null;
remembered?: { developerId?: string | null; appId?: string | null };
}
export function resolvePlayIds(s: PlayIdSources): { developerId: string | null; appId: string | null } {
return {
developerId: pickConsoleId(s.config?.developerId, s.workspace, s.profile, s.remembered?.developerId),
appId: pickConsoleId(s.config?.appId, s.remembered?.appId),
};
}
/** The ids for a loaded config (every source above). */
export function playIdsOf(config: { play?: { developerId?: string; appId?: string; packageName?: string } }, root: string): { developerId: string | null; appId: string | null } {
let profile: string | null = null;
try { profile = credentialValue(root, 'PLAY_DEVELOPER_ID') ?? null; } catch { /* unreadable profile: no id from it */ }
return resolvePlayIds({ config: config.play ?? null, workspace: process.env.STOREBOUND_PLAY_DEVELOPER_ID, profile, remembered: rememberedPlay(config.play?.packageName) });
}

View file

@ -11,7 +11,7 @@ export function appIdentity(s: ProjectSummary): AppIdentity {
name: s.name,
platforms: {
ios: s.apple && s.platforms.includes('ios') ? { bundleId: s.apple.bundleId, ascAppId: s.apple.appId } : null,
android: s.play && s.platforms.includes('android') ? { packageName: s.play.packageName } : null,
android: s.play && s.platforms.includes('android') ? { packageName: s.play.packageName, developerId: s.play.developerId ?? null, appId: s.play.appId ?? null } : null,
},
};
}

View file

@ -100,7 +100,7 @@ async function readApp(t: Target, prev: AppSnap | undefined, now: number): Promi
const useApple = !!s.apple && !t.skip.includes('apple'), usePlay = !!s.play && !t.skip.includes('play');
const snap: AppSnap = { name: s.name || t.name, ids: { appleAppId: s.apple?.appId ?? null } };
if (usePlay && !t.virtual && !prev?.ids.playDeveloperId) {
try { const { loadConfig } = await import('./lib/config'); Object.assign(snap.ids, consoleIdsFromConfig((await loadConfig(undefined, t.dir)).config)); } catch { /* links fall back to the console home */ }
try { const { loadConfig } = await import('./lib/config'); const { playIdsOf } = await import('./lib/play-ids'); const l = await loadConfig(undefined, t.dir); const play = playIdsOf(l.config, l.root); Object.assign(snap.ids, consoleIdsFromConfig(l.config), { playDeveloperId: play.developerId, playAppId: play.appId }); } catch { /* links fall back to the app list / developer chooser */ }
}
const all = e.release.all ?? [];
snap.releases = {};