@reshotdev/screenshot 0.0.1-beta.11 → 0.0.1-beta.13

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.
Files changed (66) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +84 -51
  3. package/package.json +20 -16
  4. package/src/commands/auth.js +38 -8
  5. package/src/commands/capture-dom.js +50 -0
  6. package/src/commands/compose.js +220 -0
  7. package/src/commands/doctor-target.js +36 -4
  8. package/src/commands/drifts.js +13 -1
  9. package/src/commands/publish.js +137 -12
  10. package/src/commands/pull.js +13 -8
  11. package/src/commands/refresh.js +166 -0
  12. package/src/commands/setup-wizard.js +35 -2
  13. package/src/commands/status.js +22 -2
  14. package/src/commands/variation.js +194 -0
  15. package/src/index.js +189 -47
  16. package/src/lib/api-client.js +61 -35
  17. package/src/lib/auto-update/refresh.js +598 -0
  18. package/src/lib/auto-update/scene-runtime.compose.tsx +73 -0
  19. package/src/lib/auto-update/spec.js +89 -0
  20. package/src/lib/capture-engine.js +73 -0
  21. package/src/lib/capture-script-runner.js +280 -134
  22. package/src/lib/certification.js +23 -1
  23. package/src/lib/compose-context.js +156 -0
  24. package/src/lib/compose-pack.js +42 -0
  25. package/src/lib/compose-runtime.js +34 -0
  26. package/src/lib/compose-upload.js +142 -0
  27. package/src/lib/config.js +5 -5
  28. package/src/lib/dom-capture.js +64 -0
  29. package/src/lib/output-path-template.js +3 -3
  30. package/src/lib/record-clip.js +83 -3
  31. package/src/lib/record-config.js +0 -4
  32. package/src/lib/resolve-targets.js +60 -0
  33. package/src/lib/run-manifest.js +45 -0
  34. package/src/lib/storage-providers.js +1 -1
  35. package/src/lib/style-engine.js +5 -5
  36. package/src/lib/ui-api-helpers.js +118 -0
  37. package/src/lib/ui-api.js +28 -820
  38. package/src/lib/ui-asset-cleanup.js +62 -0
  39. package/src/lib/ui-output-versions.js +165 -0
  40. package/src/lib/ui-recorder-routes.js +341 -0
  41. package/src/lib/ui-scenario-metadata.js +161 -0
  42. package/vendor/compose/dist/auto-update.cjs +5544 -0
  43. package/vendor/compose/dist/auto-update.mjs +5518 -0
  44. package/vendor/compose/dist/capture.cjs +1450 -0
  45. package/vendor/compose/dist/capture.mjs +1416 -0
  46. package/vendor/compose/dist/eligibility.cjs +5331 -0
  47. package/vendor/compose/dist/eligibility.mjs +5313 -0
  48. package/vendor/compose/dist/index.cjs +2046 -0
  49. package/vendor/compose/dist/index.mjs +1997 -0
  50. package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
  51. package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
  52. package/vendor/compose/dist/jsx-runtime.cjs +58 -0
  53. package/vendor/compose/dist/jsx-runtime.mjs +31 -0
  54. package/vendor/compose/dist/render.cjs +558 -0
  55. package/vendor/compose/dist/render.mjs +515 -0
  56. package/vendor/compose/dist/verify-cli.cjs +3806 -0
  57. package/vendor/compose/dist/verify-cli.mjs +3812 -0
  58. package/vendor/compose/dist/verify.cjs +3880 -0
  59. package/vendor/compose/dist/verify.mjs +3858 -0
  60. package/web/manager/dist/assets/{index-D2qqcFNN.js → index-D0S2otug.js} +56 -56
  61. package/web/manager/dist/index.html +1 -1
  62. package/src/commands/ci-run.js +0 -178
  63. package/src/commands/ci-setup.js +0 -288
  64. package/src/commands/ingest.js +0 -458
  65. package/src/commands/setup.js +0 -165
  66. package/src/lib/playwright-runner.js +0 -252
package/LICENSE CHANGED
@@ -175,7 +175,7 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
175
175
 
176
176
  END OF TERMS AND CONDITIONS
177
177
 
