shotops-mcp 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,20 +11,43 @@ reusable **looks** and editable **projects**, so the next release matches the la
11
11
  Built for agents (ChatGPT / Claude Code / Cursor / CI). It runs over the same `@engine` render
12
12
  spine the Studio web app and the `mockup-mcp` CLI use, driving headless Chromium (Playwright).
13
13
 
14
- Ships as **two tiers, one engine**: a free `npx shotops-mcp` **local stdio server** that
15
- renders on your own machine with no account (see [Local (free tier)](#local-free-tier---npx-shotops-mcp)
16
- below), and a **hosted** server on Vercel with an account, saved looks, and share links (see
17
- [Hosted](#hosted) below, or the public
18
- [`/connect`](https://shotops.dev/connect) page in the studio app).
14
+ Ships as **two doors, one engine**: an `npx shotops-mcp` **local stdio server** that renders on
15
+ your own machine, no account needed to preview (see [Local](#local--npx-shotops-mcp) below), and a
16
+ **hosted** server on Vercel with an account, saved looks, and share links (see [Hosted](#hosted)
17
+ below, or the public [`/connect`](https://shotops.dev/connect) page in the studio app).
18
+
19
+ ## Preview free, export store-ready with Pro
20
+
21
+ Composing and previewing a strip is free through either door — every device, caption and locale, no
22
+ watermark, and locally without an account at all. **Store-ready output needs an active trial or
23
+ Pro:** a full-resolution `render_strip`/`render_project`, and every `emit_bundle` form, including
24
+ one that only re-zips panels you already rendered. That holds wherever the render happens; local
25
+ compute being free is not the same as local output being free.
26
+
27
+ The two limits are independent and neither substitutes for the other:
28
+
29
+ - **Entitlement** is permission to create store-ready files. One server-owned decision answers it
30
+ for Studio, hosted and local alike, so no door can be talked into a different answer. Free and
31
+ anonymous callers are refused before a credit is reserved, a screenshot is read or a file is
32
+ written — a refused call costs nothing and leaves nothing behind.
33
+ - **Credits** price compute ShotOps supplies: 0.5 per hosted preview panel, 2 per hosted
34
+ full-resolution panel, plus AI. Rendering on your own machine spends none, on any plan. More
35
+ credits never unlock store-ready output on Free.
36
+
37
+ Free accounts get 40 credits a month and 2 synced projects. Every new account starts with a 7-day
38
+ Pro trial; after that Pro is €9/month or €90/year. Without any account, the hosted server allows 3
39
+ preview strips per rolling 30 days, at most 5 panels each. Call `account_status` to see exactly
40
+ where this connection stands — it spends no credits and none of that anonymous allowance.
19
41
 
20
42
  ## Zero-custody
21
43
 
22
- This server **never touches a store-signing credential.** There is no tool input, env var,
23
- or code path that accepts, stores, or forwards one. `emit_bundle` hands you a zip; **you**
24
- upload it with **your own** `fastlane`, signed in as yourself. The generated `Deliverfile`
25
- is screenshots-only and **never submits for review**. The only secrets this process holds
26
- are Supabase service credentials (to resolve your API token, read your saved look, and back
27
- share links) nothing that could reach a store on your behalf.
44
+ The hosted server and every MCP tool **never touch a store-signing credential.** `emit_bundle`
45
+ hands you a zip; **you** upload it with **your own** `fastlane`, signed in as yourself. The
46
+ generated `Deliverfile` is screenshots-only and **never submits for review**.
47
+
48
+ The one explicit exception is the local `release setup` CLI below. It validates an existing `.p8`
49
+ on your machine directly with Fastlane and Apple, then stores only its path and public identifiers.
50
+ The key is never copied, printed, or sent to ShotOps, and no release tool is installed or upgraded.
28
51
 
29
52
  ## Tools
30
53
 
@@ -36,12 +59,15 @@ share links) — nothing that could reach a store on your behalf.
36
59
  | `read_look` | Return a project's saved ShotOps look (styling only), with its latest version, the held version, the `versions` history, and the `layout` template it is composed on (derived from its values, never stored) | `project?` (default: your most recently edited project) |
37
60
  | `read_project` | Return a project's FULL current state as an opaque `ProjectFile` — frame order, per-locale caption words, locale list, styling — plus the derived `layout` and a `screenshots.cells` report for every shot × target-family × locale. Each cell says whether pixels resolve, where they resolved from, and which axes inherited | `project?` (default: your most recently edited project) |
38
61
  | `render_project` | Render a SAVED project. By default it renders the screenshots **the project already holds** — pass none at all, no upload needed — into **every supported device the project targets**. Pixels resolve independently for each device family + locale through the shared manifest; genuine fallback is reported in `shots[].inherited` and `note` | `project?`, `screenshots?` (**optional** — omit to use what the project holds; else a FLAT list matched by original `name`, with optional `variant: { family?, locale? }` per cell), `outputs?` (override the project's saved devices for this call), `locale?`, `preview?`, `output?` |
62
+ | `refine_project` | Run one bounded Agent turn against a saved project. A supported request commits once and returns the actual diff; ambiguity, missing input, unsupported intent, conflict, and failure return a typed no-write outcome | `project?`, `instruction`, `focus?` |
39
63
  | `save_look` | Persist a composed look on a project (styling only — appends a new version) | `look`, `project?`, `sourceName?` |
40
64
  | `hold_look` | Hold which saved version agents render by default (replaces pinning) — the version must exist (`read_look` `versions`) | `version`, `project?` |
41
65
  | `release_look` | Clear the held version — agents Follow latest (render the newest saved look) | `project?` |
42
- | `describe_look` | Five named design presets, two named layout templates, the user-intake script, and the full styling field catalog + defaults | *(none)* |
66
+ | `describe_look` | Five named design presets, three named layout templates, the user-intake script, and the full styling field catalog + defaults | *(none)* |
43
67
  | `request_screenshot_upload` | Mint signed upload slots for real screenshots; every returned slot carries its semantic screenshot `variant` | `count` (1–10), `names?`, `family?`, `locale?` |
44
68
  | `import_screenshot` | Get screenshots INTO ShotOps — several per call, each from one named source (`{ url }`, `{ file }`, or local-stdio `{ path }`), returning a render-ready ref per entry in input order | `screenshots` (1–10 source entries), `file?` (the ChatGPT top-level attachment), `name?`, `locale?` |
69
+ | `account_status` | What THIS connection may do, for free: signed-in account, plan, trial, remaining ShotOps credits, what a preview costs here, and whether store-ready output (full-resolution renders, any `emit_bundle`) is allowed — with a machine-readable `nextStep`. Spends no credits, uses none of the free anonymous preview allowance, uploads and writes nothing | *(none)* |
70
+ | `production_operation` | Reconnect to one durable paid Hosted render or rendering bundle, report persisted progress, request cooperative cancellation, or mint fresh result download grants without rerendering or charging again | `action` (`get`, `cancel`, or `result`), `operationId` |
45
71
  | `delete_assets` | Permanently delete private uploaded screenshots, rendered panels, or generated bundles owned by the signed-in account | `refs` (1–50 ShotOps refs) |
46
72
 
47
73
  - **`panelPresetId`** — App Store size: `r69` (default, 6.9″ iPhone 1290×2796), `r65`, `r55`.
@@ -50,22 +76,24 @@ share links) — nothing that could reach a store on your behalf.
50
76
  accepted set is `RENDERABLE_PANEL_PRESET_IDS` in `shotops-mcp/src/panelCatalog.ts`, a subset of
51
77
  the hand-kept `PANEL_PRESET_IDS` copy of `mockup-engine/appstore.ts`'s `PANEL_PRESETS` — when
52
78
  Apple revises required sizes, add the preset in the engine first, then mirror it here.
53
- - **`style`** — the structured styling input (call `describe_look` for the full catalog):
79
+ - **`style`** — the structured styling input. On both the hosted MCP and the local
80
+ `npx shotops-mcp` stdio server, call `describe_look` for the authoritative complete field
81
+ catalog and defaults:
54
82
  `{ layout?, shotLook?, background?, captions? }`. `shotLook` is **one device for every phone**;
55
83
  `captions` is one entry PER PANEL in slot order — each entry is EITHER a single caption object OR
56
84
  an **array of caption layers** stacked on that panel (headline + subline + …). Each layer's
57
85
  `text` (and a single caption's `subtitle`) is per-RENDER input (never stored — a look carries
58
86
  styling only). Captions auto-layout in a band at the top of the panel, and the device does NOT
59
87
  move to make room.
60
- - **`style.layout`** — the panel **composition** in one word: `"standard"` or `"bleed"`. It settles
61
- the headline's reserved region *and* the device size + position together, which is three numbers
62
- that are not independent of each other. `standard` (the shipped default) puts the whole device
88
+ - **`style.layout`** — the panel **composition** in one word: `"standard"`, `"bleed"`, or
89
+ `"top-bleed"`. It settles the headline's reserved region, device placement, camera pose, and
90
+ roll together. `standard` (the shipped default) puts the whole device
63
91
  under a 2-line region — about 36 characters — and crops nothing; `bleed` reserves 4 lines (~72
64
92
  characters) and runs the device 10% off the bottom edge. The region is **reserved, not fitted**:
65
93
  it is held at full size whether or not the headline fills it, which is what makes every panel in
66
94
  a swiped set land on the same line. **Precedence** — the template expands *first*, then any
67
- `shotLook.phoneHeight`/`hOffset`/`vOffset` or `captions[].sizePt`/`maxWidth` you also pass
68
- **overrides** it (`{ layout: "bleed", shotLook: { vOffset: "45" } }` is "bleed, but a bit lower").
95
+ explicit `shotLook` composition field or caption layout field you also pass **overrides** it
96
+ (`{ layout: "bleed", shotLook: { vOffset: "45" } }` is "bleed, but a bit lower").
69
97
  A pure macro: nothing stores the template id, so `save_project` persists the expanded values and
70
98
  `read_look` returns them unchanged. **The id is not lost, though — it is DERIVED back:**
71
99
  `read_project` and `read_look` both return a `layout` report read out of the values themselves, so
@@ -93,10 +121,10 @@ share links) — nothing that could reach a store on your behalf.
93
121
  background, `style.captions` supplies the words, in one render. (Alone, `style` styles every
94
122
  phone identically. If you pass `style.shotLook`/`style.background` *alongside* a `look`, the look
95
123
  wins and the result carries a note.) Also composes with `useSavedLook`/`version`.
96
- - **`clip`** — `"strip"` (default) renders one continuous canvas, so a wide/offset device can
97
- straddle the seam into the next panel (swipe-through scenes). `"panel"` clips each phone to its
98
- own panel so it can't bleed a foreign edge into the neighbouring App Store screenshot. A render
99
- option, not part of the saved look.
124
+ - **Clipping**set `look.shots[].look.clipToFrame: true` to clip one device's complete composite
125
+ (body, screen, shadow, and reflection) to its own panel; omit it or use `false` for overflow.
126
+ Pass top-level `clip: "panel"` to clip every device, or `clip: "strip"` to force continuous
127
+ overflow. Call `describe_look` for the complete Look contract on either MCP tier.
100
128
  - **`version`** — render a specific saved look version (implies the saved look). Without it,
101
129
  a project with a PINNED version renders the pin; otherwise the latest saved look.
102
130
  - **`emit_bundle` output** — a zip (`README.md`, `fastlane/Deliverfile`,
@@ -208,7 +236,7 @@ own `error` and never cancels the others — re-import just that one. In ChatGPT
208
236
  arrives as the top-level `file` parameter instead of an array entry, because the Apps SDK cannot
209
237
  fill a file parameter nested inside an array; you can pass both in the same call.
210
238
 
211
- `{ url }` and `{ file }` need the hosted server (it stores the fetched PNG under your account);
239
+ `{ url }` and `{ file }` need the hosted server (it stores the fetched screenshot as PNG under your account);
212
240
  `{ path }` needs the local stdio server (the hosted server never reads a caller-supplied
213
241
  filesystem path — that would be an LFI hole). Each refusal names the door that IS open on your
214
242
  tier. When the screenshots are only on the user's machine and you are on the hosted server, there
@@ -227,19 +255,22 @@ is no URL to give, so use the signed-slot flow below:
227
255
  `screenshots` entries accept these shapes: an inline base64 string (small payloads only —
228
256
  inline is capped and returns a clear "payload too large" error above 3MB decoded, pointing
229
257
  you back at this flow), `{ ref }` from step 1, `{ url }` (an https URL this server fetches
230
- itself: image/png, no redirects, ~20MB cap), `{ path }` (local stdio server only — see below),
258
+ itself: PNG or JPEG, converted to PNG when needed, no redirects, ~20MB cap), `{ path }` (local stdio server only — see below),
231
259
  or `{ "locales": { "<locale>": <any of those> } }` for per-locale variants (see
232
260
  [Per-locale screenshots](#per-locale-screenshots--multi-locale-bundles) above).
233
261
 
234
262
  Every raw PNG is automatically palette-optimized before rendering (TinyPNG-style, locally inside
235
263
  ShotOps): dimensions and transparency are preserved, and an already-smaller PNG is left byte-for-byte
236
- unchanged. This happens after the input reaches the MCP, so it does **not** raise the inline request
264
+ unchanged. Fetched JPEGs are converted to palette PNGs at the same boundary without changing their
265
+ dimensions. This happens after the input reaches the MCP, so it does **not** raise the inline request
237
266
  cap — use the ref/upload flow for a large source file.
238
267
 
239
- **Output** works the same way in reverse: `render_strip`/`emit_bundle` take an `output` field
240
- — `"inline"` (base64), `"urls"` (uploaded under your own prefix, short-lived signed download
241
- URLs), or omit for auto (inline under ~200KB total, `urls` above). Use `"urls"` for
242
- full-resolution work so results never transit your context either.
268
+ **Output** works the same way in reverse: `render_strip`/`render_project` take an `output` field
269
+ — `"inline"` (standard MCP `image` blocks), `"urls"` (uploaded under your own prefix and returned
270
+ as short-lived `resource_link` blocks), or omit for auto (inline under ~200KB total, `urls` above).
271
+ The model-visible `structuredContent.panels` contains ordered delivery descriptors rather than PNG
272
+ base64; ChatGPT receives the complete gallery through widget-only result metadata. Programmatic
273
+ clients read the standard MCP content blocks. Use `"urls"` for full-resolution work.
243
274
 
244
275
  **Cheap preview, then compose:** `render_strip({ preview: true })` renders at ~25% resolution
245
276
  for fast, cheap styling iteration — small enough to always come back inline; don't ship it,
@@ -256,7 +287,7 @@ screenshot unavailable; public privacy disclosures must accurately state this re
256
287
  ## Prerequisites
257
288
 
258
289
  1. **A ShotOps API token.** Sign in at the Studio app → account menu → **API tokens** →
259
- create one. It looks like `shotops_9Zq3Xr7Tp0Vn6Lk2Wd8Yb4Mf1Hc5Ju3Rs9Ea7Gt0Nz1` and is
290
+ create one. It looks like `shotops_9Zq3Xr7T…` and is
260
291
  shown once — copy it then.
261
292
  2. **Supabase service env** (already in the repo's `.env.local` for local dev): `SUPABASE_URL`
262
293
  and `SUPABASE_SERVICE_ROLE_KEY`. The server loads `../.env.local` automatically.
@@ -280,7 +311,7 @@ Deploy the hosted server at a public HTTPS origin, enable Developer Mode in Chat
280
311
  an app whose MCP server URL is `https://<your-host>/mcp`. Do not paste a personal `shotops_` token
281
312
  into the app definition: ChatGPT discovers the protected-resource metadata from the endpoint and
282
313
  runs the server's OAuth + PKCE browser sign-in flow. This is a tool-only app — no widget or UI
283
- resource is required. User-attached PNGs enter through `import_screenshot`.
314
+ resource is required. User-attached PNGs and JPEGs enter through `import_screenshot`.
284
315
 
285
316
  For public plugin submission, OpenAI supplies a domain-verification token after the MCP domain
286
317
  is entered in the Platform dashboard. Set it on the hosted server as
@@ -294,7 +325,7 @@ tests without private fixture data or third-party hosting.
294
325
 
295
326
  ```bash
296
327
  claude mcp add --transport http shotops http://localhost:8788/mcp \
297
- --header "Authorization: Bearer shotops_9Zq3Xr7Tp0Vn6Lk2Wd8Yb4Mf1Hc5Ju3Rs9Ea7Gt0Nz1"
328
+ --header "Authorization: Bearer shotops_…"
298
329
  ```
299
330
 
300
331
  Then ask the agent to, e.g., *"render these two screenshots into a 6.9″ App Store strip and
@@ -322,14 +353,14 @@ fastlane deliver # signs in as YOU, previews, uploads screenshots to a dr
322
353
  The server also runs hosted, 24/7-reachable (scale-to-zero when idle), at:
323
354
 
324
355
  ```
325
- https://shotops.dev/mcp
356
+ https://mcp.shotops.dev/mcp
326
357
  ```
327
358
 
328
359
  Nothing to install — point any MCP client at that URL with your `Authorization: Bearer
329
360
  shotops_…` header, same as local dev, just swap the base URL:
330
361
 
331
362
  ```bash
332
- claude mcp add --transport http shotops https://shotops.dev/mcp \
363
+ claude mcp add --transport http shotops https://mcp.shotops.dev/mcp \
333
364
  --header "Authorization: Bearer shotops_…"
334
365
  ```
335
366
 
@@ -340,54 +371,49 @@ since this repo is private.
340
371
 
341
372
  ### Ops (this repo's maintainer only)
342
373
 
343
- - **Deploy** (from the **repo root** the Dockerfile needs `../mockup-engine` and
344
- `../api/_lib` as build-context siblings):
374
+ `shotops-mcp` serves from its own Vercel project on `https://mcp.shotops.dev` (health:
375
+ `/health`, MCP endpoint: `/mcp`). **`docs/reference/ops-runbook.md` §Deploy owns the procedure**
376
+ — the notes here are only what is specific to this package.
377
+
378
+ - **Deploy:** run the repo's deploy script from the repo root; deploys run locally, and a push to
379
+ `main` is a production deploy. Do not hand-run a bare `vercel deploy`. Fly was retired (#68) —
380
+ `flyctl` is history, not the live procedure.
381
+ - **Runtime env** lives on the `shotops-mcp` **Vercel project**, not in this repo:
382
+ `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, and `MCP_PUBLIC_URL` (pins the public hostname as
383
+ the OAuth issuer), plus `OPENAI_APPS_CHALLENGE_TOKEN` while verifying the MCP domain for public
384
+ ChatGPT submission — nothing Apple-related, ever, by construction. Vercel env applies at the
385
+ NEXT deployment, never to the running one.
386
+ - **Verify after any deploy** — a green deploy isn't proof the server answers MCP calls:
345
387
  ```bash
346
- flyctl deploy . --config shotops-mcp/fly.toml --remote-only --ha=false
388
+ cd shotops-mcp && SHOTOPS_MCP_TOKEN=… node verify-mcp.mjs https://mcp.shotops.dev/mcp
347
389
  ```
348
- - **Secrets** (`fly secrets set -a shotops-mcp KEY=value`): `SUPABASE_URL`,
349
- `SUPABASE_SERVICE_ROLE_KEY`, plus `OPENAI_APPS_CHALLENGE_TOKEN` while verifying the MCP
350
- domain for public ChatGPT plugin submission nothing Apple-related, ever, by construction.
351
- - **Verify after any deploy** a green `flyctl deploy` isn't proof the server answers MCP
352
- calls:
353
- ```bash
354
- node verify-mcp.mjs https://shotops.dev/mcp <a-real-shotops_-token>
355
- ```
356
- Exercises `tools/list`, `read_look`, `emit_bundle` (asserts the zip unzips to
357
- README/Deliverfile/screenshots), and asserts no-token / bad-token both get `401`.
358
- - **Logs / status:** `flyctl logs -a shotops-mcp`, `flyctl status -a shotops-mcp`.
359
- - The Dockerfile is pinned to `mcr.microsoft.com/playwright:v1.61.1-noble` matching the
360
- `playwright` npm version in `package.json`. If you bump one, bump the other.
361
- - The container builds the browser harness once and serves it through a loopback-only static
362
- server at runtime. Vite is build/parity tooling only and is pruned from production
363
- dependencies; the Docker build context still includes `mockup-engine` so the harness can
364
- bundle the shared engine + GLB.
365
- - **VM size is `shared-cpu-2x` with 4GB and BOTH numbers are load-bearing for different
366
- reasons.** The **memory** was raised 2gb 4gb on 2026-07-26 (#134): a grouped multi-panel
367
- `render_strip` drove `chrome-headless` to ~1.4gb resident, the kernel OOM-killed it, and the
368
- server went unhealthy for minutes — `verify-mcp.mjs` could not run to completion at 2gb and now
369
- does. The **2 vCPUs** are the older finding below; don't conflate them, and don't cut either.
370
- - **Why 2 vCPU and not `shared-cpu-1x`** (found live, Phase 10):
371
- Chromium's software WebGL render (no GPU on the hosted tier) is CPU-heavy enough that on
372
- a single shared vCPU it can starve the whole process — health checks stop responding,
373
- SIGTERM/SIGINT get ignored, and the platform force-kills the machine
374
- mid-render. **`Virtual machine exited abruptly` has TWO causes and they look identical in the
375
- logs:** on 1 vCPU it is this CPU starvation; on 2 vCPU under load it was the #134 OOM, i.e.
376
- MEMORY, fixed by the `4gb` above (`docs/reference/ops-runbook.md` carries that half). Check
377
- which resource ran out before reaching for either fix. On 2 vCPUs the same 3-panel real-screenshot render completes in ~8s. Don't
378
- drop back to `shared-cpu-1x` to save cost — a render-triggering tool call is expected to
379
- occasionally saturate a whole core, and 1x has no second core to keep the process alive
380
- while that happens. Client callers (agents, `verify-mcp.mjs`) should still set a generous
381
- per-call timeout (≥180s) for `render_strip`/`emit_bundle` — even warm, cold-machine renders
382
- can take 60-110s before the CPU fix, and network/Supabase round trips add more on `urls`
383
- output.
384
-
385
- ## Local (free tier) — `npx shotops-mcp`
390
+ Exercises the exact registered tool set, real render/bundle/project round-trips, signed upload
391
+ refs, asset deletion, and asserts no-token / bad-token both get `401`. It needs the **Studio**
392
+ deployment live too (`delete_project` is a control-plane op) and leaves the account as it found
393
+ it (#333). Add `--artifact-dir <private-dir>` for the ChatGPT portal handoff.
394
+ - **Logs / status:** the `shotops-mcp` project in the Vercel dashboard.
395
+ - **Production builds through Nitro** — `nitro.config.ts` (`preset: 'node-server'`,
396
+ `vercel: { entryFormat: 'node' }`) compiles the Express graph into `dist-server/`. `Dockerfile`
397
+ builds the RETIRED Fly image only; it is pinned to `mcr.microsoft.com/playwright:v1.61.1-noble`
398
+ to match the `playwright` npm version in `package.json`, so if you bump one, bump the other.
399
+ - The build compiles the browser harness once (`build:harness` → `harness-dist/`) and the running
400
+ server serves it from a loopback-only static server. Vite is build/parity tooling only and is
401
+ pruned from production dependencies; `mockup-engine` stays reachable at build time so the
402
+ harness can bundle the shared engine + GLB.
403
+ - **Hosted render capacity is load-bearing and history-rich.** A `render_strip` boots headless
404
+ Chromium + WebGL for seconds, inline in the request, and full-res multi-panel renders are slow
405
+ by design seconds per panel, one panel at a time. The function duration ceiling, the memory
406
+ findings behind it, and the two distinct failure shapes (CPU starvation vs. OOM) are recorded in
407
+ `docs/reference/ops-runbook.md` §Deploy. Never cut a memory or duration setting without a green
408
+ `verify-mcp.mjs` run behind the claim. Client callers (agents, `verify-mcp.mjs`) should set a
409
+ generous per-call timeout (≥180s) for `render_strip`/`emit_bundle`.
410
+
411
+ ## Local `npx shotops-mcp`
386
412
 
387
413
  The SAME server also runs as a **local stdio MCP**, entirely on your own machine — no URL, no
388
- account, no hosted cost, and no render-timeout ceiling (the hosted server's one real limit — see
389
- "Ops" above). It's the same `registerTools`/render engine as the hosted server; only the
390
- transport and a few account-shaped tools differ.
414
+ hosted cost, and no render-timeout ceiling (the hosted server's one real limit — see "Ops" above).
415
+ Previewing needs no account. It's the same `registerTools`/render engine as the hosted server; only
416
+ the transport and a few account-shaped tools differ.
391
417
 
392
418
  ```bash
393
419
  claude mcp add shotops -- npx -y shotops-mcp
@@ -410,14 +436,19 @@ instead of `{ ref }`/`{ url }`:
410
436
  (`{ "path": ... }` only works over this LOCAL server — the hosted server rejects it, since
411
437
  reading an arbitrary server-side path there would be a local-file-inclusion hole.)
412
438
 
413
- **`emit_bundle` reads local panels off disk too** — the free tier has no account, so it can't
414
- produce `output: "urls"` refs. After rendering, pass the on-disk PNGs straight to `emit_bundle`
415
- (same local-only `{ path }` door as screenshots) to package a `fastlane deliver` zip with no upload:
439
+ **`emit_bundle` reads local panels off disk too** — an unsigned local server has no account, so it
440
+ can't produce `output: "urls"` refs. Once you are signed in on a trial or Pro, pass the on-disk PNGs
441
+ straight to `emit_bundle` (same local-only `{ path }` door as screenshots) to package a
442
+ `fastlane deliver` zip with no upload and no credits:
416
443
 
417
444
  ```json
418
445
  { "bundleId": "com.acme.app", "panels": [{ "path": "/abs/panel-01.png" }, { "path": "/abs/panel-02.png" }] }
419
446
  ```
420
447
 
448
+ `emit_bundle` and full-resolution renders are the store-ready half of the offer, so they need that
449
+ trial or Pro even here, where the compute is yours. Preview renders need neither an account nor
450
+ credits. `account_status` reports which side of the line this process is on.
451
+
421
452
  **Saving without a token:** a new `save_project` made from local `{ path }` PNGs creates a private
422
453
  **pending claim**. It uploads only that saved project's byte-free record and raw source PNGs,
423
454
  returns `status: "pending_claim"` plus `claimId` and `openUrl`, and deliberately returns no
@@ -425,22 +456,26 @@ returns `status: "pending_claim"` plus `claimId` and `openUrl`, and deliberately
425
456
  with its screenshots, and the link expires after 7 days. An existing `project` cannot be updated
426
457
  without the token that owns it. Non-path inputs are refused rather than creating a blank claim.
427
458
 
428
- Ordinary `render_strip` / `emit_bundle` calls still upload **nothing** and work fully. Other
459
+ Ordinary `render_strip` / `emit_bundle` calls still upload **nothing** — pixels and zips are written
460
+ straight to your disk, and no screenshot leaves the machine. Other
429
461
  account reads/writes (`read_look`, `save_look`, `read_project`, `render_project`, share links)
430
462
  still need a token or hosted connection. A token-backed local `render_project` reads the cloud
431
463
  manifest through the Studio control plane, downloads only the resolved cells, and renders locally:
432
464
 
433
- | | Local (free, `npx shotops-mcp`) | Hosted (`shotops_…` token) |
465
+ | | Local (`npx shotops-mcp`) | Hosted (`shotops_…` token) |
434
466
  |---|---|---|
435
- | Render compute | **your machine** ($0, no timeout) | our servers — spends your ShotOps credits: 1,000 per preview panel, 4,000 per full-resolution panel |
436
- | Auth | none (the process runs as you) | account + API token |
467
+ | Render compute | **your machine** ($0, no timeout, no credits on any plan) | our servers — spends your ShotOps credits: 0.5 per preview panel, 2 per full-resolution panel |
468
+ | Preview render | free, no account | free on an account; 3 per 30 days, ≤5 panels, without one |
469
+ | Full-resolution render, any `emit_bundle` | trial or Pro (costs no credits) | trial or Pro, and spends credits |
470
+ | Auth | none to preview; a token for account state and store-ready output | account + API token |
437
471
  | Saved looks / editable projects / project read+re-render / share links | new project: pending claim; existing/account state: token (below); share links: hosted | ✅ |
438
472
  | Screenshot input | local `{ path }` off disk | `request_screenshot_upload` → `{ ref }` |
439
473
 
440
474
  ### Optional: bridge a local render into your hosted account
441
475
 
442
476
  Connect your account and `save_project` / `read_look` / `save_look` / `read_project` /
443
- `render_project` start working too — **rendering still happens locally and for free.** A
477
+ `render_project` start working too — **rendering still happens locally and spends no credits**, and
478
+ a trial or Pro account unlocks full-resolution output and `emit_bundle` on this machine. A
444
479
  deliberate save also uploads its local source PNGs privately so the project reopens with
445
480
  screenshots on another device; normal renders and exports never upload.
446
481
 
@@ -466,6 +501,124 @@ SHOTOPS_TOKEN=shotops_… claude mcp add shotops -- npx -y shotops-mcp
466
501
  `--token` wins over `SHOTOPS_TOKEN`, which wins over a saved browser login — so a token in the
467
502
  environment is never quietly shadowed by whoever last signed in on that machine.
468
503
 
504
+ ### Local Apple release setup
505
+
506
+ Before preparing an App Store release, validate the Apple tooling and API key already on this
507
+ machine:
508
+
509
+ ```bash
510
+ npx shotops-mcp release setup
511
+ # non-interactive form:
512
+ npx shotops-mcp release setup \
513
+ --issuer-id "$APP_STORE_CONNECT_ISSUER_ID" \
514
+ --key-id "$APP_STORE_CONNECT_KEY_ID" \
515
+ --key-path /secure/AuthKey_ABC123DEFG.p8
516
+ ```
517
+
518
+ The command requires an owner-only `.p8`, Fastlane, and Apple Transporter; parses the key through
519
+ Fastlane; and makes read-only App Store Connect app-list requests to prove the key's real access.
520
+ It does not install or upgrade anything. Only Issuer ID, Key ID, and the canonical key path are
521
+ saved in `~/.shotops/release.json` (or `$SHOTOPS_CONFIG_DIR/release.json`); a failed rerun leaves the
522
+ last valid setup untouched.
523
+
524
+ For CI, create secret inputs named `APP_STORE_CONNECT_ISSUER_ID`,
525
+ `APP_STORE_CONNECT_KEY_ID`, and `APP_STORE_CONNECT_PRIVATE_KEY`. The setup command names these
526
+ inputs but never prints their values.
527
+
528
+ ### Prepare a deterministic desired release
529
+
530
+ Once signed in to ShotOps, assemble the saved owner project and repository into one local,
531
+ content-addressed workspace — before reading or changing App Store Connect:
532
+
533
+ ```bash
534
+ npx shotops-mcp release prepare --project "My project"
535
+
536
+ # Optional signed build and an explicit partial scope:
537
+ npx shotops-mcp release prepare \
538
+ --project proj_123 \
539
+ --build build/MyApp.ipa \
540
+ --locales en-US,de-DE \
541
+ --outputs iphone-6-9
542
+ ```
543
+
544
+ The command renders every authored locale and selected output by default with the same engine as
545
+ Studio and the MCP. `--locales` or `--outputs` deliberately narrows the run and records every
546
+ omission as a **partial** scope. It writes `desired-release.json` plus the rendered PNGs under
547
+ `~/.shotops/release-workspaces/<desired-id>/` (or `--output <directory>`), with user-only
548
+ permissions. Identical project, repository, scope, and build inputs produce the same manifest ID
549
+ and pixels.
550
+
551
+ Repository input conventions are explicit so no Ruby is ever evaluated:
552
+
553
+ - `fastlane/metadata/<locale>/*.txt` — supported localized Deliver metadata;
554
+ - `fastlane/metadata/*.txt` and `review_information/*.txt` — app and review metadata;
555
+ - `fastlane/metadata/app_store_rating_config.json` — Fastlane's current age-rating shape;
556
+ - `fastlane/metadata/submission_information.json` — export/content compliance fields;
557
+ - `fastlane/metadata/app_review_attachment_file.txt` — empty clears the attachment; otherwise it
558
+ names a repository-relative supported attachment;
559
+ - `fastlane/app-previews/<locale>/*` — `.mov`, `.mp4`, or `.m4v`, with the ASC device token in
560
+ each filename (for example `APP_IPHONE_67_01_demo.mp4`).
561
+
562
+ Missing files mean **unchanged**; an existing supported empty text file means **clear**. Unknown
563
+ files, fields, locales, outputs, unreadable artifacts, and Bundle ID disagreements fail closed.
564
+ Fastfile and Deliverfile are never executed. Optional `.ipa`/supported `.pkg` bytes are hashed and
565
+ referenced, never copied into ShotOps state. This step performs no ASC request, upload, version
566
+ creation, signing, submission, or release.
567
+
568
+ ### Create the exact App Store Connect plan
569
+
570
+ Read the editable App Store version and bind it to the prepared workspace without changing Apple:
571
+
572
+ ```bash
573
+ npx shotops-mcp release plan \
574
+ --workspace ~/.shotops/release-workspaces/<desired-id> \
575
+ --repo .
576
+ ```
577
+
578
+ The command resolves the Bundle ID to one accessible app and one editable iOS version, then reads
579
+ the supported metadata, review, age-rating, compliance, build, screenshot, and App Preview
580
+ baseline. It writes a user-only `release-plan.json` containing every operation as a member of one
581
+ closed, typed vocabulary — each carrying its own target, the precondition it needs, the sealed bytes
582
+ it sends, the postcondition it must reach, and a redacted summary — plus a portable
583
+ `release-review.zip` containing that exact canonical plan and its content-addressed screenshot bytes.
584
+
585
+ The plan hash-binds the project revision, desired bytes, normalized Fastlane inputs, Apple setup
586
+ identity and policy version, so later approval cannot drift onto different state. Its **approval
587
+ window is 15 minutes**; the plan itself stays readable for as long as its sealed workspace exists.
588
+ Concurrency is per resource rather than one hash of the whole app: an unrelated App Store edit is
589
+ reported without invalidating the plan, while a change to a resource the plan writes — or to the
590
+ app, version or App Store state it targets — blocks before any write.
591
+
592
+ `release-plan-envelope.json` is the separate redacted form safe for a later ShotOps coordination
593
+ step: it carries keyed commitments and digests, never metadata values, review credentials, local
594
+ paths, Apple credentials, or asset bytes. Identifiers are committed under a per-plan key that stays
595
+ on your machine, so the envelope cannot be used to confirm a guess at your Bundle ID, Apple key ID
596
+ or version string. This command talks directly to Apple with GET requests
597
+ only; it never uploads, creates a version, commits, submits, or sends the private key to ShotOps.
598
+
599
+ ### Review the exact plan locally
600
+
601
+ From the plan directory, open its read-only local browser review:
602
+
603
+ ```bash
604
+ npx shotops-mcp release review
605
+ ```
606
+
607
+ To review an artifact downloaded from owner-controlled CI, pass it directly:
608
+
609
+ ```bash
610
+ npx shotops-mcp release review release-review.zip
611
+ ```
612
+
613
+ The command verifies the exact plan hash, artifact manifest, and every embedded screenshot before
614
+ opening a nonce-protected URL on `127.0.0.1` with a random port. The page shows target app and team
615
+ fingerprint, Bundle ID, version, the approval window, complete or partial scope, every
616
+ before/after/clear, build, review, rating, compliance, preview, screenshot order/replacement/omission,
617
+ unchanged operation, and the operations excluded by the plan. Partial-scope and no-build warnings
618
+ cannot be dismissed. It loads no remote resource, sends nothing, and has no edit or approval action.
619
+ A plan past its approval window opens read-only and says why; corrupt, edited, or content-mismatched
620
+ inputs fail before a server is started.
621
+
469
622
  The saved project remembers the exact on-disk folder your screenshots came from (not just their
470
623
  filenames), so re-opening it on the SAME machine can point right back at it. Share links aren't
471
624
  bridged yet — they'd need a hosted rendering step, which would defeat local rendering's whole
@@ -496,7 +649,7 @@ shotops-mcp/
496
649
  uploads.ts Phase 10 — upload slots, ref ownership check, output:'urls' storage
497
650
  screenshotInput.ts Phase 10 — resolves screenshots[] (inline/ref/url/path) to base64
498
651
  env.ts loads ../.env.local; MCP_PORT / STUDIO_ORIGIN / MCP_PUBLIC_URL
499
- local.ts the LOCAL (free tier) npx entry point — stdio transport, no auth
652
+ local.ts the LOCAL npx entry point — stdio transport, no auth required to preview
500
653
  localDeps.ts the local ToolDeps fork — static-serve renderer, no-op/bridged persistence
501
654
  hostedBridge.ts local→hosted bridge (a real MCP client using your shotops_ token)
502
655
  harness/ the browser render page Chromium loads (render.js exposes window.renderStrip)
@@ -504,7 +657,8 @@ shotops-mcp/
504
657
  vite.harness.config.mjs the STATIC harness build (local mode's publish prerequisite) → harness-dist/
505
658
  build.mjs esbuild bundle of src/local.ts (+ @engine/@api inlined) → dist/local.js
506
659
  Dockerfile hosted image — Playwright's Chromium base + this repo's 3 npm installs
507
- fly.toml Fly app config — ROLLBACK ONLY since the Vercel cutover; see #68
660
+ fly.toml Fly app config — RETIRED (#68), kept only for the cold-deploy recipe;
661
+ the running fly.dev machine is NOT a rollback
508
662
  verify-mcp.mjs post-deploy smoke test — real MCP round-trip against a hosted URL
509
663
  verify-mcp-local.ts local stdio smoke test (tsx src/local.ts, in-repo)
510
664
  static-parity.ts pixel-diffs the static-serve render against the Vite-dev render (must be 0)
@@ -71,6 +71,7 @@ Space Grotesk
71
71
  Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
72
72
 
73
73
  Each font is licensed under the SIL Open Font License, Version 1.1:
74
+ https://openfontlicense.org/open-font-license-official-text/
74
75
 
75
76
  -----------------------------------------------------------
76
77
  SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007