Google API errors as typed findings, never raw JSON: cli/lib/google-errors.ts parses SERVICE_DISABLED (api-disabled: service, Cloud project, console page), PERMISSION_DENIED (no-app-access: which app, Users and permissions), 401 / invalid_grant (auth-revoked) and 429 (quota); health / analytics print one line with the URL, raw text in detail and the log; desktop Health tab: the line, 'Turn it on in Google Cloud' and 'Check again', raw text in a collapsed Details; Portfolio shows a Cloud project blocking several apps once. Findings exit codes are results: the sidecar reports outcome (ok / findings / declined / failed) and the command's own error line; scan / check / validate / health exit 1 with findings show and cache the findings; no bare 'Exit code N' anywhere. tests/google-errors.test.ts, boot check errors cases (api-disabled, scan-findings, scan-failed) (pitfall 57)

This commit is contained in:
CRTkafa 2026-10-13 23:20:04 +08:00
parent f69c10d3a0
commit 89256f1e80
28 changed files with 666 additions and 47 deletions

View file

@ -0,0 +1,64 @@
// Live-shape failures for the boot check and tests: Google's 403 SERVICE_DISABLED body exactly as the Play Developer
// Reporting API answers it (the project id is an example), the health snapshot `storebound health --json` writes for
// it, and `storebound scan --json` results: findings with exit 1 (by design) and a real failure with its error line.
import { errorBody } from '../../../../cli/lib/http';
import { storeReadError } from '../../../../cli/lib/google-errors';
import { errorLine } from '../../../../cli/lib/desktop-protocol';
export const EXAMPLE_CLOUD_PROJECT = '123456789000';
export const SERVICE_DISABLED_403 = `{
"error": {
"code": 403,
"message": "Google Play Developer Reporting API has not been used in project ${EXAMPLE_CLOUD_PROJECT} before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/playdeveloperreporting.googleapis.com/overview?project=${EXAMPLE_CLOUD_PROJECT} then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.",
"status": "PERMISSION_DENIED",
"details": [
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": "SERVICE_DISABLED",
"domain": "googleapis.com",
"metadata": {
"activationUrl": "https://console.developers.google.com/apis/api/playdeveloperreporting.googleapis.com/overview?project=${EXAMPLE_CLOUD_PROJECT}",
"service": "playdeveloperreporting.googleapis.com",
"consumer": "projects/${EXAMPLE_CLOUD_PROJECT}",
"serviceTitle": "Google Play Developer Reporting API",
"containerInfo": "${EXAMPLE_CLOUD_PROJECT}"
}
},
{
"@type": "type.googleapis.com/google.rpc.LocalizedMessage",
"locale": "en-US",
"message": "Google Play Developer Reporting API has not been used in project ${EXAMPLE_CLOUD_PROJECT} before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/playdeveloperreporting.googleapis.com/overview?project=${EXAMPLE_CLOUD_PROJECT} then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry."
},
{
"@type": "type.googleapis.com/google.rpc.Help",
"links": [{ "description": "Google developers console API activation", "url": "https://console.developers.google.com/apis/api/playdeveloperreporting.googleapis.com/overview?project=${EXAMPLE_CLOUD_PROJECT}" }]
}
]
}
}`;
/** The Error message storebound's Google client throws for it (play/google.ts). */
export const serviceDisabledMessage = (pkg: string): string => `GET /v1beta1/apps/${pkg}/crashRateMetricSet -> 403 ${errorBody(SERVICE_DISABLED_403, 800)}`;
/** `storebound health --json` when the Reporting API is off. */
export const healthApiDisabled = (pkg: string) => ({ date: '2026-10-13', play: storeReadError(serviceDisabledMessage(pkg), { app: pkg }) });
/** `storebound scan --json` that found problems: exit 1 by design, the result still printed. */
export const SCAN_FINDINGS = {
method: 'scan', ok: false, exitCode: 1, dryRun: false, command: 'storebound scan --json', outcome: 'findings' as const,
output: '12:00:00 [scan] 41 files\n12:00:00 [scan] 1 blocker, 0 warnings',
data: {
stacks: ['flutter'], sdks: [], counts: { blocker: 1, high: 0, medium: 0, low: 0, accepted: 0 },
declarations: { changed: false, items: [] },
findings: [{ rule: 'account-deletion', store: 'apple', severity: 'blocker', guideline: '5.1.1(v)', title: 'Sign-up without in-app account deletion',
evidence: [{ file: 'lib/auth/sign_up.dart', line: 12, text: 'createUserWithEmailAndPassword' }], fix: 'Add a "Delete account" action in the app settings', skill: 'account-deletion' }],
},
};
/** A real failure: the config does not load; the sidecar sends the command's own error line. */
const SCAN_STDERR = '\nerror: storebound.config.ts: app.name is missing';
export const SCAN_FAILED = {
method: 'scan', ok: false, exitCode: 1, dryRun: false, command: 'storebound scan --json', outcome: 'failed' as const,
output: `12:00:00 [scan] loading the config${SCAN_STDERR}`, error: errorLine(SCAN_STDERR, ''),
};
export const SCAN_FAILED_LINE = 'storebound.config.ts: app.name is missing';

