storebound/cli/lib/desktop-protocol.ts

478 lines
27 KiB
TypeScript

// Types of the `storebound serve --stdio` protocol (desktop profile). Type-only, no runtime code: the desktop app
// (apps/desktop) imports this file so both sides agree on shapes. Newline-delimited JSON-RPC 2.0 on stdio.
//
// Session: the first request must be `session.hello` with the per-session secret (hex, >= 32 bytes) the host
// generated. Every other method answers -32002 before that. One hello per process.
// Progress: notifications `progress` { requestId, line?, stream?, n?, total?, project? } while a request runs.
// Cancel: notification `$/cancelRequest` { id } (or MCP-style `notifications/cancelled` { requestId }); the request
// then answers -32800.
// Writes: every write method is a dry run unless `dryRun: false` comes with a `confirmToken` minted by the host for
// exactly (method, { project, args }) (cli/lib/confirm-token.ts). No method submits, releases or publishes anything.
export const DESKTOP_PROTOCOL = 'storebound-desktop/1';
export const ERR = {
parse: -32700,
invalidRequest: -32600,
methodNotFound: -32601,
invalidParams: -32602,
internal: -32603,
notInitialized: -32002,
confirmRequired: -32010,
confirmInvalid: -32011,
cancelled: -32800,
} as const;
export interface JsonSchemaLite { type: string; description?: string; enum?: (string | number)[]; default?: unknown; minimum?: number; maximum?: number; pattern?: string }
export interface MethodDescriptor {
method: string;
title: string;
description: string;
/** owner: a desktop-only write the owner decides (rollout, phased release, sending one reply); always a dry run
* unless the host's confirm token comes with it, and then the command's own confirmation is answered by that token. */
kind: 'read' | 'write' | 'session' | 'system' | 'portfolio' | 'account' | 'owner';
/** Talks to a store API or the internet. */
network: boolean;
/** Replaces existing data on a store (screenshots, whole forms). */
destructive: boolean;
/** Result carries structured `data` (the command's --json output). */
json: boolean;
/** Schema of `params.args` (tool methods), or of `params` (other methods). */
args: { type: 'object'; properties: Record<string, JsonSchemaLite>; required?: string[]; additionalProperties: false };
}
export interface HelloParams { secret: string; client?: { name: string; version: string } }
export interface HelloResult { protocol: typeof DESKTOP_PROTOCOL; server: { name: 'storebound'; version: string; compiled: boolean }; methods: MethodDescriptor[] }
export interface Credentials { PLAY_SERVICE_ACCOUNT?: string; ASC_KEY_ID?: string; ASC_ISSUER_ID?: string; ASC_P8?: string }
export interface ToolParams {
/** Absolute path of a folder that contains storebound.config.ts. */
project: string;
args?: Record<string, unknown>;
/** Write methods: false runs for real and needs confirmToken. Default true. */
dryRun?: boolean;
confirmToken?: string;
/** Per-call credentials (kept in memory, passed to the command's env only). Not part of the token. */
credentials?: Credentials;
}
export interface ToolResult {
method: string;
ok: boolean;
exitCode: number;
dryRun: boolean;
/** The CLI line it ran, for logs ("storebound play listing --dry"). */
command: string;
/** Text output (log lines), trimmed to the last 60 000 characters. */
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;
/** Store API quota the run saw (App Store Connect X-Rate-Limit, Google 429): the desktop's read scheduler slows down on it. */
quota?: import('./quota').QuotaInfo;
}
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 }
export interface ProjectSummary {
dir: string;
config: string;
name: string;
languages: string[];
play: { packageName: string } | null;
apple: { bundleId: string; appId: string | null } | null;
/** app.platforms (default: from the apple / play sections). */
platforms: ('ios' | 'android')[];
/** One-time in-app products in the config (one shared catalog). */
products: number;
productIds: string[];
/** Auto-renewable subscriptions in the config (all groups). */
subscriptions: number;
subscriptionIds: string[];
/** Product and subscription ids each store should sell (items may be limited to one platform). */
catalog: { ios: string[]; android: string[] };
shots: boolean;
testflightGroups: number;
/** App profile from the config (app, game, subscription-app). */
profile?: string;
/** Absolute path of the app icon the config or the usual places point at (project.icon reads it), or null. */
icon?: string | null;
/** A/B screenshot variant ids (shots.variants: store listing experiments, Product Page Optimization treatments). */
variants?: string[];
/** play.reportsBucket is set (Play statistics for analytics snapshots). */
playReports?: boolean;
/** Play production settings from the config: play.managedPublishing (null: not set, Play's default off), the number
* of play.countries (null: all countries / regions) and the App category (play.category or the App Store match). */
playRelease?: { managedPublishing: boolean | null; countries: number | null; category: string | null } | null;
}
// ---- App-centric model (one app = one product on up to two platforms; a workspace holds many apps) ----
/** One product across platforms. id = its project folder (one storebound.config.ts per app). */
export interface AppIdentity {
id: string;
name: string;
platforms: { ios: { bundleId: string; ascAppId: string | null } | null; android: { packageName: string } | null };
}
/** Where a version is on one store. */
/** Google Play production (completed in the API) is decided by the public store page: its version = `live`, 404 or an
* older live version = `review` (with `liveVersion` / `managed`), page never read (network) = `released` with
* publicPage unknown ("Couldn't check the store page"). `sent` is no longer produced (older cached reads). */
export type ReleasePhase = 'live' | 'rollout' | 'halted' | 'released' | 'approved' | 'review' | 'sent' | 'rejected' | 'testing' | 'draft' | 'other';
export interface StoreRelease {
store: 'ios' | 'android';
version: string | null;
/** iOS build number (CFBundleVersion) / highest Android versionCode. */
build: string | null;
/** The store's own state word (READY_FOR_SALE, inProgress ...). */
state: string;
phase: ReleasePhase;
/** Rollout share (0-1): Android staged rollout user fraction, iOS phased release day -> 1, 2, 5, 10, 20, 50, 100 %. */
fraction: number | null;
/** Android track (production, beta, internal ...). */
track: string | null;
/** iOS phased release: day 1-7 and its state (ACTIVE, PAUSED, COMPLETE); null without one. */
phased?: { state: string; day: number | null } | null;
/** Google Play production: the public store page (public = 200, notPublic = 404, unknown = not read). */
publicPage?: 'public' | 'notPublic' | 'unknown';
/** Google Play production in review while the store page shows an older live version: that version. */
liveVersion?: string | null;
/** Google Play production in review with managed publishing on (play.managedPublishing): approval is not readable. */
managed?: boolean;
/** The store page could not be read now: the phase is from the last known page read at this time. */
pageStaleAt?: string | null;
/** When the store record was made (iOS version createdDate), if the store says. */
date?: string | null;
}
/** A release train: one version (e.g. 2.3.0) across both stores. */
export interface ReleaseTrain { version: string; ios: StoreRelease | null; android: StoreRelease | null }
/** In-app catalog parity between the config and both stores. */
export interface CatalogParity {
checked: 'config' | 'live';
products: number;
subscriptions: number;
/** Live: ids on one store only, and config ids missing on a store the app is on. */
onlyIos: string[];
onlyAndroid: string[];
missingIos: string[];
missingAndroid: string[];
ok: boolean;
}
export interface PortfolioParams { projects: string[]; live?: boolean; credentials?: Credentials }
export interface AppStatus {
/** Project folder as given. */
project: string;
ok: boolean;
app: AppIdentity | null;
summary: ProjectSummary | null;
check: { ok: boolean; problems: string[]; warnings: string[] } | null;
/** Release trains; `source: none` offline (no store data read). `all`: every store release read (live only). */
release: { source: 'none' | 'live'; current: ReleaseTrain | null; next: ReleaseTrain | null; all?: StoreRelease[] };
catalog: CatalogParity | null;
/** A store-only app read through a generated config (no app folder). */
virtual?: boolean;
/** live only: per-platform read errors ("unknown, never false"). */
storeErrors: { ios?: string; android?: string };
/** A store that could not be read this time, shown from the last good read (its time): couldn't refresh. */
staleStores?: { ios?: string; android?: string };
errors: string[];
/** live only: release warnings read from the stores (a first in-app purchase left out of the review, pitfall 70). */
warnings?: string[];
}
export interface PortfolioResult { apps: AppStatus[]; at: string }
// ---- Offline project reads (D2) ----
/** One listing field of one language on one store: length against the store limit. */
export interface ListingField { field: string; length: number; limit: number; required: boolean; state: 'ok' | 'missing' | 'over' | 'empty-optional' }
/** Listing completeness of one language (config language id) on both stores. */
export interface ListingLocale {
lang: string;
rtl: boolean;
play: { code: string | null; fields: ListingField[] } | null;
apple: { code: string | null; fields: ListingField[] } | null;
/** Rendered screenshots found for this language (renders folder), null when there is no renders folder. */
screenshots: number | null;
/** Required fields present and within limits, 0-1 (both stores). */
complete: number;
}
export interface ListingsResult {
files: { play: string | null; apple: string | null };
/** File-level problems (missing file, unreadable section). */
problems: string[];
locales: ListingLocale[];
}
/** A local review reply draft (.storebound/replies/<store>-<id>.md) with its checks. */
export interface ReplyDraft {
file: string;
store: 'apple' | 'play';
reviewId: string;
approved: boolean;
sent: string | null;
reply: string;
rating: number | null;
title: string | null;
body: string | null;
date: string | null;
issues: { level: 'error' | 'warn'; msg: string }[];
}
export interface DraftsResult { dir: string; drafts: ReplyDraft[]; problems: string[] }
/** project.icon: the icon as a data: URL (PNG / JPEG / WebP / SVG, at most 1.5 MB), or null. */
export interface IconResult { path: string | null; dataUrl: string | null }
// ---- Store accounts and the apps on them (D3: many studios, many accounts) ----
/** One app record on a store, as `storebound apps list` reads it. */
export interface StoreApp {
store: 'ios' | 'android';
/** App Store Connect app id (numeric) / Play package name. */
id: string;
/** iOS bundle id / Android package name. */
bundleId: string;
name: string;
/** App Store version platforms (IOS, MAC_OS, TV_OS, VISION_OS); ["ANDROID"] on Play. */
platforms: string[];
sku?: string | null;
primaryLocale?: string | null;
/** Newest App Store version and its state (App Store only). */
latest?: { version: string; state: string; platform?: string | null } | null;
/** 128 px icon as a data: URL (with --icons), else null. */
icon?: string | null;
}
export interface AppsListResult { store: 'ios' | 'android'; apps: StoreApp[]; warnings: string[]; at: string }
/** Health of one store account: does the key work, and can it see apps. */
export interface AccountCheck {
store: 'ios' | 'android';
status: 'ok' | 'no-apps' | 'permission' | 'revoked' | 'api-disabled' | 'error';
/** Play: the service account email (to invite in Play Console). Never a secret. */
principal: string | null;
apps: number | null;
message: string;
/** What to do, in words. */
fix: string | null;
docs: string | null;
}
/**
* A store-only app (no app folder yet): the sidecar reads it through a generated minimal config (store ids only) in
* its temp folder. Read methods only.
*/
export interface VirtualProject { name: string; apple?: { bundleId: string; appId?: string | null } | null; play?: { packageName: string } | null }
// ---- Desktop: every CLI area in the window (prices, declarations, renders, experiments, analytics) ----
/** prices.current (`prices export --json`): the current prices on both stores, one row per product x store x region. */
export interface PriceRowData { product: string; store: 'apple' | 'play'; region: string; territory?: string; currency: string; price: number; startDate?: string; kind?: 'manual' | 'auto' }
export interface PricesCurrent { csv: string; products: string[]; stores: ('apple' | 'play')[]; missing: { product: string; store: 'apple' | 'play' }[]; rows: PriceRowData[] }
/** prices.diff (`prices import --dry --json`): the sheet against the stores. */
export interface PriceDiffRow { product: string; store: 'apple' | 'play'; region: string; startDate?: string; currency: string; from: number | null; to: number; change: 'new' | 'change' | 'same' }
export interface PricesDiff { csv: string; diff: PriceDiffRow[]; changed: string[] }
/** One "ask the owner" item: the config does not prove the answer (AGENTS.md rule 5). */
export interface DeclAsk { area: 'dataSafety' | 'appPrivacy' | 'appInfo' | 'encryption' | 'review'; id: string; text: string; fix: string; url?: string }
/** project.declarations (`inspect --declarations --json`): store declarations from the config and local files. */
export interface DeclarationsResult {
profile: string;
stores: { apple: boolean; play: boolean };
dataSafety: null | {
collectsData: boolean; encryptedInTransit: boolean; accountCreation: string[];
/** dataSafety.partialDeletion (PSL_SUPPORT_DATA_DELETION_BY_USER; asked once there are accounts); null: not answered. */
partialDeletion: boolean | 'auto90' | null;
/** PSL_SUPPORT_DATA_DELETION_BY_USER ("can users request that their data is deleted?") as the CSV answers it
* (null: not answered), where the answer comes from (dataSafety.dataDeletion, accounts.deletionUrl, ...) and the
* Delete data URL it sends. Optional: older sidecars do not send them. */
dataDeletion?: 'yes' | 'no' | 'auto90' | null;
dataDeletionSource?: string | null;
dataDeletionUrl?: string | null;
types: { id: string; category: string; shared: boolean; ephemeral: boolean; optional: boolean; collect: string[]; share: string[] }[];
/** Extra CSV rows of questions storebound does not model. */
extra: number;
/** The Play CSV the upload sends (it replaces the whole form). */
csv: string;
rows: number;
/** Play cannot read the form back: changes since the CSV storebound uploaded last (null: never uploaded from here). */
uploaded: { at: string; changes: { question: string; response: string; from: string; to: string }[] } | null;
};
/** App Store App Privacy (no API): the answers the Data safety answers suggest; the owner enters them in App Store Connect. */
appPrivacy: null | { items: { apple: string; category: string; from: string[]; purposes: string[]; thirdParty: boolean }[]; fromDataSafety: boolean };
appInfo: null | {
categories: { primary: string; primarySubOne?: string; primarySubTwo?: string; secondary?: string } | null;
/** Overrides of the age rating declaration (every other question: none / false). */
ageRating: { question: string; value: string }[];
contentRights: string | null;
free: boolean;
};
/** apple.usesNonExemptEncryption (null: not answered; storebound never guesses). */
encryption: boolean | null;
/** contact / demoAccount: masked ("d***@example.com"), null when missing; never the password. */
review: { signInRequired: boolean | null; accounts: boolean; contact: string | null; demoAccount: string | null; notes: string; notesBytes: number; notesMax: number };
ask: DeclAsk[];
}
/** project.listing_text: the listing files as they are (for export). */
export interface ListingText { play: { path: string; text: string } | null; apple: { path: string; text: string } | null }
/** project.render: one rendered image as a data: URL (null when missing or too big). */
export interface RenderImage { file: string; dataUrl: string | null; bytes: number }
/** art.live (`art live --json`): the store art that is live, per store, locale and set, with thumbnail URLs (art.thumb
* turns one into a data: URL) and whether the app folder has the same file (same / differs / missing; n/a for a
* store-only app). */
export interface LiveArtTile { index: number; type: string; w?: number; h?: number; thumb: string; state: 'same' | 'differs' | 'missing' | 'n/a'; file?: string }
export interface LiveArtSet { store: 'apple' | 'play'; locale: string; lang: string | null; kind: string; type: string; size: string; label: string; tiles: LiveArtTile[] }
export interface LiveArtResult {
generated: string;
stores: { store: 'apple' | 'play'; ok: boolean; version?: string | null; images: number; error?: string }[];
icons: (LiveArtTile & { store: 'apple' | 'play' })[];
sets: LiveArtSet[];
notes: string[];
/** The app folder has a pulled.json (art pull ran). */
pulled: boolean;
}
/** art.thumb: one live thumbnail. */
export interface ArtThumb { dataUrl: string; bytes: number }
/** project.renders (`shots gallery --json`): every render per variant, language and size, with the store check issues. */
export interface RenderIssue { level: 'error' | 'warn'; code: string; msg: string }
export interface RenderTile { src: string; kind: 'image' | 'video'; poster?: string; name: string; w: number; h: number; bytes: number; issues: RenderIssue[] }
export interface RendersResult {
title: string; generated: string;
/** The renders folder relative to the app folder. */
dir?: string;
variants: { id: string; label: string; langs: { lang: string; rows: { size: string; label: string; store: 'apple' | 'play'; tiles: RenderTile[]; issues: RenderIssue[] }[] }[] }[];
totals: { images: number; videos: number; errors: number; warnings: number };
}
/** apple.ppo_status (`apple ppo status --json`). */
export interface PpoTest { id: string; name: string; state: string; trafficProportion?: number; startDate?: string | null; endDate?: string | null; treatments: { id: string; name: string; locales: number }[] }
export interface PpoStatus { tests: PpoTest[] }
/** analytics.compare (`analytics compare --json`): daily series from the snapshots and the store changes from git. */
export interface AnalyticsTotals { impressions?: number; pageViews?: number; downloads?: number; conversion?: number | null; visitors?: number; acquisitions?: number; installs?: number }
export interface AnalyticsCompare {
snapshots: number; since: string;
series: {
apple: { date: string; impressions: number; uniqueImpressions: number; pageViews: number; uniquePageViews: number; downloads: number; firstTimeDownloads: number }[];
play: { date: string; visitors: number; acquisitions: number; installs: number }[];
};
effects: { event: { date: string; commit: string; subject: string; files: string[] }; store: 'apple' | 'play'; before: AnalyticsTotals; after: AnalyticsTotals; daysBefore: number; daysAfter: number }[];
/** Per store with no days in any snapshot: why the newest snapshot has none (no report request yet, no bucket). */
errors?: { apple?: string; play?: string };
}
/** aso.audit (`aso audit --json`, offline, cli/lib/aso-audit.ts AsoAudit): the parts the Growth tab's ASO card shows. */
export interface AsoTrendRow { storefront: string; keyword: string; rank: number | null; previous: number | null; change: number | null; arrow: '↑' | '↓' | '→' | 'new' | 'lost' | '·'; best: number | null; snapshots: number }
export interface AsoAuditView {
generated: string;
keywordField: { errors: number; warnings: number; hints: number; findings: { level: 'error' | 'warn' | 'info'; where: string; msg: string }[] };
drafts: string[];
ranks: { snapshots: number; latest: string | null; rows: AsoTrendRow[]; dates: string[]; series: { storefront: string; keyword: string; ranks: (number | null)[] }[] };
competitors: { date: string | null; gaps: { storefront: string; locale: string; gaps: { word: string; apps: number; developers: number }[] }[] };
experiments: { store: 'apple' | 'play'; what: string; why: string; how: string }[];
next: { what: string; why: string; command?: string }[];
}
/** console.fill (`console fill <form> --json`, cli/console/fill.ts): the answers it types into one console form. The
* dry run has the answers only; the real run (it ends when the owner closes the browser) adds what happened. */
export type ConsoleForm = 'apple-app-record' | 'apple-age-rating' | 'apple-app-privacy' | 'play-app-record' | 'play-content-rating' | 'play-target-audience';
export interface ConsoleAnswer { id: string; label: string; value: string; certain: boolean; source: string; note?: string }
export interface ConsoleFillResult {
form: ConsoleForm; store: 'apple' | 'play'; title: string; url: string; answers: ConsoleAnswer[];
/** Answers only the owner can give (highlighted in the browser). */
uncertain: number;
guidance: string[];
/** Real run only: fields filled, fields highlighted, whether it fell back to highlighting only, where it stopped. */
filled?: number; highlighted?: number; fallback?: unknown; stoppedAt?: string | null;
}
// ---- Editors (console parity, docs/console-parity.md) ----
/** One listing language of one store as the listing file holds it (project.editable). `exists`: the file has a section. */
export interface EditableListing { lang: string; language: string; status: string | null; exists: boolean; values: Record<string, string>; codes?: string[] }
/** project.editable (`edit --show --json`): what the desktop's editors show and change. */
export interface EditableResult {
root: string;
files: { config: string; play: string | null; apple: string | null; contact: string; shotsCopy?: string | null };
/** The screenshot captions (shots copy file): language -> scene -> eyebrow, title (two lines), sub. */
shotsCopy?: Record<string, Record<string, { eyebrow?: string; title?: string[]; sub?: string } & Record<string, unknown>>> | null;
format: 'ts' | 'js' | 'json';
/** The app folder is a git work tree; which of the editable files have uncommitted changes. storebound never commits. */
git: { repo: boolean; uncommitted: string[] };
appName: string;
appleName: string;
languages: { id: string; play: string[]; apple: string | null; rtl: boolean }[];
play: EditableListing[] | null;
apple: EditableListing[] | null;
/** The config sections the editors change, as the config holds them (plain data). */
config: {
app: Record<string, unknown>; apple: Record<string, unknown> | null; play: Record<string, unknown> | null;
products: Record<string, unknown>[]; subscriptions: Record<string, unknown>[];
dataSafety: Record<string, unknown> | null; appPrivacy: Record<string, unknown> | null; reviewAccess: Record<string, unknown> | null;
accounts: Record<string, unknown> | null; social: Record<string, unknown> | null;
/** Console form answers (Play content rating, target audience, App content declarations; the App Store record). */
console?: Record<string, unknown> | null;
/** Screenshot scenes in store order and the feature graphic (shots). */
shots: { scenes: { id: string; frames?: { src: string }[] }[]; feature: Record<string, unknown> | null } | null;
};
/** App Review contact from .storebound/credentials.json, masked (personal data); null when not set. */
contact: { firstName: string; lastName: string; phone: string; email: string } | null;
/** The App Review demo account's user name, masked; the password is never read out. */
demoAccount: string | null;
limits: Record<string, Record<string, number> | number>;
}
/** project.edit (`edit --ops --json`): the plan (and with a confirm token, the write). */
export interface EditResult {
changes: { what: string; file: string; from: string | null; to: string | null; also?: string[] }[];
files: { file: string; rel: string; created: boolean; diff: string[]; masked?: boolean }[];
issues: { level: 'error' | 'warn' | 'info'; field: string; where: string; msg: string }[];
errors: number;
written: boolean;
dry: boolean;
git: { repo: boolean; uncommitted: string[] };
}