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

@ -8,6 +8,7 @@
// Download reports > Statistics > Copy Cloud Storage URI) are read with the service account (store listing visitors,
// acquisitions, daily user installs). compare lines the snapshots up with the git history of the store files (listing
// text, screenshots, config): the days before vs after each change. Read-only on both stores except `setup`.
import { storeReadError } from './lib/google-errors';
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { createOnce } from './lib/upsert';
@ -120,11 +121,11 @@ export async function run(ctx: Ctx): Promise<void> {
const snap: Snapshot = { date: day(now), window: { start, end, days } };
if ((!store || store === 'apple') && config.apple) {
try { const r = await appleSnapshot(ctx, start, end); snap.apple = { daily: r.daily, totals: appleTotals(r.daily) }; }
catch (e) { snap.apple = { error: (e as Error).message.slice(0, 400) }; warn(TAG, `apple: ${snap.apple.error}`); }
catch (e) { snap.apple = { error: storeReadError((e as Error).message).error.slice(0, 400) }; warn(TAG, `apple: ${snap.apple.error}`); }
}
if ((!store || store === 'play') && config.play) {
try { const r = await playSnapshot(ctx, start, end); snap.play = { daily: r.daily, totals: playTotals(r.daily) }; }
catch (e) { snap.play = { error: (e as Error).message.slice(0, 400) }; warn(TAG, `play: ${snap.play.error}`); }
catch (e) { snap.play = { error: storeReadError((e as Error).message, { app: config.play.packageName }).error.slice(0, 400) }; warn(TAG, `play: ${snap.play.error}`); }
}
const rows: (string | number)[][] = [];
if (snap.apple && !('error' in snap.apple)) { const t = snap.apple.totals; rows.push(['apple', `${t.impressions} impressions`, `${t.pageViews} page views`, `${t.downloads} downloads`, pct(t.conversion ?? null)]); }

View file

@ -17,6 +17,7 @@ import {
issuesQuery, latestDailyEnd, metricQuery, parseIssues, parseMetricRows, parsePerfPower, type PlayHealth, ymd, ymdString,
} from './lib/health';
import { done, log, printJson, warn } from './lib/log';
import { plainError, storeReadError } from './lib/google-errors';
import { formatFindings } from './lib/validate-rules';
import { googleClient, REPORTING_API } from './play/google';
@ -35,7 +36,7 @@ export async function collectPlayHealth(root: string, pkg: string, days: number)
// the top issues are extra: a failure there must not hide the crash and ANR rates already read
let issues: PlayHealth['issues'] = [], issuesError: string | undefined;
try { issues = parseIssues(await g.json('GET', `${app}/errorIssues:search?${issuesQuery(end, days)}`)); } catch (e) {
issuesError = (e as Error).message;
issuesError = plainError((e as Error).message, { app: pkg });
warn('health', `play: top issues not read: ${issuesError.slice(0, 200)}`);
}
const start = ymd(new Date(Date.UTC(end.year, end.month - 1, end.day)), days);
@ -69,10 +70,10 @@ export async function collectHealth(ctx: Ctx, o: { apple: boolean; play: boolean
const { config, root } = ctx.loaded;
const h: HealthSnapshot = { date: new Date().toISOString().slice(0, 10) };
if (o.play && config.play) {
try { h.play = await collectPlayHealth(root, config.play.packageName, o.days); } catch (e) { h.play = { error: (e as Error).message }; }
try { h.play = await collectPlayHealth(root, config.play.packageName, o.days); } catch (e) { h.play = storeReadError((e as Error).message, { app: config.play.packageName }); }
}
if (o.apple && config.apple) {
try { h.apple = await collectAppleHealth(root, config.apple.bundleId, config.apple.appId, o.days); } catch (e) { h.apple = { error: (e as Error).message }; }
try { h.apple = await collectAppleHealth(root, config.apple.bundleId, config.apple.appId, o.days); } catch (e) { h.apple = storeReadError((e as Error).message); }
}
return h;
}
@ -93,6 +94,7 @@ export async function run(ctx: Ctx): Promise<void> {
const h = await collectHealth(ctx, { apple: both || a.has('apple'), play: both || a.has('play'), days });
if (a.has('json')) { printJson(h); return; }
console.log(healthTable(h));
for (const [s, x] of [['Google Play', h.play], ['App Store', h.apple]] as const) if (x && 'error' in x) console.log(`\n${s}: ${x.error}`);
console.log('\n' + formatFindings(healthFindings(h)));
if (weekly) {
const dir = ctx.loaded.resolve(a.get('out') ?? '.storebound/health');

View file

@ -15,6 +15,7 @@
// stats/store_performance/store_performance_<package>_<yyyyMM>_country.csv (Store listing acquisitions / visitors)
// stats/installs/installs_<package>_<yyyyMM>_country.csv (Daily User Installs, ...)
// https://support.google.com/googleplay/android-developer/answer/6135870
import { plainError } from './google-errors';
import { createHash } from 'node:crypto';
import { parseCsvRecords } from './csv';
import { mdTable, pct, textTable } from './table';
@ -144,7 +145,7 @@ export function storeErrors(snaps: Snapshot[]): { apple?: string; play?: string
for (const k of ['apple', 'play'] as const) {
if (series[k].length) continue;
const last = [...snaps].sort((x, y) => x.date.localeCompare(y.date)).reverse().find(s => s[k] && 'error' in s[k]!);
if (last) out[k] = (last[k] as { error: string }).error;
if (last) out[k] = plainError((last[k] as { error: string }).error, { withUrl: true });
}
return out;
}

View file

@ -69,6 +69,48 @@ export interface ToolResult {
output: string;
/** Structured result of a --json capable command. */
data?: unknown;
/** What the exit code means: 0 = ok; a documented findings code with the --json result printed (scan / check /
* validate / health exit 1 when they find problems) = findings; 3 = declined; anything else = failed. Missing in
* results cached by older versions (outcomeOf below decides then). */
outcome?: Outcome;
/** failed only: the command's own error line (stderr's last "error: ..." line, else its last meaningful line). */
error?: string;
}
export type Outcome = 'ok' | 'findings' | 'declined' | 'failed';
/** Read tools whose exit code 1 means "found problems" (the result is still printed with --json): by design, so CI fails. */
export const FINDINGS_EXIT: Readonly<Record<string, readonly number[]>> = {
scan: [1], check: [1], validate: [1], health: [1], check_aso: [1], shots_check: [1], catalog_diff: [1], aso_audit: [1],
};
/** lib/ctx DECLINED_EXIT: the owner's answer was missing or "no". */
export const DECLINED_CODE = 3;
/** The outcome of a run (`tool`: the MCP tool name or the desktop method, dots or underscores). */
export function outcomeOf(tool: string, code: number, hasData: boolean): Outcome {
if (code === 0) return 'ok';
if (code === DECLINED_CODE) return 'declined';
if (hasData && (FINDINGS_EXIT[tool.replace(/\./g, '_')] ?? []).includes(code)) return 'findings';
return 'failed';
}
/** Is this line noise rather than a message (blank, a stack frame, a lone brace, a usage line)? */
const noise = (l: string): boolean => !l.trim() || /^\s+at\s/.test(l) || /^[\s{}[\](),;]*$/.test(l) || /^\s*(usage|options|examples?):/i.test(l) || /^\s{2,}--?\w/.test(l);
/** A log line without its "12:00:00 [tag] " prefix. */
const bare = (l: string): string => l.trim().replace(/^\d\d:\d\d:\d\d \[[^\]]+\] /, '').replace(/^\S+ \[[^\]]+\] /, '');
/**
* The command's own error line: the last "error: ..." line of stderr (the CLI's top-level catch), else the first
* meaningful stderr line (unknown command / option: the message comes before the usage), else the last meaningful
* output line. '' when the command said nothing. Pure.
*/
export function errorLine(stderr: string, output = ''): string {
const err = stderr.split('\n');
const tagged = err.filter(l => /^\s*error:\s*\S/i.test(bare(l))).at(-1);
if (tagged) return bare(tagged).replace(/^error:\s*/i, '').slice(0, 500);
const first = err.find(l => !noise(l));
if (first) return bare(first).slice(0, 500);
const last = output.split('\n').filter(l => !noise(l)).at(-1);
return last ? bare(last).slice(0, 500) : '';
}
export interface ProgressParams { requestId: string | number; line?: string; stream?: 'stdout' | 'stderr'; n?: number; total?: number; project?: string }

98
cli/lib/google-errors.ts Normal file
View file

@ -0,0 +1,98 @@
// Google API errors -> typed findings. Google answers a disabled API, a missing app permission, a revoked key or a
// used-up quota with a JSON body ({"error":{"code":403,"status":"PERMISSION_DENIED","details":[{"reason":
// "SERVICE_DISABLED",...}]}}); storebound's clients put that body (on one line, maybe cut at 800 characters) into the
// Error message. This reads the message text (raw or cut), so every layer (CLI tables, health / analytics snapshots,
// the desktop window, even old cached snapshots) can say one plain line instead of showing the JSON. Pure.
export type GoogleFinding =
/** An API is not enabled in the service account's Google Cloud project (403 SERVICE_DISABLED). */
| { kind: 'api-disabled'; service: string; serviceTitle: string; projectId: string | null; activationUrl: string }
/** The service account has no access to this app in Play Console (403 PERMISSION_DENIED, not SERVICE_DISABLED). */
| { kind: 'no-app-access'; app: string | null; usersUrl: string }
/** Google refused the key itself (401 UNAUTHENTICATED, invalid_grant, Invalid JWT): revoked, deleted or expired. */
| { kind: 'auth-revoked' }
/** 429 / RESOURCE_EXHAUSTED / rateLimitExceeded: a quota is used up for now. */
| { kind: 'quota'; serviceTitle: string | null };
export const PLAY_USERS_URL = 'https://play.google.com/console/developers/users-and-permissions';
const TITLES: Record<string, string> = {
'playdeveloperreporting.googleapis.com': 'Google Play Developer Reporting API',
'androidpublisher.googleapis.com': 'Google Play Android Developer API',
'storage.googleapis.com': 'Cloud Storage API',
'storage-api.googleapis.com': 'Cloud Storage API',
};
/** What the owner loses while that API is off (the start of the one line). */
const NEEDS: Record<string, string> = {
'playdeveloperreporting.googleapis.com': 'Crash and ANR data',
'androidpublisher.googleapis.com': 'Reading and updating the app on Google Play',
'storage.googleapis.com': 'Play statistics (store listing visitors, installs)',
'storage-api.googleapis.com': 'Play statistics (store listing visitors, installs)',
};
/** A JSON string's content without its escapes (\\n as a space: one line). */
const unescape = (s: string): string => { try { return (JSON.parse(`"${s}"`) as string).replace(/\s+/g, ' ').trim(); } catch { return s.replace(/\\(.)/g, '$1'); } };
/** The API's own page in the Cloud console (the host the desktop may open), with the project chosen. */
export function enableUrl(service: string, projectId: string | null): string {
return `https://console.cloud.google.com/apis/library/${service}${projectId ? `?project=${projectId}` : ''}`;
}
/** The typed finding in a Google error text, or null when it is another error. `app`: the package, when the caller knows it. */
export function parseGoogleError(text: string, o: { app?: string | null } = {}): GoogleFinding | null {
const s = String(text ?? '');
const status = Number(/-> (\d{3})\b/.exec(s)?.[1] ?? /"code":\s*(\d{3})/.exec(s)?.[1] ?? NaN);
const app = o.app ?? /\/(?:applications|apps)\/([A-Za-z][\w.]*\.[\w.]+)/.exec(s)?.[1] ?? null;
if (/SERVICE_DISABLED|has not been used in project|accessNotConfigured|API has not been used|it is disabled\. Enable it| turned on in Google Cloud project/i.test(s)) {
const service = /"service":\s*"([a-z0-9.-]+\.googleapis\.com)"/.exec(s)?.[1] ?? /apis\/api\/([a-z0-9.-]+\.googleapis\.com)/.exec(s)?.[1]
?? /apis\/library\/([a-z0-9.-]+\.googleapis\.com)/.exec(s)?.[1] ?? /([a-z0-9-]+\.googleapis\.com)/.exec(s)?.[1] ?? 'playdeveloperreporting.googleapis.com';
const projectId = /"consumer":\s*"projects\/([a-z0-9-]+)"/i.exec(s)?.[1] ?? /in (?:Google Cloud )?project ([a-z0-9-]+)/i.exec(s)?.[1] ?? /[?&]project=([a-z0-9-]+)/i.exec(s)?.[1] ?? null;
const serviceTitle = (/"serviceTitle":\s*"((?:[^"\\]|\\.)*)"/.exec(s)?.[1] && unescape(/"serviceTitle":\s*"((?:[^"\\]|\\.)*)"/.exec(s)![1]!))
?? /"message":\s*"([^"]*?) has not been used in project/.exec(s)?.[1] ?? / needs the (.+?) turned on in Google Cloud/.exec(s)?.[1] ?? TITLES[service] ?? service;
return { kind: 'api-disabled', service, serviceTitle, projectId, activationUrl: enableUrl(service, projectId) };
}
if (status === 429 || /RESOURCE_EXHAUSTED|rateLimitExceeded|quota(?: exceeded| metric)|Quota exceeded/i.test(s)) {
const service = /"service":\s*"([a-z0-9.-]+\.googleapis\.com)"/.exec(s)?.[1];
return { kind: 'quota', serviceTitle: service ? TITLES[service] ?? service : null };
}
if (status === 401 || /UNAUTHENTICATED|invalid_grant|Invalid JWT|account not found|invalid_client/i.test(s)) return { kind: 'auth-revoked' };
if (status === 403 && /PERMISSION_DENIED|caller does not have permission|does not have permission|not have access/i.test(s)) return { kind: 'no-app-access', app, usersUrl: PLAY_USERS_URL };
return null;
}
/** The one plain line for a finding (no JSON). `withUrl`: the CLI adds where to fix it; the window has a button. */
export function findingText(f: GoogleFinding, o: { withUrl?: boolean } = {}): string {
switch (f.kind) {
case 'api-disabled': {
const what = NEEDS[f.service] ?? 'This read';
const line = `${what} needs the ${f.serviceTitle} turned on in Google Cloud project ${f.projectId ?? '(the service account\'s project)'}.`;
return o.withUrl ? `${line} Turn it on: ${f.activationUrl}` : line;
}
case 'no-app-access': {
const line = `The service account has no access to ${f.app ?? 'this app'} in Play Console: add it under Users and permissions (invite the service account, give it access to ${f.app ?? 'the app'}).`;
return o.withUrl ? `${line} ${f.usersUrl}` : line;
}
case 'auth-revoked':
return 'Google refused the service account key: it was revoked, deleted or has expired. Add a new JSON key (Google Cloud > IAM & Admin > Service accounts > Keys) and set it again.';
case 'quota':
return `The ${f.serviceTitle ?? 'Google API'} quota is used up for now: try again later (quotas reset per minute and per day).`;
}
}
/** An error text for people: the finding's line when there is one, else the text without a JSON body. */
export function plainError(text: string, o: { app?: string | null; withUrl?: boolean } = {}): string {
const f = parseGoogleError(text, o);
if (f) return findingText(f, o);
const s = String(text ?? '');
const msg = /"(?:message|detail|error_description)":\s*"((?:[^"\\]|\\.)*)"/.exec(s)?.[1];
const head = s.split(/\s[{[]"/)[0]!.trim();
return msg ? `${head} ${unescape(msg)}`.trim().slice(0, 300) : s.split('\n')[0]!.slice(0, 300);
}
/** A store read that failed, for snapshots (health, analytics) and JSON results: the plain line (with the fix URL),
* the typed finding when Google said what is wrong, and the raw text as `detail` (logs, a collapsed "Details"). */
export function storeReadError(raw: string, o: { app?: string | null } = {}): { error: string; finding?: GoogleFinding; detail?: string } {
const f = parseGoogleError(raw, o);
const error = f ? findingText(f, { withUrl: true }) : plainError(raw);
return { error, ...(f ? { finding: f } : {}), ...(error !== raw ? { detail: raw } : {}) };
}

View file

@ -18,6 +18,7 @@
// (HANGS, DISK_WRITES, LAUNCHES), GET /v1/apps/{id}/betaFeedbackCrashSubmissions (TestFlight crash reports, API 4.0+).
import type { Finding, Level } from './validate-rules';
import { mdTable, pct, textTable } from './table';
import { findingText, type GoogleFinding, parseGoogleError, plainError } from './google-errors';
import { reportingEnableUrl } from './store-apps';
export const PLAY_THRESHOLDS = { crash: 0.0109, anr: 0.0047, perDevice: 0.08 } as const;
@ -115,7 +116,10 @@ export interface AppleHealth {
betaCrashes: { build: string; count: number }[];
}
export interface HealthSnapshot { date: string; play?: PlayHealth | { error: string }; apple?: AppleHealth | { error: string } }
/** A store that could not be read: `error` is one plain line (never a JSON body), `finding` the typed Google error
* (google-errors.ts), `detail` the raw API text for logs and a collapsed "Details". */
export interface StoreReadError { error: string; finding?: GoogleFinding; detail?: string }
export interface HealthSnapshot { date: string; play?: PlayHealth | StoreReadError; apple?: AppleHealth | StoreReadError }
/** Last value of a metric over the rows (the 28-day rolling metrics: the latest row is the store's current view). */
export function lastValue(rows: MetricRow[], metric: string): number | null {
@ -186,11 +190,13 @@ export function healthFindings(h: HealthSnapshot, o: { unreadable?: Level; princ
const f: Finding[] = [];
const play = h.play;
if (play && 'error' in play) {
const on = reportingEnableUrl(play.error, o.principal ?? null);
f.push(REPORTING_OFF.test(play.error)
? { store: 'play', level: o.unreadable ?? 'warn', code: 'vitals', msg: `vitals not readable: the Google Play Developer Reporting API is off in the service account's Google Cloud project${on.project ? ` ${on.project}` : ''}`,
fix: `enable "Google Play Developer Reporting API" for that project: ${on.url} (Enable), wait a few minutes (store-health skill)` }
: { store: 'play', level: o.unreadable ?? 'warn', code: 'vitals', msg: `vitals not readable: ${play.error.slice(0, 200)}`, fix: 'store-health skill (Reporting API access)' });
const raw = play.detail ?? play.error;
const g = play.finding ?? parseGoogleError(raw);
const on = reportingEnableUrl(raw, o.principal ?? null);
f.push(g?.kind === 'api-disabled' || REPORTING_OFF.test(raw)
? { store: 'play', level: o.unreadable ?? 'warn', code: 'vitals', msg: `vitals not readable: the ${g?.kind === 'api-disabled' ? g.serviceTitle : 'Google Play Developer Reporting API'} is off in the service account's Google Cloud project${(g?.kind === 'api-disabled' ? g.projectId : on.project) ? ` ${g?.kind === 'api-disabled' ? g.projectId : on.project}` : ''}`,
fix: `enable "${g?.kind === 'api-disabled' ? g.serviceTitle : 'Google Play Developer Reporting API'}" for that project: ${g?.kind === 'api-disabled' ? g.activationUrl : on.url} (Enable), wait a few minutes (store-health skill)` }
: { store: 'play', level: o.unreadable ?? 'warn', code: 'vitals', msg: `vitals not readable: ${g ? findingText(g) : plainError(raw).slice(0, 200)}`, fix: g?.kind === 'no-app-access' ? `Play Console > Users and permissions: ${g.usersUrl}` : 'store-health skill (Reporting API access)' });
}
else if (play) {
const crash = lastValue(play.crash, 'userPerceivedCrashRate28dUserWeighted') ?? weightedAverage(play.crash, 'userPerceivedCrashRate');
@ -209,7 +215,7 @@ export function healthFindings(h: HealthSnapshot, o: { unreadable?: Level; princ
}
}
const apple = h.apple;
if (apple && 'error' in apple) f.push({ store: 'apple', level: o.unreadable ?? 'warn', code: 'diagnostics', msg: `diagnostics not readable: ${apple.error.slice(0, 200)}` });
if (apple && 'error' in apple) f.push({ store: 'apple', level: o.unreadable ?? 'warn', code: 'diagnostics', msg: `diagnostics not readable: ${plainError(apple.error).slice(0, 200)}` });
else if (apple) {
const poor = apple.metrics.filter(m => m.goal === 'poor');
for (const m of poor) f.push({ store: 'apple', level: 'warn', code: 'perf', msg: `${m.metric} ${m.value} ${m.unit} on ${m.version}: Apple rates it poor`, fix: 'Xcode Organizer > Metrics; store-health skill' });
@ -230,12 +236,12 @@ export function healthTable(h: HealthSnapshot): string {
rows.push(['play', 'ANR rate (user-perceived, 28 d)', pct(lastValue(p.anr, 'userPerceivedAnrRate28dUserWeighted')), pct(PLAY_THRESHOLDS.anr)]);
rows.push(['play', `ANR rate (user-perceived, ${p.window.days} d avg)`, pct(weightedAverage(p.anr, 'userPerceivedAnrRate')), '']);
rows.push(['play', 'top issues', p.issuesError ? 'not read' : p.issues.length, '']);
} else if (h.play) rows.push(['play', 'error', h.play.error.slice(0, 80), '']);
} else if (h.play) rows.push(['play', 'not readable', h.play.finding ? h.play.finding.kind : h.play.error.slice(0, 80), '']);
if (h.apple && !('error' in h.apple)) {
for (const m of h.apple.metrics.slice(0, 12)) rows.push(['apple', `${m.metric} (${m.version})`, `${m.value} ${m.unit}`.trim(), m.goal ?? '']);
rows.push(['apple', 'diagnostic signatures', h.apple.signatures.length, '']);
rows.push(['apple', 'TestFlight crash reports', h.apple.betaCrashes.reduce((n, b) => n + b.count, 0), '']);
} else if (h.apple) rows.push(['apple', 'error', h.apple.error.slice(0, 80), '']);
} else if (h.apple) rows.push(['apple', 'not readable', h.apple.finding ? h.apple.finding.kind : h.apple.error.slice(0, 80), '']);
return textTable(['store', 'metric', 'value', 'threshold / goal'], rows);
}

View file

@ -28,7 +28,8 @@ export interface RunOptions {
machine?: boolean;
}
export interface RunResult { code: number; out: string; data?: unknown; cancelled: boolean }
/** `err`: the stderr lines only (for the command's own error line). */
export interface RunResult { code: number; out: string; err?: string; data?: unknown; cancelled: boolean }
export interface Running { result: Promise<RunResult>; cancel(): void }
@ -70,6 +71,7 @@ export function runCommand(argv: string[], o: RunOptions): Running {
const p = rt.spawn([...selfCommand(), ...argv], { cwd: o.cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: childEnv(o.env, o.machine) });
let cancelled = false;
const lines: string[] = [];
const errs: string[] = [];
let data: unknown;
const each = (line: string, kind: 'stdout' | 'stderr'): void => {
if (kind === 'stdout' && line.startsWith(RESULT_MARK)) {
@ -77,12 +79,13 @@ export function runCommand(argv: string[], o: RunOptions): Running {
return;
}
lines.push(line);
if (kind === 'stderr') errs.push(line);
o.onLine?.(line, kind);
};
const result = (async (): Promise<RunResult> => {
await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]);
const code = await p.exited;
return { code, out: lines.join('\n').trim(), ...(data === undefined ? {} : { data }), cancelled };
return { code, out: lines.join('\n').trim(), err: errs.join('\n').trim(), ...(data === undefined ? {} : { data }), cancelled };
})();
return { result, cancel: () => { cancelled = true; p.kill(); } };
}

View file

@ -20,7 +20,7 @@ import pkg from '../package.json' with { type: 'json' };
import { canonical, parseSecret, TokenGuard } from './lib/confirm-token';
import { CONFIG_NAMES } from './lib/config';
import {
DESKTOP_PROTOCOL, ERR, type AppStatus, type MethodDescriptor, type PortfolioResult, type ProjectSummary, type ToolResult, type VirtualProject,
DESKTOP_PROTOCOL, ERR, type AppStatus, type MethodDescriptor, type PortfolioResult, type ProjectSummary, type ToolResult, type VirtualProject, errorLine, outcomeOf,
} from './lib/desktop-protocol';
import { emptyStatus, fillAppStatus, lastLine } from './lib/app-status';
import { RENDER_FILE_RE } from './lib/project-files';
@ -441,9 +441,11 @@ export class Session {
const r = this.track(id, this.run(argv, { cwd: project, env, machine: true, onLine: (line, stream) => this.progress(id, { line, stream }) }));
const res = await r.result;
if (res.cancelled || this.cancelled.has(id)) throw new RpcError(ERR.cancelled, 'Request cancelled');
const outcome = outcomeOf(t.write ? '' : t.name, res.code, res.data !== undefined);
const error = outcome === 'failed' ? errorLine(res.err ?? '', res.out) : '';
return {
method, ok: res.code === 0, exitCode: res.code, dryRun: t.write ? dryRun : false, command: `storebound ${argv.join(' ')}`, output: cut(res.out),
...(res.data === undefined ? {} : { data: res.data }),
...(res.data === undefined ? {} : { data: res.data }), outcome, ...(error ? { error } : {}),
};
}
@ -461,7 +463,7 @@ export class Session {
const argv = [...cmd, '--store', play ? 'play' : 'apple', ...(args.icons === true ? ['--icons'] : []), '--json'];
const r = await this.track(id, this.run(argv, { cwd: neutralDir(), env, machine: true, onLine: (line, stream) => this.progress(id, { line, stream }) })).result;
if (r.cancelled || this.cancelled.has(id)) throw new RpcError(ERR.cancelled, 'Request cancelled');
if (r.data === undefined) throw new RpcError(ERR.internal, lastLine(r.out) || `exit ${r.code}`);
if (r.data === undefined) throw new RpcError(ERR.internal, errorLine(r.err ?? '', r.out) || `storebound ${argv[0]} ${argv[1] ?? ''} stopped without an error message (exit ${r.code})`);
return r.data;
}