@reshotdev/screenshot 0.0.1-beta.2 → 0.0.1-beta.20

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 (81) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +138 -47
  3. package/package.json +27 -16
  4. package/src/commands/auth.js +159 -30
  5. package/src/commands/capture-dom.js +50 -0
  6. package/src/commands/certify.js +62 -0
  7. package/src/commands/compose.js +220 -0
  8. package/src/commands/doctor-release.js +74 -0
  9. package/src/commands/doctor-target.js +108 -0
  10. package/src/commands/drifts.js +16 -69
  11. package/src/commands/import-tests.js +13 -13
  12. package/src/commands/init.js +16 -277
  13. package/src/commands/publish.js +484 -257
  14. package/src/commands/pull.js +302 -35
  15. package/src/commands/refresh.js +166 -0
  16. package/src/commands/run.js +292 -12
  17. package/src/commands/setup-wizard.js +348 -496
  18. package/src/commands/status.js +334 -126
  19. package/src/commands/sync.js +28 -236
  20. package/src/commands/ui.js +1 -1
  21. package/src/commands/variation.js +194 -0
  22. package/src/commands/verify-publish.js +46 -0
  23. package/src/index.js +383 -118
  24. package/src/lib/api-client.js +172 -60
  25. package/src/lib/auto-update/refresh.js +598 -0
  26. package/src/lib/auto-update/scene-runtime.compose.tsx +73 -0
  27. package/src/lib/auto-update/spec.js +89 -0
  28. package/src/lib/capture-engine.js +179 -9
  29. package/src/lib/capture-script-runner.js +639 -214
  30. package/src/lib/certification.js +887 -0
  31. package/src/lib/compose-context.js +156 -0
  32. package/src/lib/compose-pack.js +42 -0
  33. package/src/lib/compose-runtime.js +34 -0
  34. package/src/lib/compose-upload.js +142 -0
  35. package/src/lib/config.js +186 -81
  36. package/src/lib/dom-capture.js +64 -0
  37. package/src/lib/ensure-browser.js +147 -0
  38. package/src/lib/output-path-template.js +3 -3
  39. package/src/lib/record-cdp.js +288 -16
  40. package/src/lib/record-clip.js +83 -3
  41. package/src/lib/record-config.js +1 -5
  42. package/src/lib/release-doctor.js +321 -0
  43. package/src/lib/resolve-targets.js +60 -0
  44. package/src/lib/run-manifest.js +148 -0
  45. package/src/lib/standalone-mode.js +1 -1
  46. package/src/lib/storage-providers.js +5 -5
  47. package/src/lib/style-engine.js +5 -5
  48. package/src/lib/target-contract.js +292 -0
  49. package/src/lib/ui-api-helpers.js +118 -0
  50. package/src/lib/ui-api.js +31 -824
  51. package/src/lib/ui-asset-cleanup.js +62 -0
  52. package/src/lib/ui-output-versions.js +165 -0
  53. package/src/lib/ui-recorder-routes.js +341 -0
  54. package/src/lib/ui-scenario-metadata.js +161 -0
  55. package/vendor/compose/dist/auto-update.cjs +5544 -0
  56. package/vendor/compose/dist/auto-update.mjs +5518 -0
  57. package/vendor/compose/dist/capture.cjs +1450 -0
  58. package/vendor/compose/dist/capture.mjs +1416 -0
  59. package/vendor/compose/dist/eligibility.cjs +5331 -0
  60. package/vendor/compose/dist/eligibility.mjs +5313 -0
  61. package/vendor/compose/dist/index.cjs +2046 -0
  62. package/vendor/compose/dist/index.mjs +1997 -0
  63. package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
  64. package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
  65. package/vendor/compose/dist/jsx-runtime.cjs +58 -0
  66. package/vendor/compose/dist/jsx-runtime.mjs +31 -0
  67. package/vendor/compose/dist/render.cjs +558 -0
  68. package/vendor/compose/dist/render.mjs +515 -0
  69. package/vendor/compose/dist/verify-cli.cjs +3806 -0
  70. package/vendor/compose/dist/verify-cli.mjs +3812 -0
  71. package/vendor/compose/dist/verify.cjs +3880 -0
  72. package/vendor/compose/dist/verify.mjs +3858 -0
  73. package/web/manager/dist/assets/index-D0S2otug.js +507 -0
  74. package/web/manager/dist/index.html +1 -1
  75. package/src/commands/ci-run.js +0 -123
  76. package/src/commands/ci-setup.js +0 -288
  77. package/src/commands/ingest.js +0 -458
  78. package/src/commands/setup.js +0 -137
  79. package/src/commands/validate-docs.js +0 -529
  80. package/src/lib/playwright-runner.js +0 -252
  81. package/web/manager/dist/assets/index--ZgioErz.js +0 -507
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)
@@ -21,20 +21,62 @@ Requires Node.js >= 18. Playwright browsers are installed automatically on first
21
21
  # 1. Interactive setup wizard