178
- Copyright 2026 Mindy, Inc.
178
+ Copyright 2026 The Plain Works Co., Ltd.
179
179
 
180
180
  Licensed under the Apache License, Version 2.0 (the "License");
181
181
  you may not use this file except in compliance with the License.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @reshotdev/screenshot
2
2
 
3
- Product screenshots in documentation go stale within days of a deploy. Manually recapturing them across themes, viewports, and locales is tedious and error-prone. This CLI automates screenshot and video capture in CI/CD, comparing against baselines to detect visual drift before it reaches production docs.
3
+ Product screenshots in documentation go stale within days of a UI change. Manually recapturing them across themes, viewports, and locales is tedious and error-prone. This CLI runs screenshot and video capture against a localhost build, comparing each run against the previous capture so teams can review diffs before docs change.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@reshotdev/screenshot)](https://www.npmjs.com/package/@reshotdev/screenshot)
6
6
  [![CI](https://github.com/reshotdev/screenshot/actions/workflows/ci.yml/badge.svg)](https://github.com/reshotdev/screenshot/actions/workflows/ci.yml)
@@ -31,17 +31,32 @@ reshot run
31
31
  # 4. Review captures in the web UI
32
32
  reshot studio
33
33
 
34
- # 5. Publish when you want hosted assets
35
- reshot publish
34
+ # 5. Publish when you want hosted assets — pass --auto-approve on first run
35
+ reshot publish --auto-approve
36
36
  ```
37
37
 
38
38
  For launch-grade reliability, do not treat `next dev` as the supported capture
39
39
  runtime. Use a production-like local server and see the
40
40
  [Supported Environments guide](https://reshot.dev/docs/cli/getting-started/supported-environments).
41
41
 
42
+ > **First-time setup tip:** pass `--auto-approve` to your first `reshot
43
+ > publish` so newly-captured visuals skip the review queue and become
44
+ > immediately available via `reshot pull`. Without it, every new visual
45
+ > lands in PENDING and is only visible in the studio.
46
+
47
+ > **OAuth / magic-link / Supabase apps:** skip `auth.loginSteps` and use
48
+ > `playwright codegen $YOUR_APP --save-storage=.reshot/auth-state.json`
49
+ > once, then set `"storageStatePath": ".reshot/auth-state.json"` in your
50
+ > config. See [Authentication](#authentication) below for details.
51
+
42
52
  ## Certified Targets
43
53
 
44
- This release adds a **Certified Targets** contract for apps that need stronger guarantees than ad hoc capture. Certified targets declare their readiness selectors, auth mode, required routes, and expected published assets in `reshot.config.json`, then pass the full doctor/capture/publish/delivery pipeline before release.
54
+ > **Most integrations should omit `target` entirely.** Certified targets
55
+ > are an opt-in contract for production-grade flows once basic capture is
56
+ > working — start without them and add `target` later if you need the
57
+ > stronger guarantees.
58
+
59
+ This release adds a **Certified Targets** contract for apps that need stronger guarantees than ad hoc capture. Certified targets declare their readiness selectors, localhost runtime, required routes, and expected published assets in `reshot.config.json`, then pass the full doctor/capture/publish/delivery pipeline before release.
45
60
 
46
61
  ## Configuration
47
62
 
@@ -120,8 +135,6 @@ Create `reshot.config.json` in your project root:
120
135
  | `reshot certify` | Run the full certified-target pipeline | `--scenarios`, `--tag`, `--json` |
121
136
  | `reshot drifts` | Manage visual drift notifications | `approve`, `reject`, `ignore`, `approve-all` |
122
137
  | `reshot import-tests` | Import Playwright tests as scenarios | `--dry-run`, `--no-interactive` |
123
- | `reshot ci setup` | Generate CI/CD workflow files | — |
124
- | `reshot ci run` | Capture + publish in one step (CI) | `--tag`, `--no-publish`, `--dry-run` |
125
138
 
126
139
  ## Certification Workflow
127
140
 
@@ -276,18 +289,17 @@ The recorded scenario is appended to `reshot.config.json` automatically.
276
289
 
277
290
  ## Authentication
278
291
 
279
- ### Storage State
292
+ ### Storage State (recommended)
280
293
 
281
- The CLI stores browser session state at `~/.reshot/session-state.json` (global).
282
- This is automatically captured when you run `reshot record`.
283
-
284
- To manually generate it:
294
+ For OAuth, magic-link, Supabase Auth, Clerk, Auth.js, and any other modern
295
+ auth flow that can't be scripted with form fields, capture a Playwright
296
+ storage state once and let reshot reuse it for every scenario:
285
297
 
286
298
  ```bash
287
- npx playwright codegen http://localhost:3000 --save-storage=$HOME/.reshot/session-state.json
299
+ npx playwright codegen http://localhost:3000 --save-storage=.reshot/auth-state.json
288
300
  ```
289
301
 
290
- Or reference a project-local path in your config:
302
+ Then point your config at it:
291
303
 
292
304
  ```json
293
305
  {
@@ -295,7 +307,22 @@ Or reference a project-local path in your config:
295
307
  }
296
308
  ```
297
309
 
298
- ### Login Steps
310
+ `reshot record` does the same thing interactively and writes to
311
+ `~/.reshot/session-state.json` by default.
312
+
313
+ ### Test backdoors
314
+
315
+ If your app has a dev/test backdoor that bypasses auth at the server layer
316
+ (for example a `/api/devtools` fixture endpoint, a header-based
317
+ impersonation hook, or a localhost-only cookie), you can point reshot at it
318
+ via `baseUrl` and skip storage state entirely. This is often the cleanest
319
+ option for first-party apps that already maintain such an endpoint for
320
+ testing.
321
+
322
+ ### Login Steps (password forms only)
323
+
324
+ If your app still uses a traditional username/password form, you can
325
+ script the login directly:
299
326
 
300
327
  ```json
301
328
  {
@@ -312,6 +339,8 @@ Or reference a project-local path in your config:
312
339
  ```
313
340
 
314
341
  Environment variables (`${EMAIL}`, `${PASSWORD}`) are interpolated at runtime.
342
+ `loginSteps` cannot drive OAuth, magic-link, or any redirect-based flow —
343
+ use **Storage State** above for those.
315
344
 
316
345
  ## Output Formats
317
346
 
@@ -343,63 +372,67 @@ Set `"format": "summary-video"` in scenario output config to record the full bro
343
372
 
344
373
  Crops the screenshot to the bounding box of the selected element.
345
374
 
346
- ## CI/CD Integration
347
-
348
- ### GitHub Actions
349
-
350
- ```yaml
351
- name: Visual Capture
352
- on:
353
- push:
354
- branches: [main]
355
-
356
- jobs:
357
- capture:
358
- runs-on: ubuntu-latest
359
- steps:
360
- - uses: actions/checkout@v4
361
- - uses: actions/setup-node@v4
362
- with: { node-version: 20 }
363
- - run: npm ci
364
- - run: npm start & # start your app
365
- - run: npx @reshotdev/screenshot ci run --tag ${{ github.sha }} --message "Deploy ${{ github.sha }}"
366
- env:
367
- RESHOT_TOKEN: ${{ secrets.RESHOT_TOKEN }}
375
+ ## Automation in Scripts
376
+
377
+ Use headless mode with environment variables to integrate into build scripts or local workflows:
378
+
379
+ ```bash
380
+ # In your Makefile or build script
381
+ export RESHOT_API_KEY=$(cat .reshot/api-key)
382
+ reshot run --scenarios dashboard --no-headless false
383
+ reshot publish --tag v1.2.0
368
384
  ```
369
385
 
370
- Or use the setup wizard:
386
+ Set `RESHOT_API_KEY` and `RESHOT_PROJECT_ID` to run without interactive auth:
371
387
 
372
388
  ```bash
373
- reshot ci setup
389
+ RESHOT_API_KEY=your-key RESHOT_PROJECT_ID=your-project reshot run
374
390
  ```
375
391
 
376
- This generates a `.github/workflows/reshot.yml` tailored to your project.
392
+ For headless execution, ensure:
393
+ - Your app is running on localhost (e.g., `npm run build && npm run start`)
394
+ - `headless: true` is set in `reshot.config.json`
395
+ - API credentials are available as environment variables
377
396
 
378
397
  ## Asset Map for Builds
379
398
 
380
399
  Generate a manifest of captured assets for use in documentation sites or marketing pages:
381
400
 
382
401
  ```bash
383
- reshot pull --format json --output assets.json --status approved
402
+ reshot pull --format json --output assets.json
384
403
  ```
385
404
 
405
+ The output is keyed by scenario, visual, and context (variant). `meta` mirrors
406
+ the API response; `assets` is a 3-level nested object — `scenarioKey →
407
+ visualKey → context`:
408
+
386
409
  ```json
387
410
  {
388
- "assets": [
389
- {
390
- "key": "dashboard-overview",
391
- "variant": "theme-light",
392
- "url": "https://cdn.reshot.dev/abc123/dashboard-overview.png",
393
- "format": "png",
394
- "width": 1280,
395
- "height": 720,
396
- "version": "v1.2.0"
411
+ "meta": {
412
+ "projectId": "...",
413
+ "exportedAt": "2026-04-30T12:00:00.000Z",
414
+ "totalVisuals": 12
415
+ },
416
+ "assets": {
417
+ "dashboard": {
418
+ "overview": {
419
+ "themeLight": {
420
+ "type": "image/png",
421
+ "alt": "Dashboard overview, light theme",
422
+ "steps": [
423
+ { "src": "https://cdn.reshot.dev/abc123/overview.png", "step": "overview" }
424
+ ]
425
+ },
426
+ "themeDark": { "...": "..." }
427
+ }
397
428
  }
398
- ]
429
+ }
399
430
  }
400
431
  ```
401
432
 
402
- Also supports `--format ts` (TypeScript with full metadata) and `--format csv`.
433
+ By default `pull` returns visuals in all states (approved + pending). Pass
434
+ `--status approved` to filter to released visuals only. Also supports
435
+ `--format ts` (TypeScript with full metadata) and `--format csv`.
403
436
 
404
437
  ## Drift Management
405
438
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@reshotdev/screenshot",
3
- "version": "0.0.1-beta.11",
4
- "description": "CI/CD screenshot and video capture CLI",
3
+ "version": "0.0.1-beta.13",
4
+ "description": "Screenshot and video capture CLI",
5
5
  "author": "Reshot <hello@reshot.dev>",
6
6
  "license": "MIT",
7
7
  "homepage": "https://reshot.dev",
@@ -15,7 +15,7 @@
15
15
  },
16
16
  "keywords": [
17
17
  "screenshots",
18
- "ci-cd",
18
+ "cli",
19
19
  "documentation",
20
20
  "visual-testing",
21
21
  "automation",
@@ -26,6 +26,7 @@
26
26
  },
27
27
  "files": [
28
28
  "src/",
29
+ "vendor/compose/dist/",
29
30
  "web/manager/dist/",
30
31
  "web/cropper/",
31
32
  "web/subtitle-editor/",
@@ -35,24 +36,13 @@
35
36
  "engines": {
36
37
  "node": ">=18.0.0"
37
38
  },
38
- "scripts": {
39
- "test": "node scripts/test.js",
40
- "test:unit": "node --test tests/unit/*.test.js",
41
- "test:integration": "node --test tests/integration/*.test.js",
42
- "test:all": "node --test tests/unit/*.test.js tests/integration/*.test.js",
43
- "test:dry-run": "node src/index.js --help && node src/index.js --version",
44
- "prepublishOnly": "cd web/manager && npm install && npm run build",
45
- "ui:build": "cd web/manager && npm install && npm run build",
46
- "ui:dev": "cd web/manager && npm install && npm run dev",
47
- "lint": "echo 'No linting configured yet'",
48
- "pack:check": "npm pack --dry-run"
49
- },
50
39
  "dependencies": {
51
40
  "axios": "^1.6.2",
52
41
  "chalk": "^4.1.2",
53
42
  "commander": "^11.1.0",
54
43
  "cors": "^2.8.5",
55
44
  "dotenv": "^16.3.1",
45
+ "esbuild": "^0.27.2",
56
46
  "express": "^4.18.2",
57
47
  "form-data": "^4.0.0",
58
48
  "fs-extra": "^11.2.0",
@@ -62,10 +52,24 @@
62
52
  "ora": "^7.0.1",
63
53
  "pixelmatch": "^5.3.0",
64
54
  "playwright": "^1.40.0",
55
+ "playwright-core": "^1.57.0",
65
56
  "pngjs": "^7.0.0",
66
57
  "sharp": "^0.33.2",
67
58
  "socket.io": "^4.7.2",
68
59
  "uuid": "^9.0.1",
69
60
  "@aws-sdk/client-s3": "^3.650.0"
61
+ },
62
+ "scripts": {
63
+ "test": "node scripts/test.js",
64
+ "test:unit": "node --test tests/unit/*.test.js",
65
+ "test:integration": "node --test tests/integration/*.test.js",
66
+ "test:all": "node --test tests/unit/*.test.js tests/integration/*.test.js",
67
+ "test:dry-run": "node src/index.js --help && node src/index.js --version",
68
+ "test:source": "node src/index.js",
69
+ "ui:build": "cd web/manager && npm install && npm run build",
70
+ "ui:dev": "cd web/manager && npm install && npm run dev",
71
+ "lint": "echo 'No linting configured yet'",
72
+ "build": "pnpm run ui:build",
73
+ "pack:check": "npm pack --dry-run"
70
74
  }
71
- }
75
+ }
@@ -14,7 +14,7 @@ const pkg = require("../../package.json");
14
14
 
15
15
  const DEFAULT_CALLBACK_PORT = 3721;
16
16
  const POLL_INTERVAL_MS = 2000;
17
- const DEFAULT_AUTH_TIMEOUT_MS = 120000;
17
+ const DEFAULT_AUTH_TIMEOUT_MS = 15 * 60 * 1000;
18
18
 
19
19
  const unwrapResponse = (payload) => {
20
20
  if (!payload) {
@@ -158,9 +158,9 @@ async function verifyApiKey(apiBaseUrl, apiKey, httpClient = axios) {
158
158
  }
159
159
 
160
160
  async function authCommand(options = {}) {
161
- // Support non-interactive auth via environment variables (for CI/CD)
162
- const envApiKey = process.env.RESHOT_API_KEY;
163
- const envProjectId = process.env.RESHOT_PROJECT_ID;
161
+ // Support non-interactive auth via environment variables
162
+ const envApiKey = options.apiKey || process.env.RESHOT_API_KEY;
163
+ const envProjectId = options.projectId || process.env.RESHOT_PROJECT_ID;
164
164
  const httpClient = options.httpClient || axios;
165
165
  const openFn = options.openFn || open;
166
166
  const writeSettingsFn = options.writeSettingsFn || writeSettings;
@@ -189,6 +189,33 @@ async function authCommand(options = {}) {
189
189
  };
190
190
  }
191
191
 
192
+ // The browser approval flow requires an interactive session (a human approves
193
+ // in the browser while the CLI polls). With no TTY and no env credentials we
194
+ // would otherwise open a browser nobody sees and poll for up to 15 minutes —
195
+ // i.e. hang. Detect that and exit promptly with actionable guidance.
196
+ const stdinIsInteractive =
197
+ typeof options.isInteractive === "boolean"
198
+ ? options.isInteractive
199
+ : Boolean(process.stdin && process.stdin.isTTY);
200
+ if (!stdinIsInteractive) {
201
+ console.error(
202
+ chalk.red("✖ `reshot auth` needs an interactive terminal."),
203
+ );
204
+ console.error(
205
+ chalk.gray(
206
+ " Run it in an interactive shell to approve in the browser, or set",
207
+ ),
208
+ );
209
+ console.error(
210
+ chalk.gray(
211
+ " RESHOT_API_KEY and RESHOT_PROJECT_ID for non-interactive (CI) auth.",
212
+ ),
213
+ );
214
+ const err = new Error("Interactive terminal required for browser auth");
215
+ err.code = "ENOTTY";
216
+ throw err;
217
+ }
218
+
192
219
  const apiBaseUrl = options.apiBaseUrl || getApiBaseUrl();
193
220
  const explicitPortEnv =
194
221
  process.env.RESHOT_CLI_CALLBACK_PORT || "";
@@ -232,12 +259,15 @@ async function authCommand(options = {}) {
232
259
  }
233
260
  }
234
261
 
262
+ const initiatePayload = {
263
+ callbackPort,
264
+ clientVersion: pkg.version,
265
+ ...(options.projectId ? { projectId: options.projectId } : {}),
266
+ };
267
+
235
268
  const initiateResponse = await httpClient.post(
236
269
  `${apiBaseUrl}/auth/cli/initiate`,
237
- {
238
- callbackPort,
239
- clientVersion: pkg.version,
240
- },
270
+ initiatePayload,
241
271
  { headers: { "Content-Type": "application/json" } }
242
272
  );
243
273
 
@@ -0,0 +1,50 @@
1
+ // capture-dom.js - Capture a self-contained DOM reconstruction artifact from a
2
+ // live URL (Tier-3 Phase 2). Emits <slug>.dom.html (+ sidecars), remounted.png,
3
+ // and live.png so the calibrated quality gate can diff them:
4
+ //
5
+ // reshot capture-dom <url> --out /tmp/cap
6
+ // pnpm --dir packages/compose verify diff /tmp/cap/remounted.png /tmp/cap/live.png
7
+
8
+ const chalk = require("chalk");
9
+ const path = require("path");
10
+ const { captureDomFromUrl } = require("../lib/dom-capture");
11
+
12
+ function registerCaptureDom(program) {
13
+ program
14
+ .command("capture-dom <url>")
15
+ .description("Capture a self-contained DOM reconstruction artifact from a live URL")
16
+ .option("-o, --out <dir>", "Output directory", "./.reshot/capture-dom")
17
+ .option("-s, --slug <slug>", "Artifact slug", "capture")
18
+ .option("--width <px>", "Viewport width", (v) => parseInt(v, 10))
19
+ .option("--height <px>", "Viewport height", (v) => parseInt(v, 10))
20
+ .option("--dpr <n>", "Device scale factor", (v) => parseInt(v, 10))
21
+ .action(async (url, options) => {
22
+ const outDir = path.resolve(options.out);
23
+ const settings =
24
+ options.width || options.height || options.dpr
25
+ ? {
26
+ width: options.width || 1000,
27
+ height: options.height || 800,
28
+ deviceScaleFactor: options.dpr || 2,
29
+ }
30
+ : undefined;
31
+
32
+ console.log(chalk.cyan(`\n Capturing DOM from ${url} ...\n`));
33
+ const result = await captureDomFromUrl({ url, outDir, slug: options.slug, settings });
34
+
35
+ console.log(chalk.green(` ✔ method: ${result.method}`));
36
+ console.log(` artifact: ${result.artifact}`);
37
+ console.log(` remounted: ${result.remounted}`);
38
+ console.log(` live: ${result.live}`);
39
+ if (result.sidecars.length) {
40
+ console.log(` sidecars: ${result.sidecars.map((s) => `${s.kind}(rasterized=${s.rasterized})`).join(", ")}`);
41
+ }
42
+ console.log(
43
+ chalk.gray(
44
+ `\n Verify: pnpm --dir packages/compose verify diff ${result.remounted} ${result.live}\n`,
45
+ ),
46
+ );
47
+ });
48
+ }
49
+
50
+ module.exports = { registerCaptureDom };
@@ -0,0 +1,220 @@
1
+ // compose.js - Render a local @reshot/compose file into a video pack
2
+ const path = require("path");
3
+ const fs = require("fs-extra");
4
+ const chalk = require("chalk");
5
+ const { composeDistDir } = require("../lib/compose-runtime");
6
+ const {
7
+ DEFAULT_FORMATS,
8
+ assertCaptureExists,
9
+ deriveSlug,
10
+ parseFormats,
11
+ parseSize,
12
+ resolveCapturePath,
13
+ resolveComposeContext,
14
+ resolveMetadataPath,
15
+ resolveOutBase,
16
+ } = require("../lib/compose-context");
17
+ const {
18
+ assertUploadPackExists,
19
+ packFromExistingOutputs,
20
+ } = require("../lib/compose-pack");
21
+ const {
22
+ buildDashboardUrl,
23
+ getComposeApiBaseUrl,
24
+ humanizeName,
25
+ resolveComposeProjectContext,
26
+ uploadComposition,
27
+ } = require("../lib/compose-upload");
28
+
29
+ async function runCompose(file, options = {}, deps = {}) {
30
+ const context = await resolveComposeContext(file, options);
31
+
32
+ const size = parseSize(options.size || "1440x900");
33
+ const formats = parseFormats(options.formats, options.gif);
34
+ const outBase = resolveOutBase(context.compositionPath, options.out);
35
+
36
+ const { render } = deps.renderModule || loadComposeRender();
37
+
38
+ console.log(chalk.cyan(`\n-> Rendering ${chalk.bold(context.slug)} (${size.width}x${size.height})`));
39
+ console.log(chalk.gray(` composition ${relativePath(context.compositionPath)}`));
40
+ console.log(chalk.gray(` metadata ${relativePath(context.metadataPath)}`));
41
+ console.log(chalk.gray(` capture ${relativePath(context.capturePath)}`));
42
+ console.log(chalk.gray(` out ${relativePath(outBase)}`));
43
+
44
+ const result = await withComposeEnvironment(
45
+ {
46
+ RESHOT_COMPOSE_SLUG: context.slug,
47
+ RESHOT_COMPOSE_METADATA_PATH: context.metadataPath,
48
+ RESHOT_COMPOSE_CAPTURE_PATH: context.capturePath,
49
+ },
50
+ () =>
51
+ render(context.compositionPath, {
52
+ slug: context.slug,
53
+ out: outBase,
54
+ size,
55
+ formats,
56
+ metadataPath: context.metadataPath,
57
+ capturePath: context.capturePath,
58
+ }),
59
+ );
60
+
61
+ console.log(chalk.green(`\nRendered ${context.slug}`));
62
+ printPack(result.pack || {});
63
+ return result;
64
+ }
65
+
66
+ async function runComposePush(file, options = {}, deps = {}) {
67
+ const context = await resolveComposeContext(file, options);
68
+ const outBase = resolveOutBase(context.compositionPath, options.out);
69
+ const pack = options.skipRender
70
+ ? await packFromExistingOutputs(outBase)
71
+ : (await runCompose(file, options, deps)).pack || {};
72
+
73
+ await assertUploadPackExists(pack, Boolean(options.skipRender));
74
+
75
+ const projectContext = resolveComposeProjectContext({
76
+ projectOption: options.project,
77
+ settings: deps.settings,
78
+ });
79
+ const apiBaseUrl = getComposeApiBaseUrl(deps.apiBaseUrl);
80
+ const name = options.name || humanizeName(context.slug);
81
+ const upload = deps.uploadComposition || uploadComposition;
82
+
83
+ console.log(chalk.cyan(`\n-> Uploading ${chalk.bold(name)} to project ${projectContext.projectId}`));
84
+ const response = await upload({
85
+ apiBaseUrl,
86
+ apiKey: projectContext.apiKey,
87
+ projectId: projectContext.projectId,
88
+ name,
89
+ slug: context.slug,
90
+ sourceTsx: await fs.readFile(context.compositionPath, "utf8"),
91
+ metadataJson: await fs.readFile(context.metadataPath, "utf8"),
92
+ pack,
93
+ autoApprove: Boolean(options.autoApprove),
94
+ httpClient: deps.httpClient,
95
+ });
96
+ const dashboardUrl = buildDashboardUrl(response, apiBaseUrl, projectContext.projectId);
97
+
98
+ console.log(chalk.green(`\nUploaded ${context.slug}`));
99
+ console.log(chalk.gray(` Dashboard: ${dashboardUrl}`));
100
+ printPublicUrls(response?.publicUrls);
101
+ if (response?.attributionWarning === "legacy-key-no-user-attribution") {
102
+ console.log(
103
+ chalk.yellow(
104
+ " Attribution: re-issue your API key to enable per-engineer render attribution.",
105
+ ),
106
+ );
107
+ }
108
+
109
+ return { response, dashboardUrl, projectId: projectContext.projectId };
110
+ }
111
+
112
+ function registerCompose(program) {
113
+ const compose = program
114
+ .command("compose")
115
+ .description("Render and upload local JSX compositions")
116
+ .argument("<file>", "Composition file to render")
117
+ .option("--slug <slug>", "Matrix variant slug; defaults to the composition filename")
118
+ .option("--out <path>", "Output base path; defaults to <file-stem>.composed")
119
+ .option("--size <size>", "Viewport size as WIDTHxHEIGHT", "1440x900")
120
+ .option("--formats <formats>", "Comma-separated output formats", DEFAULT_FORMATS.join(","))
121
+ .option("--gif", "Also emit a gif")
122
+ .action(async (file, options) => {
123
+ try {
124
+ await runCompose(file, normalizeCommandOptions(options));
125
+ } catch (error) {
126
+ console.error(chalk.red("Error:"), error.message);
127
+ process.exit(1);
128
+ }
129
+ });
130
+
131
+ compose
132
+ .command("push <file>")
133
+ .description("Render and upload a local JSX composition to the dashboard")
134
+ .option("--name <name>", "Composition display name")
135
+ .option("--project <projectId>", "Project id; defaults to local Reshot settings")
136
+ .option("--out <path>", "Output base path to upload when --skip-render is used")
137
+ .option("--skip-render", "Upload existing local outputs without re-rendering")
138
+ .option("--auto-approve", "Immediately approve this composition render and update live embed URLs")
139
+ .action(async (file, options) => {
140
+ try {
141
+ await runComposePush(file, normalizeCommandOptions(options));
142
+ } catch (error) {
143
+ console.error(chalk.red("Error:"), error.message);
144
+ process.exit(1);
145
+ }
146
+ });
147
+ }
148
+
149
+ function normalizeCommandOptions(options) {
150
+ return typeof options?.opts === "function" ? options.opts() : options || {};
151
+ }
152
+
153
+ function loadComposeRender() {
154
+ return require(path.join(composeDistDir(), "render.cjs"));
155
+ }
156
+
157
+ async function withComposeEnvironment(env, callback) {
158
+ const previous = {};
159
+ for (const [key, value] of Object.entries(env)) {
160
+ previous[key] = process.env[key];
161
+ process.env[key] = value;
162
+ }
163
+
164
+ try {
165
+ return await callback();
166
+ } finally {
167
+ for (const [key, value] of Object.entries(previous)) {
168
+ if (value === undefined) {
169
+ delete process.env[key];
170
+ } else {
171
+ process.env[key] = value;
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ function printPack(pack) {
178
+ for (const format of ["mp4", "webm", "poster", "gif"]) {
179
+ if (pack[format]) {
180
+ console.log(chalk.gray(` ${format.padEnd(6)} ${relativePath(pack[format])}`));
181
+ }
182
+ }
183
+ }
184
+
185
+ function printPublicUrls(publicUrls) {
186
+ if (!publicUrls || typeof publicUrls !== "object") return;
187
+ if (publicUrls.embed) {
188
+ console.log(chalk.gray(` Live embed: ${publicUrls.embed}`));
189
+ }
190
+ if (publicUrls.live?.mp4) {
191
+ console.log(chalk.gray(` Live MP4: ${publicUrls.live.mp4}`));
192
+ }
193
+ if (publicUrls.pinned?.mp4) {
194
+ console.log(chalk.gray(` Pinned MP4: ${publicUrls.pinned.mp4}`));
195
+ }
196
+ }
197
+
198
+ function relativePath(filePath) {
199
+ const relative = path.relative(process.cwd(), filePath);
200
+ return relative && !relative.startsWith("..") ? relative : filePath;
201
+ }
202
+
203
+ module.exports = {
204
+ DEFAULT_FORMATS,
205
+ assertCaptureExists,
206
+ deriveSlug,
207
+ normalizeCommandOptions,
208
+ parseFormats,
209
+ parseSize,
210
+ printPublicUrls,
211
+ registerCompose,
212
+ resolveComposeContext,
213
+ resolveComposeProjectContext,
214
+ resolveCapturePath,
215
+ resolveMetadataPath,
216
+ resolveOutBase,
217
+ runCompose,
218
+ runComposePush,
219
+ uploadComposition,
220
+ };