@reshotdev/screenshot 0.0.1-beta.12 → 0.0.1-beta.14

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 (63) hide show
  1. package/README.md +67 -22
  2. package/package.json +18 -14
  3. package/src/commands/auth.js +37 -7
  4. package/src/commands/capture-dom.js +50 -0
  5. package/src/commands/compose.js +220 -0
  6. package/src/commands/doctor-release.js +7 -0
  7. package/src/commands/doctor-target.js +36 -4
  8. package/src/commands/drifts.js +13 -1
  9. package/src/commands/publish.js +183 -21
  10. package/src/commands/pull.js +9 -4
  11. package/src/commands/refresh.js +166 -0
  12. package/src/commands/setup-wizard.js +57 -3
  13. package/src/commands/status.js +22 -2
  14. package/src/commands/variation.js +194 -0
  15. package/src/index.js +190 -10
  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 +76 -2
  21. package/src/lib/capture-script-runner.js +289 -138
  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 +2 -2
  28. package/src/lib/dom-capture.js +64 -0
  29. package/src/lib/ensure-browser.js +147 -0
  30. package/src/lib/record-clip.js +83 -3
  31. package/src/lib/record-config.js +0 -4
  32. package/src/lib/release-doctor.js +11 -3
  33. package/src/lib/resolve-targets.js +60 -0
  34. package/src/lib/run-manifest.js +45 -0
  35. package/src/lib/ui-api-helpers.js +118 -0
  36. package/src/lib/ui-api.js +28 -820
  37. package/src/lib/ui-asset-cleanup.js +62 -0
  38. package/src/lib/ui-output-versions.js +165 -0
  39. package/src/lib/ui-recorder-routes.js +341 -0
  40. package/src/lib/ui-scenario-metadata.js +161 -0
  41. package/vendor/compose/dist/auto-update.cjs +5544 -0
  42. package/vendor/compose/dist/auto-update.mjs +5518 -0
  43. package/vendor/compose/dist/capture.cjs +1450 -0
  44. package/vendor/compose/dist/capture.mjs +1416 -0
  45. package/vendor/compose/dist/eligibility.cjs +5331 -0
  46. package/vendor/compose/dist/eligibility.mjs +5313 -0
  47. package/vendor/compose/dist/index.cjs +2046 -0
  48. package/vendor/compose/dist/index.mjs +1997 -0
  49. package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
  50. package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
  51. package/vendor/compose/dist/jsx-runtime.cjs +58 -0
  52. package/vendor/compose/dist/jsx-runtime.mjs +31 -0
  53. package/vendor/compose/dist/render.cjs +558 -0
  54. package/vendor/compose/dist/render.mjs +515 -0
  55. package/vendor/compose/dist/verify-cli.cjs +3806 -0
  56. package/vendor/compose/dist/verify-cli.mjs +3812 -0
  57. package/vendor/compose/dist/verify.cjs +3880 -0
  58. package/vendor/compose/dist/verify.mjs +3858 -0
  59. package/web/manager/dist/assets/{index-CvleJUur.js → index-D0S2otug.js} +56 -56
  60. package/web/manager/dist/index.html +1 -1
  61. package/src/commands/ingest.js +0 -458
  62. package/src/commands/setup.js +0 -165
  63. package/src/lib/playwright-runner.js +0 -252
package/README.md CHANGED
@@ -31,16 +31,31 @@ 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
 
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
+
44
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
@@ -274,18 +289,17 @@ The recorded scenario is appended to `reshot.config.json` automatically.
274
289
 
275
290
  ## Authentication
276
291
 
277
- ### Storage State
278
-
279
- The CLI stores browser session state at `~/.reshot/session-state.json` (global).
280
- This is automatically captured when you run `reshot record`.
292
+ ### Storage State (recommended)
281
293
 
282
- 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:
283
297
 
284
298
  ```bash
285
- 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
286
300
  ```
287
301
 
288
- Or reference a project-local path in your config:
302
+ Then point your config at it:
289
303
 
290
304
  ```json
291
305
  {
@@ -293,7 +307,22 @@ Or reference a project-local path in your config:
293
307
  }
294
308
  ```
295
309
 
296
- ### 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:
297
326
 
298
327
  ```json
299
328
  {
@@ -310,6 +339,8 @@ Or reference a project-local path in your config:
310
339
  ```
311
340
 
312
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.
313
344
 
314
345
  ## Output Formats
315
346
 
@@ -368,26 +399,40 @@ For headless execution, ensure:
368
399
  Generate a manifest of captured assets for use in documentation sites or marketing pages:
369
400
 
370
401
  ```bash
371
- reshot pull --format json --output assets.json --status approved
402
+ reshot pull --format json --output assets.json
372
403
  ```
373
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
+
374
409
  ```json
375
410
  {
376
- "assets": [
377
- {
378
- "key": "dashboard-overview",
379
- "variant": "theme-light",
380
- "url": "https://cdn.reshot.dev/abc123/dashboard-overview.png",
381
- "format": "png",
382
- "width": 1280,
383
- "height": 720,
384
- "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
+ }
385
428
  }
386
- ]
429
+ }
387
430
  }
388
431
  ```
