credential broker: Play tokens from POST /api/google/token (cached, single flight, local key fallback), grants and APIs per package in validate, Play write preflight, apps check and the desktop Accounts view; Google 403 names the API or grant, no retry

This commit is contained in:
CRTkafa 2026-10-25 22:50:52 +07:00
parent 304407e523
commit 2c5e30090f
27 changed files with 1145 additions and 43 deletions

View file

@ -9,7 +9,8 @@ import { join, resolve } from 'node:path';
import pkg from '../package.json' with { type: 'json' };
import type { Args } from './lib/args';
import { CONFIG_NAMES } from './lib/config';
import { credentialStatus } from './lib/credentials';
import { credentialStatus, maskUser } from './lib/credentials';
import { type PlayCredentials, playCredentials } from './lib/google-auth';
import { log, printJson } from './lib/log';
import { textTable } from './lib/table';
import { androidSdk, type AndroidSdkLookup, detectTools, findPlaywright, installCommands, type ToolStatus } from './lib/tools';
@ -38,6 +39,9 @@ export interface DoctorResult {
/** Store credentials of the project folder (env, .storebound/credentials.json, profile): set, missing or
* placeholder (a template value, treated as missing). Never the values. */
credentials: { key: string; status: 'set' | 'missing' | 'placeholder' }[];
/** Where Play tokens come from: the credential broker (~/.storebound/broker.json) or the local key file; the
* e-mail masked, never a key. */
play: PlayCredentials;
/** macOS: a non-Apple rsync first on PATH (Homebrew 3.x breaks Xcode's exportArchive, pitfall 54), else null. */
foreignRsync: string | null;
}
@ -50,10 +54,24 @@ export function doctor(cwd: string, env: Record<string, string | undefined> = pr
usedFor: 'screenshot renders and web captures from a terminal (the desktop app brings its own)', install: pw ? [] : installCommands('playwright'), url: 'https://playwright.dev/docs/intro' });
const project = CONFIG_NAMES.some(n => existsSync(join(cwd, n))) ? cwd : null;
const sdk = androidSdk([join(cwd, 'android'), cwd], env);
return { storebound: pkg.version, bun: rt.bunVersion, runtime: `${rt.runtime.name} ${rt.runtime.version}`, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found || t.status === 'outdated').length, androidSdk: sdk, credentials: credentialStatus(cwd),
return { storebound: pkg.version, bun: rt.bunVersion, runtime: `${rt.runtime.name} ${rt.runtime.version}`, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found || t.status === 'outdated').length, androidSdk: sdk, credentials: credentialStatus(cwd), play: maskedPlay(cwd),
foreignRsync: process.platform === 'darwin' ? foreignRsync(env.PATH) : null };
}
function maskedPlay(cwd: string): PlayCredentials {
const p = playCredentials(cwd);
return { ...p, principal: p.principal ? maskUser(p.principal) : null };
}
/** The one line about Play access: which source, and the broker's state. */
export function playLine(p: PlayCredentials): string {
const who = p.principal ? ` for ${p.principal}` : '';
if (p.source === 'broker') return `Play: tokens from the credential broker ${p.broker!.url}${who}${p.local ? ' (local key as fallback)' : ''}`;
const broken = p.broker?.error ? ` · credential broker not usable: ${p.broker.error}` : '';
if (p.source === 'file') return `Play: local service account key${who}${broken}`;
return `Play: no service account key and no credential broker${broken}`;
}
/** One install command of a tool, run only with --yes; sudo / admin commands are printed, never run. */
export function installPlan(r: DoctorResult, id: string, platform: NodeJS.Platform = process.platform): { tool: DoctorRow; command: string | null; runnable: boolean } {
const tool = r.tools.find(t => t.id === id);
@ -95,6 +113,7 @@ export async function run(args: Args): Promise<void> {
: `Android SDK: not found (play build needs it). Checked: ${r.androidSdk.checked.join('; ')}`);
const set = r.credentials.filter(c => c.status === 'set').map(c => c.key), ph = r.credentials.filter(c => c.status === 'placeholder').map(c => c.key);
log('doctor', `credentials: ${set.length ? set.join(', ') : 'none set'}${ph.length ? ` · placeholders (treated as missing): ${ph.join(', ')}` : ''}`);
log('doctor', playLine(r.play));
if (r.foreignRsync) log('doctor', `rsync: ${r.foreignRsync} is first on PATH and breaks Xcode exportArchive ("Copy failed") outside storebound; storebound apple build puts /usr/bin first. Fix for everything else: brew unlink rsync, or put /usr/bin before it`);
const missing = r.tools.filter(t => !t.found || t.status === 'outdated');
if (!missing.length) { log('doctor', 'everything storebound can use is here'); return; }