README, docs and site: one story (the whole job, no avoidable rejections, the desktop app, your AI, install)
README is a short front page (about 120 lines) with one real visual per section; every former section moved unchanged into docs/*.md with a docs/README.md index (journey, rejections, getting-started, commands, growth, studios, store-art, listing-languages, api, skills, mcp, safety, faq, project). The site tells the same story on one page: the teaser in the hero, real CLI output as text, the desktop app as a section with light/dark captures of eight screens and the per-system downloads (desktop/latest.json); /desktop/ redirects to /#download. New capture tools: tools/site-desktop-shots.ts (fixture-mode desktop frames) and tools/site-term-shots.ts (real CLI output as text and WebP). tests/docs-links.test.ts checks README, docs and site links resolve.
|
|
@ -23,7 +23,7 @@ ${Object.entries(COMMANDS).map(([k, [, d]]) => line([k, d])).join('\n')}
|
|||
${COMMON}
|
||||
credentials: PLAY_SERVICE_ACCOUNT, ASC_KEY_ID, ASC_ISSUER_ID, ASC_P8, ANDROID_KEYSTORE(_PASSWORD) / ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD, REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD (env or .storebound/credentials.json);
|
||||
profiles for many accounts: ~/.storebound/credentials/<profile>.json (same names), picked with --account <profile>
|
||||
docs: README.md and the skills/ folder of the toolchain`);
|
||||
docs: README.md, docs/ and the skills/ folder of the toolchain`);
|
||||
}
|
||||
|
||||
/** Usage of one command: its header usage lines (or the table line), description and accepted options. */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Where each AI coding tool looks for Agent Skills and MCP servers in a project, and pure merge helpers that add the
|
||||
// storebound entry without touching anything else in those files. Formats checked against each tool's docs in
|
||||
// October 2026 (see README "Agent support").
|
||||
// October 2026 (see docs/project.md "Agent support").
|
||||
|
||||
export type AgentId = 'claude' | 'codex' | 'cursor' | 'copilot' | 'windsurf' | 'gemini' | 'opencode';
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export const DATA_SAFETY_UPLOADED = '.storebound/data-safety.uploaded.csv';
|
|||
/**
|
||||
* Play Data safety "Account creation" (question PSL_SUPPORTED_ACCOUNT_CREATION_METHODS): the options Play Console
|
||||
* shows (Data safety > Data collection and security > Account creation) and the response ids storebound writes.
|
||||
* Google publishes no list of these ids (README "Not verified"): compare with a Console export (App content > Data
|
||||
* Google publishes no list of these ids (docs/project.md "Not verified"): compare with a Console export (App content > Data
|
||||
* safety > Export to CSV) when an import is refused.
|
||||
*/
|
||||
export const ACCOUNT_CREATION: Record<string, string> = {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { homedir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
|
||||
export const BUNDLE_ID = 'dev.storebound.desktop';
|
||||
export const PAGE = 'https://storebound.dev/desktop/';
|
||||
export const PAGE = 'https://storebound.dev/#download';
|
||||
export const MANIFEST: Record<'stable' | 'beta', string> = {
|
||||
stable: 'https://storebound.dev/desktop/latest.json',
|
||||
beta: 'https://storebound.dev/desktop/beta/latest.json',
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
// (uncoveredChars) and measures, in the page, which face drew every character (measureFonts). `shots check` turns a
|
||||
// glyph drawn by a fallback the config did not choose, or by a system font, into an ERROR.
|
||||
//
|
||||
// Default stack per script (the template defaults, documented in the README and the store-screenshots skill):
|
||||
// Default stack per script (the template defaults, documented in docs/store-art.md and the store-screenshots skill):
|
||||
// Latin incl. Latin Extended-A / B and Vietnamese, Cyrillic, Greek: the configured title / body font
|
||||
// (defaults: game Paytone One + Montserrat, app Inter + Inter; all four cover Latin Extended and Vietnamese;
|
||||
// Inter and Montserrat cover Cyrillic too). A Cyrillic or Greek line switches to Noto Sans as a whole.
|
||||
|
|
|
|||
40
docs/README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# storebound docs
|
||||
|
||||
The [README](../README.md) is the short tour; everything in detail lives here.
|
||||
|
||||
## Start
|
||||
|
||||
- [Getting started](getting-started.md): the 5-minute quick start, coming from fastlane, credentials, install for your AI tool
|
||||
- [The journey](journey.md): why storebound, how agents use it, every step from setup to Submit (API, console or yours)
|
||||
- [FAQ](faq.md)
|
||||
|
||||
## Ship
|
||||
|
||||
- [Commands](commands.md): every command, grouped (setup and checks, Google Play, App Store, TestFlight, after launch,
|
||||
screenshots and video, many apps and release trains)
|
||||
- [Catch rejections before you submit](rejections.md): `storebound scan`, every rule with its guideline or policy
|
||||
- [Store art](store-art.md): screenshots and previews, fonts, variants, video
|
||||
- [Listing in more languages](listing-languages.md)
|
||||
- [Growth extras and console forms](growth.md): PPP prices, Apple Ads, web presence, ad-hoc builds, RevenueCat,
|
||||
`console plan` / `console verify` / `console fill`
|
||||
- [Many apps, studios, release trains and workflows](studios.md)
|
||||
- [Every endpoint](api.md): `storebound api` and the [API coverage](api-coverage.md) table
|
||||
|
||||
## Agents and safety
|
||||
|
||||
- [Rules for AI agents](agents-core.md) (the source of `AGENTS.md`)
|
||||
- [Skills](skills.md)
|
||||
- [MCP server and the desktop machine interface](mcp.md)
|
||||
- [Safety model](safety.md)
|
||||
- [Idempotency: every write converges](idempotency.md)
|
||||
- [Pitfalls: lessons from real use](pitfalls.md)
|
||||
|
||||
## Desktop app
|
||||
|
||||
- [storebound desktop](desktop.md): design, safety model, screens
|
||||
- [Releasing storebound desktop](desktop-release.md): build, sign, check, publish
|
||||
|
||||
## Project
|
||||
|
||||
- [Roadmap, contributing and credits](project.md) (agent support details, what is not verified yet, develop)
|
||||
- [Research notes](research/fastlane-revenuecat.md)
|
||||
33
docs/api.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Every endpoint
|
||||
|
||||
The commands above cover the journey. For anything else, `storebound api` calls **every operation** of the App Store
|
||||
Connect API (1303 operations, OpenAPI 4.5.1) and of the Google Play Developer APIs (Android Publisher v3: 145,
|
||||
Play Developer Reporting v1beta1: 25), generated from the official specs pinned in `specs/` (source URLs, dates and
|
||||
SHA-256 in `specs/SOURCES.json`). Same credentials, same retries on 429 / 5xx (Retry-After honoured), JSON:API
|
||||
documents on the Apple side.
|
||||
|
||||
```sh
|
||||
storebound api list --store apple --search "customerReviews" # find an operation
|
||||
storebound api apple apps_getCollection --param 'filter[bundleId]=com.example.app'
|
||||
storebound api apple GET /v1/apps/123/appStoreVersions --all-pages # METHOD path works too
|
||||
storebound api play reviews.list --param packageName=com.example.app --all-pages --json
|
||||
storebound api play POST /v1beta1/apps/com.example.app/crashRateMetricSet:query --body query.json
|
||||
storebound api coverage # counts per API and resource, what commands wrap
|
||||
```
|
||||
|
||||
- **GET runs right away** (so do the Reporting API `:query` calls and other POSTs that only read).
|
||||
- **Every write is a dry run by default**: it prints the exact request (method, URL, body; the credential is never
|
||||
shown) and is only sent with `--yes` from an interactive terminal.
|
||||
- **Owner-only operations** (submit, release, delete, cancel, refunds, prices, testers and users, review replies,
|
||||
product page tests, Play `edits.commit`, app signing; the list is in `cli/api/gate.ts`) also ask you to type the
|
||||
operation id before anything is sent.
|
||||
- **Agents read, never write**: MCP has one tool for this, `api_get` (GET operations only). Under MCP or the desktop
|
||||
app no write goes out, whatever the flags.
|
||||
- Small on purpose: one table row per operation plus generic helpers (`cli/api/`), typed from the rows (path and
|
||||
required parameters, JSON:API request and response shapes). `bun tools/gen-api.ts` regenerates the tables and
|
||||
[docs/api-coverage.md](api-coverage.md) (every operation, its gate and the commands that already call it);
|
||||
CI fails when they are stale.
|
||||
|
||||
---
|
||||
|
||||
[All docs](README.md) · [README](../README.md)
|
||||
168
docs/commands.md
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
# Commands
|
||||
|
||||
Every write command takes `--dry`; re-runs are idempotent; output is one timestamped line per step, so long jobs can
|
||||
run in the background with a log (`storebound shots render > .storebound/render.log 2>&1 &`).
|
||||
|
||||
<details>
|
||||
<summary><b>Setup and checks</b></summary>
|
||||
|
||||
| command | kind | what |
|
||||
|---|---|---|
|
||||
| `init [--profile app\|game\|subscription-app]` | local | copy the templates of a profile (default app), gitignore `.storebound/` |
|
||||
| `import fastlane [--dir fastlane] [--profile ...]` | local | config, `store/appstore.md`, `store/listing.md` and screenshot frames from a fastlane setup; never overwrites, reports what is not mapped |
|
||||
| `install --agent <tool>` | local | skills + MCP entry for an AI tool |
|
||||
| `mcp` | local | stdio MCP server wrapping the commands |
|
||||
| `serve --stdio` | local | JSON-RPC session for the storebound desktop app (see [Desktop machine interface](mcp.md#desktop-machine-interface)) |
|
||||
| `inspect [--json]` | local | offline summary of the config: name, store ids, languages, products |
|
||||
| `check` | local | config, copy limits, data safety, products, subscriptions, profile (delete-account URL, demo account, terms URL), files; warns on untranslated release notes |
|
||||
| `catalog diff [--store] [--threshold 10]` | API, read-only | one catalog vs both stores: missing / extra / mismatched products and subscriptions, US and override prices, locales, offers, per-region price differences between the stores; exit 1 on mismatches |
|
||||
| `check --aso` | local | keyword audit: App Store keyword field rules, Play stuffing / claims |
|
||||
| `scan [--store apple\|play] [--json] [--write] [--originality --against <folders>]` | local, read-only | rejection risks in the project itself (stack, SDKs, Info.plist, manifest, listings) with guideline, evidence file:line, fix and skill; Data safety / App Privacy / ATT / privacy manifest proposal as a diff (`--write` adds the detected answers to the config); exit 1 on blockers |
|
||||
| `validate [--apple] [--play]` | API, read-only | readiness report with ordered blockers plus the local renders checks (`--no-local` skips); exit 1 while any blocker is left |
|
||||
| `originality --against <folders> [--json]` | local, read-only | Guideline 4.3 check vs your other apps: name, listing text, icon and screenshot perceptual hashes, project structure; scores, near-duplicate flags, suggestions; exit 1 when flagged |
|
||||
| `whats-new [--since <tag>]` | local | draft player-facing release notes from git log into both listing files |
|
||||
| `whats-new --languages en,de,tr` / `check` / `apply` | local | one file per language (`store/whats-new/`) for your agent (or a `--translate` hook of yours) to translate, per-store limits (Play 500, App Store 4000), then into both listing files |
|
||||
| `listing translate --to de,ja [--from en] [--translate "<cmd>"]` | local | per-language listing sections in both listing files (App Store name, subtitle, promotional text, keywords, description; Play title, short, full) for your agent (or a hook) to translate, marked as drafts until reviewed; keywords cleaned up |
|
||||
| `listing check` / `listing reviewed --lang <ids>` | local | every language section against the store limits and keyword rules, untranslated and unreviewed drafts (exit 1 on a problem) / remove the draft marker after a human read it |
|
||||
| `flow list \| validate \| run <name> [NAME=value] [--dry \| --apply [--yes]] [--from <step> \| --resume]` | local + whatever the steps call | repo-local workflows (`store/workflows/*.yml`): steps in order with progress; writes run as dry runs unless `--apply`, owner steps are listed as your click and never run (see [Repo-local workflows](studios.md#repo-local-workflows)) |
|
||||
| `catalog pull [--write]` | API, read-only + local | IAP and subscription texts on the stores vs the config: a proposal diff (`.storebound/pull/catalog.json`); the config is never rewritten |
|
||||
| `ads scan --ios <path>` | local | AdAttributionKit / SKAdNetwork: ad network ids, postback copy endpoints, attribution windows and cooldown, conversion value updates; exit 1 on a blocker |
|
||||
| `privacy-manifest --ios <path>` | local | PrivacyInfo.xcprivacy, required-reason APIs, SDKs without manifests |
|
||||
| `api list \| coverage \| apple\|play <operation>` | API | every App Store Connect / Google Play operation (see [Every endpoint](api.md)); GET runs, writes are dry unless `--yes` in a terminal |
|
||||
| `desktop [--download]` | local | open the storebound desktop app (in an app folder: add or select it there); not installed: the download for this system, `--download` saves it to ~/Downloads after checking its signature (never installs) |
|
||||
| `doctor [--json]` | local | which local tools are here (ffmpeg, Chrome, playwright, Xcode, adb, git) and how to install the missing ones |
|
||||
| `icons` | local | App Store 1024 (no alpha), Play 512, web, Capacitor sources, Android legacy launcher icons |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Google Play</b></summary>
|
||||
|
||||
| command | kind | what |
|
||||
|---|---|---|
|
||||
| `play listing [--include-drafts]` | API | app details, title / short / full description per language, promo video URL (`play.video`); prints the plan first (old -> new per field, draft languages skipped), `--dry` stops there, the real run asks and sends only changes |
|
||||
| `play listing pull [--write]` | API, read-only + local | live listings back into `store/listing.md`: diff first, writes only with `--write` |
|
||||
| `play images [--variant b]` | API | icon, feature graphic, phone / 7" / 10" screenshots, one edit per language |
|
||||
| `play products` | API | one-time products: regional prices from `convertRegionPrices` + your overrides, activate |
|
||||
| `play subscriptions [--csv]` | API | subscriptions: listings + benefits, one auto-renewing base plan, regional prices, intro and extra offers, activate (plan first, asks) |
|
||||
| `play data-safety` | API | Data safety form (CSV import); `--csv` writes the file |
|
||||
| `play build [--version-code N] [--flavor f]` | local + API read | `build.prebuild` first (Capacitor: web build + cap sync; stale copied web assets stop the build), then Gradle `bundleRelease` (Flutter `flutter build appbundle`; Capacitor, React Native, Unity export found) with the next free versionCode and the upload key from env / `.storebound` (passed through the environment, never argv); AAB checked and copied to `build/android/` |
|
||||
| `play upload [<aab>] --track internal\|alpha\|beta` | API | AAB to a testing track with release notes; versionCode checked first; `--dry` is read-only, a real run asks |
|
||||
| `play release <aab>` | API | upload + tracks (`--internal completed --production draft`, `--production inProgress --fraction 0.05`), release notes |
|
||||
| `play rollout` | API | `--fraction x`, `--halt`, `--resume`, `--complete` (asks first, except halt) |
|
||||
| `play next-version-code` | API, read-only | next free versionCode |
|
||||
| `play status` | API, read-only | tracks, listings, products |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>App Store</b></summary>
|
||||
|
||||
| command | kind | what |
|
||||
|---|---|---|
|
||||
| `apple bundle` | API | register the bundle id, find the app record |
|
||||
| `apple listing --version x.y.z [--build N] [--include-drafts]` | API | prints the plan first (version: keep / update / create / refuse with the reason; per locale create / update, old -> new per field; draft locales skipped), `--dry` stops there, the real run asks and applies exactly it: version (manual release), the build with exactly that number, texts per locale (name: the locale's `### name`, else `apple.name`, else `app.name`) |
|
||||
| `apple listing pull [--version x.y.z] [--write]` | API, read-only + local | live texts back into `store/appstore.md`: diff first, writes only with `--write` |
|
||||
| `apple screenshots [--variant b]` | API | 6.9" + 6.5" (+ 13" iPad with `apple.ipad`, + 6.3" with `apple.iphoneMedium`) per locale, MD5-idempotent |
|
||||
| `apple previews [--poster 5]` | API | app preview videos per locale, checked first, MD5-idempotent, poster frame |
|
||||
| `apple iap` / `apple iap-review` | API | IAPs (consumable, non-consumable, non-renewing), localizations, price schedules, availability / review screenshots |
|
||||
| `apple iap-submit [--with-version] [--resolve-iaps] [--no-submit]` | API | IAP / subscription / group versions into a review submission (the first of a type together with the app version); reuses the open submission (a draft, or one back from review with UNRESOLVED_ISSUES; `--resolve-iaps` marks its REJECTED IAP items resolved), never a second one; asks before adding and before submitting |
|
||||
| `apple submit --build <n> [--version x.y.z]` | API | a version to App Review: build n (by number, VALID) attached, the open review submission reused (a new draft only when none is open), the ready IAPs / subscriptions with it, submitted with the same retry as resubmit; plan table without `--yes` (the owner's step; the desktop's Submit for review) |
|
||||
| `apple resubmit --build <n> [--resolve-iaps]` | API | after a rejection: attaches build n to the rejected version, reuses the UNRESOLVED_ISSUES submission, adds missing items, marks the REJECTED version item resolved (IAP items with `--resolve-iaps`) and resubmits (a REJECTED item left stops it at once; retried while Apple says "not ready yet" only when none is left); plan table without `--yes` |
|
||||
| `apple offer-codes [--codes]` | API | offer code campaigns for one-time purchases; custom and one-time-use codes only with `--codes` (asks; codes saved to `.storebound/offer-codes/`) |
|
||||
| `apple win-back` | API | win-back offers for lapsed subscribers (asks: they go live on their start date) |
|
||||
| `apple promoted [--enable]` | API | promoted in-app purchases: create (disabled), order, switch on (asks) |
|
||||
| `apple preorder status \| enable --release-date \| end` | API | pre-orders per territory (enable / end ask first) |
|
||||
| `apple cpp list \| create \| upload \| submit \| visible` | API | custom product pages from the config and a shots variant; submit / visible ask first |
|
||||
| `apple events list \| create \| upload \| submit` | API | in-app events with schedule, localizations, card and details images; submit asks first |
|
||||
| `apple subscriptions [--csv] [--codes]` | API | groups, subscriptions, levels, localizations, availability, equalized prices + overrides + CSV (scheduled changes), introductory and promotional offers, offer code campaigns, review screenshots (plan first, asks; custom codes only with `--codes`) |
|
||||
| `apple appinfo` | API | categories, age rating, content rights, Free, territories; plan first (old -> new), only changes are sent |
|
||||
| `apple review-details` | API | App Review contact, demo account (from `.storebound/`, masked), notes (+ sign-in steps, where account deletion is) |
|
||||
| `apple encryption` | API | export compliance answer from `apple.usesNonExemptEncryption` (never guessed) |
|
||||
| `apple release --phased on\|pause\|resume\|complete`, `--manual-release` | API | phased release, release an approved version (resume / complete / release ask first) |
|
||||
| `apple signing status` | API, read-only | bundle id capabilities, distribution certificates (with / without a private key on this Mac), App Store profiles per bundle id, installed or not |
|
||||
| `apple signing ensure [--keychain] [--no-install]` | API | missing capabilities, a distribution certificate from a key made here (kept 0600 in `.storebound/signing/`, never printed), App Store profiles installed for Xcode; dry first, asks; Developer ID needs the Account Holder (403 explained) |
|
||||
| `apple build [--version x.y.z] [--build-number N]` | local + API read | `build.prebuild` first (Capacitor: web build + cap sync; stale copied web assets stop the build), then xcodebuild archive + exportArchive with a generated ExportOptions.plist (app-store-connect, team, signing style, profiles) and the API key; next build number; Capacitor, React Native, Flutter, Unity projects found |
|
||||
| `apple upload [<ipa>] [--via api\|altool]` | API | IPA through the App Store Connect API build upload (or altool), build number checked, waits until VALID (exit 0 / 1 / 7); dry first, asks |
|
||||
| `apple next-build` | API, read-only | next safe build number |
|
||||
| `apple status` | API | versions, builds, IAPs, submissions; `--wait-review` (exit 0 / 1 / 7); `--cancel-submission <id>` |
|
||||
| `apple ppo status \| create --variant b \| upload --variant b` | API | Product Page Optimization test from `shots` variants: treatments, localizations, screenshot sets |
|
||||
| `apple ppo start \| stop` | API | start / stop the test (asks first; a stopped test cannot be restarted) |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>TestFlight</b></summary>
|
||||
|
||||
| command | kind | what |
|
||||
|---|---|---|
|
||||
| `apple testflight status` | API, read-only | groups, testers and builds per group, recent builds with internal / external beta states |
|
||||
| `apple testflight groups [sync]` | API | list the groups (read-only); `sync` creates the ones of `apple.testflight.groups` that are missing |
|
||||
| `apple testflight testers [--csv f] [--remove] [--invite]` | API | testers from a CSV in `.storebound/` into their groups (asks first; emails masked) |
|
||||
| `apple testflight what-to-test --build N` | API | "What to Test" per locale from `store/appstore.md` (`whatToTest`, else `whatsNew`) |
|
||||
| `apple testflight info` | API | Beta App Description, feedback email, URLs, Beta App Review contact and notes |
|
||||
| `apple testflight distribute --build N --group a,b [--submit-review]` | API | the build to groups; external groups and Beta App Review ask first |
|
||||
| `apple testflight wait --build N [--timeout 60m]` | API, read-only | until processing ends, with progress; exit 0 VALID / 1 FAILED / 7 timeout |
|
||||
| `apple testflight expire --keep N \| --older-than D` | API | expire old builds (never attached or processing ones); dry run, then asks |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>After launch</b></summary>
|
||||
|
||||
| command | kind | what |
|
||||
|---|---|---|
|
||||
| `health [--weekly] [--days 7]` | API, read-only | Play vitals (user-perceived crash / ANR vs 1.09 % / 0.47 %, devices, top issues), App Store metrics, diagnostics, TestFlight crashes; `--weekly` writes a Markdown report |
|
||||
| `reviews list [--unanswered]` | API, read-only | recent reviews of both stores |
|
||||
| `reviews draft` | API + local | one reply draft per unanswered review in `.storebound/replies/` |
|
||||
| `reviews send` | API | sends drafts with `approved: true` (checks length and incentives, asks first) |
|
||||
| `prices export [--no-subscriptions]` | API + local | current prices of every product and subscription (Apple current + scheduled, Play base plan regions), both stores, as `store/prices.csv`; subscription rows feed `apple subscriptions --csv` / `play subscriptions --csv` |
|
||||
| `prices import [--reconvert]` | API | diff table, then (asks first) Apple price schedules incl. future-dated changes and Play regional prices |
|
||||
| `analytics setup` | API | App Store analytics report request (once, Admin key) |
|
||||
| `analytics snapshot [--days 28]` | API + local | impressions, page views, downloads (App Store), visitors, acquisitions, installs (Play reports bucket) -> `.storebound/analytics/` |
|
||||
| `analytics compare` | local | snapshots vs the git history of the store files: before / after each listing change |
|
||||
| `reports apple-sales \| apple-finance \| play-earnings \| play-sales` | API, read-only + local | sales / finance reports into `.storebound/reports/`, summary per product and currency, CSV export |
|
||||
| `triage [--days 14]` | API, read-only | TestFlight crash and screenshot feedback, Play error reports: grouped by signature with devices, OS versions, builds |
|
||||
| `aso rank [--storefronts us,de]` | network + local | App Store rank per keyword and storefront plus competitors from the public iTunes Search API (rate limited) -> `.storebound/aso/` |
|
||||
| `aso competitors [--term <kw>] [--storefront us,tr] [--top 10]` | network + local | top apps per keyword (name, developer, rating count, first release, last update, localized genre; no subtitles: not in any public API) and the words in their names you do not cover, same rate limit -> `.storebound/aso/competitors/` |
|
||||
| `aso suggest [--locale en-US] [--write]` | local (+ Apple Ads API with credentials) | keyword candidates (own text, competitor names, localized category names, Apple Ads keyword suggestions with their relative 0-100 popularity) scored by relevance and competition, and a 100-byte keyword field per locale as a diff; `--write` puts it into `store/appstore.md` as a draft for review. Popularity is never estimated |
|
||||
| `aso audit` | local | one report: `check --aso`, free keyword room, rank trend arrows, competitor gaps, Play short description and density, App Store conversion from the analytics snapshots, PPO / Play experiment ideas and the next 3 actions |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Screenshots and video</b></summary>
|
||||
|
||||
| command | kind | what |
|
||||
|---|---|---|
|
||||
| `shots capture [--source web\|ios-sim\|android-emu\|folder] [--concurrency N] [--only-missing \| --force]` | local | raw frames from your running app or game; web: parallel contexts, optional one boot per language (`capture.session`), skips unchanged frames, timing per step |
|
||||
| `shots fonts` | network | font subsets for your copy (Noto for CJK / Arabic / Cyrillic / Devanagari ...), your own local font files, and the glyph coverage check per language |
|
||||
| `shots render [--variant b \| --variants] [--concurrency N]` | local | every store size and language in the game or app style, feature graphic, contact sheet, overflow report |
|
||||
| `shots jpeg` | local | JPEG q92 copies of the Play renders (about 4x faster uploads) |
|
||||
| `shots check [--variants] [--json]` | local, read-only | store rules: sizes, alpha, format, bytes, counts, overflow, fallback / missing glyphs, videos, folder frames |
|
||||
| `shots gallery` | local | `<renders>/index.html` review page |
|
||||
| `shots record [--source ...]` | local | app or gameplay clips: web (Chromium screencast), simulator, emulator, folder |
|
||||
| `shots video [--target iphone,ipad,youtube]` | local | app previews + promo video with captions, poster, checks |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Many apps and release trains</b></summary>
|
||||
|
||||
| command | kind | what |
|
||||
|---|---|---|
|
||||
| `ws init` | local | find the app folders below and write `storebound.workspace.json` (globs like `games/*`) |
|
||||
| `ws list [--all]` | local | apps (folders + store-only apps), studios, accounts and whether each credential profile has its keys |
|
||||
| `ws status [--offline] [--apps a,b]` | API, read-only | portfolio table: every app x both stores (live / rollout % / next version), catalog parity, check; parallel |
|
||||
| `ws check`, `ws validate [--apps a,b]` | local / API, read-only | the offline check / the readiness report of every app in one table |
|
||||
| `ws run <command> --apps a,b \| --all [--apply]` | per command | any read command in every app; writes as dry runs unless `--apply`; never submit / release / rollout / send |
|
||||
| `ws apps import --account <profile>` | API, read-only + local cache | the apps a store account sees, paired with the folders (the desktop's rules) |
|
||||
| `ws desktop export [--out f \| --merge [--apply]]`, `ws desktop import` | local | the workspace to and from the desktop app's state (labels, never keys) |
|
||||
| `release status [--app a,b] [--versions]` | API, read-only | both stores on one train per version: build > testing > review > live > rollout % |
|
||||
| `release plan <version> [--app a]` | API, read-only | what is left for that version on each store, in fix order, with the owner's next step |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
[All docs](README.md) · [README](../README.md)
|
||||
|
|
@ -238,7 +238,7 @@ site/). Workflow artifacts of this public repository can be downloaded by anyone
|
|||
|
||||
| URL | served from | how it gets there |
|
||||
|---|---|---|
|
||||
| /desktop/ (downloads page) | /var/www/storebound.dev/desktop/index.html | `site/desktop/index.html`, with the site (Jenkins + /usr/local/sbin/storebound-publish) |
|
||||
| /#download (the downloads, a section of the home page; /desktop/ redirects there) | /var/www/storebound.dev/index.html, /var/www/storebound.dev/desktop/index.html | `site/index.html`, `site/desktop/index.html`, with the site (Jenkins + /usr/local/sbin/storebound-publish) |
|
||||
| /desktop/latest.json, /desktop/beta/latest.json | /var/www/storebound-desktop/ | storebound-desktop-publish |
|
||||
| /desktop/dl/<version>/<files> | /var/www/storebound-desktop/dl/ | storebound-desktop-publish |
|
||||
|
||||
|
|
@ -308,6 +308,6 @@ files stay where they are, because links and checksums must not change.
|
|||
2. Forgejo Actions enabled, the Linux runner registered, a `desktop-v0.1.0` tag built, `add` of the AppImage and
|
||||
deb.
|
||||
3. Windows: a runner, or ship macOS + Linux first. The page and latest.json list only what exists.
|
||||
4. `publish --yes` to stable. Then open storebound.dev/desktop/ on each OS and run `storebound desktop --download`.
|
||||
4. `publish --yes` to stable. Then open storebound.dev/#download on each OS and run `storebound desktop --download`.
|
||||
5. An updater round trip: publish 0.1.1 to beta, switch a 0.1.0 install to Beta, Check for updates, Install.
|
||||
6. Link /desktop/ from the main page, and update README and CHANGELOG.
|
||||
6. Update CHANGELOG (the home page's download section and README already point at storebound.dev/#download).
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ Each write is a dry run, then the native dialog, then the real run, with one pro
|
|||
|
||||
### The same model in the terminal
|
||||
|
||||
The CLI workspace (`storebound.workspace.json`, README "Many apps and studios") holds the same studios, accounts
|
||||
The CLI workspace (`storebound.workspace.json`, docs/studios.md "Many apps and studios") holds the same studios, accounts
|
||||
(credential profile names instead of keychain entries), folders and overrides. The pairing rules
|
||||
(`cli/lib/identity.ts`) and the release lanes (`cli/lib/train.ts`) are one implementation for both; the window
|
||||
re-exports them (`src/lib/identity.ts`, `src/lib/model.ts`). `storebound ws desktop import` reads `state.json` into a
|
||||
|
|
|
|||
50
docs/faq.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# FAQ
|
||||
|
||||
**Is it only for games?** No. It started with my game, but every command is about store records, not engines or
|
||||
genres. `init --profile app` and `--profile subscription-app` give you templates for a utility app with accounts and
|
||||
for a subscription app (Data safety, App Privacy, declarations, listing and screenshot copy), the `app` screenshot
|
||||
style, account deletion and demo account checks, and subscriptions on both stores. Game-only advice (IARC game
|
||||
questions, gameplay footage, ad mediation) is marked as such in the skills.
|
||||
|
||||
**Do I need Claude (or any AI tool)?** No. The CLI is a normal command and the skills are Markdown checklists you can
|
||||
read yourself. The AI integrations are optional adapters generated from the same files.
|
||||
|
||||
**Does it fill the questionnaires?** The ones with an API, yes: Play Data safety (the whole form from `dataSafety` in
|
||||
the config; the import replaces the form, so the config is the single truth), Apple age rating, content rights,
|
||||
categories, price and availability, the export compliance answer and the App Review details. The stores have no
|
||||
public API for Play's App content cards (IARC content rating, target audience, ads, advertising ID, app access,
|
||||
government / financial / health / news) or for Apple's App Privacy labels. For those, `init` gives you prepared
|
||||
answers (`store/declarations.md`, `store/app-privacy.md`, per profile: a utility app with an account, a subscription
|
||||
app, or a free-to-play game with ads and IAP),
|
||||
and the `store-declarations` skill has the exact clicks: an agent with browser tools can fill them in while you
|
||||
watch, or you copy them in yourself. Either way the answers are your statements to the stores: change what does not
|
||||
match your app, and the agent asks you whenever the code does not prove an answer. `validate` cannot see these
|
||||
forms, so `store-preflight` lists them as a manual check.
|
||||
|
||||
**My app or game is Unity / Godot / Flutter / React Native / native, not web.** Screenshots and clips come from the iOS Simulator
|
||||
(`ios-sim`), an Android emulator or device (`android-emu`) or a folder you fill with any tool; only the rendering of
|
||||
the store images uses a headless browser. The store commands do not care about the engine at all: they work on
|
||||
your AAB / build numbers and listing files.
|
||||
|
||||
**Is my store account safe?** storebound uses the official APIs with keys you create and can revoke, never your
|
||||
password. Store writes are dry runs until you say otherwise, and the public steps (submit, release) stay with you.
|
||||
Use an App Store Connect key with the App Manager role and a Play service account limited to your app.
|
||||
|
||||
**Why Bun?** One fast runtime with TypeScript, `fetch`, a test runner and a static server built in: no build step and
|
||||
zero runtime dependencies. It needs Bun installed (it does not run on plain Node).
|
||||
|
||||
**Does it upload my video to YouTube?** No. Play only takes a YouTube URL, and uploading to YouTube needs your Google
|
||||
account; `shots video` makes the file, you upload it and paste the URL into `play.video`.
|
||||
|
||||
**Can it run store experiments for me?** On the App Store, mostly: `apple ppo create / upload` builds a Product Page
|
||||
Optimization test from your `shots` variants (treatments, locales, screenshots) and you start it (`apple ppo start`
|
||||
asks first). Results per treatment are only shown in App Store Connect. Play store listing experiments have no API:
|
||||
storebound renders and checks the variant images, you set the experiment up in Play Console.
|
||||
|
||||
**Does it answer reviews by itself?** No. It drafts one file per review, your agent helps write the replies, and only
|
||||
the drafts you mark `approved: true` are sent, by you, after a confirmation. Replies that offer something for a rating
|
||||
are refused.
|
||||
|
||||
---
|
||||
|
||||
[All docs](README.md) · [README](../README.md)
|
||||
114
docs/getting-started.md
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# Getting started
|
||||
|
||||
## 5-minute quick start
|
||||
|
||||
You need [Bun](https://bun.sh) 1.1+ (and `ffmpeg` for icons, screenshots and video).
|
||||
|
||||
```sh
|
||||
cd my-app
|
||||
bun add -d storebound playwright # or: bun add -d git+https://git.crt.fyi/CRT/storebound.git playwright
|
||||
bunx storebound init # --profile app (default) | subscription-app | game: config, store/*.md, declarations, privacy policy, .storebound/ (gitignored)
|
||||
# fill in storebound.config.ts and the store/ files
|
||||
bunx storebound check # offline: config, copy limits, Data safety answers, products, files
|
||||
```
|
||||
|
||||
Add credentials (below), then walk the journey with dry runs first:
|
||||
|
||||
```sh
|
||||
bunx storebound apple bundle --dry
|
||||
bunx storebound play listing --dry
|
||||
bunx storebound play products --dry && bunx storebound apple iap --dry
|
||||
bunx storebound apple subscriptions --dry && bunx storebound play subscriptions --dry
|
||||
bunx storebound apple review-details --dry # contact + demo account from .storebound/ (masked) + notes
|
||||
bunx storebound play data-safety --dry --csv store/data-safety.csv
|
||||
bunx storebound apple appinfo --dry
|
||||
bunx storebound icons --dry
|
||||
bunx storebound shots capture && bunx storebound shots render && bunx storebound shots gallery
|
||||
bunx storebound validate # read-only: what still blocks a submission, per store, in fix order
|
||||
bunx storebound catalog diff # read-only: products + subscriptions on both stores vs the config, prices per region
|
||||
```
|
||||
|
||||
After launch:
|
||||
|
||||
```sh
|
||||
bunx storebound apple testflight what-to-test --build 42 --dry # then distribute --build 42 --group Team
|
||||
bunx storebound health --weekly # vitals + diagnostics, .storebound/health/<date>.md
|
||||
bunx storebound reviews draft # reply drafts in .storebound/replies/, then reviews send
|
||||
bunx storebound prices export && bunx storebound prices import --dry
|
||||
bunx storebound analytics snapshot && bunx storebound analytics compare
|
||||
```
|
||||
|
||||
Or ask your agent: "take my app to Google Play and the App Store", "fill in the store questionnaires", "set up the
|
||||
in-app purchases and a yearly subscription with a free trial on both stores", "do both stores sell the same
|
||||
catalog", "the reviewer could not sign in", "make store screenshots in 5 languages", "is my app ready to submit", "why was my
|
||||
update rejected", "put build 42 on TestFlight", "how are our crash rates", "answer the new reviews", "did the new
|
||||
screenshots help". The matching skill takes over.
|
||||
|
||||
## Coming from fastlane
|
||||
|
||||
`storebound import fastlane` turns an existing fastlane setup into storebound files. It only reads (no Ruby is run),
|
||||
never overwrites anything and prints what it could not map:
|
||||
|
||||
```sh
|
||||
bunx storebound import fastlane --dry # the plan + report; default --dir: fastlane/, ios/fastlane/, android/fastlane/
|
||||
bunx storebound import fastlane # writes the files below; an existing file gets a <name>.imported.<ext> copy
|
||||
bunx storebound init # adds the remaining templates, keeps the imported files
|
||||
bunx storebound check
|
||||
```
|
||||
|
||||
| fastlane | storebound |
|
||||
|---|---|
|
||||
| Appfile `app_identifier`, `package_name` | `apple.bundleId`, `play.packageName` |
|
||||
| deliver `metadata/<locale>/*.txt` (+ `default/`) | `store/appstore.md` per App Store locale; URLs, copyright, categories and review notes in the config |
|
||||
| supply `metadata/android/<lang>/*.txt`, newest `changelogs/<versionCode>.txt` | `store/listing.md` per Play language |
|
||||
| deliver and supply language folders | `languages[]` with both store codes (`tr` + `tr-TR`, `zh-Hans` + `zh-CN`, `he` + `iw-IL`) |
|
||||
| `screenshots/<locale>/`, `images/phoneScreenshots/`, `tenInchScreenshots/` | `store/shots/frames/<lang>/<phone\|tablet>/NN.png` with a `folder` capture source |
|
||||
| supply `icon`, `featureGraphic` | `play.icon`, `store/renders/<lang>/feature/1024x500.*` |
|
||||
|
||||
Keys and accounts are never copied: `json_key_file`, the API key, team ids, the App Review contact and demo account are
|
||||
named in the report with where they go (`.storebound/credentials.json`). Submission and release flags, `price_tier`,
|
||||
the age rating config, lanes and signing (`match`) are listed with their storebound counterpart. The
|
||||
`migrate-from-fastlane` skill walks an agent through it.
|
||||
|
||||
## Credentials (never in the repo)
|
||||
|
||||
Read from environment variables, or from `.storebound/credentials.json` in your project (gitignored by `init`):
|
||||
|
||||
| name | what |
|
||||
|---|---|
|
||||
| `PLAY_SERVICE_ACCOUNT` | path to the Google Cloud service account JSON key (or the JSON itself) with access to your app in Play Console |
|
||||
| `ASC_KEY_ID`, `ASC_ISSUER_ID` | App Store Connect API team key (Users and Access > Integrations) |
|
||||
| `ASC_P8` | path to `AuthKey_<id>.p8` (default `~/.appstoreconnect/private_keys/AuthKey_<id>.p8`) |
|
||||
| `ASC_VENDOR_NUMBER` | vendor number for `reports apple-sales` / `apple-finance` (Payments and Financial Reports) |
|
||||
| `ANDROID_KEYSTORE`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` | upload key for `play build` (path to the .jks / .p12 and its passwords; handed to Gradle through the environment) |
|
||||
| `reviewContact` | (file only) App Review contact name / phone / email |
|
||||
| `reviewDemoAccount` / `REVIEW_DEMO_USER`, `REVIEW_DEMO_PASSWORD` | demo account for App Review (`reviewAccess.signInRequired`); never in the config or the repo, masked in logs |
|
||||
|
||||
Several store accounts on one machine: credential profiles (`~/.storebound/credentials/<profile>.json`, the same names
|
||||
except the per-app upload keystore)
|
||||
picked with `--account <profile>` or by a workspace ([Many apps and studios](studios.md#many-apps-and-studios)).
|
||||
|
||||
|
||||
## Install for your AI tool
|
||||
|
||||
Every route gives the agent the same skills and the same MCP tools. Nothing here requires Claude: the skills are
|
||||
plain Markdown and the CLI is a normal command.
|
||||
|
||||
| tool | install |
|
||||
|---|---|
|
||||
| Claude Code | `/plugin marketplace add https://git.crt.fyi/CRT/storebound.git` then `/plugin install storebound@crtkafa` (skills, MCP server, `storebound` on PATH). Or per project: `bunx storebound install --agent claude` |
|
||||
| OpenAI Codex | `bunx storebound install --agent codex` (skills in `.agents/skills`, MCP in `.codex/config.toml`; Codex reads that file for trusted projects). The repo is also a Codex plugin (`.codex-plugin/plugin.json`, skills only) |
|
||||
| Gemini CLI | `gemini extensions install https://git.crt.fyi/CRT/storebound.git` (skills, MCP server, `GEMINI.md`). Or `bunx storebound install --agent gemini` |
|
||||
| Cursor | `bunx storebound install --agent cursor` (skills in `.agents/skills`, `.cursor/mcp.json`); the repo's `.cursor/rules/storebound.mdc` is an agent-requested rule you can copy |
|
||||
| VS Code / GitHub Copilot | `bunx storebound install --agent copilot` (skills in `.github/skills`, `.vscode/mcp.json`) |
|
||||
| Windsurf (Devin Desktop) | `bunx storebound install --agent windsurf` (skills in `.agents/skills`, MCP in your global `mcp_config.json`) |
|
||||
| OpenCode | `bunx storebound install --agent opencode` (skills in `.opencode/skills`, `opencode.json`) |
|
||||
| anything that reads `AGENTS.md` | copy or link `AGENTS.md` (rules + skill index) and use the CLI |
|
||||
|
||||
`install` takes several agents at once (`--agent codex,cursor`), `--dry` to preview, `--no-skills` / `--no-mcp`,
|
||||
and `--command "<cmd>"` to choose how the MCP server starts. It only adds or updates the `storebound` entry and its
|
||||
own skill folders; everything else in those files stays as it is, and re-running it is safe.
|
||||
|
||||
---
|
||||
|
||||
[All docs](README.md) · [README](../README.md)
|
||||
77
docs/growth.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# Growth extras and console forms
|
||||
|
||||
Optional commands for after (or around) the first release. Each has `--dry` and `--json`; store and API writes ask in
|
||||
a terminal first and are never offered over MCP.
|
||||
|
||||
- **Purchasing power prices:** `storebound prices ppp [--base 4.99] [--source worldbank|bigmac|file]` proposes a price
|
||||
per region from the bundled World Bank ICP PPP conversion factors (`cli/data/ppp-worldbank.json`, CC BY 4.0, dated
|
||||
and cited; refresh with `bun tools/update-ppp.ts`), clamped to `prices.ppp.floor`..`ceiling` of the US price and
|
||||
snapped to each store's price points. It writes a sheet in the `prices import` format (default
|
||||
`store/prices-ppp.csv`) and shows the diff; `storebound prices import --csv store/prices-ppp.csv --dry` applies it.
|
||||
Skill: `ppp-pricing`.
|
||||
- **Apple Ads:** `storebound ads apple status | campaigns | report [--by campaign|keyword] [--csv out.csv]`: spend,
|
||||
taps, installs, CPA, read-only (OAuth client credentials from env / `.storebound/`, never printed). `bid`, `budget`
|
||||
and `pause` print old -> new and ask first. Skill: `apple-ads`.
|
||||
- **Web presence:** `storebound web init [--landing]` writes `apple-app-site-association`,
|
||||
`.well-known/assetlinks.json` (Play app signing SHA-256), the Smart App Banner tag and an optional minimal landing
|
||||
page (store marks from simple-icons, privacy / support links from the config). `storebound web check <domain>`
|
||||
verifies the live files: 200 without redirects, JSON content type, valid JSON, ids match the config. Skill: `web-presence`.
|
||||
- **Ad-hoc and internal sharing:** `storebound apple adhoc devices | register | profile | options | export` (UDIDs
|
||||
masked; registering asks first) and `storebound play share --aab app.aab` (Play internal app sharing; asks first,
|
||||
prints the install link). Skill: `adhoc-distribution`.
|
||||
- **RevenueCat (optional):** `storebound revenuecat diff | sync` mirrors products, subscriptions, entitlements and a
|
||||
default offering into a RevenueCat project (REST API v2, secret key from env / `.storebound/`). sync only creates and
|
||||
attaches, never deletes. Runtime entitlements, receipts and paywalls stay RevenueCat's job. Skill: `revenuecat-sync`.
|
||||
- **Console forms without an API, agent-first:** `storebound console plan <form> [--json]` gives your AI agent the
|
||||
start URL (deep links when the ids are known), the steps, the answer sheet from the config (each answer certain or
|
||||
"ask owner", with the console's exact labels), the buttons it must never press and the UI facts verified on the real
|
||||
consoles; the agent drives the page with its own browser tool (Claude in Chrome, a built-in browser, Playwright),
|
||||
stops before the final button and tells you what to press. `storebound console verify <form> [--from-text -]` then
|
||||
checks the page against the config (App Privacy per-type summaries, Play Create app input states, summaries, or the
|
||||
App Store Connect API) and exits 1 on a mismatch. Both are read-only MCP tools (`console_plan`, `console_verify`).
|
||||
Besides the forms of `console fill`, plans cover the Play pages an agent drives alone: `play-store-settings` (the
|
||||
App category, which the Play API cannot set), `play-app-content` (every App content card in order with one batch
|
||||
of owner questions) and its cards (`play-privacy-policy`, `play-ads`, `play-sign-in-details`,
|
||||
`play-advertising-id`, `play-government-apps`, `play-financial-features`, `play-health-apps`), and the first
|
||||
production release (`play-production-countries`, `play-production-release`). Each plan ships a ready-made in-page
|
||||
helper (`inPageLoop`) for long flows.
|
||||
- **Scripted fast path:** `storebound console fill <form>` (`apple-app-record`,
|
||||
`apple-age-rating`, `apple-app-privacy`, `play-app-record`, `play-content-rating`, `play-target-audience`) opens a
|
||||
visible browser, goes to the form by itself (Apps > + > New App, the app's App Privacy, Play All apps > Create app,
|
||||
Play app list > App content > Start / Manage;
|
||||
navigation clicks only, never Save / Submit / Publish / Create / Next), fills what the config proves, highlights the
|
||||
rest and stops before the final button: you press it. **Sign-in:** storebound's own profile
|
||||
(one per store, `~/.storebound/console-profile/<store>`): you sign in there once, it is kept between runs
|
||||
(session restore on launch, graceful close; tick "Keep me signed in" / "Stay signed in"). storebound starts that
|
||||
Chrome itself as a normal window (no `--enable-automation`, `navigator.webdriver` false) so Google accepts the
|
||||
sign-in; if Google still answers `/signin/rejected` it says so. A Chrome running with remote debugging is used in
|
||||
place. **It never copies your Google cookies by default:** Google binds them to your device (Chrome's Device Bound
|
||||
Session Credentials) and signs out sessions whose cookies look copied, which can sign you out of your real Chrome.
|
||||
`--use-chrome-session google|apple` is an explicit opt-in (a temporary copy of only that store's cookies, deleted at
|
||||
the end) that prints this warning; see the console-autofill skill. It detects sign-in pages by URL, frame and text (Apple's starts with no password field),
|
||||
waits while you sign in and then opens the form from step 1; after printing manual steps it keeps watching and picks
|
||||
up where you are. **One login, many studios:** it finds the Play developer account (by `play.packageName`, or
|
||||
`play.developerId`) and the App Store Connect team (account menu; `apple.teamId`, or by bundle id) that has the
|
||||
app and remembers it in `~/.storebound/console-accounts.json` (ids only).
|
||||
**Play Create app** (`play-app-record`, the Play Developer API cannot create apps): the developer account comes from
|
||||
`play.developerId`, the workspace account, or `play.developerAccount` (its name on "Choose a developer account");
|
||||
with several accounts and none set it stops and lists them. It fills App name (`play.name` or `app.name`, 30
|
||||
characters), Default language, App or game (the profile) and Free or paid (`play.free`, else Free for IAP / ads:
|
||||
an owner check, a free app can never become paid). It never ticks the Developer Program Policies or US export laws
|
||||
declarations and never presses the form's Create app: you do. With `play.developerId` it opens the form's own URL
|
||||
(`/console/u/0/developers/<id>/create-new-app`); it fills App package name from `play.packageName` and presses the
|
||||
read-only "Check availability", and clicks the inner `[role=radio]` of Play's material radios (the native input
|
||||
does not update the form). After Create app, grant your service account access to the new app (Users and
|
||||
permissions > the service account > Add app; a permission grant, so it is yours): app-level permissions do not
|
||||
cover apps created later. When a page has changed, your agent drives that part with its browser tool from
|
||||
`storebound console plan`. **Console pages change; this is AI-assisted: review every field before you press the
|
||||
final button.** Desktop: "Fill in
|
||||
console" on the App info tab. Never over MCP. Skill: `console-autofill`.
|
||||
|
||||
MCP gets only the safe parts: `prices_ppp`, `web_init`, `apple_adhoc_options` (local files, dry run by default) and
|
||||
the reads `ads_apple_status`, `ads_apple_campaigns`, `ads_apple_report`, `web_check`, `apple_adhoc_devices`,
|
||||
`revenuecat_diff`.
|
||||
|
||||
---
|
||||
|
||||
[All docs](README.md) · [README](../README.md)
|
||||
101
docs/journey.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# The journey: from an empty folder to Submit for review
|
||||
|
||||
## Why storebound
|
||||
|
||||
Hey, I'm CRTkafa. I built this while getting my own game through both stores and hit every wall on the way: draft
|
||||
apps, edits that die when you touch the Console, the "first IAP must ship with a version" dance, Data safety CSVs that
|
||||
silently need one more answer, an IARC questionnaire, target audience, App Privacy labels that are saved but not
|
||||
published, screenshots in 7 sizes x 14 languages... I wrote each fix down as code or as a checklist. None of those
|
||||
walls is game-only: a to-do app with accounts and a subscription hits the same ones, plus a few of its own (a demo
|
||||
account for the reviewer, account deletion, subscription disclosures), so storebound covers apps and games alike. It
|
||||
works for me, so I open-sourced it. Use it too.
|
||||
|
||||
No account, no sign-up, no telemetry. A small Bun CLI that talks to the official store APIs,
|
||||
Agent Skills that walk your AI coding tool (or you) through the parts that are still console clicks, and an MCP server
|
||||
so agents can call the CLI safely. **storebound never presses Submit, Release or Publish for you.**
|
||||
|
||||
## How agents use storebound
|
||||
|
||||
**Do it, don't ask.** Your AI agent and storebound do the work: API calls, uploads, builds, CSV and template
|
||||
exports, every console page (open, Get Started, Edit, Next, Save draft), console forms and the check afterwards. You
|
||||
are not asked to click through consoles or run commands one by one.
|
||||
|
||||
- **You press only the final public or irreversible buttons:** Submit for review, releasing a version or a production
|
||||
rollout, App Privacy Publish, legal agreements; plus deleting store data, changing live prices, sending review
|
||||
replies and inviting or removing testers. The agent prepares each one and shows you the dry run first.
|
||||
- **You answer only what no file can prove** (Data safety, App Privacy, age rating, export compliance, partial
|
||||
deletion): all of it in one batch at the start (`storebound check` / `validate` list the questions), saved in the
|
||||
config so nobody asks again.
|
||||
- **The loop:** plan, the agent drives (API first, its browser for consoles without an API), verify by reading back,
|
||||
continue. `check` and `--dry` catch what a store would refuse before anything is written.
|
||||
|
||||
The full rules are in [AGENTS.md](../AGENTS.md) (generated from `docs/agents-core.md`).
|
||||
|
||||
## Profiles and after launch
|
||||
|
||||
Pick a profile and start from honest templates: `init --profile app` (utility / productivity app, optional account,
|
||||
analytics, no ads; the default), `--profile subscription-app` (accounts + auto-renewable subscriptions) or
|
||||
`--profile game` (free-to-play with ads and IAP).
|
||||
|
||||
It keeps going after launch: TestFlight groups and testers, crash and ANR vitals against the store thresholds, review
|
||||
replies you approve before they go out, prices for many products and regions from one CSV, store analytics next to
|
||||
your listing changes, and App Store product page tests from your screenshot variants.
|
||||
|
||||

|
||||
|
||||
## The journey
|
||||
|
||||
Everything an app or game needs before review, in the order I do it. For each step: what storebound does through the store
|
||||
APIs (or on your machine), what the skill walks your agent through where the stores have no public API, and what stays
|
||||
your own click.
|
||||
|
||||
- **storebound**: a CLI command (also an MCP tool). Every write has `--dry`; MCP write tools default to a dry run.
|
||||
- **skill + agent (UI)**: no public API exists for it. The named skill has the exact navigation and the prepared
|
||||
answers. An agent with browser tools (a Chrome extension, computer use, a Playwright MCP ...) can click through it
|
||||
while you watch; without one, you click with the skill as your checklist.
|
||||
- **you**: public, legal or money steps. The skills tell every agent to stop and hand these to you.
|
||||
|
||||
| # | step | storebound (API / local) | skill + agent (UI) | you |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **Set up** | `init --profile app\|game\|subscription-app` (config, `store/*.md`, declarations, App Privacy and privacy templates per profile), `install --agent <tool>`, `check` | Play: service account + API access invite; App Store: API team key (`play-first-release`, `appstore-first-release`) | developer accounts; Paid Applications agreement, banking and tax; Play payments profile |
|
||||
| 2 | **App records and bundle id** | `apple bundle` registers the bundle id, `apple status` finds the app record | App Store Connect > New App (no API); Play Console > Create app; the very first AAB upload when Play needs it from the Console | the declarations you accept when creating the Play app |
|
||||
| 3 | **Builds and testers** | `play next-version-code`, `play build`, `play upload --track internal`, `apple signing ensure`, `apple build`, `apple upload` (waits for processing), `apple encryption`, `apple testflight groups / what-to-test / info / distribute` (internal groups) | Play internal testers list, Apple sandbox testers (`play-first-release`, `testflight`) | `apple testflight testers` (Apple emails the invitations), external groups and Beta App Review (`distribute --submit-review`) |
|
||||
| 4 | **In-app products and subscriptions** | `play products` (regional prices from USD + your overrides, activated), `apple iap` (consumable, non-consumable, non-renewing; localizations, price schedule, availability), `apple iap-review`, `apple subscriptions` (groups, levels, equalized prices, intro and promotional offers, offer codes), `play subscriptions` (base plans, regional prices, offers), `catalog diff` (both stores against one catalog) | Play license testers, Apple sandbox testers, "Add for Review" of the first IAPs and subscriptions into the version's submission, Play promo codes (`store-iap-setup`, `store-subscriptions`) | custom offer codes (`apple subscriptions --codes` asks first) |
|
||||
| 5 | **Store listings, every language** | `play listing` (title, short / full description, contact email + website, promo video URL), `apple listing --version x --build N` (name, subtitle, description, keywords, promo text, URLs, privacy URL, manual release), `listing translate` (drafts per language), `whats-new`, `check` (limits), `check --aso` | Play store settings: category and tags (`play-first-release`) | |
|
||||
| 6 | **Play questionnaires** | `play data-safety`: the whole Data safety form from `dataSafety` in the config (CSV import through the API) | Policy and programs > App content, answers prepared in `store/declarations.md` (`store-declarations`): privacy policy, app access with the demo account (`review-access`), delete-account URL (`account-deletion`), ads, content rating (IARC), target audience and content, advertising ID, news, government, financial and health features; Data safety summary check | the answers themselves are your statements: the agent asks when the code does not prove them |
|
||||
| 7 | **App Store questionnaires** | `apple appinfo` (categories, age rating declaration, content rights, Free price, availability), `apple encryption` (export compliance from `apple.usesNonExemptEncryption`, never guessed), `apple review-details` (contact, demo account from `.storebound/`, notes) | App Privacy nutrition labels from `store/app-privacy.md`, then **Publish** (`store-declarations`); encryption documentation when the answer is yes (`export-compliance`); the first availability record if the API refuses it | the export compliance answer (a legal statement, set once in the config) |
|
||||
| 8 | **Privacy and accounts** | privacy policy page template (`init`), `privacy-manifest --ios <path>` (PrivacyInfo.xcprivacy, required-reason APIs, SDK manifests), `validate` checks the policy, delete-account and terms URLs answer | host the pages, link them in both consoles and inside the app; in-app account deletion (`privacy-policy`, `privacy-manifest`, `account-deletion`) | |
|
||||
| 9 | **Icons** | `icons`: App Store 1024 without alpha, Play 512, adaptive + legacy Android launcher icons, web icons, Capacitor sources | | |
|
||||
| 10 | **Screenshots and previews** | `shots capture / render / check / gallery`, `play images`, `apple screenshots`, `shots record / video`, `apple previews`, `apple ppo create / upload` (App Store product page tests from `shots` variants; see [Store art](store-art.md)) | Play store listing experiments (optional, `store-screenshots`) | uploading the promo video to YouTube; `apple ppo start / stop` |
|
||||
| 11 | **Validate** | `scan` (offline: rejection risks in the project files, see [Catch rejections before you submit](rejections.md)), `check` (offline) and `validate` (read-only on both stores: every blocker in fix order, plus the local renders) | the UI-only items `validate` cannot see (`store-preflight` lists them) | |
|
||||
| 12 | **Submit** | `play release --version-code N --production draft`, `apple status` | Play: countries / regions, open the production draft and save it (`play-first-release`) | **Play: Send changes for review. App Store: Submit for Review.** |
|
||||
| 13 | **After review** | `apple status --wait-review`, `apple release --phased on / pause`, `play rollout --halt`, reply drafts (`review-rejections`) | | releasing the approved version, resuming or completing a phased release or rollout (terminal commands that ask first, `release-management`) |
|
||||
| 14 | **Live app** | `health [--weekly]` (crash / ANR vitals vs Play's thresholds, App Store diagnostics; also in `validate`), `reviews list / draft`, `analytics snapshot / compare`, `prices export`, `prices import --dry`, `apple ppo status` | `store-health`, `review-replies`, `store-analytics`; Play statistics come from the reports bucket (no API) | `reviews send` (approved drafts only), `prices import` (asks first) |
|
||||
|
||||
New personal Play developer accounts also need a closed test with 12+ testers for 14 days before production; the
|
||||
`play-first-release` skill covers it.
|
||||
|
||||
## API or console, honestly
|
||||
|
||||
- **Through the API** (storebound does it): bundle id, builds on tracks, versions and build selection, listings in
|
||||
every language, graphics, screenshots, app previews, in-app products and prices (also from one CSV), Play Data
|
||||
safety, Apple age rating, categories, content rights, price and availability, export compliance answer, App Review
|
||||
details with the demo account, subscriptions (Apple groups, levels, prices, introductory and promotional offers,
|
||||
offer codes; Play base plans, regional prices, offers), phased release and staged rollout, TestFlight groups / testers / What to Test / Test Information / build
|
||||
processing / expiring builds, crash and ANR vitals and App Store diagnostics, TestFlight feedback, review replies,
|
||||
App Store analytics reports, sales and finance reports, App Store Product Page Optimization tests, IAP and
|
||||
subscription review submissions, offer codes for one-time purchases, win-back offers, promoted IAPs, pre-orders,
|
||||
custom product pages and in-app events, and pulling live listing texts back.
|
||||
- **Console only** (no public API; the skill + your agent's browser, or you): creating the App Store Connect app record
|
||||
and the Play app, Play App content (IARC content rating, target audience, ads, advertising ID, app access,
|
||||
government / financial / health / news, app access with the demo account), Play category and tags, Play countries,
|
||||
App Privacy labels and their Publish, Play promo codes, Play pre-registration, Play store listing experiments, test results of both stores' experiments, promotional images of promoted IAPs.
|
||||
Play store statistics have no API either: storebound reads the monthly exports from your Play reports bucket.
|
||||
- **Only you**: agreements, banking and tax, and the final Send for review / Submit for Review (apps, IAPs, custom
|
||||
product pages, in-app events). Releases and rollouts after approval, Beta App Review, tester invitations, sending
|
||||
review replies, applying price changes, starting / stopping a product page test, creating offer codes, win-back
|
||||
offers, switching on promoted IAPs, pre-orders and expiring builds too. None of these is an MCP tool; the terminal commands ask first.
|
||||
|
||||
---
|
||||
|
||||
[All docs](README.md) · [README](../README.md)
|
||||
16
docs/listing-languages.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Listing in more languages
|
||||
|
||||
`storebound listing translate --to de,ja` adds a section per language to `store/appstore.md` (name, subtitle,
|
||||
promotional text, keywords, description) and `store/listing.md` (title, short and full description) with the source
|
||||
text and a marker, `<!-- storebound-translation from=en-US status=translate -->`. Your agent translates in place
|
||||
(`listing-localization` skill: native wording, researched keywords, the store limits) and sets `status=draft`; or
|
||||
`--translate "<command>"` runs a command of yours once per field (source text on stdin, `STOREBOUND_TARGET_LANG`,
|
||||
`STOREBOUND_FIELD`, `STOREBOUND_LIMIT` in the environment). storebound itself calls no translation service.
|
||||
`listing check` enforces each store's limits (App Store subtitle 30, promotional text 170, keywords 100 without
|
||||
duplicates or name words, description 4000; Play title 30, short 80, full 4000) and lists drafts; `storebound check`
|
||||
warns while one is left. After a human read a draft, `listing reviewed --lang de` removes the marker. Reviewed sections
|
||||
are never overwritten without `--force`. MCP: `listing_translate`, `listing_check`.
|
||||
|
||||
---
|
||||
|
||||
[All docs](README.md) · [README](../README.md)
|
||||
47
docs/mcp.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# MCP server and the desktop machine interface
|
||||
|
||||
`storebound mcp` speaks MCP over stdio, tools only: spec revision **2026-07-28** (stateless: `server/discover`,
|
||||
per-request `_meta`, `resultType`, cacheable `tools/list`) and, on the same process, clients that still open with
|
||||
`initialize` (2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05). It runs each tool as a CLI call in the project directory
|
||||
(the server's working directory, or `STOREBOUND_PROJECT`) and returns the output.
|
||||
|
||||
- One tool per command. Read-only: `scan`, `check`, `check_aso`, `aso_competitors` (no snapshot saved), `aso_suggest` (never writes), `aso_audit`, `validate`, `catalog_diff`, `health`, `reviews_list`, `prices_diff`,
|
||||
`analytics_compare`, `apple_status`, `apple_next_build`, `apple_testflight_status`, `apple_ppo_status`,
|
||||
`play_status`, `play_next_version_code`, `privacy_manifest`, `shots_check`, `workspace_list`, `workspace_status`,
|
||||
`release_status`, `release_plan`, `flow_list`, `flow_run` (dry mode only), `listing_check`, `api_get` (any App Store
|
||||
Connect or Google Play GET operation by id). Local writes: `import_fastlane`, `whats_new`, `listing_translate`, `icons`,
|
||||
`health_report`, `reviews_draft`, `prices_export`, `analytics_snapshot`, `shots_capture`, `shots_render`,
|
||||
`shots_gallery`, `shots_record`, `shots_video`. Store writes: `play_listing`, `play_images`, `play_products`,
|
||||
`play_data_safety`, `play_subscriptions`, `play_release_testing`, `apple_bundle`, `apple_iap`, `apple_iap_review`,
|
||||
`apple_subscriptions`, `apple_listing`,
|
||||
`apple_screenshots`, `apple_previews`, `apple_appinfo`, `apple_review_details`, `apple_encryption`,
|
||||
`apple_phased_release`, `apple_testflight_groups`, `apple_testflight_what_to_test`, `apple_testflight_info`,
|
||||
`apple_testflight_distribute` (internal groups only), `apple_ppo_create`, `apple_ppo_upload`, `analytics_setup`.
|
||||
- Annotations say what is read-only, destructive (replaces data), idempotent and network-bound.
|
||||
- No MCP SDK: a small newline-delimited JSON-RPC server (server/discover, initialize, ping, tools/list, tools/call,
|
||||
cancellation), so storebound keeps zero runtime dependencies.
|
||||
|
||||
Manual config for a tool that `install` does not know: command `bunx` with args `["storebound", "mcp"]` (or the
|
||||
absolute path of `bin/storebound` plus `mcp`).
|
||||
|
||||
## Desktop machine interface
|
||||
|
||||
`storebound serve --stdio` is what the desktop app (`apps/desktop`, in progress) runs as its sidecar: newline-delimited
|
||||
JSON-RPC on the same core as the MCP server (tool list, validation, forbidden flags, child-process runner), with typed
|
||||
methods (`check`, `validate`, `apple.status`, `play.listing`, ... plus `project.inspect` and `portfolio.status` over
|
||||
many project folders), structured results (the commands' `--json` output), progress notifications and cancellation.
|
||||
The first request is `session.hello` with a per-session secret only the host knows. Write methods are dry runs unless
|
||||
the call carries a single-use confirm token (HMAC over method, arguments and expiry) that the host mints after the
|
||||
owner confirmed a native dialog showing that dry run. Sending for review is there only as **owner actions**
|
||||
(`owner.apple_submit`, `owner.apple_resubmit`, `owner.apple_iap_submit`, `owner.play_production`, next to the rollout,
|
||||
phased release, price and reply owner actions): the window shows the plan (dry run) with the build or versionCode the
|
||||
owner picked by number, then a native dialog, and only the host's token after that dialog runs it. Nothing publishes,
|
||||
releases an approved version or cancels a submission, and the MCP server (AI tools) has none of the owner actions.
|
||||
Play's "Send changes for review" has no API: the Releases tab shows its console plan and a deep link. Types:
|
||||
`cli/lib/desktop-protocol.ts`.
|
||||
`bun tools/build-sidecars.ts` compiles the CLI for macOS, Linux and Windows (`bun build --compile`). Design, safety
|
||||
model and the Dependencies screen (tool sources and licenses): [docs/desktop.md](desktop.md).
|
||||
|
||||
---
|
||||
|
||||
[All docs](README.md) · [README](../README.md)
|
||||
99
docs/project.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# Roadmap, contributing and credits
|
||||
|
||||
## Roadmap
|
||||
|
||||
Wave 2 (TestFlight, vitals, review replies, price sheets, analytics, MCP 2026-07-28, Product Page Optimization) shipped
|
||||
in 0.4.0. 0.5.0 made storebound app-first (profiles, app screenshot style, account deletion, review access) and
|
||||
brought the full in-app catalog: subscriptions on both stores with offers and offer codes, non-renewing products and
|
||||
the cross-store `catalog diff`. Next, roughly in this order:
|
||||
|
||||
- Play prepaid base plans for `non_renewing`.
|
||||
- The App Asset Library (API 4.5.1) for screenshots, custom product pages and in-app events once the screenshot sets
|
||||
it replaces stop working.
|
||||
- Play store listing experiments and their results, as soon as Google offers an API.
|
||||
- App preview treatments and alternate icons in Product Page Optimization tests.
|
||||
- The iPhone Duo screenshot classes Apple lists for apps built with the iOS 27.1 SDK (required from April 2027).
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests are welcome: store rule updates (with a source), new capture sources, adapters for more AI
|
||||
tools. See [CONTRIBUTING.md](../CONTRIBUTING.md); `bun run ci` must be green.
|
||||
|
||||
<details>
|
||||
<summary><b>Agent support: formats and what is not verified</b></summary>
|
||||
|
||||
Checked against each tool's own docs in October 2026:
|
||||
|
||||
- **Agent Skills**: frontmatter is only `name` (lowercase-hyphen, equals the folder) and `description`; the generator
|
||||
fails if a skill adds anything else.
|
||||
- **Claude Code**: plugin manifest + root `.mcp.json` with `${CLAUDE_PLUGIN_ROOT}`; `claude plugin validate .` passes.
|
||||
- **Codex**: `.codex-plugin/plugin.json` with `"skills": "./skills/"`. Not verified: whether Codex starts the root
|
||||
`.mcp.json` from a plugin and expands `${CLAUDE_PLUGIN_ROOT}` there, so use `install --agent codex` for the MCP
|
||||
server. OpenAI also documents a newer portable root `plugin.json`; not shipped yet.
|
||||
- **Gemini CLI**: `gemini-extension.json` with `${extensionPath}${/}`, `GEMINI.md` as context, `skills/` inside the
|
||||
extension. Google now points unpaid users to Antigravity CLI; not tested there. The `.gemini/skills` folder used by
|
||||
`install --agent gemini` is not verified (the extension route is).
|
||||
- **Windsurf**: the current docs name `~/.config/devin/mcp_config.json`; `install` keeps using
|
||||
`~/.codeium/windsurf/mcp_config.json` when that file already exists (older installs).
|
||||
- **MCP**: built for 2026-07-28 (final, modelcontextprotocol.io) and dual-era: `initialize` clients get 2025-11-25
|
||||
(or 2025-06-18 / 2025-03-26 / 2024-11-05). Tested with both handshakes on one stdio process, not yet against every
|
||||
client listed above.
|
||||
- **Not verified against a live account yet** (no credentials during development; request builders are tested with
|
||||
fixtures from the official docs): the scale of the Play Reporting API rates (read as fractions, 0.0109 = 1.09 %),
|
||||
the App Store review reply limit (5970 characters is commonly reported; Apple documents none, storebound only
|
||||
warns), the TestFlight "What to Test" limit (4000 assumed), whether an analytics segment checksum covers the gzip
|
||||
file or its content (both are accepted), that a new App Store price schedule replaces the old one (Apple's example
|
||||
implies it), stopping a Product Page Optimization test with `started: false`, and `APP_IPHONE_61` for the 6.3"
|
||||
iPhone set. From 0.5.0 (request bodies follow Apple's and Google's published schemas, tested with fixtures, not
|
||||
yet run against a live account): free promotional offers and offer codes sent with territories but no price point,
|
||||
subscription prices and introductory offers created inline through `PATCH /v1/subscriptions/{id}`, Play
|
||||
pay-as-you-go offers as a `relativeDiscount`, Play "developer determined" offers as offers without targeting plus an
|
||||
offer tag, the ISO 8601 strings for Play billing periods (Google lists them only in words), the Play subscription
|
||||
benefit length (40, from Play Console), the Data safety sign-up method ids (`PSL_ACM_*`, Google publishes none:
|
||||
compare with a Console export) and the data deletion question ids (`PSL_ACCOUNT_DELETION_URL` is confirmed by a
|
||||
Play 400; `PSL_SUPPORT_DATA_DELETION_BY_USER` with its three options and `PSL_DATA_DELETION_URL` are in Google's
|
||||
empty form exported 2026-10-12, `cli/data/play-data-safety-form.csv`, which every generated pair is checked
|
||||
against). From the store
|
||||
operations (built from the App Store Connect API 4.5.1 OpenAPI spec and the cited Help pages, tested with recorded
|
||||
answers, not yet run live): the first IAP of a type sent through `reviewSubmissionItems` next to the app version
|
||||
(`--no-submit` leaves the draft for the UI if Apple refuses it), free IAP offer code and win-back prices without a
|
||||
price point, the order request for promoted purchases, the Play earnings / sales zip names beyond the documented
|
||||
prefix (storebound lists the prefix), and where Xcode stores `AttributionCopyEndpoint` (top level or inside an
|
||||
`AdAttributionKit` dictionary: both are read).
|
||||
- **Apple IAP description**: 45 characters (App Store Connect Help, "In-app purchase information"); an older App Store
|
||||
marketing page says 55. storebound checks 45.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Develop</b></summary>
|
||||
|
||||
```sh
|
||||
bun install
|
||||
bun run ci # adapters fresh (gen-adapters --check), tests, typecheck, secrets / personal data scrub
|
||||
bun tools/gen-adapters.ts # after editing skills/*/SKILL.md or docs/agents-core.md
|
||||
```
|
||||
|
||||
`skills/*/SKILL.md` and `docs/agents-core.md` are the single source; `AGENTS.md`, `GEMINI.md`, the plugin manifests,
|
||||
`.github/copilot-instructions.md` and `.cursor/rules/storebound.mdc` are generated and committed, and CI fails when
|
||||
they are stale.
|
||||
|
||||
</details>
|
||||
|
||||
## Thanks and idea sources
|
||||
|
||||
Some skills were sparked by other people's open work. I wrote my own text; the ideas deserve the credit:
|
||||
|
||||
- [rorkai/app-store-connect-cli-skills](https://github.com/rorkai/app-store-connect-cli-skills) (MIT): App Store Connect
|
||||
workflow ideas.
|
||||
- [rshankras/claude-code-apple-skills](https://github.com/rshankras/claude-code-apple-skills) (MIT): Apple platform and
|
||||
App Store topic ideas.
|
||||
|
||||
Built with Bun and plain `fetch` against the Google Play Developer API v3, the Play Developer Reporting API and the App
|
||||
Store Connect API. Screenshot
|
||||
fonts come from Google Fonts at render time (Noto, Paytone One, Montserrat, Inter; SIL Open Font License 1.1). Not affiliated
|
||||
with Google or Apple.
|
||||
|
||||
---
|
||||
|
||||
[All docs](README.md) · [README](../README.md)
|
||||
102
docs/rejections.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# Catch rejections before you submit
|
||||
|
||||
App Store rejections cost days: you wait for review, read one sentence in the Resolution Center, fix it, and wait
|
||||
again. On Google Play a policy problem can mean a blocked update, a removal or a strike on the account. Most of the
|
||||
usual ones are visible in the project long before that. `storebound scan` reads the project itself, offline and
|
||||
read-only (Xcode project, Info.plist and build settings, entitlements, PrivacyInfo.xcprivacy, Podfile.lock,
|
||||
Package.resolved, Cartfile, Gradle files and version catalogs, AndroidManifest.xml (and the merged manifest of a local
|
||||
build), res/xml and strings.xml, CMake / ndk-build files, Unity ProjectVersion.txt, package.json,
|
||||
Expo app.json, capacitor.config, Cordova config.xml, pubspec, Unity Packages/manifest.json, ProjectSettings and
|
||||
External Dependency Manager files, Godot export presets), plus your config and listing files. It works out the stack
|
||||
and the SDKs (ads, analytics, attribution, crash, sign-in, payments, push, with versions and the file:line they come
|
||||
from) and reports every rejection risk it can prove, each with the guideline, the evidence, the fix, a severity and the
|
||||
skill to open. On the teaser fixture (`tests/fixtures/scan/teaser`):
|
||||
|
||||
```text
|
||||
$ storebound scan
|
||||
Rejection risks: 2 blocker, 3 high, 0 medium, 0 low
|
||||
1. BLOCKER 2.1(a) · App Store · Sign-in is required but there is no demo account for App Review
|
||||
at storebound.config.ts:12 reviewAccess: { signInRequired: true, instructions: 'Sign in with the demo account.' },
|
||||
at storebound.config.ts:11 accounts: { enabled: true },
|
||||
at Podfile.lock:3 Google Sign-In: GoogleSignIn 7.1.0
|
||||
2. BLOCKER 5.1.1(v) · App Store · Accounts can be created but no in-app account deletion was found
|
||||
at storebound.config.ts:11 accounts: { enabled: true },
|
||||
at Planner/LoginView.swift:11 Button("Create account") { Task { try await AccountApi.signUp() } }
|
||||
at Planner/LoginView.swift:17 static func signUp() async throws {
|
||||
3. HIGH 4.8 · App Store · Third-party login (Google / Facebook / ...) without Sign in with Apple or another equivalent login option
|
||||
at Podfile.lock:3 Google Sign-In: GoogleSignIn 7.1.0
|
||||
at Planner/LoginView.swift:9 GIDSignIn.sharedInstance.signIn(withPresenting: rootViewController()) { _, _ in }
|
||||
4. HIGH 3.1.1 · App Store · Restorable purchases (non-consumables or subscriptions) but no Restore Purchases path was found
|
||||
at storebound.config.ts:16 com.example.planner.pro (non_consumable)
|
||||
5. HIGH 5.1.2(i) · App Store · Tracking-capable code without App Tracking Transparency (Google AdMob, IDFA)
|
||||
at Podfile.lock:2 Google AdMob: Google-Mobile-Ads-SDK 11.10.0
|
||||
at Planner/Ads.swift:7 print(ASIdentifierManager.shared().advertisingIdentifier)
|
||||
```
|
||||
|
||||
Every finding also has a `fix:` line and the skill. Below the risks comes the declarations proposal: the Play Data
|
||||
safety and App Store App Privacy answers, App Tracking Transparency and privacy manifest entries the detected SDKs and
|
||||
code imply, each marked **detected (file:line)** or **ask owner**, as a diff against `dataSafety` / `appPrivacy` in
|
||||
the config. Nothing is written unless you run `storebound scan --write` (only detected answers; questions stay
|
||||
questions). The MCP tool `scan` is read-only and cannot write; `validate` includes the open scan findings; the desktop
|
||||
app shows them on the Checks tab. A finding you decided is fine goes into the config with its reason:
|
||||
`scan: { ignore: [{ rule: 'demo-account', reason: '...' }] }`; a rule with several checks (e.g. `play-metadata`) takes `match: '<text in the finding title>'` to accept just one of them.
|
||||
|
||||
| rule | App Store | Google Play | what scan proves |
|
||||
|---|---|---|---|
|
||||
| `demo-account`, `play-app-access` | 2.1(a) | App access | a sign-in (config, auth SDKs, sign-in code) without `reviewAccess` or without the demo account in `.storebound/` |
|
||||
| `account-deletion`, `play-deletion-url` | 5.1.1(v) | User Data: account deletion | sign-up or social login without a deletion path in code or `accounts.inApp`; no `accounts.deletionUrl` |
|
||||
| `login-services` | 4.8 | | Google / Facebook / other third-party login without Sign in with Apple or another equivalent option |
|
||||
| `restore-purchases`, `external-purchase`, `play-payments` | 3.1.1 | Payments | non-consumables or subscriptions without a restore path; card / PayPal SDKs next to in-app products; "buy on our website" (Play: unless `play.billingPrograms` lists user choice billing or external offers) |
|
||||
| `subscription-info` | 3.1.2(c), 3.1.2 | | no Terms of Use URL, no Terms link or renewal wording in the description, no legal links or "Manage subscription" in the app |
|
||||
| `att` | 5.1.2(i) | | tracking-capable SDKs or IDFA without ATT; the prompt without its purpose string; the string without a prompt |
|
||||
| `purpose-strings`, `unused-permission` | 5.1.1(ii), 5.1.1(iv), ITMS-90683 | | a used permission without its NS*UsageDescription; empty or vague strings; declared but unused |
|
||||
| `privacy-manifest` | ITMS-91053 / 91061 | | no PrivacyInfo.xcprivacy, required-reason APIs not declared, SDKs older than their first manifest version |
|
||||
| `privacy-policy-link`, `play-privacy-policy` | 5.1.1(i) | User Data: privacy policy | no privacy link in the app's sources (Play: every app; medium when it collects data), an `http://` link |
|
||||
| `privacy-labels` | 5.1.2 | User Data: Data safety | SDK data types missing from `dataSafety`; Play also: an answer narrower than the SDK's disclosure (purposes, sharing), SDKs with no `dataSafety` at all |
|
||||
| `play-prominent-disclosure` | | User Data: prominent disclosure, Location permissions | background location without a disclosure that says "location" and "background"; location, contacts, calendar or phone number access without any disclosure or rationale text in sources or `strings.xml` |
|
||||
| `play-background-location`, `play-photo-video`, `play-all-files-access`, `play-accessibility`, `play-install-packages`, `play-health-connect`, `play-full-screen-intent`, `play-vpn` | | Location permissions, Photo and Video Permissions, All files access, AccessibilityService API, REQUEST_INSTALL_PACKAGES, Health Connect, Full-screen intent, VpnService | the restricted permission or service in the manifest (or the merged manifest of a local build), each with the core-use test and the Play Console declaration it needs; `isAccessibilityTool` lowers the accessibility finding to medium |
|
||||
| `play-fgs`, `play-exact-alarm`, `play-query-all-packages`, `play-sms-call-log` | | Foreground service types, exact alarms, package visibility, SMS and Call Log | foreground services without a type or its permission (and the Console declaration); USE_EXACT_ALARM; QUERY_ALL_PACKAGES; SMS and call log permissions |
|
||||
| `kids-ads`, `play-families-ads`, `play-families-ad-id`, `play-families-location` | 1.3 | Families | a Kids Category age band with third-party ads or analytics SDKs; `play.targetAges` under 13 with an ad SDK that is not Families self-certified (or older than its certified version), the AD_ID permission in a child-only app (mixed audience: ad / attribution SDKs need child-directed treatment), location in a child-only app |
|
||||
| `play-ad-id` | | Advertising ID | targetSdk 33+ with ad / attribution SDKs and no AD_ID permission (medium when the merged manifest proves it is missing; Google Mobile Ads adds it itself) |
|
||||
| `play-16kb` | | 16 KB page sizes (updates blocked from 2027-02-01) | Unity before 6000.0.38f1 / 2022.3.56f1 / 2021.3.48f1, React Native before 0.77, NDK before r28 for the app's own C/C++ without the 16 KB flags, AGP before 8.5.1 with uncompressed native libraries; else prebuilt `.so` files to check |
|
||||
| `play-ads-launch` | | Ads, Better Ads Experiences | an interstitial shown from `onCreate` of the launcher activity (Android native; other engines: review-only) |
|
||||
| `play-testing-requirement` | | Testing for new personal accounts | `play.accountType: 'personal'` created after 2023-11-13 (12 testers, 14 days of closed testing before production); low when the account type is not set |
|
||||
| `other-platforms` | 2.3.10 | | "Android", "Google Play" and other stores in the App Store text or screenshot captions |
|
||||
| `metadata-keywords`, `kids-terms`, `play-metadata` | 2.3.7, 2.3.8 | Metadata | other apps' names, prices and "best / #1" in name, subtitle or keywords; keyword lists; "For Kids" outside the Kids Category. Play title over 30 characters, emoji, ALL CAPS, rank / price / promotion terms; other apps in the title or short description; repeated keywords or keyword lists in the full description |
|
||||
| `play-impersonation` | | Impersonation | a well-known brand in the package name or app name (low: a check, not a verdict) |
|
||||
| `placeholder-text` | 2.1(a) | Metadata | lorem ipsum, TODO, template lines in the listings |
|
||||
| `beta-wording` | 2.2 | | "beta", "demo", "trial version", "test build", "early access" in the app name (config, CFBundleDisplayName) or the App Store name, subtitle, keywords, promotional text and description ("demo account" is fine) |
|
||||
| `originality` (with `--originality`) | 4.3 | Repetitive content | a near-duplicate of one of your own apps (`storebound originality` scores: name, listing text, icon and screenshot hashes, project structure) |
|
||||
| `min-functionality` | 4.2 | | Capacitor `server.url` / Cordova `<content src>` on a remote site; a few native files around a remote WKWebView |
|
||||
| `deprecated-api`, `private-api` | 2.5.1 (ITMS-90809) | | UIWebView; `App-Prefs:` / `prefs:root` URL schemes |
|
||||
| `ipv6`, `ats` | 2.5.5, App Transport Security | | IPv4 literals in server URLs; NSAllowsArbitraryLoads |
|
||||
| `export-compliance` | export compliance | | no ITSAppUsesNonExemptEncryption and no `apple.usesNonExemptEncryption`, or the two disagree (never guessed) |
|
||||
| `review-prompt` | 5.6.1, 3.2.2(x) / 5.6.3 | | a custom "rate us" prompt without the system API; rewards for ratings |
|
||||
| `play-target-sdk` | | Target API level | below API 36 (Wear OS / Automotive 35, TV / XR 34) for new apps and updates since 2026-08-31 |
|
||||
| `play-package-name` | | Play Console upload | the reserved `com.example` prefix |
|
||||
|
||||
`storebound scan --originality [--against <folders>]` adds the Guideline 4.3 / Play repetitive content check against
|
||||
your other apps to the same report (folders default to `originality.against` in the config; `storebound originality`
|
||||
stays the command with every score and suggestion).
|
||||
|
||||
Rules cite Apple's App Review Guidelines and the Play Policy Center / Play Console Help pages they come from (checked
|
||||
2026-10-11, in the code comments of `cli/lib/scan/rules.ts` and `cli/lib/scan/play-rules.ts`); the SDK table with each
|
||||
vendor's data disclosure page and Google's Families self-certified ads SDK list is `cli/lib/scan/sdks.ts`. Play config
|
||||
the rules read: `play.targetAges` (Target audience age groups), `play.accountType` / `play.accountCreated`, and
|
||||
`play.billingPrograms`. When the project was built locally, scan also reads the merged manifest under
|
||||
`build/intermediates` (the permissions libraries add); otherwise it says that it could not. Not a rule because no Play
|
||||
policy page covers it: device admin apps (BIND_DEVICE_ADMIN); not a policy at all: Play Integrity / anti-tamper.
|
||||
|
||||
**Checked at review, not by scan** (no file shows them): crashes and hangs on the reviewer's device (2.1, `store-health`),
|
||||
links that are dead at review time (`validate` checks the configured URLs), screenshots and descriptions that do not
|
||||
match the app (2.3, `store-screenshots`), unfinished UI (4.0), "too simple" and spam / template apps (4.2, 4.3,
|
||||
`portfolio-originality`), IAPs that fail in the sandbox (2.1(b), `store-iap-setup`), the content of the privacy policy
|
||||
(`privacy-policy`), local law (5.2). On Google Play: whether a restricted permission really serves the app's core
|
||||
purpose (the Console declaration and its video decide), the wording and timing of a prominent disclosure, ad
|
||||
placements in Unity, Flutter or React Native code, the Health apps declaration answers, and the alignment inside a
|
||||
prebuilt `.so` (scan lists them; `zipalign -c -P 16` and `check_elf_alignment.sh` check the built app). The
|
||||
`review-rejections` skill covers what to do when one of these comes back.
|
||||
|
||||
---
|
||||
|
||||
[All docs](README.md) · [README](../README.md)
|
||||
25
docs/safety.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Safety model
|
||||
|
||||
- **Credentials** live in env vars or the gitignored `.storebound/credentials.json`, never in the config or the repo.
|
||||
storebound only talks to the official store APIs (plus Google Fonts, your own Play reports bucket and your own URLs);
|
||||
see [SECURITY.md](../SECURITY.md).
|
||||
- **Dry run everywhere.** Every write command takes `--dry`; every MCP write tool has `dry_run`, **default true**.
|
||||
Raw API writes (`storebound api`) are dry unless `--yes` is given in an interactive terminal; owner-only operations
|
||||
also need the operation id typed, and MCP only gets the read-only `api_get`.
|
||||
- **Nothing public or irreversible through an agent.** Submitting for review (App Review and Beta App Review),
|
||||
releasing a version, production releases, resuming or completing a rollout or phased release, canceling a
|
||||
submission, deleting, inviting or removing testers, sending review replies, applying prices and starting or
|
||||
stopping a product page test and creating custom offer codes are not MCP tools. Subscription syncs through MCP only
|
||||
create what is new; price changes of live subscriptions wait for a terminal run. They stay terminal commands that ask for confirmation, and the skills tell every agent to ask the owner
|
||||
first ([docs/agents-core.md](agents-core.md)).
|
||||
- **Personal data stays local.** Tester lists, the App Review demo account, reply drafts, health reports and analytics
|
||||
snapshots live in the gitignored `.storebound/` (the demo account is masked in every log line); tester emails are masked in every log line, and the config refuses a tester CSV path
|
||||
outside `.storebound/`.
|
||||
- **Idempotent writes.** Re-running a command converges (MD5 checks for screenshots and previews, one Play edit per
|
||||
language), so a failed upload is fixed by running it again.
|
||||
- **Simulators and emulators**: storebound shuts down only a simulator it booted itself and never boots, wipes or
|
||||
reboots an Android emulator or device.
|
||||
|
||||
---
|
||||
|
||||
[All docs](README.md) · [README](../README.md)
|
||||
41
docs/skills.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Skills
|
||||
|
||||
In journey order. Each one says which steps are API (storebound) and which are console clicks, with the exact
|
||||
navigation.
|
||||
|
||||
| skill | for |
|
||||
|---|---|
|
||||
| `migrate-from-fastlane` | `storebound import fastlane`: ids, listings, languages and screenshots from fastlane; keys and accounts stay out |
|
||||
| `play-first-release` | Google Play from zero to "In review" |
|
||||
| `appstore-first-release` | App Store from API key to "Waiting for review", incl. xcodebuild with the API key |
|
||||
| `build-and-sign` | API signing (capabilities, certificate, profiles), `apple build` / `apple upload`, `play build` / `play upload`, for native, Capacitor, React Native, Flutter and Unity projects |
|
||||
| `testflight` | beta groups, testers from a CSV that never enters git, What to Test, Test Information, Beta App Review, waiting for processing, expiring old builds |
|
||||
| `store-iap-setup` | in-app products on both stores (consumable, non-consumable, non-renewing), prices and the price sheet CSV, catalog diff, testers, review submissions through the API and the first-IAP-with-version rule |
|
||||
| `store-subscriptions` | subscription groups, levels, base plans, prices, intro and promotional offers, offer codes, disclosure and Terms (3.1.2), first submission |
|
||||
| `review-access` | demo account for reviewers (App Review fields, Play App access), never committed |
|
||||
| `account-deletion` | Apple 5.1.1(v) in-app deletion, Play delete-account web page and Data safety |
|
||||
| `aso-basics` | keyword field, subtitle, cross-localization, Play placement without stuffing, keyword ranks, competitors, keyword field proposals and the ASO audit (iTunes Search API, Apple Ads popularity with credentials) |
|
||||
| `app-store-promotion` | IAP offer codes, win-back offers, promoted IAPs, pre-orders (Play pre-registration: console guide), custom product pages, in-app events |
|
||||
| `store-declarations` | every questionnaire: Play Data safety (API) and App content (IARC, target audience, ads, advertising ID, app access, government / financial / health), Apple App Privacy labels and age rating |
|
||||
| `export-compliance` | encryption answer, Info.plist key, declarations; never exempt by default |
|
||||
| `privacy-policy` | one policy page for all your apps with a per-app services table (templates per profile) |
|
||||
| `privacy-manifest` | PrivacyInfo.xcprivacy, required-reason APIs, SDK manifests (ITMS-91053 / 91061) |
|
||||
| `app-icons` | one artwork to every icon, adaptive / legacy launcher pitfalls |
|
||||
| `store-screenshots` | capture from any engine, copy, fonts, render, checks, gallery, variants, App Store product page tests, Play experiments, upload |
|
||||
| `app-previews` | app or gameplay clips, App Store previews, Play promo video, checks, upload |
|
||||
| `store-preflight` | run and read `check`, `check --aso`, `validate`, `privacy-manifest` and `release plan` before submitting; `ws check` / `ws validate` for many apps |
|
||||
| `release-management` | updates: release trains (`release status`, `release plan`), Apple phased + manual release, Play staged rollout, halt / pause (not a rollback), review wait, pulling console edits back |
|
||||
| `store-api` | any App Store Connect / Google Play operation without a command: find it, read freely, writes dry until the owner sends them |
|
||||
| `store-health` | crash / ANR vitals vs Play's bad behavior thresholds, App Store diagnostics, weekly report, crash and feedback triage |
|
||||
| `review-replies` | reply drafts, tone, no incentives, store limits, the owner approves and sends |
|
||||
| `store-analytics` | App Store analytics reports, Play reports bucket, before / after listing changes, sales / finance / earnings reports |
|
||||
| `whats-new` | player-facing release notes from git, one file per language for your agent to translate, store limits checked |
|
||||
| `listing-localization` | the listing in more languages: drafts per language, native wording and keyword research, store limits, review before push |
|
||||
| `release-flows` | repo-local workflows (`store/workflows/*.yml`): release and beta routines with dry runs, resume and the owner's clicks |
|
||||
| `review-rejections` | triage a rejection (fix / clarify / push back / appeal), reply template, the guidelines apps and games hit |
|
||||
| `ads-mediation-checks` | AdMob, AppLovin MAX and LevelPlay: ids, debug tools, adapters, app-ads.txt, SKAdNetwork, AdAttributionKit (`ads scan`), no-fill (`ads-levelplay-checks` stays as a pointer) |
|
||||
| `portfolio-originality` | Guideline 4.3 gate for developers shipping many similar apps or games, with `storebound originality` scores |
|
||||
|
||||
---
|
||||
|
||||
[All docs](README.md) · [README](../README.md)
|
||||
85
docs/store-art.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# Store art: screenshots and previews
|
||||
|
||||
Screenshots and previews are one step of the journey, but the one that takes the longest by hand, so storebound
|
||||
does a lot here. Two looks: `shots.style: 'game'` (sunburst, sparkles, badges) and `shots.style: 'app'` (calm
|
||||
gradient, a clean device frame, headline + subline). Everything below was made by storebound itself from two tiny
|
||||
fictional demos: [a game](../examples/demo-game) ("Prism Puddle", procedural art) and [an app](../examples/demo-app)
|
||||
("Tidy Lists", one static page with sample data): raw frames captured from the web build, captions in several
|
||||
languages, every store size, a review gallery and an app preview video. No store account needed to try it.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
| Play feature graphic | App preview (App Store, 886 x 1920) |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||

|
||||
|
||||
<details>
|
||||
<summary>Contact sheet of one language (every size, packed into a grid)</summary>
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Frames.** Give your app or game a few switches (language, demo data or a fixed seed, no ads, jump to a screen,
|
||||
freeze, autoplay) and
|
||||
let `shots capture` take the frames: Playwright for a web build, `simctl` for anything that runs in the iOS
|
||||
Simulator (Unity, Godot, Flutter, React Native, native, Capacitor), `adb` for an Android emulator or device, or bring a folder of
|
||||
your own frames. The status bar is cropped off and redrawn clean (9:41, full battery).
|
||||
2. **Copy.** Per language and scene: eyebrow, two-line title, subtitle, badges. Fonts are subset from Google Fonts to
|
||||
exactly your characters, so Japanese, Chinese, Korean, Arabic (right to left) and Hindi just work. `shots fonts`
|
||||
checks every character of every language against the font's cmap (TTF / OTF / WOFF / WOFF2, read in plain
|
||||
TypeScript): a glyph the font lacks gets an automatic Noto fallback (logged), no fallback is a clear error, and a
|
||||
render that drew any glyph with a fallback you did not choose, or with a system font, fails `shots check`.
|
||||
|
||||
| script | title / body font (default stack) |
|
||||
|---|---|
|
||||
| Latin incl. Latin Extended (tr, pl, cs, ro, hu ...) and Vietnamese | your `shots.fonts`; default game Paytone One + Montserrat, app Inter (all cover them) |
|
||||
| Cyrillic, Greek | Noto Sans (whole line) |
|
||||
| Arabic, Hebrew, Devanagari, Bengali, Thai | Noto Sans Arabic / Hebrew / Devanagari / Bengali / Thai (whole line) |
|
||||
| Japanese, Korean, Chinese | Noto Sans JP / KR / SC (TC for zh-Hant, TW, HK) (whole line) |
|
||||
| a character the stack lacks | Noto Sans Display (titles) or Noto Sans (body), Noto Sans <Script>, then Noto Sans Symbols 2 / Symbols / Math: an ERROR in `shots check` unless listed in `shots.fonts.fallback` |
|
||||
|
||||
Some display fonts lack Latin Extended: Lilita One has no ş ğ İ ą ę ń ś ź ż ć ă ș ț č ř ě ů ő ű or Vietnamese,
|
||||
so Turkish, Polish, Czech, Romanian, Hungarian or Vietnamese titles need another title font (Paytone One, Baloo 2,
|
||||
Rowdies) or `fonts: { ..., fallback: ['Noto Sans Display'] }` to accept the mixed glyphs.
|
||||
|
||||
**Your own brand font.** `shots.fonts.title` / `body` / `fallback[]` also take a local file (relative to the
|
||||
config; TTF, OTF, WOFF or WOFF2, checked by extension and signature) or `{ family, file, weight?, style?, license? }`:
|
||||
|
||||
```ts
|
||||
fonts: {
|
||||
title: { family: 'ER Display', file: 'apps/game/public/assets/fonts/er-display.woff2', license: 'OFL-1.1' },
|
||||
body: 'Montserrat',
|
||||
fallback: ['Noto Sans Display'], // tried in order, before the automatic Noto fallbacks
|
||||
},
|
||||
```
|
||||
|
||||
`shots fonts` runs the same cmap check on the file (`pl title: ę ł ą Ł not in ER Display -> Noto Sans Display`),
|
||||
prints its size, glyph count, sha256 and license, and records it in `fonts.json`. The file is never copied: render
|
||||
(and `shots video` captions) serve it from where it is, and a changed file changes `fonts.css` (the capture cache
|
||||
misses) and makes `shots render` ask for `shots fonts` again. `shots check` counts the local font and listed
|
||||
fallbacks as configured, not as fallbacks, and warns when a local font has no `license` note.
|
||||
3. **Render.** Every store size at exact pixels: iPhone 6.9" and 6.5", 13" iPad (if your app runs on iPad), Play
|
||||
phone, 7" and 10" tablets, the 1024 x 500 feature graphic. 24-bit RGB, no alpha (both stores reject it).
|
||||
4. **Check and review.** `shots check` applies the store rules locally (sources cited in `cli/shots/spec.ts`);
|
||||
`shots gallery` puts everything on one page with problems outlined. `validate` repeats the checks next to the
|
||||
store state.
|
||||
5. **Variants.** Other first screenshot, order or copy as `shots.variants`, rendered side by side, for a Play store
|
||||
listing experiment or an App Store Product Page Optimization test (set up in the consoles; storebound makes and
|
||||
checks the images).
|
||||
6. **Video.** `shots record` films the autoplay mode, `shots video` cuts 15-30 s App Store previews (886 x 1920,
|
||||
1200 x 1600) and a 9:16 / 16:9 promo for YouTube with captions in every language, `apple previews` uploads them.
|
||||
Play takes a YouTube URL in `play.video`.
|
||||
|
||||
Details: the [store-screenshots](../skills/store-screenshots/SKILL.md) and [app-previews](../skills/app-previews/SKILL.md)
|
||||
skills.
|
||||
|
||||
---
|
||||
|
||||
[All docs](README.md) · [README](../README.md)
|
||||
137
docs/studios.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Many apps, studios, release trains and workflows
|
||||
|
||||
## Many apps and studios
|
||||
|
||||
A studio with twenty games, or a developer with apps under two store accounts, works from one folder above the app
|
||||
folders. `storebound ws init` writes `storebound.workspace.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"apps": ["games/*", { "path": "apps/notes", "apple": "solo-apple" }],
|
||||
"studios": [
|
||||
{ "name": "Acme Games", "color": "indigo", "apps": ["games/*"],
|
||||
"accounts": [{ "store": "apple", "profile": "acme-apple" }, { "store": "play", "profile": "acme-play" }] }
|
||||
],
|
||||
"accounts": [{ "store": "apple", "profile": "solo-apple", "label": "Solo" }],
|
||||
"ignore": ["games/old-*"],
|
||||
"overrides": { "pairs": [], "unpairs": [], "links": [], "appStudio": [] },
|
||||
"concurrency": 4
|
||||
}
|
||||
```
|
||||
|
||||
- **apps**: folders or globs (only folders with a `storebound.config.*` count; `node_modules` and dot folders are
|
||||
skipped). An object entry names the folder's own credential profiles.
|
||||
- **studios**: name, colour (the desktop's set), the store accounts and the app folders (globs) that belong to it.
|
||||
- **accounts**: store accounts without a studio. An account is a store and a **credential profile** name, never a key.
|
||||
- **ignore**: folder globs, `app:<store app key>` and `account:<profile>`, as in the desktop's ignore list.
|
||||
- **overrides**: the desktop's pairing corrections (`pairs`, `unpairs`, `links` of a store app to a folder,
|
||||
`appStudio`), with folders named by their path. A `storebound.workspace.ts` with `export default { ... }` works too.
|
||||
|
||||
Which keys an app uses: its own entry's profiles, else the single account of that store in its studio, else the
|
||||
single account of that store in the workspace, else the folder's own credentials (env or `.storebound/`).
|
||||
|
||||
**Credential profiles** keep many accounts apart on one machine: `~/.storebound/credentials/<profile>.json` with the
|
||||
account names of `.storebound/credentials.json` (`PLAY_SERVICE_ACCOUNT`, `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_P8`,
|
||||
`ASC_VENDOR_NUMBER`; the Android upload keystore belongs to the app and stays in the project;
|
||||
relative key paths are relative to that folder, which `STOREBOUND_CREDENTIALS_DIR` can move), or env
|
||||
`STOREBOUND_PROFILE_<NAME>_<KEY>` (for CI). Any command takes `--account <profile>`. `ws list` shows per account whether
|
||||
its profile has keys; no command prints a key.
|
||||
|
||||
`ws status`, `ws check`, `ws validate` and `ws run` read the apps a few at a time (`concurrency`, 1-8, or
|
||||
`--concurrency`), each app with its own keys handed to its child process only. `ws run` takes any read command and
|
||||
the MCP tool set; write commands get `--dry` unless `--apply`, and submit, release, rollout, send, start / stop,
|
||||
testers and `--yes` are refused: those stay with the owner in the app folder.
|
||||
|
||||
`ws apps import --account <profile>` lists the apps a store account sees (as `apps list`) into
|
||||
`.storebound/workspace/` and pairs them with the folders by the desktop's rules (`cli/lib/identity.ts`, the same code):
|
||||
a folder takes the store apps its config names, the remaining iOS and Android apps pair by id, close id or name,
|
||||
`unpairs` keep two apart, and the rest are store-only apps that `ws status` reads too.
|
||||
|
||||
**The desktop app** keeps the same model (studios, accounts, folders, overrides). `ws desktop import` turns its
|
||||
`state.json` (or `--state <file>`) into a workspace file and lists the credential profiles to create: the desktop's
|
||||
keychain is never read. `ws desktop export` prints the workspace as a desktop state (`--out <file>`), and
|
||||
`--merge --apply` adds it to the desktop's `state.json` while the app is closed (a backup is kept; nothing is
|
||||
removed; new accounts get their keys in the desktop's Accounts screen).
|
||||
|
||||
MCP: `workspace_list` (offline) and `workspace_status` (read-only).
|
||||
|
||||
## Release trains
|
||||
|
||||
One version, both stores, one track: **build > testing > review > live > rollout**. The CLI and the desktop's Releases
|
||||
tab share the code (`cli/lib/train.ts`, `cli/lib/portfolio.ts`), so both show the same stage for the same store state.
|
||||
|
||||
```
|
||||
$ storebound release status
|
||||
app version train App Store Google Play
|
||||
------ ------- ------- ---------------------- -----------------------------
|
||||
Puzzle 2.3.0 current rollout 20% (build 45) rollout 10% (build 230)
|
||||
Puzzle 2.4.0 next build testing (build 240, internal)
|
||||
```
|
||||
|
||||
`release plan <version>` lists what is left for that version on each store in fix order: config problems (`check`),
|
||||
the version on the store (how to create it when it is missing), store blockers (`validate`, while nothing is
|
||||
submitted), the next move and the warnings. Owner steps (submit for review, promote to production, release an approved
|
||||
version, raise or complete a rollout) are named with where the owner does them; storebound does not do them. A store
|
||||
it cannot read is "unknown", never "not there". Both commands work in an app folder (that app) and in a workspace
|
||||
(every app, or `--app a,b`); `--json` gives the structured result. MCP: `release_status`, `release_plan` (read-only).
|
||||
## Repo-local workflows
|
||||
|
||||
Your release routine as a file in the repo, run step by step. `storebound init` writes two to start from
|
||||
(`store/workflows/release.yml`, `beta.yml`; app and game versions):
|
||||
|
||||
```yaml
|
||||
# store/workflows/release.yml (app profile, shortened)
|
||||
params:
|
||||
VERSION: "1.0.0"
|
||||
steps:
|
||||
- id: next-build
|
||||
if: apple
|
||||
run: apple next-build
|
||||
capture: BUILD
|
||||
- id: build
|
||||
if: apple
|
||||
run: apple build --version ${VERSION} --build-number ${BUILD}
|
||||
- id: upload
|
||||
if: apple
|
||||
run: apple upload --no-wait
|
||||
# wait, play-build, play-upload, whats-new, listing, validate, scan, plan ...
|
||||
- id: submit
|
||||
owner: "App Store Connect: Submit for Review (version ${VERSION}, build ${BUILD}) ..."
|
||||
```
|
||||
|
||||
The plan of that file (`--dry`: nothing runs; without it the reads run and `${BUILD}` is filled in):
|
||||
|
||||
```
|
||||
$ storebound flow run release VERSION=1.2.0 --dry
|
||||
[flow] 1/12 next-build: storebound apple next-build (plan only)
|
||||
[flow] 2/12 build: storebound apple build --version 1.2.0 --build-number ${BUILD} --dry (plan only)
|
||||
[flow] 3/12 upload: storebound apple upload --no-wait --dry (plan only)
|
||||
[flow] 4/12 wait: storebound apple testflight wait --build ${BUILD} --version 1.2.0 --dry (plan only)
|
||||
[flow] 5/12 play-build: storebound play build --version-name 1.2.0 --dry (plan only)
|
||||
[flow] 6/12 play-upload: storebound play upload --track internal --dry (plan only)
|
||||
[flow] 7/12 whats-new: storebound whats-new apply (if exists store/whats-new: false)
|
||||
[flow] 8/12 listing: storebound apple listing --version 1.2.0 --build ${BUILD} --dry (plan only)
|
||||
[flow] 9/12 validate: storebound validate (plan only)
|
||||
[flow] 10/12 scan: storebound scan (plan only)
|
||||
[flow] 11/12 plan: storebound release plan 1.2.0 (plan only)
|
||||
[flow] 12/12 submit: YOUR CLICK App Store Connect: Submit for Review (version 1.2.0, build ${BUILD}). Play Console: promote the internal release and Send changes for review
|
||||
```
|
||||
|
||||
- Read steps (check, validate, scan, release plan, next-build, status) run; **write steps run with `--dry`** unless
|
||||
`--apply`, and even then each command asks its own confirmation; wait steps check once; `sh:` steps (your own build
|
||||
commands) run only with `--apply`. `--dry` prints the plan without running anything. `--apply --yes` is your
|
||||
approval of every write step of the run (no terminal needed); without it, a step whose confirmation cannot be
|
||||
answered stops the flow as "declined", never "ok".
|
||||
- Owner steps (submit, release, phased release, rollout, review replies, price imports, tester invitations, Beta App
|
||||
Review) are never executed, also when written as `run:`; the flow ends with the list of your clicks.
|
||||
- `capture:` turns a step's last output line into a variable (`${BUILD}`), `if:` skips a step (`apple`, `play`,
|
||||
`exists <path>`, `${VAR} == x`), `flow:` reuses another flow (`private: true` sub-flows), `before` / `after` /
|
||||
`onError` are hooks, `continueOnError` keeps going.
|
||||
- A failed step stops the flow; `--resume` starts again at that step with the captured variables
|
||||
(`.storebound/flows/<name>.json`), `--from <step>` anywhere. `flow validate` checks commands, variables, duplicate ids
|
||||
and sub-flow cycles. Several flows can also live in `storebound.workflows.yml|json|ts`.
|
||||
- MCP: `flow_list` and `flow_run` (the dry mode only; `--apply` stays a terminal command). Skill: `release-flows`.
|
||||
|
||||
---
|
||||
|
||||
[All docs](README.md) · [README](../README.md)
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
"README.md",
|
||||
"LICENSE",
|
||||
"CHANGELOG.md",
|
||||
"docs/agents-core.md"
|
||||
"docs/*.md"
|
||||
],
|
||||
"workspaces": [
|
||||
"apps/*"
|
||||
|
|
|
|||
|
|
@ -4,209 +4,17 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>storebound desktop · download</title>
|
||||
<meta name="description" content="storebound desktop: every app and game on Google Play and the App Store in one window, driving the same storebound CLI. Downloads for macOS, Windows and Linux with checksums and signatures.">
|
||||
<link rel="canonical" href="https://storebound.dev/desktop/">
|
||||
<meta name="robots" content="noindex">
|
||||
<link rel="canonical" href="https://storebound.dev/#desktop">
|
||||
<meta http-equiv="refresh" content="0; url=../#download">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#f8fafc" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#0b1120" media="(prefers-color-scheme: dark)">
|
||||
<link rel="icon" href="../favicon.ico?v=2" sizes="16x16 32x32 48x48">
|
||||
<link rel="manifest" href="../manifest.webmanifest?v=2">
|
||||
<link rel="apple-touch-icon" href="../apple-touch-icon.png?v=2">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="storebound">
|
||||
<meta property="og:url" content="https://storebound.dev/desktop/">
|
||||
<meta property="og:title" content="storebound desktop · download">
|
||||
<meta property="og:description" content="Every app and game on both stores in one window. macOS, Windows, Linux.">
|
||||
<meta property="og:image" content="https://storebound.dev/og.png?v=2">
|
||||
<link rel="stylesheet" href="../style.css?v=20261012">
|
||||
<script>document.documentElement.classList.add('js')</script>
|
||||
<style>
|
||||
/* page-only parts; colours and type come from ../style.css tokens (fallbacks keep the page readable alone) */
|
||||
.dl-hero { padding: 56px 0 40px; }
|
||||
.dl-hero h1 { font-size: clamp(30px, 5vw, 46px); font-weight: 760; }
|
||||
.dl-main { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 16px; margin-top: 28px; }
|
||||
.dl-main .meta { color: var(--muted, #64748b); font-size: 14.5px; }
|
||||
.dl-notice { margin-top: 22px; max-width: 46em; padding: 14px 16px; border-radius: 12px; border: 1px solid var(--ui-ink, #92400e); background: var(--ui-bg, #fef3c7); color: var(--ui-ink, #92400e); font-size: 15px; }
|
||||
.dl-notice b { font-weight: 700; }
|
||||
.dl-notice ol { margin: 8px 0 0; padding-left: 20px; }
|
||||
.dl-table { width: 100%; border-collapse: collapse; margin-top: 24px; font-size: 15px; }
|
||||
.dl-table th, .dl-table td { text-align: left; padding: 12px 10px; border-bottom: 1px solid var(--line, #e2e8f0); vertical-align: top; }
|
||||
.dl-table th { font: 600 12.5px var(--mono, monospace); text-transform: uppercase; letter-spacing: .08em; color: var(--muted, #64748b); }
|
||||
.dl-table tr.here td { background: var(--accent-soft, #eef2ff); }
|
||||
.dl-table .sha { font: 12.5px/1.5 var(--mono, monospace); color: var(--ink-2, #334155); overflow-wrap: anywhere; display: block; max-width: 34em; }
|
||||
.dl-table .fname { font-weight: 600; overflow-wrap: anywhere; }
|
||||
.dl-table .small { font-size: 13.5px; color: var(--muted, #64748b); }
|
||||
.dl-steps { margin: 16px 0 0; padding-left: 20px; color: var(--ink-2, #334155); max-width: 50em; }
|
||||
.dl-steps li { margin: 6px 0; }
|
||||
.dl-key { display: block; margin-top: 8px; font: 13px/1.6 var(--mono, monospace); overflow-wrap: anywhere; }
|
||||
@media (max-width: 720px) {
|
||||
.dl-table thead { display: none; }
|
||||
.dl-table, .dl-table tbody, .dl-table tr, .dl-table td { display: block; width: 100%; }
|
||||
.dl-table tr { border-bottom: 1px solid var(--line, #e2e8f0); padding: 8px 0; }
|
||||
.dl-table td { border: 0; padding: 4px 0; }
|
||||
}
|
||||
</style>
|
||||
<script>location.replace('../#download')</script>
|
||||
<style>body { font: 16px/1.6 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 40px 16px; background: #f8fafc; color: #0f172a; } a { color: #4f46e5; } @media (prefers-color-scheme: dark) { body { background: #0b1120; color: #f1f5f9; } a { color: #818cf8; } }</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
|
||||
<header class="top">
|
||||
<div class="wrap top-in">
|
||||
<a class="brand" href="../" aria-label="storebound home">
|
||||
<img class="mark" src="../img/logo-mark.png?v=2" srcset="../img/logo-mark.png?v=2 1x, ../img/logo-mark@2x.png?v=2 2x" width="28" height="28" alt="storebound logo">
|
||||
<span>storebound</span>
|
||||
</a>
|
||||
<nav aria-label="Primary">
|
||||
<a href="../">Home</a>
|
||||
<a href="#all">All downloads</a>
|
||||
<a href="#verify">Verify</a>
|
||||
<a class="nav-git" href="https://git.crt.fyi/CRT/storebound" target="_blank" rel="noopener">git<span class="ext" aria-hidden="true">↗</span><span class="sr-only"> (opens in a new tab)</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<section class="dl-hero">
|
||||
<div class="wrap">
|
||||
<p class="kicker">Desktop app · preview</p>
|
||||
<h1>storebound desktop</h1>
|
||||
<p class="sub">Every app and game you ship, on Google Play and the App Store, in one window: releases on both stores, catalog parity, listings, crash health, reviews and TestFlight. It drives the same open source storebound CLI, keeps store keys in the system keychain, shows a dry run before every change, and never presses Submit.</p>
|
||||
|
||||
<div class="dl-main" id="primary" aria-live="polite">
|
||||
<p class="meta" id="status">Looking up the latest release…</p>
|
||||
</div>
|
||||
<noscript><p class="note">The download list needs JavaScript. Without it: <a href="latest.json">latest.json</a> lists every file with its SHA-256.</p></noscript>
|
||||
<div id="notice"></div>
|
||||
<p class="note">Already use the CLI? <code>storebound desktop</code> opens the app in your app folder, or prints the download for your system; <code>storebound desktop --download</code> saves the installer to ~/Downloads after checking its signature (it never installs anything).</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sec alt" id="all">
|
||||
<div class="wrap">
|
||||
<p class="kicker">All downloads</p>
|
||||
<h2>Every system, with checksums</h2>
|
||||
<p class="sub" id="all-sub">The app updates itself from this site: each update is signed, and the app checks the signature before it replaces anything. It asks before installing.</p>
|
||||
<table class="dl-table" id="table" hidden>
|
||||
<thead><tr><th scope="col">System</th><th scope="col">File</th><th scope="col">SHA-256</th></tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sec" id="verify">
|
||||
<div class="wrap">
|
||||
<p class="kicker">Verify</p>
|
||||
<h2>Check a download yourself</h2>
|
||||
<ol class="dl-steps">
|
||||
<li>Checksum: <code>shasum -a 256 <file></code> (macOS, Linux) or <code>Get-FileHash <file></code> (Windows) must print the SHA-256 above.</li>
|
||||
<li>Signature: every file has a <code>.sig</code> next to it (the app's update key, minisign). With <a href="https://jedisct1.github.io/minisign/" target="_blank" rel="noopener">minisign</a>: <code>base64 -d <file>.sig > f.minisig && minisign -Vm <file> -x f.minisig -P <key></code>, with the public key
|
||||
<code class="dl-key">RWQqOO/ZeIr3RBmolt/fx108m89QNEUndRTFrucjx0CBMoeFb7/xhrvW</code></li>
|
||||
<li>Or let the CLI do both: <code>storebound desktop --download</code>.</li>
|
||||
</ol>
|
||||
<p class="note">The npm package <code>storebound</code> stays the CLI, MCP server and agent skills; it does not contain this app. The app brings its own copy of the CLI.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="foot">
|
||||
<div class="wrap foot-in">
|
||||
<div>
|
||||
<a class="brand" href="../" aria-label="storebound home">
|
||||
<img class="mark" src="../img/logo-mark.png?v=2" srcset="../img/logo-mark.png?v=2 1x, ../img/logo-mark@2x.png?v=2 2x" width="28" height="28" alt="storebound logo">
|
||||
<span>storebound</span>
|
||||
</a>
|
||||
<p class="fine">MIT · made by CRTkafa · not affiliated with Google or Apple.<br>App Store is a trademark of Apple Inc.; Google Play is a trademark of Google LLC.</p>
|
||||
</div>
|
||||
<nav aria-label="Footer">
|
||||
<a href="https://git.crt.fyi/CRT/storebound/src/branch/main/docs/desktop.md" target="_blank" rel="noopener">desktop docs<span class="ext" aria-hidden="true">↗</span><span class="sr-only"> (opens in a new tab)</span></a>
|
||||
<a href="https://git.crt.fyi/CRT/storebound/src/branch/main/CHANGELOG.md" target="_blank" rel="noopener">CHANGELOG<span class="ext" aria-hidden="true">↗</span><span class="sr-only"> (opens in a new tab)</span></a>
|
||||
<a href="https://git.crt.fyi/CRT/storebound/src/branch/main/SECURITY.md" target="_blank" rel="noopener">SECURITY<span class="ext" aria-hidden="true">↗</span><span class="sr-only"> (opens in a new tab)</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Reads latest.json (same origin, published with the binaries) and fills the page. Everything from it is set as
|
||||
// text, never as HTML; only storebound.dev/desktop/dl/ links are shown.
|
||||
(function () {
|
||||
var LABEL = {
|
||||
'macos-universal-dmg': ['macOS 11 or later', 'Apple silicon and Intel, .dmg'],
|
||||
'windows-x86_64-nsis': ['Windows 10 / 11', 'x64 installer, installs for your user only'],
|
||||
'linux-x86_64-appimage': ['Linux', 'x64 AppImage (Ubuntu 22.04+, Debian 12+)'],
|
||||
'linux-x86_64-deb': ['Linux (Debian, Ubuntu)', 'x64 .deb package']
|
||||
};
|
||||
var ORDER = ['macos-universal-dmg', 'windows-x86_64-nsis', 'linux-x86_64-appimage', 'linux-x86_64-deb'];
|
||||
function el(tag, attrs, text) { var e = document.createElement(tag); for (var k in attrs || {}) e.setAttribute(k, attrs[k]); if (text != null) e.textContent = text; return e; }
|
||||
function mb(n) { return (n / 1e6).toFixed(1) + ' MB'; }
|
||||
function ours(u) { try { var x = new URL(u, location.href); return x.protocol === 'https:' && x.host === 'storebound.dev' && x.pathname.indexOf('/desktop/dl/') === 0; } catch (e) { return false; } }
|
||||
function system() {
|
||||
var p = ((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || '') + ' ' + navigator.userAgent;
|
||||
if (/android|iphone|ipad|ipod/i.test(p)) return 'mobile';
|
||||
if (/mac/i.test(p)) return 'macos-universal-dmg';
|
||||
if (/win/i.test(p)) return 'windows-x86_64-nsis';
|
||||
if (/linux|x11/i.test(p)) return 'linux-x86_64-appimage';
|
||||
return null;
|
||||
}
|
||||
var status = document.getElementById('status');
|
||||
var primary = document.getElementById('primary');
|
||||
var notice = document.getElementById('notice');
|
||||
fetch('latest.json', { cache: 'no-cache' }).then(function (r) {
|
||||
if (r.status === 404) throw new Error('none');
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
}).then(function (m) {
|
||||
var d = m.downloads || {};
|
||||
var keys = ORDER.filter(function (k) { return d[k] && ours(d[k].url); });
|
||||
if (!keys.length) throw new Error('none');
|
||||
var here = system();
|
||||
var pick = d[here] && keys.indexOf(here) >= 0 ? here : null;
|
||||
primary.textContent = '';
|
||||
if (pick) {
|
||||
var a = el('a', { 'class': 'btn btn-primary', href: d[pick].url }, 'Download for ' + LABEL[pick][0]);
|
||||
primary.appendChild(a);
|
||||
} else {
|
||||
primary.appendChild(el('a', { 'class': 'btn btn-primary', href: '#all' }, 'Choose your system'));
|
||||
}
|
||||
var meta = 'Version ' + m.version + (m.pub_date ? ' · ' + m.pub_date.slice(0, 10) : '') + (pick ? ' · ' + mb(d[pick].size) : '') + (here === 'mobile' ? ' · the desktop app runs on macOS, Windows and Linux' : '');
|
||||
primary.appendChild(el('p', { 'class': 'meta' }, meta));
|
||||
var unsigned = keys.filter(function (k) { return !d[k].codeSigned && (k === 'macos-universal-dmg' || k === 'windows-x86_64-nsis'); });
|
||||
if (unsigned.length) {
|
||||
var n = el('div', { 'class': 'dl-notice', role: 'note' });
|
||||
n.appendChild(el('b', null, 'Unsigned preview. '));
|
||||
n.appendChild(document.createTextNode('This early build is not yet signed by Apple or Microsoft, so your system asks once before opening it. Check the SHA-256 below first.'));
|
||||
var ol = el('ol');
|
||||
if (unsigned.indexOf('macos-universal-dmg') >= 0) ol.appendChild(el('li', null, 'macOS: drag storebound to Applications and open it; when macOS refuses, open System Settings > Privacy & Security and choose Open Anyway (once).'));
|
||||
if (unsigned.indexOf('windows-x86_64-nsis') >= 0) ol.appendChild(el('li', null, 'Windows: when SmartScreen shows "Windows protected your PC", choose More info > Run anyway.'));
|
||||
n.appendChild(ol);
|
||||
notice.appendChild(n);
|
||||
}
|
||||
var rows = document.getElementById('rows');
|
||||
keys.forEach(function (k) {
|
||||
var x = d[k];
|
||||
var tr = el('tr', k === pick ? { 'class': 'here' } : {});
|
||||
var sys = el('td');
|
||||
sys.appendChild(el('b', null, LABEL[k][0]));
|
||||
sys.appendChild(el('div', { 'class': 'small' }, LABEL[k][1] + ((k === 'macos-universal-dmg' || k === 'windows-x86_64-nsis') && !x.codeSigned ? ' · unsigned preview' : '')));
|
||||
var file = el('td');
|
||||
file.appendChild(el('a', { 'class': 'fname', href: x.url }, x.file));
|
||||
var sub = el('div', { 'class': 'small' }, mb(x.size) + ' · ');
|
||||
sub.appendChild(el('a', { href: x.url + '.sig' }, 'signature'));
|
||||
file.appendChild(sub);
|
||||
var sha = el('td');
|
||||
sha.appendChild(el('code', { 'class': 'sha' }, x.sha256));
|
||||
tr.appendChild(sys); tr.appendChild(file); tr.appendChild(sha);
|
||||
rows.appendChild(tr);
|
||||
});
|
||||
document.getElementById('table').hidden = false;
|
||||
}).catch(function (e) {
|
||||
var none = e && e.message === 'none';
|
||||
status.textContent = none
|
||||
? 'The first desktop release is not published yet. Until then the storebound CLI does everything the app will show.'
|
||||
: 'Could not read the release list (' + (e && e.message) + '). latest.json has every file and its SHA-256.';
|
||||
if (none) primary.appendChild(el('a', { 'class': 'btn btn-ghost', href: '../#start' }, 'Get the CLI'));
|
||||
else primary.appendChild(el('a', { 'class': 'btn btn-ghost', href: 'latest.json' }, 'latest.json'));
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<!-- The desktop app is a section of the home page now; this URL stays because the CLI (cli/lib/desktop-dist.ts PAGE)
|
||||
and older links point here. latest.json and dl/ next to it are served by the release server, not this file. -->
|
||||
<p>The desktop app moved to the home page: <a href="../#download">storebound.dev/#download</a>.</p>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
BIN
site/img/desktop/catalog-dark.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
site/img/desktop/catalog-light.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
site/img/desktop/checks-dark.webp
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
site/img/desktop/checks-light.webp
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
site/img/desktop/console-plan-dark.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
site/img/desktop/console-plan-light.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
site/img/desktop/growth-dark.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
site/img/desktop/growth-light.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
site/img/desktop/portfolio-dark.webp
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
site/img/desktop/portfolio-light.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
site/img/desktop/prices-dark.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
site/img/desktop/prices-light.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
site/img/desktop/send-for-review-dark.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
site/img/desktop/send-for-review-light.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
site/img/desktop/testflight-dark.webp
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
site/img/desktop/testflight-light.webp
Normal file
|
After Width: | Height: | Size: 50 KiB |
15
site/img/term/check.txt
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
$ storebound check
|
||||
[check] config ok: storebound.config.ts
|
||||
[check] play listing: 2 languages
|
||||
[check] hint: the App Store name is "Example App" in every locale: App Store names are unique, so if it is taken App Store Connect refuses the record ("already being used"). Then set apple.name with a short suffix, e.g. "Example App: <tagline>" (30 characters at most); app.name stays the Play and on-device name
|
||||
[check] appstore listing: 2 locales
|
||||
[check] data safety: 768 CSV rows
|
||||
[check] shots copy: 2 languages x 4 scenes
|
||||
[check] hint: Play App category "Productivity" (apple.info.categories (PRODUCTIVITY)) is a console step, not an API one: `storebound console plan play-store-settings`
|
||||
[check] WARN 31 Play App content answer(s) are not in the config: ask the owner in one batch at the start (`storebound console plan play-app-content` lists them), then write them into the config
|
||||
- App Review needs a demo account (sign-in required) · add reviewDemoAccount to .storebound/credentials.json · review-access skill
|
||||
- missing file: store/icon/icon.png
|
||||
- missing file: store/icon/foreground.png
|
||||
- missing file: store/icon/background.png
|
||||
|
||||
error: 4 problems
|
||||
BIN
site/img/term/check.webp
Normal file
|
After Width: | Height: | Size: 60 KiB |
160
site/img/term/console-plan.txt
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
$ storebound console plan play-content-rating
|
||||
Play Console: Content rating (IARC)
|
||||
start: https://play.google.com/console
|
||||
then: Pick the developer account if asked (set play.developerId for a deep link)
|
||||
then: the app (com.example.prismpuddle) in the app list
|
||||
then: Policy and programs > App content
|
||||
then: Content rating > Start (or Manage)
|
||||
|
||||
Steps:
|
||||
1. Open App content > Content ratings (/app-content/content-rating-overview), then "Start questionnaire": it opens /app-content/content-rating-iarc-questionnaire. [click: "Start questionnaire"]
|
||||
2. Page 1: type the email address and pick the category (material radio: JS click on the inner [role=radio]). The IARC Terms of Use checkbox is an agreement: never tick it; the owner ticks it (it is in the batch). Then Next. [click: "Next"]
|
||||
3. The questionnaire is ONE long page of sections (Violence, Fear, Sexuality, Gambling, Language, Controlled Substance, Crude Humor, Digital Purchases, Miscellaneous). Answer each section's Yes / No as the sheet says; each Yes reveals follow-ups right below it, so answer top to bottom and re-read the page after every Yes. Run it as one in-page job (plan.inPageLoop) and poll window.__log.
|
||||
4. Violence follow-ups (after Yes): the humans / non-humans checkboxes, then setting, childlike style, reactions, presentation checkboxes, blood level, historical war setting, innocent characters, fierce sounds.
|
||||
5. Fear, Sexuality, Gambling, Language, Controlled Substance, Crude Humor follow-ups (after Yes): answer the questions shown as the sheet says (details.<section>.other).
|
||||
6. Digital Purchases follow-ups (after "purchases of digital goods" Yes): the random items (loot box) question, then trading between players.
|
||||
7. Miscellaneous: user interaction (after Yes: block, report, moderation, friends only), location sharing, Nazi symbols, Korea history, terrorism, crimes.
|
||||
8. Press Save (stores the draft; nothing goes to IARC yet), then Next: the Summary shows the rating per authority (PEGI, ESRB, USK, ClassInd, Google Play ...). [click: "Save", "Next"]
|
||||
9. Summary: stop. Do NOT press its Save (the final button here is "Save", not "Submit"): tell the owner the ratings shown and let them press Save (it applies the IARC ratings).
|
||||
10. Verify: read the Summary page as text and pipe it to `storebound console verify play-content-rating --from-text -`.
|
||||
|
||||
Answer sheet:
|
||||
[ok] Email address: s***@example.com -> type "Email address" @ Email address and category
|
||||
[ask] Category: (you decide) -> pick "Category" @ Email address and category
|
||||
Pick the category; set console.contentRating.category to have it filled.
|
||||
[you] IARC Terms of Use: the owner ticks it (an agreement) -> untick "IARC Terms of Use" @ Email address and category
|
||||
An agreement on page 1 (a checkbox): only the owner ticks it, never the agent or storebound.
|
||||
[ask] Violence: (you decide) -> pick "Does the app contain (any )violen" @ Questionnaire (Does the app contain (any )violen)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.violence.humans, violence.nonHumans, violence.setting, violence.childlike, violence.reactions, violence.presentation, violence.blood, violence.historicalWar, violence.innocents, violence.fierceSounds too.
|
||||
[ask] Fear / horror: (you decide) -> pick "Does the app contain …scary" @ Questionnaire (Does the app contain …scary)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.fear.other too.
|
||||
[ask] Sexuality or nudity: (you decide) -> pick "Does the app contain …sexual" @ Questionnaire (Does the app contain …sexual)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.sexuality.other too.
|
||||
[ask] Language (profanity): (you decide) -> pick "Does the app contain …profan" @ Questionnaire (Does the app contain …profan)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.language.other too.
|
||||
[ask] Controlled substances (drugs, alcohol, tobacco): (you decide) -> pick "Does the app contain …drug" @ Questionnaire (Does the app contain …drug)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.controlledSubstances.other too.
|
||||
[ask] Crude humour: (you decide) -> pick "crude humour" @ Questionnaire (crude humour)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.crudeHumor.other too.
|
||||
[ask] Gambling with real money: (you decide) -> pick "Does the app allow …gambl…real money" @ Questionnaire (Does the app allow …gambl…real money)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.gambling.other too.
|
||||
[ask] Simulated gambling: (you decide) -> pick "simulated gambling" @ Questionnaire (simulated gambling)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.gambling.other too.
|
||||
[ok] Random paid items (loot boxes): No -> pick "No" @ Questionnaire (random (paid )items)
|
||||
[ask] Users can interact or exchange content: (you decide) -> pick "users …interact" @ Questionnaire (users …interact)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.userInteraction.block, userInteraction.report, userInteraction.moderation, userInteraction.friendsOnly too.
|
||||
[ask] Shares the user's location with other users: (you decide) -> pick "share …(physical )location" @ Questionnaire (share …(physical )location)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled.
|
||||
[ask] Digital purchases: (you decide) -> pick "digital goods" @ Questionnaire (digital goods)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.digitalPurchases.playerTrading too.
|
||||
[ask] Unrestricted internet access (browser): (you decide) -> pick "unrestricted internet" @ Questionnaire (unrestricted internet)
|
||||
Answer from the app's real content; set console.contentRating.answers to have it filled.
|
||||
[ask] Miscellaneous: Nazi symbols or references?: (you decide) -> pick "nazi" @ Questionnaire: Miscellaneous follow-ups (nazi)
|
||||
Answer from the app's real content; set console.contentRating.details.miscellaneous.naziSymbols to have it filled.
|
||||
[ask] Miscellaneous: content about Korean history that may be sensitive?: (you decide) -> pick "korea" @ Questionnaire: Miscellaneous follow-ups (korea)
|
||||
Answer from the app's real content; set console.contentRating.details.miscellaneous.koreaHistory to have it filled.
|
||||
[ask] Miscellaneous: terrorism or extremist content?: (you decide) -> pick "terror" @ Questionnaire: Miscellaneous follow-ups (terror)
|
||||
Answer from the app's real content; set console.contentRating.details.miscellaneous.terrorism to have it filled.
|
||||
[ask] Miscellaneous: depicts or encourages crimes?: (you decide) -> pick "crime" @ Questionnaire: Miscellaneous follow-ups (crime)
|
||||
Answer from the app's real content; set console.contentRating.details.miscellaneous.crimes to have it filled.
|
||||
|
||||
Never press: "IARC Terms of Use box", "Save (Summary page)", "Publish", "Submit", "Create", "Create app", "Send", "Release", "Definitions and Examples", "?", "More information"
|
||||
Final button (the owner's): Save (Summary page)
|
||||
Rule: Press only navigation (Next, Back, Get Started, Edit, Set Up, Start / Manage links) and draft saves (Save draft / Save (questionnaire page)). Never press Save (Summary page) or anything that publishes, submits, creates, sends or accepts; stop and tell the owner.
|
||||
|
||||
UI facts:
|
||||
- Verified 2026-10-12 (an agent with a browser tool on a real game):
|
||||
- App content > Content ratings is /app-content/content-rating-overview; "Start questionnaire" opens /app-content/content-rating-iarc-questionnaire.
|
||||
- Page 1: email address, the category radio, and an IARC Terms of Use checkbox: an agreement, the owner ticks it, never the agent.
|
||||
- The questionnaire is one long page of sections; each Yes reveals follow-ups (Violence: humans / non-humans checkboxes, then setting, childlike style, reactions, presentation checkboxes, blood level, historical war setting, innocent characters, fierce sounds; Digital Purchases: digital goods, then loot boxes, then player trading; Miscellaneous: user interaction then block / report / moderation / friends-only, location sharing, Nazi symbols, Korea history, terrorism, crimes). Follow-up wording here is storebound's: match by topic and the console's own labels.
|
||||
- Save on the questionnaire stores it; Next goes to the Summary with per-authority ratings. The Summary's final button is "Save" (not "Submit"): the owner's.
|
||||
- Example result (the game of that run): PEGI 7, ESRB E10+, USK 12, ClassInd 18 (paid random items alone made it 18), Google Play 7+.
|
||||
- The email address field gets the full app.contactEmail from the config (printed masked here).
|
||||
- Play Console radios and checkboxes are <material-radio> / <material-checkbox>: computer-tool clicks and setting the native input (form_input) can leave the Angular model unchanged. Click the inner [role=radio] / [role=checkbox] (or the <input> itself) with a JS el.click(), then read aria-checked (or input.checked). Their labels sit in sibling nodes, not in an aria-label: walk up from the control to the nearest parent whose innerText is not empty and match by "starts with" (verified 2026-10-12).
|
||||
- An app's Play Console home is /console/u/0/developers/<developerId>/app/<appId>/app-dashboard (verified 2026-10-12); with play.appId (or the id storebound remembered) the plan starts there.
|
||||
|
||||
Driving:
|
||||
- Use your own browser tool (Claude in Chrome, a built-in browser, Playwright). Read the page as text or an accessibility tree first; click by visible text; adapt when the page differs from these steps (the console changes without notice), but never press anything in buttons.neverPress.
|
||||
- Long flows run INSIDE the page: one Runtime.evaluate / javascript call times out at about 45 s on big Play pages (the IARC questionnaire, App content), so never loop over many controls in one call. Paste plan.inPageLoop once per page (one short call; it defines window.__sb), start the job with window.__sb.run([{ role: "radio", label: "No", within: "<question text>" }, { role: "checkbox", label: "<box label>", want: true }, ...]) (it returns at once), then poll window.__log.slice(-20) and window.__done in short calls every 2-5 s until the log says done. The helper only picks radios and ticks boxes (never agreements); press Next / Save yourself afterwards, by their visible text.
|
||||
- Wait for each step to render (poll for the next heading or input, up to ~10 s) before acting; never act on a step you have not read.
|
||||
- An answer with status ask-owner: ask the owner in chat before filling it (AGENTS.md rule 5), or leave it empty and tell the owner. Never guess.
|
||||
- Stop before the final button (buttons.final) and tell the owner exactly what to press; then run the verify command.
|
||||
- Never sign in, type a password, accept an agreement or tick a declaration: the owner does that in the browser.
|
||||
|
||||
In-page helper (plan.inPageLoop: paste it once per page in one short call, then window.__sb.run([...]) and poll window.__log):
|
||||
(() => {
|
||||
const norm = (t) => (t || "").replace(/\s+/g, " ").trim();
|
||||
const log = (m) => { window.__log.push(new Date().toISOString().slice(11, 19) + " " + m); };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const labelOf = (el) => {
|
||||
const own = norm(el.getAttribute("aria-label"));
|
||||
if (own) return own;
|
||||
for (let a = el; a && a !== document.body; a = a.parentElement) { const t = norm(a.innerText); if (t) return t; }
|
||||
return "";
|
||||
};
|
||||
const NEVER = /terms of use|terms of service|i agree|agree to|declaration|developer program polic|export laws/i;
|
||||
const CONTROLS = "[role=radio], [role=checkbox], input[type=radio], input[type=checkbox]";
|
||||
const scopeOf = (text) => {
|
||||
const want = norm(text).toLowerCase(); let best = null;
|
||||
for (const el of document.querySelectorAll("section, fieldset, div, [role=radiogroup], [role=group]"))
|
||||
if (norm(el.innerText).toLowerCase().includes(want) && el.querySelector(CONTROLS) && (!best || best.contains(el))) best = el;
|
||||
return best;
|
||||
};
|
||||
const find = (role, label, scope) => {
|
||||
const want = norm(label).toLowerCase();
|
||||
const hits = [...(scope || document).querySelectorAll("[role=" + role + "], input[type=" + role + "]")]
|
||||
.filter((el) => el.getClientRects().length && labelOf(el).toLowerCase().startsWith(want));
|
||||
return hits.length === 1 ? hits[0] : null;
|
||||
};
|
||||
const set = async (s) => {
|
||||
const scope = s.within ? scopeOf(s.within) : null;
|
||||
if (s.within && !scope) return "no block with \"" + s.within + "\"";
|
||||
const el = find(s.role, s.label, scope);
|
||||
if (!el) return "not found or ambiguous";
|
||||
if (NEVER.test(labelOf(el))) return "refused: an agreement (the owner ticks it)";
|
||||
const host = el.closest("material-radio, material-checkbox");
|
||||
const inner = el.matches("[role=radio], [role=checkbox]") ? el : (host && host.querySelector("[role=radio], [role=checkbox]")) || el;
|
||||
const state = () => (inner.hasAttribute("aria-checked") ? inner.getAttribute("aria-checked") === "true" : !!inner.checked);
|
||||
const want = s.want !== false;
|
||||
if (state() !== want) inner.click();
|
||||
await sleep(150);
|
||||
return state() === want ? "ok" : "did not change (read the control, try the <input> itself)";
|
||||
};
|
||||
window.__log = []; window.__done = false;
|
||||
window.__sb = { log, sleep, labelOf, scopeOf, find, set };
|
||||
window.__sb.run = (steps) => {
|
||||
window.__done = false;
|
||||
window.__run = (async () => {
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const s = steps[i], tag = (i + 1) + "/" + steps.length + " " + s.role + " \"" + s.label + "\"" + (s.within ? " in \"" + s.within + "\"" : "");
|
||||
try { log(tag + ": " + await set(s)); } catch (e) { log(tag + ": error " + e.message); }
|
||||
}
|
||||
log("done"); window.__done = true;
|
||||
})();
|
||||
return "started " + steps.length + " steps: poll window.__log";
|
||||
};
|
||||
return "storebound helper ready: window.__sb.run([...]), then poll window.__log.slice(-20) and window.__done";
|
||||
})()
|
||||
|
||||
Verify: storebound console verify play-content-rating --from-text -
|
||||
- The Summary shows each question with the Yes / No of the answer sheet, and a rating per authority.
|
||||
|
||||
Ask the owner first:
|
||||
- Category: ask the owner (Pick the category; set console.contentRating.category to have it filled.)
|
||||
- IARC Terms of Use: an agreement, the owner ticks it
|
||||
- Violence: ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.violence.humans, violence.nonHumans, violence.setting, violence.childlike, violence.reactions, violence.presentation, violence.blood, violence.historicalWar, violence.innocents, violence.fierceSounds too.)
|
||||
- Fear / horror: ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.fear.other too.)
|
||||
- Sexuality or nudity: ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.sexuality.other too.)
|
||||
- Language (profanity): ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.language.other too.)
|
||||
- Controlled substances (drugs, alcohol, tobacco): ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.controlledSubstances.other too.)
|
||||
- Crude humour: ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.crudeHumor.other too.)
|
||||
- Gambling with real money: ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.gambling.other too.)
|
||||
- Simulated gambling: ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.gambling.other too.)
|
||||
- Users can interact or exchange content: ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.userInteraction.block, userInteraction.report, userInteraction.moderation, userInteraction.friendsOnly too.)
|
||||
- Shares the user's location with other users: ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled.)
|
||||
- Digital purchases: ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled. If Yes, the page asks follow-ups: set console.contentRating.details.digitalPurchases.playerTrading too.)
|
||||
- Unrestricted internet access (browser): ask the owner (Answer from the app's real content; set console.contentRating.answers to have it filled.)
|
||||
- Miscellaneous: Nazi symbols or references?: ask the owner (Answer from the app's real content; set console.contentRating.details.miscellaneous.naziSymbols to have it filled.)
|
||||
- Miscellaneous: content about Korean history that may be sensitive?: ask the owner (Answer from the app's real content; set console.contentRating.details.miscellaneous.koreaHistory to have it filled.)
|
||||
- Miscellaneous: terrorism or extremist content?: ask the owner (Answer from the app's real content; set console.contentRating.details.miscellaneous.terrorism to have it filled.)
|
||||
- Miscellaneous: depicts or encourages crimes?: ask the owner (Answer from the app's real content; set console.contentRating.details.miscellaneous.crimes to have it filled.)
|
||||
BIN
site/img/term/console-plan.webp
Normal file
|
After Width: | Height: | Size: 115 KiB |
10
site/img/term/products.txt
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
$ storebound play products --dry
|
||||
[play] 1/2 com.example.app.no_ads: would create · 174 regions · 2 listings (dry)
|
||||
[play] 2/2 com.example.app.gems_100: would create · 174 regions · 2 listings (dry)
|
||||
dry run: play products: 2 change(s); nothing written
|
||||
item action detail
|
||||
------------------------ ------ ---------------------------------
|
||||
com.example.app.no_ads create 174 regions, 2 listings, USD 2.99
|
||||
com.example.app.gems_100 create 174 regions, 2 listings, USD 0.99
|
||||
[play] not on Google Play: com.example.app.no_ads, com.example.app.gems_100. If you created them in the last minutes, the store's list can lag a minute after creating; re-run before acting on this plan
|
||||
[play] products checked (dry run, nothing written)
|
||||
BIN
site/img/term/products.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
59
site/img/term/release-plan.txt
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
$ storebound release plan 1.0.0
|
||||
[release] Example App: validate (read-only, both stores)
|
||||
Example App 1.0.0
|
||||
both stores (fix first):
|
||||
1. CONFIG App Review needs a demo account (sign-in required) · add reviewDemoAccount to .storebound/credentials.json · review-access skill
|
||||
-> storebound check
|
||||
2. CONFIG missing file: store/icon/icon.png
|
||||
-> storebound check
|
||||
3. CONFIG missing file: store/icon/foreground.png
|
||||
-> storebound check
|
||||
4. CONFIG missing file: store/icon/background.png
|
||||
-> storebound check
|
||||
App Store: not on the store
|
||||
5. STORE 1.0.0 is not on App Store yet: upload a build (Xcode / Transporter), then create the version with its texts
|
||||
-> storebound apple next-build; storebound apple listing --version 1.0.0 --build <N> --dry
|
||||
6. BLOCKER no App Store version being prepared
|
||||
-> storebound apple listing --version x.y.z
|
||||
7. BLOCKER no build attached to the version
|
||||
-> storebound apple listing --version x.y.z --build N (pick the build number you just uploaded)
|
||||
8. BLOCKER en-US: no localization on the version
|
||||
-> storebound apple listing
|
||||
9. BLOCKER tr: no localization on the version
|
||||
-> storebound apple listing
|
||||
10. BLOCKER no App Review information
|
||||
-> storebound apple review-details
|
||||
11. BLOCKER age rating declaration not readable / missing
|
||||
-> storebound apple appinfo
|
||||
12. BLOCKER no app price schedule
|
||||
-> storebound apple appinfo
|
||||
13. BLOCKER sign-in required for review but no demo account in .storebound/credentials.json (reviewDemoAccount) or REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD
|
||||
-> review-access skill
|
||||
14. BLOCKER 2.1(a): Sign-in is required but there is no demo account for App Review (storebound.config.ts:35)
|
||||
-> Create a demo account that sees every feature (not a sign-up the reviewer must do), put it in .storebound/credentials.json as reviewDemoAccount (never in the repo), then `storebound apple review-details`. · review-access skill
|
||||
15. warn 2.1(a): Placeholder or template text in the store listing (store/appstore.md:20)
|
||||
-> Replace the template lines and any lorem ipsum / TODO with the real copy before you push the listing. · aso-basics skill
|
||||
Google Play: not on the store
|
||||
16. STORE 1.0.0 is not on Google Play yet: upload the AAB to a testing track
|
||||
-> storebound play next-version-code; storebound play release <aab> --internal completed --dry
|
||||
17. BLOCKER no listing for the default language en-US
|
||||
-> storebound play listing
|
||||
18. BLOCKER en-US: no listing
|
||||
-> storebound play listing
|
||||
19. BLOCKER tr-TR: no listing
|
||||
-> storebound play listing
|
||||
20. BLOCKER no release on any track
|
||||
-> storebound play release <aab>
|
||||
21. BLOCKER sign-in required for review but no demo account in .storebound/credentials.json (reviewDemoAccount) or REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD
|
||||
-> review-access skill
|
||||
22. BLOCKER Play: Package name: Package name com.example.app uses the reserved com.example prefix (storebound.config.ts:46)
|
||||
-> Pick your own reverse-domain package name (it can never change after the first upload) and set it in the Gradle applicationId and play.packageName. · play-first-release skill
|
||||
23. warn production has 0 countries / regions (the API cannot set them)
|
||||
-> storebound console plan play-production-countries
|
||||
24. warn sign-in required: Play Console > App content > App access must list the demo credentials and steps (no API)
|
||||
-> review-access skill
|
||||
25. warn Play: App access: Sign-in is required but there are no reviewer credentials for Play App access (storebound.config.ts:35)
|
||||
-> Play Console > App content > App access: add the demo account and instructions (keep the credentials in .storebound/credentials.json only). · review-access skill
|
||||
26. warn Play: Metadata: Placeholder or template text in the store listing (store/listing.md:23)
|
||||
-> Replace the template lines and any lorem ipsum / TODO with the real copy before you push the listing. · aso-basics skill
|
||||
|
||||
BIN
site/img/term/release-plan.webp
Normal file
|
After Width: | Height: | Size: 107 KiB |
37
site/img/term/scan.txt
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
$ storebound scan
|
||||
storebound scan · storebound.config.ts
|
||||
stack: iOS (native) · targets: iOS · 11 files
|
||||
|
||||
SDKs
|
||||
Google AdMob ads, tracking-capable · CocoaPods Google-Mobile-Ads-SDK 11.10.0 (Podfile.lock:2)
|
||||
Google Sign-In auth · CocoaPods GoogleSignIn 7.1.0 (Podfile.lock:3)
|
||||
|
||||
Rejection risks: 2 blocker, 3 high, 0 medium, 0 low
|
||||
1. BLOCKER 2.1(a) · App Store · Sign-in is required but there is no demo account for App Review
|
||||
at storebound.config.ts:12 reviewAccess: { signInRequired: true, instructions: 'Sign in with the demo account.' },
|
||||
at storebound.config.ts:11 accounts: { enabled: true },
|
||||
at Podfile.lock:3 Google Sign-In: GoogleSignIn 7.1.0
|
||||
fix: Create a demo account that sees every feature (not a sign-up the reviewer must do), put it in .storebound/credentials.json as reviewDemoAccount (never in the repo), then `storebound apple review-details`.
|
||||
skill: review-access · https://developer.apple.com/app-store/review/guidelines/
|
||||
2. BLOCKER 5.1.1(v) · App Store · Accounts can be created but no in-app account deletion was found
|
||||
at storebound.config.ts:11 accounts: { enabled: true },
|
||||
at Planner/LoginView.swift:11 Button("Create account") { Task { try await AccountApi.signUp() } }
|
||||
at Planner/LoginView.swift:17 static func signUp() async throws {
|
||||
fix: Add "Delete account" in the app (it deletes the account and its data, not only signs out; with Sign in with Apple also revoke the token), then set accounts.inApp to where it starts, e.g. "Settings > Account > Delete account".
|
||||
skill: account-deletion · https://developer.apple.com/app-store/review/guidelines/
|
||||
3. HIGH 4.8 · App Store · Third-party login (Google / Facebook / ...) without Sign in with Apple or another equivalent login option
|
||||
at Podfile.lock:3 Google Sign-In: GoogleSignIn 7.1.0
|
||||
at Planner/LoginView.swift:9 GIDSignIn.sharedInstance.signIn(withPresenting: rootViewController()) { _, _ in }
|
||||
fix: Offer Sign in with Apple (Signing & Capabilities > Sign in with Apple, ASAuthorizationAppleIDProvider) or another login that limits data to name and email, lets people hide their email and does not track. Not needed when the app only uses your own account system, or for the exemptions in 4.8 (education / enterprise accounts, government ID, a client for that one service).
|
||||
skill: review-rejections · https://developer.apple.com/app-store/review/guidelines/
|
||||
4. HIGH 3.1.1 · App Store · Restorable purchases (non-consumables or subscriptions) but no Restore Purchases path was found
|
||||
at storebound.config.ts:16 com.example.planner.pro (non_consumable)
|
||||
fix: Add a visible "Restore Purchases" button (StoreKit 2: AppStore.sync(); StoreKit 1: restoreCompletedTransactions; RevenueCat: restorePurchases) on the store / paywall screen or in Settings.
|
||||
skill: store-iap-setup · https://developer.apple.com/app-store/review/guidelines/
|
||||
5. HIGH 5.1.2(i) · App Store · Tracking-capable code without App Tracking Transparency (Google AdMob, IDFA)
|
||||
at Podfile.lock:2 Google AdMob: Google-Mobile-Ads-SDK 11.10.0
|
||||
at Planner/Ads.swift:7 print(ASIdentifierManager.shared().advertisingIdentifier)
|
||||
fix: If any SDK tracks (personalized ads, attribution, IDFA): add NSUserTrackingUsageDescription and request ATT before the SDKs start, and answer App Privacy "used for tracking". If nothing tracks: configure the ad SDKs for non-personalized ads and answer "no tracking".
|
||||
skill: ads-mediation-checks · https://developer.apple.com/app-store/review/guidelines/
|
||||
|
||||
… 52 more lines: the declarations proposal (Data safety, App Privacy, ATT, privacy manifest) and the config diff
|
||||
BIN
site/img/term/scan.webp
Normal file
|
After Width: | Height: | Size: 112 KiB |
984
site/index.html
87
site/main.js
|
|
@ -1,4 +1,5 @@
|
|||
// storebound.dev: copy buttons, AI tool tabs, the hero demo (real CLI output) and the How it works stepper. No network, no storage.
|
||||
// storebound.dev: copy buttons, tabs (AI tools, desktop screens), the teaser player, the demo (real CLI output), the
|
||||
// How it works stepper and the desktop downloads (reads desktop/latest.json, same origin). No storage, no third parties.
|
||||
(() => {
|
||||
const copyText = async text => {
|
||||
try { await navigator.clipboard.writeText(text); return true; } catch {}
|
||||
|
|
@ -268,4 +269,88 @@
|
|||
wide.addEventListener('change', layout);
|
||||
layout();
|
||||
}
|
||||
// Desktop downloads: desktop/latest.json (same origin, published with the binaries by tools/desktop-release.ts) fills
|
||||
// the per-system buttons and the checksum table. Everything from it is set as text, never as HTML; only
|
||||
// storebound.dev/desktop/dl/ links are used. Before the first release the buttons stay off and say so.
|
||||
const dl = document.getElementById('download');
|
||||
if (dl) {
|
||||
const LABEL = {
|
||||
'macos-universal-dmg': ['macOS 11 or later', 'Apple silicon and Intel, .dmg'],
|
||||
'windows-x86_64-nsis': ['Windows 10 / 11', 'x64 installer, installs for your user only'],
|
||||
'linux-x86_64-appimage': ['Linux', 'x64 AppImage (Ubuntu 22.04+, Debian 12+)'],
|
||||
'linux-x86_64-deb': ['Linux (Debian, Ubuntu)', 'x64 .deb package'],
|
||||
};
|
||||
const ORDER = Object.keys(LABEL);
|
||||
const el = (tag, attrs, text) => { const e = document.createElement(tag); for (const k in attrs || {}) e.setAttribute(k, attrs[k]); if (text != null) e.textContent = text; return e; };
|
||||
const mb = n => (n / 1e6).toFixed(1) + ' MB';
|
||||
const ours = u => { try { const x = new URL(u, location.href); return x.protocol === 'https:' && x.host === 'storebound.dev' && x.pathname.startsWith('/desktop/dl/'); } catch { return false; } };
|
||||
const system = () => {
|
||||
const p = ((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || '') + ' ' + navigator.userAgent;
|
||||
if (/android|iphone|ipad|ipod/i.test(p)) return 'mobile';
|
||||
if (/mac/i.test(p)) return 'macos-universal-dmg';
|
||||
if (/win/i.test(p)) return 'windows-x86_64-nsis';
|
||||
if (/linux|x11/i.test(p)) return 'linux-x86_64-appimage';
|
||||
return null;
|
||||
};
|
||||
const status = document.getElementById('dl-status');
|
||||
const notice = document.getElementById('dl-notice');
|
||||
const buttons = [...dl.querySelectorAll('[data-dl]')];
|
||||
const off = text => {
|
||||
status.textContent = text;
|
||||
for (const b of buttons) b.querySelector('small').textContent += ' · coming soon';
|
||||
};
|
||||
fetch('desktop/latest.json', { cache: 'no-cache' }).then(r => {
|
||||
if (r.status === 404) throw new Error('none');
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
}).then(m => {
|
||||
const d = m.downloads || {};
|
||||
const keys = ORDER.filter(k => d[k] && ours(d[k].url));
|
||||
if (!keys.length) throw new Error('none');
|
||||
const here = system();
|
||||
status.textContent = 'Version ' + m.version + (m.pub_date ? ' · ' + m.pub_date.slice(0, 10) : '') + (here === 'mobile' ? ' · the desktop app runs on macOS, Windows and Linux' : '');
|
||||
for (const b of buttons) {
|
||||
const k = b.dataset.dl, x = d[k];
|
||||
if (!x || !keys.includes(k)) { b.querySelector('small').textContent += ' · not in this release'; continue; }
|
||||
b.href = x.url;
|
||||
b.classList.remove('is-off');
|
||||
b.removeAttribute('aria-disabled');
|
||||
b.querySelector('small').textContent += ' · ' + mb(x.size) + (k === here ? ' · your system' : '');
|
||||
if (k === here) { b.classList.add('is-here', 'btn-primary'); b.classList.remove('btn-ghost'); }
|
||||
}
|
||||
const unsigned = keys.filter(k => !d[k].codeSigned && (k === 'macos-universal-dmg' || k === 'windows-x86_64-nsis'));
|
||||
if (unsigned.length) {
|
||||
const n = el('div', { class: 'dl-notice', role: 'note' });
|
||||
n.appendChild(el('b', null, 'Unsigned preview. '));
|
||||
n.appendChild(document.createTextNode('This early build is not yet signed by Apple or Microsoft, so your system asks once before opening it. Check the SHA-256 under "Checksums and signatures" first.'));
|
||||
const ol = el('ol');
|
||||
if (unsigned.includes('macos-universal-dmg')) ol.appendChild(el('li', null, 'macOS: drag storebound to Applications and open it; when macOS refuses, open System Settings > Privacy & Security and choose Open Anyway (once).'));
|
||||
if (unsigned.includes('windows-x86_64-nsis')) ol.appendChild(el('li', null, 'Windows: when SmartScreen shows "Windows protected your PC", choose More info > Run anyway.'));
|
||||
n.appendChild(ol);
|
||||
notice.appendChild(n);
|
||||
}
|
||||
const rows = document.getElementById('dl-rows');
|
||||
for (const k of keys) {
|
||||
const x = d[k];
|
||||
const tr = el('tr');
|
||||
const sys = el('td');
|
||||
sys.appendChild(el('b', null, LABEL[k][0]));
|
||||
sys.appendChild(el('div', { class: 'small' }, LABEL[k][1] + ((k === 'macos-universal-dmg' || k === 'windows-x86_64-nsis') && !x.codeSigned ? ' · unsigned preview' : '')));
|
||||
const file = el('td');
|
||||
file.appendChild(el('a', { class: 'fname', href: x.url }, x.file));
|
||||
const sub = el('div', { class: 'small' }, mb(x.size) + ' · ');
|
||||
sub.appendChild(el('a', { href: x.url + '.sig' }, 'signature'));
|
||||
file.appendChild(sub);
|
||||
const sha = el('td');
|
||||
sha.appendChild(el('code', { class: 'sha' }, x.sha256));
|
||||
tr.append(sys, file, sha);
|
||||
rows.appendChild(tr);
|
||||
}
|
||||
document.getElementById('dl-table').hidden = false;
|
||||
}).catch(e => {
|
||||
off(e && e.message === 'none'
|
||||
? 'The first desktop release is not published yet. Until then the storebound CLI does everything the app shows.'
|
||||
: 'Could not read the release list (' + (e && e.message) + '). desktop/latest.json has every file and its SHA-256.');
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -2,10 +2,6 @@
|
|||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://storebound.dev/</loc>
|
||||
<lastmod>2026-10-12</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://storebound.dev/desktop/</loc>
|
||||
<lastmod>2026-10-12</lastmod>
|
||||
<lastmod>2026-10-13</lastmod>
|
||||
</url>
|
||||
</urlset>
|
||||
|
|
|
|||
|
|
@ -661,3 +661,88 @@ h2 { font-size: clamp(28px, 4.2vw, 40px); font-weight: 720; letter-spacing: -.03
|
|||
.st-next .ic { width: 15px; height: 15px; transition: transform .15s ease; }
|
||||
.st-next:hover .ic { transform: translateX(2px); }
|
||||
@media (min-width: 1024px) { html.js .st-panel:not([hidden]) { display: flex; flex-direction: column; } }
|
||||
|
||||
/* ---- 2026-10-13 one-page story: hero teaser, the whole job, desktop tabs + downloads, your AI, what it covers ---- */
|
||||
/* phone: the section links scroll sideways in a second row instead of disappearing */
|
||||
@media (max-width: 640px) {
|
||||
.top-in { height: auto; flex-wrap: wrap; padding-top: 10px; padding-bottom: 6px; row-gap: 4px; }
|
||||
.top nav.nav-main { position: relative; order: 3; width: calc(100% + 2 * var(--gutter)); overflow-x: auto; scrollbar-width: none; margin: 0 calc(-1 * var(--gutter)); padding: 0 var(--gutter) 4px; }
|
||||
.top nav.nav-main::-webkit-scrollbar { display: none; }
|
||||
.top nav.nav-main a:not(.nav-git) { display: inline-block; white-space: nowrap; flex: none; padding: 6px 9px; font-size: 14px; }
|
||||
.top nav.nav-main .nav-git { flex: none; }
|
||||
html { scroll-padding-top: 108px; }
|
||||
}
|
||||
.hero-video { margin: 0; min-width: 0; position: relative; }
|
||||
.hero-video::before { content: ""; position: absolute; inset: 8% -4% -6% 6%; z-index: -1; border-radius: 40px; background: color-mix(in srgb, var(--accent) 28%, transparent); filter: blur(60px); opacity: .5; }
|
||||
.hero-video video { width: 100%; aspect-ratio: 16 / 9; border-radius: 14px; border: 1px solid rgb(148 163 184 / .2); background: #0b1020; box-shadow: var(--shadow); }
|
||||
.hero-video figcaption { margin-top: 12px; font-size: 13px; color: var(--muted); text-align: center; text-wrap: balance; }
|
||||
|
||||
.sub-h { font-size: 20px; font-weight: 700; margin: 56px 0 18px; }
|
||||
.ship { display: grid; gap: 28px; margin-top: 36px; align-items: start; }
|
||||
@media (min-width: 1020px) { .ship { grid-template-columns: minmax(0, 1.2fr) minmax(0, .8fr); } }
|
||||
.does { list-style: none; padding: 0; margin: 0; display: grid; gap: 12px; }
|
||||
.does li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; display: grid; gap: 4px; min-width: 0; }
|
||||
.does b { font-size: 16px; color: var(--ink); }
|
||||
.does span { font-size: 14.5px; color: var(--ink-2); }
|
||||
.ship-demo { max-width: 760px; }
|
||||
.cols-you { display: grid; gap: 20px; min-width: 0; }
|
||||
.cols-you .sub { margin-top: 0; }
|
||||
.you-box { border: 1px solid var(--you-line); background: color-mix(in srgb, var(--you-bg) 45%, var(--surface)); border-radius: var(--radius); padding: 18px 20px; }
|
||||
.you-box ul { margin: 12px 0 0; padding-left: 18px; color: var(--ink-2); font-size: 15px; display: grid; gap: 6px; }
|
||||
.you-box strong { color: var(--ink); }
|
||||
|
||||
/* real CLI output as text */
|
||||
.term-real { margin: 0; }
|
||||
.term-real pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12.5px; line-height: 1.6; max-height: 560px; overflow-y: auto; }
|
||||
.term-real .o { color: color-mix(in srgb, var(--code-ink) 62%, var(--code-bg)); }
|
||||
.term-cap { padding: 0 18px 14px; font-size: 12px; color: var(--code-muted); }
|
||||
.term-cap a { color: inherit; }
|
||||
@media (max-width: 640px) { .term-real pre { font-size: 11.5px; padding: 14px; } }
|
||||
|
||||
/* desktop app: one tab per screen, light / dark captures */
|
||||
.desk-tabs { margin-top: 32px; }
|
||||
.desk-panel { margin: 0; }
|
||||
.desk-panel img { width: 100%; border-radius: 12px; border: 1px solid var(--line); box-shadow: var(--shadow); background: var(--surface); }
|
||||
.desk-panel figcaption { margin-top: 12px; font-size: 14px; color: var(--muted); }
|
||||
|
||||
.dl { margin-top: 44px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 22px; min-width: 0; }
|
||||
.dl-head h3 { font-size: 20px; }
|
||||
.dl-status { margin-top: 6px; font-size: 14.5px; color: var(--muted); }
|
||||
.dl-os { list-style: none; padding: 0; margin: 18px 0 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 190px), 1fr)); }
|
||||
.dl-os .btn { width: 100%; flex-direction: column; align-items: flex-start; gap: 2px; padding: 12px 16px; min-height: 64px; }
|
||||
.dl-os .btn b { font-size: 15.5px; }
|
||||
.dl-os .btn small { font-weight: 500; font-size: 12.5px; color: var(--muted); }
|
||||
.dl-os .btn.is-off { opacity: .6; cursor: default; pointer-events: none; }
|
||||
.dl-os .btn.is-here { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; }
|
||||
.dl-notice { margin-top: 16px; padding: 14px 16px; border-radius: 12px; border: 1px solid var(--ui-ink); background: var(--ui-bg); color: var(--ui-ink); font-size: 14.5px; }
|
||||
.dl-notice ol { margin: 8px 0 0; padding-left: 20px; }
|
||||
.dl-verify { margin-top: 18px; }
|
||||
.dl-verify summary, .chat-more summary { cursor: pointer; font-weight: 600; color: var(--ink); min-height: 32px; }
|
||||
.dl-table { width: 100%; border-collapse: collapse; margin-top: 14px; font-size: 14.5px; }
|
||||
.dl-table th, .dl-table td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||
.dl-table th { font: 600 12px var(--mono); text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
|
||||
.dl-table .sha { font: 12px/1.5 var(--mono); overflow-wrap: anywhere; display: block; }
|
||||
.dl-table .fname { font-weight: 600; overflow-wrap: anywhere; }
|
||||
.dl-table .small { font-size: 13px; color: var(--muted); }
|
||||
@media (max-width: 720px) {
|
||||
.dl-table thead { display: none; }
|
||||
.dl-table, .dl-table tbody, .dl-table tr, .dl-table td { display: block; width: 100%; }
|
||||
.dl-table td { border: 0; padding: 4px 0; }
|
||||
.dl-table tr { border-bottom: 1px solid var(--line); padding: 8px 0; }
|
||||
}
|
||||
.dl-steps { margin: 14px 0 0; padding-left: 20px; color: var(--ink-2); font-size: 14.5px; }
|
||||
.dl-steps li { margin: 6px 0; }
|
||||
.dl-key { display: block; margin-top: 6px; font: 12.5px/1.6 var(--mono); overflow-wrap: anywhere; }
|
||||
|
||||
.agent-setup { min-width: 0; }
|
||||
.session .sub-h { margin-top: 0; }
|
||||
.chat-more { margin-top: 32px; }
|
||||
.chat-more .chat { margin-top: 14px; }
|
||||
|
||||
.covers { list-style: none; padding: 0; margin: 36px 0 0; display: grid; gap: 12px; }
|
||||
@media (min-width: 640px) { .covers { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (min-width: 980px) { .covers { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||
.covers a { display: grid; align-content: start; gap: 4px; height: 100%; padding: 16px 18px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; text-decoration: none; color: var(--ink-2); font-size: 14.5px; transition: border-color .15s ease; }
|
||||
.covers a:hover { border-color: var(--accent); }
|
||||
.covers b { color: var(--ink); font-size: 16px; }
|
||||
.ship-demo .demo::before { inset: 8% 0 -6% 6%; }
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ changes or deletes a group.
|
|||
text instead. Pick the build by the exact number you uploaded, never "the latest"; `--version x.y.z` when the same
|
||||
build number exists for two versions.
|
||||
- Write it for testers: what changed, what to try, known issues. Short; it is read on a phone. Apple documents no
|
||||
maximum; storebound stops at 4000 characters (assumption, see the README's unverified list).
|
||||
maximum; storebound stops at 4000 characters (assumption, see the unverified list in docs/project.md).
|
||||
|
||||
## 4. Test Information (needed for external testing)
|
||||
`storebound apple testflight info --dry`: Beta App Description per locale (`apple.testflight.description`), feedback
|
||||
|
|
|
|||
|
|
@ -48,9 +48,9 @@ describe('minisign', () => {
|
|||
const conf = JSON.parse(readFileSync(join(import.meta.dir, '../apps/desktop/src-tauri/tauri.conf.json'), 'utf8'));
|
||||
expect(PUBLIC_KEY).toBe(conf.plugins.updater.pubkey);
|
||||
expect(parsePublicKey(PUBLIC_KEY).keyId).toMatch(/^[0-9A-F]{16}$/);
|
||||
// the downloads page prints the minisign key line for people who verify by hand
|
||||
// the home page's download section prints the minisign key line for people who verify by hand
|
||||
const line = Buffer.from(PUBLIC_KEY, 'base64').toString().split('\n')[1]!;
|
||||
expect(readFileSync(join(import.meta.dir, '../site/desktop/index.html'), 'utf8')).toContain(`<code class="dl-key">${line}</code>`);
|
||||
expect(readFileSync(join(import.meta.dir, '../site/index.html'), 'utf8')).toContain(`<code class="dl-key">${line}</code>`);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -23,10 +23,11 @@ describe("first rule: do it, don't ask", () => {
|
|||
test('reaches every generated adapter, README and the lead of store-pitfalls', () => {
|
||||
for (const f of ['AGENTS.md', 'GEMINI.md', '.github/copilot-instructions.md', '.cursor/rules/storebound.mdc'])
|
||||
expect(read(f).toLowerCase()).toContain(RULE);
|
||||
// the first section of README carries the rule; the full text lives in docs/journey.md
|
||||
const readme = read('README.md');
|
||||
const at = readme.indexOf('## How agents use storebound');
|
||||
expect(at).toBeGreaterThan(0);
|
||||
expect(readme.slice(0, at).split('\n## ').length).toBe(1); // the first section of README
|
||||
const first = readme.split('\n## ')[1] ?? '';
|
||||
expect(first.toLowerCase()).toContain(RULE);
|
||||
expect(read('docs/journey.md')).toContain('## How agents use storebound');
|
||||
const pit = read('skills/store-pitfalls/SKILL.md');
|
||||
expect(/^## (.*)$/m.exec(pit)?.[1]?.toLowerCase()).toContain(RULE);
|
||||
for (const s of ['console-autofill', 'play-first-release', 'appstore-first-release', 'store-declarations'])
|
||||
|
|
@ -43,7 +44,8 @@ describe("first rule: do it, don't ask", () => {
|
|||
/\b(do|fill|finish) (that|this) (part )?by hand\b/i,
|
||||
/\bfor the owner to copy\b/i,
|
||||
];
|
||||
const files = ['docs/agents-core.md', 'README.md',
|
||||
const files = ['docs/agents-core.md', 'README.md', // the README's former sections, now in docs/
|
||||
...['journey', 'getting-started', 'commands', 'rejections', 'growth', 'studios', 'store-art', 'faq', 'safety', 'mcp', 'skills', 'listing-languages', 'api', 'project'].map(n => `docs/${n}.md`),
|
||||
...readdirSync(join(root, 'skills')).map(n => `skills/${n}/SKILL.md`).filter(f => { try { read(f); return true; } catch { return false; } })];
|
||||
|
||||
test('no skill or rule hands navigation, exports, uploads or forms to the owner', () => {
|
||||
|
|
|
|||
80
tests/docs-links.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// Links in README.md, docs/*.md and the site resolve: relative links and images point at files that exist, #anchors
|
||||
// at headings (GitHub / Forgejo slugs) or element ids, and links into this repo on git.crt.fyi
|
||||
// (/raw/branch/main/<path>, /src/branch/main/<path>) at files in the checkout. Other external URLs are not fetched.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const REPO = /^https:\/\/git\.crt\.fyi\/CRT\/storebound\/(?:raw|src)\/branch\/main\/([^?#]+)(#.*)?$/;
|
||||
|
||||
/** GitHub-style heading slug: lower case, punctuation dropped, spaces to hyphens. */
|
||||
const slug = (h: string): string => h.trim().toLowerCase().replace(/<[^>]+>/g, '').replace(/[`*_]/g, '')
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, '').replace(/\s/g, '-');
|
||||
|
||||
function anchorsOf(file: string): Set<string> {
|
||||
const src = readFileSync(file, 'utf8');
|
||||
if (file.endsWith('.html')) return new Set([...src.matchAll(/\sid="([^"]+)"/g)].map(m => m[1]!));
|
||||
const out = new Set<string>();
|
||||
const seen = new Map<string, number>();
|
||||
let fence = false;
|
||||
for (const line of src.split('\n')) {
|
||||
if (/^```/.test(line)) fence = !fence;
|
||||
const m = !fence && /^#{1,6} (.+)$/.exec(line);
|
||||
if (!m) continue;
|
||||
const s = slug(m[1]!);
|
||||
const n = seen.get(s) ?? 0;
|
||||
seen.set(s, n + 1);
|
||||
out.add(n ? `${s}-${n}` : s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function linksOf(file: string): string[] {
|
||||
let src = readFileSync(file, 'utf8');
|
||||
if (file.endsWith('.md')) src = src.replace(/^```[\s\S]*?^```/gm, '').replace(/`[^`\n]*`/g, '');
|
||||
const md = [...src.matchAll(/\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)].map(m => m[1]!);
|
||||
const html = [...src.matchAll(/\s(?:href|src|srcset|poster)="([^"]+)"/g)].flatMap(m => m[1]!.split(',').map(s => s.trim().split(/\s+/)[0]!));
|
||||
return [...md, ...html].filter(Boolean);
|
||||
}
|
||||
|
||||
function problems(file: string): string[] {
|
||||
const out: string[] = [];
|
||||
const base = dirname(file);
|
||||
for (const link of linksOf(file)) {
|
||||
const repo = REPO.exec(link);
|
||||
let target: string, anchor: string | undefined;
|
||||
if (repo) { target = join(ROOT, decodeURIComponent(repo[1]!)); anchor = repo[2]?.slice(1); }
|
||||
else if (/^[a-z]+:/i.test(link) || link.startsWith('//')) continue;
|
||||
// served by the desktop release server, not the site (tools/vds/apache-storebound-desktop.conf)
|
||||
else if (/(^|\/)latest\.json$/.test(link)) continue;
|
||||
else {
|
||||
const [p, a] = link.split('#') as [string, string | undefined];
|
||||
target = p ? join(base, decodeURIComponent(p.split('?')[0]!)) : file;
|
||||
anchor = a;
|
||||
}
|
||||
const rel = relative(ROOT, file);
|
||||
if (!existsSync(target)) { out.push(`${rel}: ${link} (no such file)`); continue; }
|
||||
if (anchor && statSync(target).isFile() && /\.(md|html)$/.test(target) && !anchorsOf(target).has(anchor))
|
||||
out.push(`${rel}: ${link} (no #${anchor})`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const docs = readdirSync(join(ROOT, 'docs')).filter(n => n.endsWith('.md')).map(n => join(ROOT, 'docs', n));
|
||||
|
||||
describe('links resolve', () => {
|
||||
test('README.md and docs/*.md', () => {
|
||||
expect([join(ROOT, 'README.md'), ...docs].flatMap(problems)).toEqual([]);
|
||||
});
|
||||
test('site pages', () => {
|
||||
const pages = ['site/index.html', 'site/desktop/index.html'].map(p => join(ROOT, p)).filter(existsSync);
|
||||
expect(pages.flatMap(problems)).toEqual([]);
|
||||
});
|
||||
test('the checker catches a missing file, a missing anchor and a stale repo link', () => {
|
||||
expect(slug('Install for your AI tool')).toBe('install-for-your-ai-tool');
|
||||
expect(slug('Credentials (never in the repo)')).toBe('credentials-never-in-the-repo');
|
||||
expect(anchorsOf(join(ROOT, 'docs/getting-started.md')).has('install-for-your-ai-tool')).toBe(true);
|
||||
expect(REPO.exec('https://git.crt.fyi/CRT/storebound/raw/branch/main/site/img/showcase.webp')?.[1]).toBe('site/img/showcase.webp');
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
// Renders the diagrams in docs/diagrams/*.mmd with the Mermaid CLI (@mermaid-js/mermaid-cli, a devDependency; never
|
||||
// shipped). Plain SVG text (no HTML labels), a fixed dark canvas so it reads the same on light and dark pages.
|
||||
// bun tools/diagrams.ts
|
||||
// journey.mmd -> docs/img/journey.svg (README), site/img/journey.svg (wide screens) and site/img/journey-tall.svg
|
||||
// journey.mmd -> docs/img/journey.svg (docs/journey.md), site/img/journey.svg (wide screens) and site/img/journey-tall.svg
|
||||
// (phones: every row turned into a column). Chrome: STOREBOUND_CHROME, Playwright's Chromium, or Google Chrome.
|
||||
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
|
|
|||
230
tools/site-desktop-shots.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
#!/usr/bin/env bun
|
||||
// Real frames of the storebound desktop app for the website and the README: the Vite dev server of apps/desktop in
|
||||
// fixture mode (VITE_FIXTURES=1: made-up example.com-style data for the demo game "Prism Puddle" and the other demo
|
||||
// apps, no Tauri, no store accounts), captured headlessly at 1280x800 @2x in both themes, with the two dev-build
|
||||
// markers hidden (they are not in a release build). Nothing is sent anywhere: the owner actions and console plans
|
||||
// only show the fixture's dry run; no "Continue" or final button is pressed.
|
||||
//
|
||||
// Views (each as <view>-light.webp and <view>-dark.webp in site/img/desktop/):
|
||||
// portfolio Portfolio: every app of every studio, both stores
|
||||
// send-for-review Prism Puddle > Releases > "Send for review": build 52 picked, "Submit for review" pressed, so the
|
||||
// plan panel (dry run table + "Continue" confirm) is open
|
||||
// checks Prism Puddle > Checks: the "Rejection risks" card (scan findings) at the top
|
||||
// growth Prism Puddle > Growth: the store analytics chart with the pointer mid-chart (crosshair + tooltip)
|
||||
// catalog Prism Puddle > Catalog: the parity matrix, every product on both stores
|
||||
// prices Prism Puddle > Prices: the "Products x key territories" matrix
|
||||
// testflight Prism Puddle > TestFlight
|
||||
// console-plan Prism Puddle > Releases > Google Play card > "Send changes for review": the console plan panel
|
||||
// (steps, answer sheet, never-press list)
|
||||
//
|
||||
// Each view is checked in the page (expected text visible, every '@' address in the visible text on example.com, zero
|
||||
// page errors; the transient toasts are hidden); the PNGs go to a temp dir, cwebp encodes them (q 76, lowered to 65
|
||||
// at most until a file is <= 150 KB). SHOTS_DEBUG=1 prints the text inside each frame (for alt texts).
|
||||
// Needs cwebp (brew install webp) and the desktop app's dependencies (bun install in apps/desktop).
|
||||
// Run: bun tools/site-desktop-shots.ts (SHOTS_TMP=<dir> to keep the PNGs somewhere else)
|
||||
import { mkdirSync, mkdtempSync, rmSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeBrowser, launch } from '../cli/shots/browser';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const OUT = join(ROOT, 'site/img/desktop');
|
||||
const VIEW = { width: 1280, height: 800 };
|
||||
const MAX_BYTES = 150 * 1024;
|
||||
const Q_START = 76;
|
||||
const Q_MIN = 65;
|
||||
const WAIT = 15_000;
|
||||
const THEMES = ['light', 'dark'] as const;
|
||||
type Theme = (typeof THEMES)[number];
|
||||
const log = (m: string): void => console.log(`${new Date().toISOString().slice(11, 19)} [site-shots] ${m}`);
|
||||
|
||||
/** A view: the start route, what to do before the shot, and text that must be visible in it. */
|
||||
interface View { name: string; start: string; prepare: (page: any) => Promise<void>; expect: string[] }
|
||||
|
||||
const scrollTo = async (loc: any, offset: number): Promise<void> => {
|
||||
await loc.first().waitFor({ state: 'visible', timeout: WAIT });
|
||||
await loc.first().evaluate((el: HTMLElement, off: number) => {
|
||||
el.scrollIntoView({ block: 'start' });
|
||||
document.getElementById('content')?.scrollBy({ top: -off });
|
||||
}, offset);
|
||||
};
|
||||
/** The send-for-review section of the Releases tab, its store state read (fixture answers). */
|
||||
const submitSection = async (page: any): Promise<void> => {
|
||||
const section = page.locator('section[data-section="submit"]');
|
||||
await scrollTo(section, 20);
|
||||
// store reads run on demand: press each card's "Read state" button if it has not been read yet
|
||||
for (const title of ['App Store: submit for review', 'Google Play: production release']) {
|
||||
const card = section.locator('.card').filter({ hasText: title }).first();
|
||||
await card.waitFor({ state: 'visible', timeout: WAIT });
|
||||
const read = card.locator('button', { hasText: 'Read state' });
|
||||
if (await read.count()) await read.first().click({ timeout: WAIT });
|
||||
await card.locator('select').first().waitFor({ state: 'visible', timeout: WAIT });
|
||||
}
|
||||
};
|
||||
|
||||
const VIEWS: View[] = [
|
||||
{ name: 'portfolio', start: 'portfolio', expect: ['Portfolio', 'Prism Puddle', 'Tidy Lists', 'Orbit Hop'], prepare: async () => {} },
|
||||
{
|
||||
name: 'send-for-review', start: 'app:releases', expect: ['Send for review', 'Submit for review', 'Plan (dry run, nothing sent yet)', 'Continue: Submit for review', 'build 52'],
|
||||
prepare: async page => {
|
||||
await submitSection(page);
|
||||
const apple = page.locator('section[data-section="submit"] .card').filter({ hasText: 'App Store: submit for review' }).first();
|
||||
await apple.locator('select').first().selectOption('52', { timeout: WAIT });
|
||||
await apple.locator('button', { hasText: 'Submit for review' }).first().click({ timeout: WAIT });
|
||||
await page.locator('.oa-panel .plan-table').waitFor({ state: 'visible', timeout: WAIT });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'checks', start: 'app:checks', expect: ['Checks', 'Rejection risks'],
|
||||
prepare: async page => {
|
||||
const first = page.locator('#content .card').first();
|
||||
await first.waitFor({ state: 'visible', timeout: WAIT });
|
||||
if (!((await first.innerText({ timeout: WAIT })) as string).includes('Rejection risks')) throw new Error('checks: the first card is not "Rejection risks"');
|
||||
await first.locator('.li').first().waitFor({ state: 'visible', timeout: WAIT });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'growth', start: 'app:growth', expect: ['Growth', 'Store analytics'],
|
||||
prepare: async page => {
|
||||
await scrollTo(page.locator('.card').filter({ hasText: 'Store analytics' }), 20);
|
||||
await page.waitForTimeout(300);
|
||||
const svg = page.locator('.lchart svg').first();
|
||||
await svg.waitFor({ state: 'visible', timeout: WAIT });
|
||||
const b = await svg.boundingBox();
|
||||
if (!b) throw new Error('growth: no chart box');
|
||||
// a real pointer move, in steps, to the middle of the chart: crosshair and tooltip
|
||||
await page.mouse.move(b.x + b.width * 0.3, b.y + b.height * 0.42);
|
||||
await page.mouse.move(b.x + b.width * 0.55, b.y + b.height * 0.42, { steps: 12 });
|
||||
await page.locator('.vx-tip').first().waitFor({ state: 'visible', timeout: WAIT });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'catalog', start: 'app:catalog', expect: ['Catalog', 'Parity matrix', 'coins_small', 'vip_pass_monthly'],
|
||||
prepare: async page => { await scrollTo(page.locator('.card').filter({ hasText: 'Parity matrix' }), 20); },
|
||||
},
|
||||
{
|
||||
name: 'prices', start: 'app:prices', expect: ['Prices', 'Products x key territories'],
|
||||
prepare: async page => { await scrollTo(page.locator('.card').filter({ hasText: 'Products x key territories' }), 20); },
|
||||
},
|
||||
{
|
||||
name: 'testflight', start: 'app:testflight', expect: ['TestFlight', 'Recent builds'],
|
||||
prepare: async page => { await page.locator('#content .card').first().waitFor({ state: 'visible', timeout: WAIT }); },
|
||||
},
|
||||
{
|
||||
name: 'console-plan', start: 'app:releases', expect: ['Play Console: send the first production release for review', '4 steps', 'answers from your config', 'Never pressed by anyone else'],
|
||||
prepare: async page => {
|
||||
await submitSection(page);
|
||||
const play = page.locator('section[data-section="submit"] .card').filter({ hasText: 'Google Play: production release' }).first();
|
||||
await play.locator('button', { hasText: 'Send changes for review' }).first().click({ timeout: WAIT });
|
||||
await page.locator('.cp-panel .cp-steps').waitFor({ state: 'visible', timeout: WAIT });
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Encodes one PNG to WebP, lowering the quality until it fits MAX_BYTES (never below Q_MIN). */
|
||||
async function encode(png: string, webp: string): Promise<{ bytes: number; q: number }> {
|
||||
for (let q = Q_START; q >= Q_MIN; q -= 3) {
|
||||
const p = Bun.spawn(['cwebp', '-quiet', '-q', String(q), '-m', '6', '-resize', String(VIEW.width), '0', png, '-o', webp], { stdout: 'ignore', stderr: 'pipe' });
|
||||
const timer = setTimeout(() => p.kill(), 60_000);
|
||||
const code = await p.exited;
|
||||
clearTimeout(timer);
|
||||
if (code !== 0) throw new Error(`cwebp failed for ${png}: ${await new Response(p.stderr).text()}`);
|
||||
const bytes = statSync(webp).size;
|
||||
if (bytes <= MAX_BYTES) return { bytes, q };
|
||||
}
|
||||
throw new Error(`${webp}: over ${MAX_BYTES / 1024} KB even at q ${Q_MIN}`);
|
||||
}
|
||||
|
||||
/** Visible text checks: the expected strings, and every e-mail-like address on example.com. */
|
||||
async function checkText(page: any, view: View, theme: Theme): Promise<void> {
|
||||
const text: string = await page.evaluate(() => document.body.innerText);
|
||||
const missing = view.expect.filter(s => !text.includes(s));
|
||||
if (missing.length) throw new Error(`${view.name}-${theme}: expected text not visible: ${missing.join(', ')}`);
|
||||
const ats = text.match(/[^\s@]*@[^\s@]*/g) ?? [];
|
||||
const bad = ats.filter(a => !/^[\w.+-]+@([\w-]+\.)*example\.(com|org|net)\b/i.test(a));
|
||||
if (bad.length) throw new Error(`${view.name}-${theme}: '@' outside example.com in the visible text: ${bad.join(', ')}`);
|
||||
if (process.env.SHOTS_DEBUG) {
|
||||
// the text inside the frame only (for alt texts): text nodes whose box intersects the viewport, topmost layer
|
||||
const inView: string = await page.evaluate(() => {
|
||||
const out: string[] = [];
|
||||
const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
||||
for (let n = w.nextNode(); n; n = w.nextNode()) {
|
||||
const s = n.textContent?.trim();
|
||||
if (!s) continue;
|
||||
const r = document.createRange();
|
||||
r.selectNodeContents(n);
|
||||
const b = r.getBoundingClientRect();
|
||||
if (b.width === 0 || b.bottom <= 0 || b.top >= innerHeight || b.right <= 0 || b.left >= innerWidth) continue;
|
||||
const top = document.elementFromPoint(Math.min(innerWidth - 1, Math.max(0, b.left + 2)), Math.min(innerHeight - 1, Math.max(0, b.top + b.height / 2)));
|
||||
if (top && !(top === n.parentElement || n.parentElement?.contains(top) || top.contains(n.parentElement))) continue;
|
||||
out.push(s);
|
||||
}
|
||||
return out.join(' | ');
|
||||
});
|
||||
console.log(`--- ${view.name}-${theme} ---\n${inView}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const which = Bun.spawnSync(['which', 'cwebp']);
|
||||
if (which.exitCode !== 0) throw new Error('cwebp not found (brew install webp)');
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
const tmp = process.env.SHOTS_TMP ?? mkdtempSync(join(tmpdir(), 'site-desktop-shots-'));
|
||||
mkdirSync(tmp, { recursive: true });
|
||||
const port = 14_800 + Math.floor(Math.random() * 500);
|
||||
const vite = Bun.spawn(['bunx', 'vite', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], {
|
||||
cwd: join(ROOT, 'apps/desktop'), env: { ...process.env, VITE_FIXTURES: '1' }, stdout: 'ignore', stderr: 'pipe',
|
||||
});
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
let browser: any = null;
|
||||
let total = 0;
|
||||
try {
|
||||
for (let i = 0; ; i++) {
|
||||
try { if ((await fetch(base, { signal: AbortSignal.timeout(2000) })).ok) break; } catch { /* not up yet */ }
|
||||
if (i > 120) throw new Error('vite dev server did not start within 30 s');
|
||||
if (i % 20 === 0) log('waiting for the vite dev server ...');
|
||||
await Bun.sleep(250);
|
||||
}
|
||||
browser = await launch(ROOT, { headless: true });
|
||||
for (const theme of THEMES) {
|
||||
const ctx = await browser.newContext({ viewport: VIEW, deviceScaleFactor: 2, colorScheme: theme, reducedMotion: 'reduce' });
|
||||
ctx.setDefaultTimeout(WAIT);
|
||||
ctx.setDefaultNavigationTimeout(30_000);
|
||||
try {
|
||||
for (const view of VIEWS) {
|
||||
const page = await ctx.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (e: unknown) => errors.push(String(e)));
|
||||
await page.goto(`${base}/?start=${encodeURIComponent(view.start)}&theme=${theme}&lang=en`, { timeout: 30_000 });
|
||||
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||
// the dev-build markers (not in a release build) and the transient "... read" toasts of the store reads
|
||||
await page.addStyleTag({ content: '.brand small, .dev-pill, .toasts { display: none !important; }' });
|
||||
await page.waitForTimeout(1000);
|
||||
if (view.name !== 'growth') await page.mouse.move(VIEW.width - 2, VIEW.height - 2);
|
||||
await view.prepare(page);
|
||||
if (view.name !== 'growth') await page.mouse.move(VIEW.width - 2, VIEW.height - 2);
|
||||
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||
await page.waitForTimeout(1000);
|
||||
await checkText(page, view, theme);
|
||||
if (errors.length) throw new Error(`${view.name}-${theme}: page errors:\n ${[...new Set(errors)].join('\n ')}`);
|
||||
const png = join(tmp, `${view.name}-${theme}.png`);
|
||||
await page.screenshot({ path: png, type: 'png', timeout: 30_000 });
|
||||
await page.close();
|
||||
const webp = join(OUT, `${view.name}-${theme}.webp`);
|
||||
const { bytes, q } = await encode(png, webp);
|
||||
total += bytes;
|
||||
console.log(`site/img/desktop/${view.name}-${theme}.webp ${(bytes / 1024).toFixed(1)} KB (q ${q})`);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
log(`${VIEWS.length * THEMES.length} files, ${(total / 1024).toFixed(1)} KB in all -> site/img/desktop/`);
|
||||
} finally {
|
||||
if (browser) await closeBrowser(browser).catch(() => {});
|
||||
vite.kill();
|
||||
if (!process.env.SHOTS_TMP) rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) await main();
|
||||
303
tools/site-term-shots.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
#!/usr/bin/env bun
|
||||
// Terminal shots for the website and the README: runs real storebound CLI commands and saves, per command,
|
||||
// site/img/term/<name>.txt the cleaned output (first line "$ <command>"; no ANSI, no clock prefix, no "(Ns)" timer,
|
||||
// temp / repo / home paths made short and relative)
|
||||
// site/img/term/<name>.webp that text drawn as the site's terminal component (site/style.css .term, dark theme),
|
||||
// rendered by headless Chromium (cli/shots/browser.ts) and encoded with cwebp
|
||||
// Long outputs are cut for the image only, with a dim last line that says exactly how much was left out; the .txt
|
||||
// keeps the whole output. Store-reading commands run in a throwaway project (storebound init in a temp folder) whose
|
||||
// store answers come from the teaser store double (tests/helpers/teaser-store-mock.ts, preloaded through bunfig.toml),
|
||||
// with placeholder keys (generated here, sb@example.com): nothing reaches the network. Fails when a command's output
|
||||
// is missing its expected marker, or a command or the browser hangs past its timeout.
|
||||
// bun tools/site-term-shots.ts all shots
|
||||
// bun tools/site-term-shots.ts --text only the .txt files (no browser)
|
||||
// bun tools/site-term-shots.ts scan check only these shots
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeBrowser, launch } from '../cli/shots/browser';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const CLI = join(ROOT, 'cli', 'index.ts');
|
||||
const MOCK = join(ROOT, 'tests', 'helpers', 'teaser-store-mock.ts');
|
||||
const OUT = join(ROOT, 'site', 'img', 'term');
|
||||
const DEMO = join(ROOT, 'examples', 'demo-game');
|
||||
const CMD_TIMEOUT_MS = 90_000;
|
||||
const BROWSER_TIMEOUT_MS = 30_000;
|
||||
const MAX_WEBP = 120 * 1024;
|
||||
const log = (m: string): void => console.log(`${new Date().toISOString().slice(11, 19)} [term-shots] ${m}`);
|
||||
|
||||
/** Rejects after `ms` with a message naming what hung. */
|
||||
async function timed<T>(what: string, ms: number, p: Promise<T>): Promise<T> {
|
||||
let t: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([p, new Promise<T>((_, rej) => { t = setTimeout(() => rej(new Error(`${what}: no answer after ${ms / 1000} s`)), ms); })]);
|
||||
} finally { clearTimeout(t); }
|
||||
}
|
||||
|
||||
/** Runs `bun cli/index.ts <argv>` with NO_COLOR and empty store keys (plus `env`), killed after CMD_TIMEOUT_MS. */
|
||||
async function run(argv: string[], cwd: string, env: Record<string, string> = {}): Promise<{ code: number; out: string }> {
|
||||
const started = Date.now();
|
||||
const p = Bun.spawn(['bun', CLI, ...argv], {
|
||||
cwd, stdout: 'pipe', stderr: 'pipe',
|
||||
env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', ASC_KEY_ID: '', ASC_ISSUER_ID: '', ASC_P8: '', PLAY_SERVICE_ACCOUNT: '', ...env },
|
||||
});
|
||||
const beat = setInterval(() => log(` still running: storebound ${argv.join(' ')} (${Math.round((Date.now() - started) / 1000)} s)`), 10_000);
|
||||
const kill = setTimeout(() => p.kill('SIGKILL'), CMD_TIMEOUT_MS);
|
||||
try {
|
||||
const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]);
|
||||
if (p.signalCode === 'SIGKILL') throw new Error(`storebound ${argv.join(' ')}: killed after ${CMD_TIMEOUT_MS / 1000} s. Output so far:\n${out}${err}`);
|
||||
return { code, out: out + err };
|
||||
} finally { clearInterval(beat); clearTimeout(kill); }
|
||||
}
|
||||
|
||||
// --- the throwaway project -------------------------------------------------------------------------------------------
|
||||
|
||||
interface Project { dir: string; keys: Record<string, string> }
|
||||
async function makeProject(): Promise<Project> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-site-term-'));
|
||||
const init = await run(['init'], dir);
|
||||
if (!existsSync(join(dir, 'storebound.config.ts'))) throw new Error(`storebound init wrote no config:\n${init.out}`);
|
||||
writeFileSync(join(dir, 'bunfig.toml'), `preload = [${JSON.stringify(MOCK)}]\n`);
|
||||
const ec = generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
||||
const rsa = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
||||
writeFileSync(join(dir, 'k.p8'), ec);
|
||||
writeFileSync(join(dir, 'sa.json'), JSON.stringify({ client_email: 'sb@example.com', private_key: rsa, token_uri: 'https://oauth2.googleapis.com/token' }));
|
||||
return { dir, keys: { ASC_KEY_ID: 'ABCDE12345', ASC_ISSUER_ID: '00000000-0000-0000-0000-000000000000', ASC_P8: join(dir, 'k.p8'), PLAY_SERVICE_ACCOUNT: join(dir, 'sa.json') } };
|
||||
}
|
||||
|
||||
/** The two products the teaser test puts into the config (tests/teaser.test.ts), before `dataSafety`. */
|
||||
const PRODUCTS = ` products: [
|
||||
{ id: 'com.example.app.no_ads', type: 'non_consumable', usd: 2.99, listings: { en: { title: 'No Ads', description: 'Removes every ad.' }, tr: { title: 'Reklamsız', description: 'Tüm reklamları kaldırır.' } } },
|
||||
{ id: 'com.example.app.gems_100', type: 'consumable', usd: 0.99, listings: { en: { title: '100 Gems', description: 'A pouch of 100 gems.' }, tr: { title: '100 Mücevher', description: '100 mücevherlik bir kese.' } } },
|
||||
],
|
||||
dataSafety: {`;
|
||||
|
||||
// --- cleaning --------------------------------------------------------------------------------------------------------
|
||||
|
||||
const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
||||
/** Every spelling of a folder the output may use (macOS: /var is /private/var). */
|
||||
const spellings = (p: string): string[] => [...new Set([p, realpathSync(p)])].sort((a, b) => b.length - a.length);
|
||||
|
||||
/** Output lines without ANSI, the clock prefix and the "(Ns)" timer; `paths` folders become short relative names. */
|
||||
export function clean(out: string, paths: [string, string][]): string[] {
|
||||
const subs = paths.flatMap(([abs, short]) => spellings(abs).map(s => [s, short] as const));
|
||||
return out.replace(ANSI, '').split('\n').map(l => {
|
||||
let x = l.replace(/^\d\d:\d\d:\d\d /, '').replace(/ \(\d+s\)$/, '').trimEnd();
|
||||
for (const [abs, short] of subs) x = x.split(`${abs}/`).join(short ? `${short}/` : '').split(abs).join(short || '.');
|
||||
return x;
|
||||
}).filter((l, i, a) => !(l === '' && (i === 0 || a[i - 1] === '' || i === a.length - 1)));
|
||||
}
|
||||
|
||||
// --- the shots -------------------------------------------------------------------------------------------------------
|
||||
|
||||
interface Ran { code: number; lines: string[] }
|
||||
interface Shot {
|
||||
name: string;
|
||||
/** The command as a reader types it. */
|
||||
cmd: string;
|
||||
/** The term-bar title (the project folder). */
|
||||
title: string;
|
||||
/** Exit codes that are the expected result. */
|
||||
codes: number[];
|
||||
/** Every one must be in the output (string: a line contains it; RegExp: a line matches). */
|
||||
markers: (string | RegExp)[];
|
||||
run(p: Project): Promise<{ code: number; out: string; paths: [string, string][] }>;
|
||||
/** The lines for the .txt (default: all). */
|
||||
text?(lines: string[]): string[];
|
||||
/** The lines for the image; a line starting with "…" is drawn dim. */
|
||||
image(lines: string[]): string[];
|
||||
}
|
||||
|
||||
/** First `n` lines, then a dim line with the exact number left out. */
|
||||
/** A line cut to what fits one row of the 960 px terminal (the .txt keeps it whole). */
|
||||
const short = (l: string): string => l.length > 112 ? `${l.slice(0, 111).trimEnd()}…` : l;
|
||||
|
||||
const head = (lines: string[], n: number, what: string): string[] =>
|
||||
lines.length <= n ? lines : [...lines.slice(0, n), `… ${lines.length - n} more ${what}`];
|
||||
|
||||
const SHOTS: Shot[] = [
|
||||
{
|
||||
name: 'release-plan', cmd: 'storebound release plan 1.0.0', title: 'my-app',
|
||||
codes: [0], markers: [/^\s+1\. /, /^\s+2\. /],
|
||||
run: async p => ({ ...await run(['release', 'plan', '1.0.0'], p.dir, { ...p.keys, TEASER_STORE: 'new' }), paths: [[p.dir, '']] }),
|
||||
// the step headlines (each step also has a "->" line with the fix command), cut after whole steps
|
||||
image: lines => {
|
||||
const heads = lines.filter(l => !/^\s+->/.test(l) && l.trim());
|
||||
const arrows = lines.filter(l => /^\s+->/.test(l)).length;
|
||||
const isStep = (l: string) => /^\s+\d+\. /.test(l);
|
||||
const total = heads.filter(isStep).length;
|
||||
const shown = heads.slice(0, 24);
|
||||
while (shown.length && !isStep(shown.at(-1)!)) shown.pop();
|
||||
const left = total - shown.filter(isStep).length;
|
||||
return [...shown.map(short), `… ${left > 0 ? `${left} more steps; ` : ''}each of the ${total} steps also has a -> line with the command that fixes it (${arrows} lines)`];
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'scan', cmd: 'storebound scan', title: 'tests/fixtures/scan/teaser',
|
||||
codes: [1], markers: ['Rejection risks:', 'BLOCKER', /^\s+1\. /],
|
||||
run: async () => {
|
||||
const r = await run(['scan', '--dir', 'tests/fixtures/scan/teaser'], ROOT, { REVIEW_DEMO_USER: '', REVIEW_DEMO_PASSWORD: '' });
|
||||
return { ...r, paths: [[join(ROOT, 'tests/fixtures/scan/teaser'), ''], [ROOT, '']] };
|
||||
},
|
||||
// the findings part: everything up to the declarations proposal
|
||||
text: lines => {
|
||||
const end = lines.findIndex(l => l.startsWith('Declarations proposal'));
|
||||
if (end < 0) return lines;
|
||||
const rest = lines.slice(end).filter(l => l.trim()).length;
|
||||
return [...lines.slice(0, end), `… ${rest} more lines: the declarations proposal (Data safety, App Privacy, ATT, privacy manifest) and the config diff`];
|
||||
},
|
||||
image: lines => {
|
||||
const from = lines.findIndex(l => l.startsWith('Rejection risks:'));
|
||||
const body = lines.slice(from).filter(l => !l.startsWith('…') && l.trim());
|
||||
const shown = body.filter(l => !/^\s+(fix|skill):/.test(l));
|
||||
const hidden = body.length - shown.length;
|
||||
return [...lines.slice(0, from), ...shown.map(short), `… plus a fix: and a skill: line per finding (${hidden} lines)`];
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'products', cmd: 'storebound play products --dry', title: 'my-app',
|
||||
codes: [0], markers: ['com.example.app.no_ads', 'com.example.app.gems_100'],
|
||||
run: async p => {
|
||||
const cfg = join(p.dir, 'storebound.config.ts');
|
||||
const src = readFileSync(cfg, 'utf8');
|
||||
if (!src.includes(' dataSafety: {')) throw new Error('the init config has no " dataSafety: {" line to put the products before');
|
||||
writeFileSync(cfg, src.replace(' dataSafety: {', PRODUCTS));
|
||||
try { return { ...await run(['play', 'products', '--dry'], p.dir, { ...p.keys, TEASER_STORE: 'new' }), paths: [[p.dir, '']] }; }
|
||||
finally { writeFileSync(cfg, src); }
|
||||
},
|
||||
image: lines => head(lines.map(short), 24, 'lines'),
|
||||
},
|
||||
{
|
||||
name: 'check', cmd: 'storebound check', title: 'my-app',
|
||||
codes: [1], markers: ['config ok', /^error: \d+ problems?$/],
|
||||
run: async p => ({ ...await run(['check'], p.dir), paths: [[p.dir, '']] }),
|
||||
image: lines => head(lines.map(short), 24, 'lines'),
|
||||
},
|
||||
{
|
||||
name: 'console-plan', cmd: 'storebound console plan play-content-rating', title: 'examples/demo-game',
|
||||
codes: [0], markers: ['start: https://play.google.com/console', 'Steps:', 'Answer sheet:', 'Never press:'],
|
||||
run: async () => ({ ...await run(['console', 'plan', 'play-content-rating'], DEMO), paths: [[DEMO, ''], [ROOT, '']] }),
|
||||
image: lines => {
|
||||
// the head: title, start URL, the steps (first line of each), a sample of the answer sheet, the never-press list
|
||||
const at = (s: string) => lines.findIndex(l => l.startsWith(s));
|
||||
const steps = at('Steps:'), sheet = at('Answer sheet:'), never = at('Never press:'), facts = at('UI facts:');
|
||||
const stepLines = lines.slice(steps + 1, sheet).filter(l => /^\s+\d+\. /.test(l));
|
||||
const sheetRows = lines.slice(sheet + 1, never).filter(l => /^\s+\[/.test(l));
|
||||
const sample = [sheetRows.find(l => l.includes('[ok]')), sheetRows.find(l => l.includes('[you]')), sheetRows.find(l => l.includes('[ask]'))].filter((l): l is string => !!l);
|
||||
const after = lines.slice(facts).filter(l => l.trim()).length;
|
||||
return [
|
||||
...lines.slice(0, steps), 'Steps:', ...stepLines.slice(0, 6).map(short), `… ${stepLines.length - 6} more steps`, '',
|
||||
'Answer sheet:', ...sample.map(short), `… ${sheetRows.length - sample.length} more answers (${sheetRows.filter(l => l.includes('[ask]')).length} of all ${sheetRows.length} are "ask the owner")`, '',
|
||||
...lines.slice(never, facts).filter(l => l.trim()).map(l => l.startsWith('Never press:') ? l : short(l)), `… UI facts and driving notes (${after} lines)`,
|
||||
].filter((l, i, a) => !(l === '' && a[i - 1] === ''));
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// --- drawing ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
const esc = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
/** One output line as HTML: dim elisions, red blockers and errors, amber warnings, green ok and planned creates. */
|
||||
export function lineHtml(l: string): string {
|
||||
if (l.startsWith('$ ')) return `<span class="p">$</span> <span class="cmd">${esc(l.slice(2))}</span>`;
|
||||
if (l.trimStart().startsWith('…')) return `<span class="c">${esc(l)}</span>`;
|
||||
let h = esc(l);
|
||||
h = h.replace(/\b(BLOCKER|HIGH|error|ERROR|FAIL|failed|missing)\b|\[(you|ask)\]/g, m => `<span class="bad">${m}</span>`);
|
||||
h = h.replace(/\b(WARN|warn|MEDIUM|ask owner)\b/g, m => `<span class="warn">${m}</span>`);
|
||||
// green: "[ok]", "config ok", "all good", and a dry run's planned creates ("would create", the table's create column)
|
||||
h = h.replace(/\[ok\]|\b(ok|all good|would create)\b|(?<= {2})create(?= {2})/g, m => `<span class="ok">${m}</span>`);
|
||||
return h;
|
||||
}
|
||||
|
||||
/** The page: site/style.css's .term in its dark tokens (--code-bg, --code-ink, --code-muted, --mono). */
|
||||
const page = (title: string, lines: string[]): string => `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
:root { --code-bg: #060a14; --code-ink: #e2e8f0; --code-muted: #64748b; --radius: 14px;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 12px 32px -16px rgba(0, 0, 0, .7);
|
||||
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; }
|
||||
html, body { margin: 0; background: #0b1120; }
|
||||
body { padding: 24px; width: 960px; box-sizing: content-box; }
|
||||
.term { background: var(--code-bg); color: var(--code-ink); border-radius: var(--radius); box-shadow: var(--shadow); border: 1px solid color-mix(in srgb, var(--code-ink) 10%, transparent); overflow: hidden; }
|
||||
.term-bar { display: flex; align-items: center; gap: 6px; padding: 10px 14px; border-bottom: 1px solid color-mix(in srgb, var(--code-ink) 10%, transparent); }
|
||||
.term-bar span { width: 10px; height: 10px; border-radius: 50%; background: #334155; }
|
||||
.term-bar em { margin-left: 8px; font: 12px var(--mono); color: var(--code-muted); font-style: normal; }
|
||||
.term pre { margin: 0; padding: 16px 18px 20px; color: var(--code-ink); font: 13.5px/1.75 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.p { color: #818cf8; } .cmd { color: #a5b4fc; } .c { color: var(--code-muted); }
|
||||
.bad { color: #fca5a5; font-weight: 600; } .warn { color: #fcd34d; } .ok { color: #86efac; }
|
||||
</style></head><body><div class="term"><div class="term-bar"><span></span><span></span><span></span><em>${esc(title)}</em></div><pre><code>${lines.map(lineHtml).join('\n')}</code></pre></div></body></html>`;
|
||||
|
||||
/** cwebp at 1600 px wide: lossy q80 and lossless, the smaller one; when that is over MAX_WEBP, lossy at q76, q72, q68. */
|
||||
async function toWebp(png: string, out: string): Promise<{ bytes: number; mode: string }> {
|
||||
const encode = async (mode: string, args: string[]): Promise<{ bytes: number; mode: string; file: string }> => {
|
||||
const file = `${png}.${mode}.webp`;
|
||||
const p = Bun.spawn(['cwebp', '-quiet', ...args, '-resize', '1600', '0', png, '-o', file], { stdout: 'ignore', stderr: 'pipe' });
|
||||
const code = await timed(`cwebp ${mode}`, 60_000, p.exited);
|
||||
if (code !== 0) throw new Error(`cwebp ${mode} failed: ${await new Response(p.stderr).text()}`);
|
||||
return { bytes: statSync(file).size, mode, file };
|
||||
};
|
||||
const tried = [await encode('q80', ['-q', '80', '-m', '6']), await encode('lossless', ['-lossless', '-z', '9'])];
|
||||
let best = tried.reduce((a, b) => (b.bytes < a.bytes ? b : a));
|
||||
for (const q of [76, 72, 68]) {
|
||||
if (best.bytes <= MAX_WEBP) break;
|
||||
const t = await encode(`q${q}`, ['-q', String(q), '-m', '6']);
|
||||
tried.push(t);
|
||||
if (t.bytes < best.bytes) best = t;
|
||||
}
|
||||
writeFileSync(out, readFileSync(best.file));
|
||||
for (const t of tried) rmSync(t.file, { force: true });
|
||||
return { bytes: best.bytes, mode: best.mode };
|
||||
}
|
||||
|
||||
// --- main ------------------------------------------------------------------------------------------------------------
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const textOnly = args.includes('--text');
|
||||
const only = args.filter(a => !a.startsWith('--'));
|
||||
const shots = only.length ? SHOTS.filter(s => only.includes(s.name)) : SHOTS;
|
||||
if (only.length && shots.length !== only.length) throw new Error(`unknown shot(s); known: ${SHOTS.map(s => s.name).join(', ')}`);
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
const project = await makeProject();
|
||||
const home = homedir();
|
||||
const done: { shot: Shot; image: string[] }[] = [];
|
||||
try {
|
||||
for (const shot of shots) {
|
||||
log(`run: ${shot.cmd}`);
|
||||
const r = await shot.run(project);
|
||||
const all = clean(r.out, [...r.paths, [project.dir, ''], [home, '~']]);
|
||||
if (!shot.codes.includes(r.code)) throw new Error(`${shot.cmd}: exit ${r.code}, expected ${shot.codes.join(' or ')}:\n${all.join('\n')}`);
|
||||
for (const m of shot.markers) {
|
||||
const hit = typeof m === 'string' ? all.some(l => l.includes(m)) : all.some(l => m.test(l));
|
||||
if (!hit) throw new Error(`${shot.cmd}: the output has no ${String(m)}:\n${all.join('\n')}`);
|
||||
}
|
||||
for (const bad of [project.dir, home, ROOT]) if (all.some(l => l.includes(bad))) throw new Error(`${shot.cmd}: an absolute path is left in the output (${bad})`);
|
||||
const text = [`$ ${shot.cmd}`, ...(shot.text ? shot.text(all) : all)];
|
||||
writeFileSync(join(OUT, `${shot.name}.txt`), `${text.join('\n')}\n`);
|
||||
const image = [`$ ${shot.cmd}`, ...shot.image(shot.text ? shot.text(all) : all)];
|
||||
log(` ${shot.name}.txt: ${text.length} lines (image: ${image.length})`);
|
||||
done.push({ shot, image });
|
||||
}
|
||||
} finally { rmSync(project.dir, { recursive: true, force: true }); }
|
||||
if (textOnly) return;
|
||||
|
||||
const browser = await timed('browser launch', BROWSER_TIMEOUT_MS, launch(ROOT, { headless: true }));
|
||||
try {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1008, height: 800 }, deviceScaleFactor: 2, colorScheme: 'dark' });
|
||||
const tab = await ctx.newPage();
|
||||
tab.setDefaultTimeout(BROWSER_TIMEOUT_MS);
|
||||
for (const { shot, image } of done) {
|
||||
await timed(`render ${shot.name}`, BROWSER_TIMEOUT_MS, tab.setContent(page(shot.title, image), { waitUntil: 'load' }));
|
||||
const png = join(tmpdir(), `sb-term-${shot.name}-${process.pid}.png`);
|
||||
await timed(`screenshot ${shot.name}`, BROWSER_TIMEOUT_MS, tab.locator('.term').screenshot({ path: png }));
|
||||
const out = join(OUT, `${shot.name}.webp`);
|
||||
const w = await toWebp(png, out);
|
||||
rmSync(png, { force: true });
|
||||
log(` ${shot.name}.webp: ${(w.bytes / 1024).toFixed(1)} KB (${w.mode})`);
|
||||
if (w.bytes > MAX_WEBP) throw new Error(`${shot.name}.webp is ${w.bytes} bytes, over ${MAX_WEBP}`);
|
||||
}
|
||||
} finally { await closeBrowser(browser); }
|
||||
}
|
||||
|
||||
if (import.meta.main) await main();
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
# storebound desktop downloads on storebound.dev (included in the :443 vhost of /etc/apache2/sites-available/storebound.dev.conf
|
||||
# as Include /etc/apache2/storebound-desktop.conf ; see docs/desktop-release.md).
|
||||
# The page itself (site/desktop/index.html) comes with the site; the binaries and latest.json live outside the site's
|
||||
# The downloads are a section of the home page (storebound.dev/#download, it reads latest.json); site/desktop/index.html
|
||||
# only redirects there and comes with the site. The binaries and latest.json live outside the site's
|
||||
# document root, because the site's publish rsync deletes everything that is not in the repository.
|
||||
Alias /desktop/latest.json /var/www/storebound-desktop/latest.json
|
||||
Alias /desktop/beta/latest.json /var/www/storebound-desktop/beta/latest.json
|
||||
|
|
|
|||