389
432
 
390
- 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`.
391
436
 
392
437
  ## Drift Management
393
438
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reshotdev/screenshot",
3
- "version": "0.0.1-beta.12",
3
+ "version": "0.0.1-beta.14",
4
4
  "description": "Screenshot and video capture CLI",
5
5
  "author": "Reshot <hello@reshot.dev>",
6
6
  "license": "MIT",
@@ -22,10 +22,11 @@
22
22
  "playwright"
23
23
  ],
24
24
  "bin": {
25
- "reshot": "./src/index.js"
25
+ "reshot": "src/index.js"
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) {
@@ -159,8 +159,8 @@ async function verifyApiKey(apiBaseUrl, apiKey, httpClient = axios) {
159
159
 
160
160
  async function authCommand(options = {}) {
161
161
  // Support non-interactive auth via environment variables
162
- const envApiKey = process.env.RESHOT_API_KEY;
163
- const envProjectId = process.env.RESHOT_PROJECT_ID;
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
+ };
@@ -52,6 +52,13 @@ async function doctorReleaseCommand(options = {}) {
52
52
  }
53
53
  }
54
54
 
55
+ const advisories = report.summary?.advisories || [];
56
+ if (advisories.length > 0) {
57
+ for (const advisory of advisories.slice(0, 10)) {
58
+ console.log(chalk.yellow(` ⚠ ${advisory.scope}: ${advisory.message}`));
59
+ }
60
+ }
61
+
55
62
  if (report.reportPath) {
56
63
  console.log(chalk.gray(`\n Report: ${report.reportPath}`));
57
64
  }
@@ -11,14 +11,46 @@ async function doctorTargetCommand(options = {}) {
11
11
  .filter(Boolean)
12
12
  : null;
13
13
 
14
+ // Emit an immediate banner BEFORE any async work so the command is never
15
+ // silent — previously it produced zero output while preparing fixtures and
16
+ // launching a browser, which read as a hang.
17
+ if (!options.json) {
18
+ console.log(chalk.cyan("🩺 Running target doctor…"));
19
+ console.log(
20
+ chalk.gray(
21
+ scenarioKeys
22
+ ? ` scenarios: ${scenarioKeys.join(", ")}`
23
+ : " scenarios: all certified",
24
+ ),
25
+ );
26
+ }
27
+
14
28
  const progressLogger = options.json
15
29
  ? null
16
30
  : (message) => console.log(chalk.gray(` → ${message}`));
17
31
 
18
- const report = await runDoctorTarget({
19
- scenarioKeys,
20
- onProgress: progressLogger,
21
- });
32
+ let report;
33
+ try {
34
+ report = await runDoctorTarget({
35
+ scenarioKeys,
36
+ onProgress: progressLogger,
37
+ timeoutMs: options.timeout ? Number(options.timeout) : undefined,
38
+ });
39
+ } catch (error) {
40
+ // Fail fast with an actionable message + report path rather than hanging.
41
+ if (!options.json) {
42
+ console.error(chalk.red(`\n ✖ Target doctor aborted: ${error.message}`));
43
+ console.error(
44
+ chalk.gray(
45
+ " Confirm the dev server is reachable and the target is configured (see .reshot/reports).",
46
+ ),
47
+ );
48
+ } else {
49
+ console.log(JSON.stringify({ ok: false, error: error.message }, null, 2));
50
+ }
51
+ process.exitCode = 1;
52
+ return { ok: false, error: error.message };
53
+ }
22
54
 
23
55
  if (options.json) {
24
56
  console.log(JSON.stringify(report, null, 2));
@@ -234,7 +234,7 @@ async function batchDriftAction(apiKey, projectId, action, options = {}) {
234
234
 
235
235
  try {
236
236
  // Fetch all pending drifts
237
- const response = await apiClient.getDrifts(apiKey, projectId, { status: "PENDING" });
237
+ const response = await apiClient.getDrifts(apiKey, projectId, { status: "pending" });
238
238
  const drifts = response.drifts || [];
239
239
 
240
240
  if (drifts.length === 0) {
@@ -268,6 +268,18 @@ async function batchDriftAction(apiKey, projectId, action, options = {}) {
268
268
  console.log(chalk.red(` ${failed} drift(s) failed`));
269
269
  }
270
270
  } catch (error) {
271
+ const status = error.reshot?.status ?? error.response?.status;
272
+ if (status === 400 || status === 404) {
273
+ console.log(chalk.green(" ✓ No pending drifts to approve."));
274
+ console.log(
275
+ chalk.gray(
276
+ " Drifts only exist when a capture differs from a baseline. New captures\n" +
277
+ " with no prior version go to the review queue — use `reshot publish\n" +
278
+ " --auto-approve` or approve them in the studio."
279
+ )
280
+ );
281
+ return;
282
+ }
271
283
  console.error(chalk.red("Error:"), error.message);
272
284
  process.exit(1);
273
285
  }