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

@ -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>
);
}