View file

@ -66,7 +66,25 @@ export const en = {
'common.unknown': 'Unknown',
'common.cancel': 'Cancel',
'common.close': 'Close',
'common.exit': 'Exit code {code}',
'common.exitNoMessage': 'The command stopped without an error message (exit {code}); the log has the details',
'gerr.apiDisabled': '{what} needs the {service} turned on in Google Cloud project {project}.',
'gerr.what.reporting': 'Crash and ANR data',
'gerr.what.publisher': 'Reading the app on Google Play',
'gerr.what.storage': 'Play statistics',
'gerr.what.other': 'This read',
'gerr.projectUnknown': '(the service account\'s project)',
'gerr.turnOn': 'Turn it on in Google Cloud',
'gerr.checkAgain': 'Check again',
'gerr.noAccess': 'The service account has no access to {app} in Play Console: add it under Users and permissions.',
'gerr.thisApp': 'this app',
'gerr.openUsers': 'Open Users and permissions',
'gerr.revoked': 'Google refused the service account key: it was revoked, deleted or has expired. Add a new JSON key in Accounts.',
'gerr.quota': 'The Google API quota is used up for now. Try again later.',
'gerr.details': 'Details',
'gerr.openFailed': 'Could not open the page',
'gerr.portfolio': '{count} Google Cloud projects need an API turned on: affects {apps}',
'gerr.portfolio_one': '1 Google Cloud project needs an API turned on: affects {apps}',
'gerr.portfolioProject': 'Project {project}: {service}',
'common.cachedFrom': 'Last read {when} (saved copy)',
'common.readWhen': 'Read {when}',
'common.readFailedKeep': 'The last read failed, so this shows the previous one: {error}',

View file

@ -68,7 +68,25 @@ export const tr: Record<keyof typeof en, string> = {
'common.unknown': 'Bilinmiyor',
'common.cancel': 'İptal',
'common.close': 'Kapat',
'common.exit': 'Çıkış kodu {code}',
'common.exitNoMessage': 'Komut bir hata mesajı vermeden durdu (çıkış {code}); ayrıntılar günlükte',
'gerr.apiDisabled': '{what} için Google Cloud projesi {project} içinde {service} açılmalı.',
'gerr.what.reporting': 'Çökme ve ANR verileri',
'gerr.what.publisher': 'Uygulamayı Google Play\'den okumak',
'gerr.what.storage': 'Play istatistikleri',
'gerr.what.other': 'Bu okuma',
'gerr.projectUnknown': '(hizmet hesabının projesi)',
'gerr.turnOn': 'Google Cloud\'da aç',
'gerr.checkAgain': 'Tekrar kontrol et',
'gerr.noAccess': 'Hizmet hesabının Play Console\'da {app} için erişimi yok: Kullanıcılar ve izinler bölümünden ekleyin.',
'gerr.thisApp': 'bu uygulama',
'gerr.openUsers': 'Kullanıcılar ve izinler\'i aç',
'gerr.revoked': 'Google hizmet hesabı anahtarını reddetti: iptal edilmiş, silinmiş ya da süresi dolmuş. Hesaplar\'da yeni bir JSON anahtarı ekleyin.',
'gerr.quota': 'Google API kotası şimdilik doldu. Daha sonra tekrar deneyin.',
'gerr.details': 'Ayrıntılar',
'gerr.openFailed': 'Sayfa açılamadı',
'gerr.portfolio': '{count} Google Cloud projesinde bir API açılmalı: etkilenenler {apps}',
'gerr.portfolio_one': '1 Google Cloud projesinde bir API açılmalı: etkilenenler {apps}',
'gerr.portfolioProject': 'Proje {project}: {service}',
'common.cachedFrom': 'Son okuma {when} (kayıtlı kopya)',
'common.readWhen': '{when} okundu',
'common.readFailedKeep': 'Son okuma başarısız oldu, bu yüzden bir öncekini gösteriyor: {error}',

View file

@ -1,6 +1,7 @@
// Pure view models for the screens: release timeline stages, health series against the store thresholds, review
// inbox stats, catalog parity matrix, the portfolio's "needs attention" list, and the command palette matcher.
// "Unknown, never false": every derivation keeps "not read" apart from "none" / "ok".
import { plainError } from '../../../../cli/lib/google-errors';
import type { AppStatus, ProjectSummary } from '../api';
import { t } from '../i18n';
@ -23,7 +24,8 @@ export interface AppleHealth {
signatures: { build: string; type: string; signature: string; weight: number }[];
betaCrashes: { build: string; count: number }[];
}
export interface HealthSnapshot { date: string; play?: PlayHealth | { error: string }; apple?: AppleHealth | { error: string } }
export type StoreReadError = import('../../../../cli/lib/health').StoreReadError;
export interface HealthSnapshot { date: string; play?: PlayHealth | StoreReadError; apple?: AppleHealth | StoreReadError }
export interface Review { store: 'apple' | 'play'; id: string; rating: number; title?: string; body: string; where?: string; date: string; appVersion?: string; reply?: { text: string; date?: string; state?: string } }
export interface TfGroup { id: string; name: string; internal: boolean; testers: number; builds: number | 'all'; publicLink: boolean }
export interface TfBuild { version: string | null; build: string | null; processing: string | null; internal: string | null; external: string | null; uploaded: string | null }
@ -47,7 +49,7 @@ export interface ScanResult {
declarations: { changed: boolean; items: { area: string; what: string; value: string; status: 'detected' | 'ask'; evidence: { file: string; line: number }[] }[] };
}
export const isError = (x: unknown): x is { error: string } => !!x && typeof x === 'object' && typeof (x as { error?: unknown }).error === 'string';
export const isError = (x: unknown): x is StoreReadError => !!x && typeof x === 'object' && typeof (x as { error?: unknown }).error === 'string';
// ---- Release timeline ----
// The stages and lanes live in cli/lib/train.ts, shared with `storebound release status | plan`.
@ -139,8 +141,8 @@ export function attention(projectId: string, name: string, e: AppStatus | undefi
for (const w of (e.check?.warnings ?? []).slice(0, 3)) out.push({ ...base, tone: 'warn', text: w, tab: 'checks' });
const l = live ?? (e.release.source === 'live' ? e : undefined);
if (l) {
if (l.storeErrors.ios) out.push({ ...base, tone: 'warn', text: t('att.storeError', { store: 'App Store', error: l.storeErrors.ios }), tab: 'releases' });
if (l.storeErrors.android) out.push({ ...base, tone: 'warn', text: t('att.storeError', { store: 'Google Play', error: l.storeErrors.android }), tab: 'releases' });
if (l.storeErrors.ios) out.push({ ...base, tone: 'warn', text: t('att.storeError', { store: 'App Store', error: plainError(l.storeErrors.ios) }), tab: 'releases' });
if (l.storeErrors.android) out.push({ ...base, tone: 'warn', text: t('att.storeError', { store: 'Google Play', error: plainError(l.storeErrors.android) }), tab: 'releases' });
for (const r of l.release.all ?? []) {
if (r.phase === 'rejected') out.push({ ...base, tone: 'bad', text: t('att.rejected', { store: r.store === 'ios' ? 'App Store' : 'Google Play', version: r.version ?? '' }), tab: 'releases' });
if (r.phase === 'halted') out.push({ ...base, tone: 'bad', text: t('att.halted', { store: r.store === 'ios' ? 'App Store' : 'Google Play', version: r.version ?? '' }), tab: 'releases' });

View file

@ -0,0 +1,20 @@
// What a command's result means for the window: exit 1 of scan / check / validate / health with the --json result
// printed is "found problems" (shown as the findings), 3 is "declined"; only a real failure is an error, and then in
// the command's own words (its error line, Google errors as one plain line), never a bare "Exit code N".
import { errorLine, type Outcome, outcomeOf, type ToolResult } from '../../../../cli/lib/desktop-protocol';
import { plainError } from '../../../../cli/lib/google-errors';
import { t } from '../i18n';
export function outcome(method: string, r: Pick<ToolResult, 'exitCode' | 'data' | 'outcome'>): Outcome {
return r.outcome ?? outcomeOf(method, r.exitCode, r.data !== undefined);
}
/** The result carries data the screen can show (ok, or findings). */
export const usable = (method: string, r: Pick<ToolResult, 'exitCode' | 'data' | 'outcome'>): boolean =>
r.data !== undefined && ['ok', 'findings'].includes(outcome(method, r));
/** A failed run in words: the command's error line (Google JSON bodies as one plain line). */
export function failureText(r: Pick<ToolResult, 'exitCode' | 'output' | 'error'>): string {
const line = r.error || errorLine('', r.output ?? '');
return line ? plainError(line) : t('common.exitNoMessage', { code: r.exitCode });
}

View file

@ -1,5 +1,6 @@
// Workspace: app folders (one storebound.config.ts per app), which store accounts each app uses, and starting a new
// folder from the templates (`storebound init` with a profile, confirmed in a native dialog).
import { failureText } from '../lib/results';
import { useEffect, useRef, useState } from 'react';
import { api, errorText, newJob, onProgress, type Project, type ToolResult, type WriteOutcome } from '../api';
import { devSwitch } from '../dev/switches';
@ -69,7 +70,7 @@ export function AppFolders() {
const apple = d.accounts.filter(a => a.kind === 'apple');
const status = !run ? '' : run.error ? t('run.failed') : !run.result ? t('run.running') : run.outcome
? (run.outcome.confirmed ? t('run.applied') : run.outcome.stage === 'dialog' ? t('run.cancelled') : t('run.dryFailed'))
: run.result.ok ? t('run.done') : t('common.exit', { code: run.result.exitCode });
: run.result.ok ? t('run.done') : failureText(run.result);
return (
<div className="page">

View file

@ -1,8 +1,10 @@
// Portfolio: every app of every studio on both stores. KPIs, a toolbar (search, studio chips with counts, sort,
// grouping), one row per app (live version and state per store with its place on the release track, next version,
// crash rate, catalog parity, config check), grouped by studio; then what needs attention and the workspace state.
import { plainError } from '../../../../cli/lib/google-errors';
import { BlockedProjectsNotice, blockedProjects } from '../ui/StoreError';
import { useEffect, useMemo, useState } from 'react';
import { api, type AppStatus, type StoreRelease, type ToolResult } from '../api';
import { api, type AppStatus, newJob, type StoreRelease, type ToolResult } from '../api';
import { type Key, t } from '../i18n';
import { ago, pct, share } from '../lib/format';
import { filterSort, groupByStudio, type ListRow, type SortBy, studioCounts, type UnifiedApp } from '../lib/identity';
@ -37,7 +39,7 @@ function StoreCell({ e, store, on }: { e: AppStatus | undefined; store: 'ios' |
if (!on) return <span className="muted small">{t('app.notOnStore')}</span>;
if (!e) return <span className="muted small">{t('common.unknown')}</span>;
const err = e.storeErrors[store];
if (err) return <span title={err}><Chip tone="warn">{t('portfolio.unreadable')}</Chip></span>;
if (err) return <span title={plainError(err)}><Chip tone="warn">{t('portfolio.unreadable')}</Chip></span>;
const all = e.release.all ?? [e.release.current?.ios, e.release.current?.android, e.release.next?.ios, e.release.next?.android].filter((x): x is StoreRelease => !!x);
const { now, next } = storeNow(all, store);
const r = now ?? next;
@ -67,9 +69,9 @@ function NextCell({ e }: { e: AppStatus | undefined }) {
}
/** Last cached health read of every app (the Health tab reads it; the portfolio only shows what is saved). */
function useCachedHealth(targets: string[]): Record<string, { h: HealthSnapshot; at: string } | null> {
function useCachedHealth(targets: string[], round = 0): Record<string, { h: HealthSnapshot; at: string } | null> {
const [m, set] = useState<Record<string, { h: HealthSnapshot; at: string } | null>>({});
const key = targets.join();
const key = targets.join() + '#' + round;
useEffect(() => {
for (const id of targets) {
api.cached<ToolResult>('health', id).then(c => set(x => ({ ...x, [id]: c?.value.data ? { h: c.value.data as HealthSnapshot, at: c.at } : null })), () => {});
@ -131,7 +133,21 @@ export function Portfolio() {
const [query, setQuery] = useState('');
const setView = (v: Partial<View>) => setViewState(x => { const n = { ...x, ...v }; try { localStorage.setItem(VIEW_KEY, JSON.stringify(n)); } catch { /* private mode */ } return n; });
const visible = useMemo(() => d.apps.filter(a => !a.ignored), [d.apps]);
const health = useCachedHealth(visible.map(a => a.target));
const [round, setRound] = useState(0);
const [rechecking, setRechecking] = useState(false);
const health = useCachedHealth(visible.map(a => a.target), round);
const blocked = useMemo(() => blockedProjects(visible.map(a => {
const h = health[a.target]?.h, l = d.liveFor(a);
return { app: a.name, errors: [h?.play && isError(h.play) ? h.play : null, l?.storeErrors.android] };
})), [visible, health, d]);
// "Check again": read health of the blocked apps again (one at a time: the same Cloud project's quota), then the cache
const recheck = async () => {
setRechecking(true);
const names = new Set(blocked.flatMap(b => b.apps));
for (const a of visible.filter(x => names.has(x.name))) await api.call<ToolResult>('health', a.target, { args: {} }, newJob('health')).catch(() => null);
setRechecking(false);
setRound(n => n + 1);
};
const open = (a: UnifiedApp) => nav.go({ screen: 'app', appId: a.key, tab: 'releases' });
const all = useMemo(() => visible.map(app => {
@ -222,6 +238,7 @@ export function Portfolio() {
</div>
{d.portfolioError && <Notice tone="bad">{d.portfolioError}</Notice>}
<BlockedProjectsNotice blocked={blocked} onRetry={recheck} busy={rechecking} />
{neverImported > 0 && <Notice tone="info" icon="download" action={<button className="btn btn-sm" onClick={() => d.importApps()} disabled={d.importBusy}>{t('action.importApps')}</button>}>{t('portfolio.notImported', { count: neverImported })}</Notice>}
{!known && d.ready && <Notice tone="info" action={<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)}>{t('action.readStores')}</button>}>{t('portfolio.notReadNotice')}</Notice>}

View file

@ -9,6 +9,7 @@ import { Icon } from '../../ui/icons';
import { Bar, Card, Chip, Empty, ErrorFoot, Notice, Skeleton, Sparkline } from '../../ui/kit';
import { exportName, healthMd } from '../../lib/exports';
import { useExports } from '../../ui/Exports';
import { StoreReadNotice } from '../../ui/StoreError';
function Metric({ title, s }: { title: string; s: Series }) {
return (
@ -146,9 +147,9 @@ export function HealthTab({ app }: { app: UnifiedApp }) {
</div>
{r.error && <Notice tone="warn">{t('common.readFailedKeep', { error: r.error })}</Notice>}
<h2 className="label">Google Play</h2>
{h.play === undefined ? <Notice tone="info">{t('health.noPlay')}</Notice> : isError(h.play) ? <Notice tone="warn">{t('health.unreadable', { store: 'Google Play', error: h.play.error })}</Notice> : <Play h={h.play} />}
{h.play === undefined ? <Notice tone="info">{t('health.noPlay')}</Notice> : isError(h.play) ? <StoreReadNotice store="Google Play" e={h.play} app={app.android?.app.id ?? null} onRetry={r.refresh} busy={r.loading} unreadable="health.unreadable" /> : <Play h={h.play} />}
<h2 className="label">App Store</h2>
{h.apple === undefined ? <Notice tone="info">{t('health.noApple')}</Notice> : isError(h.apple) ? <Notice tone="warn">{t('health.unreadable', { store: 'App Store', error: h.apple.error })}</Notice> : <Apple h={h.apple} />}
{h.apple === undefined ? <Notice tone="info">{t('health.noApple')}</Notice> : isError(h.apple) ? <StoreReadNotice store="App Store" e={h.apple} onRetry={r.refresh} busy={r.loading} unreadable="health.unreadable" /> : <Apple h={h.apple} />}
</>
);
}

View file

@ -2,6 +2,7 @@
// rollout %), then every version read from the stores, then sending for review (Submit.tsx): App Store submit /
// resubmit / in-app purchases and the Google Play production release as owner actions, the Play steps without an API
// as console plans. Releasing an approved version (manual release) stays in App Store Connect.
import { plainError } from '../../../../../cli/lib/google-errors';
import { useState } from 'react';
import type { ReleaseTrain, StoreRelease } from '../../api';
import type { UnifiedApp } from '../../lib/identity';
@ -155,7 +156,7 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
const errs = Object.entries(l.storeErrors);
return (
<>
{errs.map(([s, msg]) => <Notice key={s} tone="warn">{t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: msg })}</Notice>)}
{errs.map(([s, msg]) => <Notice key={s} tone="warn">{t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: plainError(msg) })}</Notice>)}
{l.release.next && <Train title={t('tl.next')} train={l.release.next} tf={tf.data} tone="accent" app={app} />}
{l.release.current && <Train title={t('tl.live')} train={l.release.current} tf={tf.data} tone="ok" app={app} />}
{!l.release.next && !l.release.current && <Card pad={false}><Empty compact illo="unknown" title={t('releases.noVersions')}>{t('releases.noVersionsText')}</Empty></Card>}

View file

@ -14,6 +14,7 @@ import { setCrashContext } from './lib/crash';
import { normalizeAccounts, normalizeProjects, normalizeWorkspace } from './lib/normalize';
import { keepLines, progressOf } from './lib/progress';
import { fixHint } from './lib/views';
import { failureText, usable } from './lib/results';
export type ToastKind = 'job' | 'ok' | 'bad' | 'info';
export interface Toast {
@ -336,13 +337,13 @@ export function useWrite(projectId: string | null) {
const tid = d.toast({ id: job, kind: 'job', title: w.title, detail: t('write.dryRunning'), progress: null, job });
try {
const o = await api.writeWithConfirm(w.method, projectId, w.args, job);
if (o.stage === 'dry-run') d.updateToast(tid, { kind: 'bad', title: t('write.dryFailed'), detail: withHint(lastLine(o.dryRun.output) || undefined), progress: undefined, job: undefined });
if (o.stage === 'dry-run') d.updateToast(tid, { kind: 'bad', title: t('write.dryFailed'), detail: withHint(failureText(o.dryRun)), progress: undefined, job: undefined });
else if (o.stage === 'dialog') d.updateToast(tid, { kind: 'info', title: t('write.notApplied'), detail: t('write.notAppliedDetail'), progress: undefined, job: undefined });
else if (o.result?.ok) { d.updateToast(tid, { kind: 'ok', title: w.done, detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); }
// exit 3: the command stopped for an owner decision it did not get (a price change of a live product: the
// Catalog tab's owner action applies it); everything else it could do was done
else if (o.result?.exitCode === 3) { d.updateToast(tid, { kind: 'info', title: t('write.declined'), detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); }
else d.updateToast(tid, { kind: 'bad', title: t('write.failed'), detail: withHint(lastLine(o.result?.output ?? '') || undefined), progress: undefined, job: undefined });
else d.updateToast(tid, { kind: 'bad', title: t('write.failed'), detail: withHint(o.result ? failureText(o.result) : undefined), progress: undefined, job: undefined });
return o;
} catch (e) {
const msg = errorText(e);
@ -387,12 +388,12 @@ export function useRead<T>(target: string, method: string, o: { network: boolean
try {
const r = await api.call<ToolResult>(method, target, { args: JSON.parse(argsKey) as Record<string, unknown> }, job);
if (my !== seq.current) return;
if (r.ok && r.data !== undefined) {
if (usable(method, r)) {
notifyAfterRead(method, dataRef.current, r.data, d.settings, o.appName);
set({ data: r.data as T, at: new Date().toISOString(), fromCache: false, loading: false, error: null, command: r.command });
if (tid) d.updateToast(tid, { kind: 'ok', title: t('toast.readOk', { what: o.title ?? method }), detail: undefined, progress: undefined, job: undefined });
} else {
const msg = lastLine(r.output) || t('common.exit', { code: r.exitCode });
const msg = failureText(r);
set(x => ({ ...x, loading: false, error: msg, command: r.command }));
if (tid) d.updateToast(tid, { kind: 'bad', title: t('toast.readFailedWhat', { what: o.title ?? method }), detail: withHint(msg), progress: undefined, job: undefined });
}

View file

@ -4,6 +4,7 @@
// write_with_confirm (the host shows the answers again in a native dialog and mints the token only on its button) ->
// a visible browser the owner signs in to; storebound fills the fields, moves through the pages and never submits or publishes. The run
// lasts until the owner closes that browser; Cancel here stops the command, which closes it.
import { failureText } from '../lib/results';
import { type KeyboardEvent, useEffect, useRef, useState } from 'react';
import { api, type ConsoleFillResult, type ConsoleForm, errorText, newJob, onProgress, type Project, type ToolResult } from '../api';
import { type Key, t } from '../i18n';
@ -34,7 +35,7 @@ export function ConsoleFillButton({ project, form, small = true, primary }: { pr
setSt({ s: 'dry' });
try {
const r = await api.call<ToolResult & { data?: ConsoleFillResult }>('console.fill', project.id, { args: { form } }, newJob('console.fill-dry'));
if (!r.ok || !r.data) { setSt({ s: 'idle' }); d.toast({ kind: 'bad', title: t('cf.dryFailed', { form: name }), detail: tail(r.output) || undefined }); return; }
if (!r.ok || !r.data) { setSt({ s: 'idle' }); d.toast({ kind: 'bad', title: t('cf.dryFailed', { form: name }), detail: failureText(r) }); return; }
setSt({ s: 'review', data: r.data });
} catch (e) {
setSt({ s: 'idle' });
@ -55,7 +56,7 @@ export function ConsoleFillButton({ project, form, small = true, primary }: { pr
else if (o.stage === 'dry-run') setSt({ s: 'done', data, result: null, ok: false, detail: tail(o.dryRun.output) });
else {
const res = (o.result?.data ?? null) as ConsoleFillResult | null;
setSt({ s: 'done', data, result: res, ok: !!o.result?.ok, detail: o.result?.ok ? undefined : tail(o.result?.output ?? '') });
setSt({ s: 'done', data, result: res, ok: !!o.result?.ok, detail: o.result?.ok ? undefined : o.result ? failureText(o.result) : undefined });
}
} catch (e) {
const msg = errorText(e);

View file

@ -5,6 +5,7 @@
// record and the age rating). Nothing here clicks in a console: the owner's AI agent (or the owner) drives the page
// with this plan, `Copy plan` hands it over as JSON; "Fill in console" (ui/ConsoleFill.tsx) is the scripted fast path
// where it exists. Every form of `console plan`, including the Play pages added for the first production release.
import { failureText } from '../lib/results';
import { type KeyboardEvent, useEffect, useRef, useState } from 'react';
import { api, errorText, newJob, type Project, type ToolResult } from '../api';
import { type Key, t } from '../i18n';
@ -61,7 +62,7 @@ export function ConsolePlanButton({ project, form, primary, label }: { project:
setBusy(true);
try {
const r = await api.call<ToolResult & { data?: Plan }>('console.plan', project.id, { args: { form } }, newJob('console.plan'));
if (!r.ok || !r.data) d.toast({ kind: 'bad', title: t('cp.failed', { form: nameOf(form) }), detail: r.output.trim().split('\n').at(-1) });
if (!r.ok || !r.data) d.toast({ kind: 'bad', title: t('cp.failed', { form: nameOf(form) }), detail: failureText(r) });
else setPlan(r.data);
} catch (e) { d.toast({ kind: 'bad', title: t('cp.failed', { form: nameOf(form) }), detail: errorText(e) }); } finally { setBusy(false); }
};
@ -88,7 +89,7 @@ function PlanPanel({ project, plan, onClose }: { project: Project; plan: Plan; o
try {
const r = await api.call<ToolResult & { data?: VerifyResult }>('console.verify', project.id, { args: { form: plan.form, ...(text.trim() ? { text: text.trim() } : {}) } }, newJob('console.verify'));
if (r.data) setVerify({ busy: false, result: r.data });
else setVerify({ busy: false, error: r.output.trim().split('\n').at(-1)?.replace(/^\S+ \[[^\]]+\] /, '') ?? t('common.exit', { code: r.exitCode }) });
else setVerify({ busy: false, error: failureText(r) });
} catch (e) { setVerify({ busy: false, error: errorText(e) }); }
};
const copy = () => { navigator.clipboard?.writeText(JSON.stringify(plan, null, 2)).then(() => d.toast({ kind: 'ok', title: t('cp.copied') }), () => {}); };

View file

@ -4,6 +4,7 @@
// the dialog's own button. While the real run goes (a submit retries up to 10 minutes while Apple says "not ready
// yet"), its progress lines stream into the panel; Cancel stops the command. Used for submit for review, resubmit,
// in-app purchases to review, the Play production release and price changes of existing products.
import { failureText } from '../lib/results';
import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from 'react';
import { api, errorText, newJob, onProgress, type Project, type ToolResult } from '../api';
import { t } from '../i18n';
@ -72,7 +73,7 @@ export function OwnerAction(p: OwnerActionProps) {
const r = o.result!;
const declined = r.exitCode === DECLINED_EXIT;
setSt({ s: 'done', dry, ok: r.ok, declined, lines: lines.slice(-200), detail: tail(r.output) });
d.toast({ kind: r.ok ? 'ok' : declined ? 'info' : 'bad', title: r.ok ? p.done : declined ? t('write.declined') : t('write.failed'), detail: tail(r.output) || undefined });
d.toast({ kind: r.ok ? 'ok' : declined ? 'info' : 'bad', title: r.ok ? p.done : declined ? t('write.declined') : t('write.failed'), detail: (r.ok || declined ? tail(r.output) : failureText(r)) || undefined });
if (r.ok) p.after?.();
}
} catch (e) {

View file

@ -0,0 +1,102 @@
// A store that could not be read, in one plain line: Google's typed errors (an API turned off in the Cloud project, no
// access to the app, a revoked key, a used-up quota) with the button that fixes them, and the raw API text only in a
// collapsed "Details" and the log (never a JSON dump in the window).
import { useEffect } from 'react';
import { api, errorText } from '../api';
import { type GoogleFinding, parseGoogleError, plainError } from '../../../../cli/lib/google-errors';
import { t, type Key } from '../i18n';
import { useData } from '../store';
import { Icon } from './icons';
import { Notice } from './kit';
const WHAT: Record<string, Key> = {
'playdeveloperreporting.googleapis.com': 'gerr.what.reporting',
'androidpublisher.googleapis.com': 'gerr.what.publisher',
'storage.googleapis.com': 'gerr.what.storage',
'storage-api.googleapis.com': 'gerr.what.storage',
};
/** The typed finding of a store read error (from the snapshot, else parsed from its text). */
export function findingOf(e: { error: string; finding?: GoogleFinding; detail?: string }, app?: string | null): GoogleFinding | null {
return e.finding ?? parseGoogleError(e.detail ?? e.error, { app });
}
/** The one line for a finding, in the window's language. */
export function findingLine(f: GoogleFinding): string {
switch (f.kind) {
case 'api-disabled': return t('gerr.apiDisabled', { what: t(WHAT[f.service] ?? 'gerr.what.other'), service: f.serviceTitle, project: f.projectId ?? t('gerr.projectUnknown') });
case 'no-app-access': return t('gerr.noAccess', { app: f.app ?? t('gerr.thisApp') });
case 'auth-revoked': return t('gerr.revoked');
case 'quota': return t('gerr.quota');
}
}
export function TurnOnButton({ url, primary = true }: { url: string; primary?: boolean }) {
const d = useData();
return (
<button className={`btn btn-sm${primary ? ' btn-primary' : ''}`} onClick={() => api.openExternal(url).catch(e => d.toast({ kind: 'bad', title: t('gerr.openFailed'), detail: errorText(e) }))}>
<Icon name="external" />{t('gerr.turnOn')}
</button>
);
}
/** One store's read error. `unreadable`: the sentence for an unknown error ({store} / {error}). */
export function StoreReadNotice({ store, e, app, onRetry, busy, unreadable }: {
store: string; e: { error: string; finding?: GoogleFinding; detail?: string }; app?: string | null; onRetry?: () => void; busy?: boolean; unreadable: Key;
}) {
const d = useData();
const f = findingOf(e, app);
const raw = e.detail ?? e.error;
useEffect(() => { api.logWebview('warn', `${store} could not be read${f ? ` (${f.kind})` : ''}`, raw).catch(() => {}); }, [store, raw]); // eslint-disable-line react-hooks/exhaustive-deps
const line = f ? findingLine(f) : t(unreadable, { store, error: plainError(raw) });
const retry = onRetry && <button className="btn btn-sm" onClick={onRetry} disabled={busy}><Icon name="refresh" className={busy ? 'spin' : undefined} />{t('gerr.checkAgain')}</button>;
const open = (url: string) => api.openExternal(url).catch(err => d.toast({ kind: 'bad', title: t('gerr.openFailed'), detail: errorText(err) }));
return (
<Notice tone="warn" action={<div className="row" style={{ gap: 8, flex: 'none' }}>
{f?.kind === 'api-disabled' && <TurnOnButton url={f.activationUrl} />}
{f?.kind === 'no-app-access' && <button className="btn btn-sm btn-primary" onClick={() => open(f.usersUrl)}><Icon name="external" />{t('gerr.openUsers')}</button>}
{retry}
</div>}>
<div className="stack" style={{ gap: 4 }}>
<span className="store-error-line">{line}</span>
{raw !== line && <details className="toast-log store-error-details"><summary className="small">{t('gerr.details')}</summary><pre className="log selectable">{raw}</pre></details>}
</div>
</Notice>
);
}
export interface BlockedProject { projectId: string | null; service: string; serviceTitle: string; activationUrl: string; apps: string[] }
/** api-disabled findings across apps, one entry per Cloud project and API (several apps share one service account). Pure. */
export function blockedProjects(items: { app: string; errors: ({ error: string; finding?: GoogleFinding; detail?: string } | string | undefined | null)[] }[]): BlockedProject[] {
const out = new Map<string, BlockedProject>();
for (const it of items) for (const e of it.errors) {
if (!e) continue;
const f = typeof e === 'string' ? parseGoogleError(e) : findingOf(e);
if (f?.kind !== 'api-disabled') continue;
const k = `${f.projectId ?? '?'}|${f.service}`;
const b = out.get(k) ?? { projectId: f.projectId, service: f.service, serviceTitle: f.serviceTitle, activationUrl: f.activationUrl, apps: [] };
if (!b.apps.includes(it.app)) b.apps.push(it.app);
out.set(k, b);
}
return [...out.values()];
}
/** The portfolio's one notice for Cloud projects that block reads of several apps. */
export function BlockedProjectsNotice({ blocked, onRetry, busy }: { blocked: BlockedProject[]; onRetry: () => void; busy: boolean }) {
if (!blocked.length) return null;
const apps = [...new Set(blocked.flatMap(b => b.apps))];
return (
<Notice tone="warn" icon="cloud" action={<button className="btn btn-sm" onClick={onRetry} disabled={busy}><Icon name="refresh" className={busy ? 'spin' : undefined} />{t('gerr.checkAgain')}</button>}>
<div className="stack blocked-projects" style={{ gap: 6 }}>
<span className="strong">{t('gerr.portfolio', { count: blocked.length, apps: apps.join(', ') })}</span>
{blocked.map(b => (
<div key={`${b.projectId}|${b.service}`} className="row" style={{ gap: 8, flexWrap: 'wrap' }}>
<span className="grow small">{findingLine({ kind: 'api-disabled', ...b })}</span>
<TurnOnButton url={b.activationUrl} />
</div>
))}
</div>
</Notice>
);
}