485 lines
28 KiB
TypeScript
485 lines
28 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[];
|
|
/** developerId / appId: the Play Console ids for deep links (play-ids.ts), null when unknown. */
|
|
play: { packageName: string; developerId?: string | null; appId?: string | null } | 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; developerId?: string | null; appId?: string | null } | 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';
|
|
/** App Store platform of the version (IOS, MAC_OS, ...); absent on Play. */
|
|
platform?: string;
|
|
version: string | null;
|
|
/** iOS build number (CFBundleVersion) / highest Android versionCode. */
|
|
build: string | null;
|
|
/** The store's reason text of a rejection, when the status carries one (the state word alone does not). */
|
|
reason?: 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;
|
|
/** listing.allowPlatformMentions: functional mentions of other platforms the owner allowed (pitfall 77) */
|
|
allowPlatformMentions?: { store: 'apple' | 'play' | 'both'; text: string; reason: 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[] };
|
|
}
|