22
22
  reshot setup
23
23
 
24
- # 2. Capture screenshots from your config
24
+ # 2. Start your app with a production-like local server
25
+ npm run build
26
+ npm run start
27
+
28
+ # 3. Capture screenshots from your config
25
29
  reshot run
26
30
 
27
- # 3. Review captures in the web UI
31
+ # 4. Review captures in the web UI
28
32
  reshot studio
33
+
34
+ # 5. Publish when you want hosted assets — pass --auto-approve on first run
35
+ reshot publish --auto-approve
29
36
  ```
30
37
 
38
+ For launch-grade reliability, do not treat `next dev` as the supported capture
39
+ runtime. Use a production-like local server and see the
40
+ [Supported Environments guide](https://reshot.dev/docs/cli/getting-started/supported-environments).
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
+
52
+ ## Certified Targets
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
+
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.
60
+
31
61
  ## Configuration
32
62
 
33
- Create `docsync.config.json` in your project root:
63
+ Create `reshot.config.json` in your project root:
34
64
 
35
65
  ```json
36
66
  {
37
67
  "baseUrl": "http://localhost:3000",
68
+ "target": {
69
+ "key": "docs-app",
70
+ "displayName": "Docs App",
71
+ "tier": "certified",
72
+ "owner": "Docs Team",
73
+ "baseUrl": "http://localhost:3000",
74
+ "captureSafe": false,
75
+ "supportedLocalCommand": "npm run build && npm run start",
76
+ "defaultAuthMode": "fixture",
77
+ "requiredEnv": ["PROJECT_ID"],
78
+ "certificationScenarioKeys": ["dashboard"]
79
+ },
38
80
  "assetDir": ".reshot/output",
39
81
  "concurrency": 2,
40
82
  "viewport": { "width": 1280, "height": 720 },
@@ -55,6 +97,15 @@ Create `docsync.config.json` in your project root:
55
97
  "name": "Dashboard",
56
98
  "url": "/dashboard",
57
99
  "requiresAuth": true,
100
+ "captureClass": "fixture-auth",
101
+ "ready": {
102
+ "selector": "[data-loaded='true']",
103
+ "expression": "window.__APP_READY__ === true"
104
+ },
105
+ "requiredRoutes": ["/dashboard"],
106
+ "requiredSelectors": ["[data-testid='dashboard-content']"],
107
+ "expectedArtifacts": ["overview", "analytics"],
108
+ "publishPolicy": "required",
58
109
  "readySelector": "[data-loaded='true']",
59
110
  "steps": [
60
111
  { "action": "screenshot", "key": "overview", "description": "Dashboard overview" },
@@ -76,14 +127,27 @@ Create `docsync.config.json` in your project root:
76
127
  | `reshot record [title]` | Interactive recording via Chrome DevTools | `--browser`, `--url`, `--port` |
77
128
  | `reshot sync` | Upload traces/docs to Reshot platform | `--trace-dir`, `--dry-run` |
78
129
  | `reshot studio` | Launch web management UI | `--port`, `--no-open` |
79
- | `reshot validate` | Check config and bindings | `--strict`, `--fix` |
80
130
  | `reshot status` | View project status and sync history | `--jobs`, `--drifts`, `--json` |
81
131
  | `reshot publish` | Upload assets with versioning | `--tag`, `--message`, `--dry-run` |
82
132
  | `reshot pull` | Generate asset map for builds | `--format json\|ts\|csv`, `--output`, `--status` |
133
+ | `reshot doctor target` | Audit target routes, readiness, and auth contract | `--scenarios`, `--json` |
134
+ | `reshot verify publish` | Validate publish, pull/export, and hosted delivery | `--scenarios`, `--tag`, `--json` |
135
+ | `reshot certify` | Run the full certified-target pipeline | `--scenarios`, `--tag`, `--json` |
83
136
  | `reshot drifts` | Manage visual drift notifications | `approve`, `reject`, `ignore`, `approve-all` |
84
137
  | `reshot import-tests` | Import Playwright tests as scenarios | `--dry-run`, `--no-interactive` |
85
- | `reshot ci setup` | Generate CI/CD workflow files | — |
86
- | `reshot ci run` | Capture + publish in one step (CI) | `--tag`, `--no-publish`, `--dry-run` |
138
+
139
+ ## Certification Workflow
140
+
141
+ Use these commands when a target app needs release-grade verification:
142
+
143
+ ```bash
144
+ reshot doctor target
145
+ reshot run --scenarios dashboard
146
+ reshot verify publish --tag v1.0.0
147
+ reshot certify --tag v1.0.0
148
+ ```
149
+
150
+ Certification reports are written to `.reshot/reports/certification.json`.
87
151
 
88
152
  ## Step Types
89
153
 
@@ -221,23 +285,44 @@ During recording:
221
285
  - Press **C** to start/stop a video clip
222
286
  - Press **Q** to quit and save
223
287
 
224
- The recorded scenario is appended to `docsync.config.json` automatically.
288
+ The recorded scenario is appended to `reshot.config.json` automatically.
225
289
 
226
290
  ## Authentication
227
291
 
228
- ### Storage State (from Playwright)
292
+ ### Storage State (recommended)
293
+
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:
229
297
 
230
298
  ```bash
231
299
  npx playwright codegen http://localhost:3000 --save-storage=.reshot/auth-state.json
232
300
  ```
233
301
 
302
+ Then point your config at it:
303
+
234
304
  ```json
235
305
  {
236
306
  "storageStatePath": ".reshot/auth-state.json"
237
307
  }
238
308
  ```
239
309
 
240
- ### 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:
241
326
 
242
327
  ```json
243
328
  {
@@ -254,6 +339,8 @@ npx playwright codegen http://localhost:3000 --save-storage=.reshot/auth-state.j
254
339
  ```
255
340
 
256
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.
257
344
 
258
345
  ## Output Formats
259
346
 
@@ -285,63 +372,67 @@ Set `"format": "summary-video"` in scenario output config to record the full bro
285
372
 
286
373
  Crops the screenshot to the bounding box of the selected element.
287
374
 
288
- ## CI/CD Integration
289
-
290
- ### GitHub Actions
291
-
292
- ```yaml
293
- name: Visual Capture
294
- on:
295
- push:
296
- branches: [main]
297
-
298
- jobs:
299
- capture:
300
- runs-on: ubuntu-latest
301
- steps:
302
- - uses: actions/checkout@v4
303
- - uses: actions/setup-node@v4
304
- with: { node-version: 20 }
305
- - run: npm ci
306
- - run: npm start & # start your app
307
- - run: npx @reshotdev/screenshot ci run --tag ${{ github.sha }} --message "Deploy ${{ github.sha }}"
308
- env:
309
- 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
310
384
  ```
311
385
 
312
- Or use the setup wizard:
386
+ Set `RESHOT_API_KEY` and `RESHOT_PROJECT_ID` to run without interactive auth:
313
387
 
314
388
  ```bash
315
- reshot ci setup
389
+ RESHOT_API_KEY=your-key RESHOT_PROJECT_ID=your-project reshot run
316
390
  ```
317
391
 
318
- 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
319
396
 
320
397
  ## Asset Map for Builds
321
398
 
322
399
  Generate a manifest of captured assets for use in documentation sites or marketing pages:
323
400
 
324
401
  ```bash
325
- reshot pull --format json --output assets.json --status approved
402
+ reshot pull --format json --output assets.json
326
403
  ```
327
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
+
328
409
  ```json
329
410
  {
330
- "assets": [
331
- {
332
- "key": "dashboard-overview",
333
- "variant": "theme-light",
334
- "url": "https://cdn.reshot.dev/abc123/dashboard-overview.png",
335
- "format": "png",
336
- "width": 1280,
337
- "height": 720,
338
- "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
+ }
339
428
  }
340
- ]
429
+ }
341
430
  }
342
431
  ```
343
432
 
344
- 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`.
345
436
 
346
437
  ## Drift Management
347
438
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@reshotdev/screenshot",
3
- "version": "0.0.1-beta.2",
4
- "description": "CI/CD screenshot and video capture CLI",
3
+ "version": "0.0.1-beta.20",
4
+ "description": "Screenshot and video capture CLI",
5
5
  "author": "Reshot <hello@reshot.dev>",
6
6
  "license": "MIT",
7
7
  "homepage": "https://reshot.dev",
@@ -13,12 +13,20 @@
13
13
  "publishConfig": {
14
14
  "access": "public"
15
15
  },
16
- "keywords": ["screenshots", "ci-cd", "documentation", "visual-testing", "automation", "playwright"],
16
+ "keywords": [
17
+ "screenshots",
18
+ "cli",
19
+ "documentation",
20
+ "visual-testing",
21
+ "automation",
22
+ "playwright"
23
+ ],
17
24
  "bin": {
18
- "reshot": "./src/index.js"
25
+ "reshot": "src/index.js"
19
26
  },
20
27
  "files": [
21
28
  "src/",
29
+ "vendor/compose/dist/",
22
30
  "web/manager/dist/",
23
31
  "web/cropper/",
24
32
  "web/subtitle-editor/",
@@ -28,24 +36,13 @@
28
36
  "engines": {
29
37
  "node": ">=18.0.0"
30
38
  },
31
- "scripts": {
32
- "test": "node scripts/test.js",
33
- "test:unit": "node --test tests/unit/*.test.js",
34
- "test:integration": "node --test tests/integration/*.test.js",
35
- "test:all": "node --test tests/unit/*.test.js tests/integration/*.test.js",
36
- "test:dry-run": "node src/index.js --help && node src/index.js --version",
37
- "prepublishOnly": "cd web/manager && npm install && npm run build",
38
- "ui:build": "cd web/manager && npm install && npm run build",
39
- "ui:dev": "cd web/manager && npm install && npm run dev",
40
- "lint": "echo 'No linting configured yet'",
41
- "pack:check": "npm pack --dry-run"
42
- },
43
39
  "dependencies": {
44
40
  "axios": "^1.6.2",
45
41
  "chalk": "^4.1.2",
46
42
  "commander": "^11.1.0",
47
43
  "cors": "^2.8.5",
48
44
  "dotenv": "^16.3.1",
45
+ "esbuild": "^0.27.2",
49
46
  "express": "^4.18.2",
50
47
  "form-data": "^4.0.0",
51
48
  "fs-extra": "^11.2.0",
@@ -55,10 +52,24 @@
55
52
  "ora": "^7.0.1",
56
53
  "pixelmatch": "^5.3.0",
57
54
  "playwright": "^1.40.0",
55
+ "playwright-core": "^1.57.0",
58
56
  "pngjs": "^7.0.0",
59
57
  "sharp": "^0.33.2",
60
58
  "socket.io": "^4.7.2",
61
59
  "uuid": "^9.0.1",
62
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"
63
74
  }
64
75
  }
@@ -14,6 +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 = 15 * 60 * 1000;
17
18
 
18
19
  const unwrapResponse = (payload) => {
19
20
  if (!payload) {
@@ -95,15 +96,27 @@ function startLocalStatusServer(requestedPort, options = {}) {
95
96
  });
96
97
  }
97
98
 
98
- async function waitForCompletion(apiBaseUrl, authToken, expiresAtIso) {
99
+ async function waitForCompletion(
100
+ apiBaseUrl,
101
+ authToken,
102
+ expiresAtIso,
103
+ options = {},
104
+ ) {
105
+ const httpClient = options.httpClient || axios;
106
+ const spinnerFactory = options.spinnerFactory || ora;
99
107
  const expiresAt = expiresAtIso
100
108
  ? Date.parse(expiresAtIso)
101
109
  : Date.now() + 5 * 60 * 1000;
102
- const statusSpinner = ora("Waiting for browser authentication…").start();
110
+ const timeoutMs = Math.max(
111
+ 1,
112
+ Number(options.timeoutMs || DEFAULT_AUTH_TIMEOUT_MS),
113
+ );
114
+ const deadline = Math.min(expiresAt, Date.now() + timeoutMs);
115
+ const statusSpinner = spinnerFactory("Waiting for browser authentication…").start();
103
116
 
104
117
  try {
105
- while (Date.now() < expiresAt) {
106
- const statusResponse = await axios.get(`${apiBaseUrl}/auth/cli/status`, {
118
+ while (Date.now() < deadline) {
119
+ const statusResponse = await httpClient.get(`${apiBaseUrl}/auth/cli/status`, {
107
120
  params: { token: authToken },
108
121
  });
109
122
  const payload = unwrapResponse(statusResponse.data);
@@ -127,39 +140,114 @@ async function waitForCompletion(apiBaseUrl, authToken, expiresAtIso) {
127
140
  await wait(POLL_INTERVAL_MS);
128
141
  }
129
142
 
130
- throw new Error("Authentication timed out before completion.");
143
+ throw new Error(
144
+ "Authentication timed out before completion. Re-run `reshot auth` and use the printed auth URL if the browser handoff stalls.",
145
+ );
131
146
  } catch (error) {
132
147
  statusSpinner.fail("Browser authentication failed");
133
148
  throw error;
134
149
  }
135
150
  }
136
151
 
137
- async function verifyApiKey(apiBaseUrl, apiKey) {
138
- await axios.get(`${apiBaseUrl}/auth/cli/verify`, {
152
+ async function verifyApiKey(apiBaseUrl, apiKey, httpClient = axios) {
153
+ const response = await httpClient.get(`${apiBaseUrl}/auth/cli/verify`, {
139
154
  headers: {
140
155
  Authorization: `Bearer ${apiKey}`,
141
156
  },
142
157
  });
158
+ return response?.data || null;
143
159
  }
144
160
 
145
- async function authCommand() {
146
- const apiBaseUrl = getApiBaseUrl();
161
+ async function authCommand(options = {}) {
162
+ // Support non-interactive auth via environment variables
163
+ const envApiKey = options.apiKey || process.env.RESHOT_API_KEY;
164
+ const envProjectId = options.projectId || process.env.RESHOT_PROJECT_ID;
165
+ const httpClient = options.httpClient || axios;
166
+ const openFn = options.openFn || open;
167
+ const writeSettingsFn = options.writeSettingsFn || writeSettings;
168
+ const startLocalStatusServerFn =
169
+ options.startLocalStatusServerFn || startLocalStatusServer;
170
+ const waitForCompletionFn = options.waitForCompletionFn || waitForCompletion;
171
+ const verifyApiKeyFn = options.verifyApiKeyFn || verifyApiKey;
172
+ const spinnerFactory = options.spinnerFactory || ora;
173
+ const timeoutMs = Number(options.timeoutMs || DEFAULT_AUTH_TIMEOUT_MS);
174
+ if (envApiKey && envProjectId) {
175
+ const platformUrl = process.env.RESHOT_PLATFORM_URL || "https://reshot.dev";
176
+ // Best-effort resolve the project name so settings + the setup report
177
+ // don't record `projectName: null` on this non-interactive path.
178
+ let projectName = null;
179
+ try {
180
+ const apiBaseUrl = options.apiBaseUrl || getApiBaseUrl();
181
+ const verified = await verifyApiKeyFn(apiBaseUrl, envApiKey, httpClient);
182
+ // /auth/cli/verify wraps its payload in an { data: … } envelope.
183
+ const payload = verified?.data || verified;
184
+ projectName = payload?.project?.name || null;
185
+ } catch {
186
+ // Verification is best-effort here; keep going without the name.
187
+ }
188
+ writeSettingsFn({
189
+ projectId: envProjectId,
190
+ projectName,
191
+ apiKey: envApiKey,
192
+ platformUrl,
193
+ linkedAt: new Date().toISOString(),
194
+ cliVersion: pkg.version,
195
+ });
196
+ console.log(chalk.green("✔ Authenticated via environment variables"));
197
+ console.log(
198
+ chalk.gray(` Project: ${projectName || envProjectId}`),
199
+ );
200
+ console.log(chalk.gray(` Platform: ${platformUrl}`));
201
+ return {
202
+ mode: "cloud-connected",
203
+ projectId: envProjectId,
204
+ projectName,
205
+ platformUrl,
206
+ };
207
+ }
208
+
209
+ // The browser approval flow requires an interactive session (a human approves
210
+ // in the browser while the CLI polls). With no TTY and no env credentials we
211
+ // would otherwise open a browser nobody sees and poll for up to 15 minutes —
212
+ // i.e. hang. Detect that and exit promptly with actionable guidance.
213
+ const stdinIsInteractive =
214
+ typeof options.isInteractive === "boolean"
215
+ ? options.isInteractive
216
+ : Boolean(process.stdin && process.stdin.isTTY);
217
+ if (!stdinIsInteractive) {
218
+ console.error(
219
+ chalk.red("✖ `reshot auth` needs an interactive terminal."),
220
+ );
221
+ console.error(
222
+ chalk.gray(
223
+ " Run it in an interactive shell to approve in the browser, or set",
224
+ ),
225
+ );
226
+ console.error(
227
+ chalk.gray(
228
+ " RESHOT_API_KEY and RESHOT_PROJECT_ID for non-interactive (CI) auth.",
229
+ ),
230
+ );
231
+ const err = new Error("Interactive terminal required for browser auth");
232
+ err.code = "ENOTTY";
233
+ throw err;
234
+ }
235
+
236
+ const apiBaseUrl = options.apiBaseUrl || getApiBaseUrl();
147
237
  const explicitPortEnv =
148
- process.env.RESHOT_CLI_CALLBACK_PORT ||
149
- process.env.DOCSYNC_CLI_CALLBACK_PORT ||
150
- "";
238
+ process.env.RESHOT_CLI_CALLBACK_PORT || "";
151
239
  const basePort = parseInt(explicitPortEnv || `${DEFAULT_CALLBACK_PORT}`, 10);
152
240
  const hasExplicitPort = Boolean(explicitPortEnv);
153
241
 
154
242
  let localServer;
155
243
  let callbackPort;
156
- const spinner = ora("Requesting authentication session…").start();
244
+ const spinner = spinnerFactory("Requesting authentication session…").start();
157
245
 
158
246
  try {
159
247
  if (hasExplicitPort) {
160
248
  // Respect an explicitly configured port and fail fast with a clear error
161
249
  // if it is not available.
162
- const { server, port } = await startLocalStatusServer(basePort, {
250
+ const { server, port } = await startLocalStatusServerFn(basePort, {
163
251
  explicit: true,
164
252
  });
165
253
  localServer = server;
@@ -169,12 +257,12 @@ async function authCommand() {
169
257
  // but automatically fall back to any available port so users never have
170
258
  // to think about port conflicts.
171
259
  try {
172
- const { server, port } = await startLocalStatusServer(basePort);
260
+ const { server, port } = await startLocalStatusServerFn(basePort);
173
261
  localServer = server;
174
262
  callbackPort = port;
175
263
  } catch (error) {
176
264
  if (error && error.code === "EADDRINUSE") {
177
- const { server, port } = await startLocalStatusServer(0);
265
+ const { server, port } = await startLocalStatusServerFn(0);
178
266
  localServer = server;
179
267
  callbackPort = port;
180
268
  console.log(
@@ -188,12 +276,15 @@ async function authCommand() {
188
276
  }
189
277
  }
190
278
 
191
- const initiateResponse = await axios.post(
279
+ const initiatePayload = {
280
+ callbackPort,
281
+ clientVersion: pkg.version,
282
+ ...(options.projectId ? { projectId: options.projectId } : {}),
283
+ };
284
+
285
+ const initiateResponse = await httpClient.post(
192
286
  `${apiBaseUrl}/auth/cli/initiate`,
193
- {
194
- callbackPort,
195
- clientVersion: pkg.version,
196
- },
287
+ initiatePayload,
197
288
  { headers: { "Content-Type": "application/json" } }
198
289
  );
199
290
 
@@ -207,21 +298,47 @@ async function authCommand() {
207
298
  spinner.succeed("Authentication session created");
208
299
  console.log(chalk.gray(`Token expires at ${expiresAt || "unknown time"}`));
209
300
  console.log(chalk.gray(`Settings will be stored in ${SETTINGS_PATH}`));
210
-
211
- await open(authUrl, { wait: false });
301
+ console.log(chalk.gray("Auth URL:"));
302
+ console.log(chalk.cyan(authUrl));
212
303
  console.log(
213
- chalk.blue(
214
- "A browser window has been opened. Complete the flow to continue."
215
- )
304
+ chalk.gray(
305
+ "If the browser did not open, copy the URL above into a browser and complete the approval flow there.",
306
+ ),
216
307
  );
217
308
 
218
- const status = await waitForCompletion(apiBaseUrl, authToken, expiresAt);
219
- await verifyApiKey(apiBaseUrl, status.project.apiKey);
309
+ let browserOpened = false;
310
+ try {
311
+ await openFn(authUrl, { wait: false });
312
+ browserOpened = true;
313
+ console.log(
314
+ chalk.blue(
315
+ "A browser window has been opened. Approve the session there to continue.",
316
+ )
317
+ );
318
+ } catch (error) {
319
+ console.log(
320
+ chalk.yellow(
321
+ `Could not open a browser automatically: ${error.message}`,
322
+ ),
323
+ );
324
+ console.log(
325
+ chalk.gray(
326
+ "Continue by opening the auth URL manually. The CLI will keep waiting for approval.",
327
+ ),
328
+ );
329
+ }
330
+
331
+ const status = await waitForCompletionFn(apiBaseUrl, authToken, expiresAt, {
332
+ httpClient,
333
+ spinnerFactory,
334
+ timeoutMs,
335
+ });
336
+ await verifyApiKeyFn(apiBaseUrl, status.project.apiKey, httpClient);
220
337
 
221
338
  // Derive platformUrl from apiBaseUrl (remove /api suffix)
222
- const platformUrl = apiBaseUrl.replace(/\/api\/?$/, '') || 'http://localhost:3000';
339
+ const platformUrl = apiBaseUrl.replace(/\/api\/?$/, '') || 'https://reshot.dev';
223
340
 
224
- writeSettings({
341
+ writeSettingsFn({
225
342
  projectId: status.project.id,
226
343
  projectName: status.project.name,
227
344
  apiKey: status.project.apiKey,
@@ -249,6 +366,15 @@ async function authCommand() {
249
366
  )
250
367
  );
251
368
  console.log(chalk.gray(`Settings saved to ${SETTINGS_PATH}`));
369
+ console.log(chalk.gray("Mode: cloud-connected"));
370
+ return {
371
+ mode: "cloud-connected",
372
+ browserOpened,
373
+ authUrl,
374
+ projectId: status.project.id,
375
+ projectName: status.project.name,
376
+ platformUrl,
377
+ };
252
378
  } finally {
253
379
  if (localServer) {
254
380
  localServer.close();
@@ -257,3 +383,6 @@ async function authCommand() {
257
383
  }
258
384
 
259
385
  module.exports = authCommand;
386
+ module.exports.waitForCompletion = waitForCompletion;
387
+ module.exports.verifyApiKey = verifyApiKey;
388
+ module.exports.startLocalStatusServer = startLocalStatusServer;