@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.
- package/LICENSE +1 -1
- package/README.md +138 -47
- package/package.json +27 -16
- package/src/commands/auth.js +159 -30
- package/src/commands/capture-dom.js +50 -0
- package/src/commands/certify.js +62 -0
- package/src/commands/compose.js +220 -0
- package/src/commands/doctor-release.js +74 -0
- package/src/commands/doctor-target.js +108 -0
- package/src/commands/drifts.js +16 -69
- package/src/commands/import-tests.js +13 -13
- package/src/commands/init.js +16 -277
- package/src/commands/publish.js +484 -257
- package/src/commands/pull.js +302 -35
- package/src/commands/refresh.js +166 -0
- package/src/commands/run.js +292 -12
- package/src/commands/setup-wizard.js +348 -496
- package/src/commands/status.js +334 -126
- package/src/commands/sync.js +28 -236
- package/src/commands/ui.js +1 -1
- package/src/commands/variation.js +194 -0
- package/src/commands/verify-publish.js +46 -0
- package/src/index.js +383 -118
- package/src/lib/api-client.js +172 -60
- package/src/lib/auto-update/refresh.js +598 -0
- package/src/lib/auto-update/scene-runtime.compose.tsx +73 -0
- package/src/lib/auto-update/spec.js +89 -0
- package/src/lib/capture-engine.js +179 -9
- package/src/lib/capture-script-runner.js +639 -214
- package/src/lib/certification.js +887 -0
- package/src/lib/compose-context.js +156 -0
- package/src/lib/compose-pack.js +42 -0
- package/src/lib/compose-runtime.js +34 -0
- package/src/lib/compose-upload.js +142 -0
- package/src/lib/config.js +186 -81
- package/src/lib/dom-capture.js +64 -0
- package/src/lib/ensure-browser.js +147 -0
- package/src/lib/output-path-template.js +3 -3
- package/src/lib/record-cdp.js +288 -16
- package/src/lib/record-clip.js +83 -3
- package/src/lib/record-config.js +1 -5
- package/src/lib/release-doctor.js +321 -0
- package/src/lib/resolve-targets.js +60 -0
- package/src/lib/run-manifest.js +148 -0
- package/src/lib/standalone-mode.js +1 -1
- package/src/lib/storage-providers.js +5 -5
- package/src/lib/style-engine.js +5 -5
- package/src/lib/target-contract.js +292 -0
- package/src/lib/ui-api-helpers.js +118 -0
- package/src/lib/ui-api.js +31 -824
- package/src/lib/ui-asset-cleanup.js +62 -0
- package/src/lib/ui-output-versions.js +165 -0
- package/src/lib/ui-recorder-routes.js +341 -0
- package/src/lib/ui-scenario-metadata.js +161 -0
- package/vendor/compose/dist/auto-update.cjs +5544 -0
- package/vendor/compose/dist/auto-update.mjs +5518 -0
- package/vendor/compose/dist/capture.cjs +1450 -0
- package/vendor/compose/dist/capture.mjs +1416 -0
- package/vendor/compose/dist/eligibility.cjs +5331 -0
- package/vendor/compose/dist/eligibility.mjs +5313 -0
- package/vendor/compose/dist/index.cjs +2046 -0
- package/vendor/compose/dist/index.mjs +1997 -0
- package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
- package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
- package/vendor/compose/dist/jsx-runtime.cjs +58 -0
- package/vendor/compose/dist/jsx-runtime.mjs +31 -0
- package/vendor/compose/dist/render.cjs +558 -0
- package/vendor/compose/dist/render.mjs +515 -0
- package/vendor/compose/dist/verify-cli.cjs +3806 -0
- package/vendor/compose/dist/verify-cli.mjs +3812 -0
- package/vendor/compose/dist/verify.cjs +3880 -0
- package/vendor/compose/dist/verify.mjs +3858 -0
- package/web/manager/dist/assets/index-D0S2otug.js +507 -0
- package/web/manager/dist/index.html +1 -1
- package/src/commands/ci-run.js +0 -123
- package/src/commands/ci-setup.js +0 -288
- package/src/commands/ingest.js +0 -458
- package/src/commands/setup.js +0 -137
- package/src/commands/validate-docs.js +0 -529
- package/src/lib/playwright-runner.js +0 -252
- 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
|
|
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
|
|
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
|
[](https://www.npmjs.com/package/@reshotdev/screenshot)
|
|
6
6
|
[](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.
|
|
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
|
-
#
|
|
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 `
|
|
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
|
-
|
|
86
|
-
|
|
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 `
|
|
288
|
+
The recorded scenario is appended to `reshot.config.json` automatically.
|
|
225
289
|
|
|
226
290
|
## Authentication
|
|
227
291
|
|
|
228
|
-
### Storage State (
|
|
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
|
-
|
|
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
|
-
##
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
```
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
386
|
+
Set `RESHOT_API_KEY` and `RESHOT_PROJECT_ID` to run without interactive auth:
|
|
313
387
|
|
|
314
388
|
```bash
|
|
315
|
-
reshot
|
|
389
|
+
RESHOT_API_KEY=your-key RESHOT_PROJECT_ID=your-project reshot run
|
|
316
390
|
```
|
|
317
391
|
|
|
318
|
-
|
|
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
|
|
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
|
-
"
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
"
|
|
338
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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": [
|
|
16
|
+
"keywords": [
|
|
17
|
+
"screenshots",
|
|
18
|
+
"cli",
|
|
19
|
+
"documentation",
|
|
20
|
+
"visual-testing",
|
|
21
|
+
"automation",
|
|
22
|
+
"playwright"
|
|
23
|
+
],
|
|
17
24
|
"bin": {
|
|
18
|
-
"reshot": "
|
|
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
|
}
|
package/src/commands/auth.js
CHANGED
|
@@ -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(
|
|
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
|
|
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() <
|
|
106
|
-
const statusResponse = await
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
301
|
+
console.log(chalk.gray("Auth URL:"));
|
|
302
|
+
console.log(chalk.cyan(authUrl));
|
|
212
303
|
console.log(
|
|
213
|
-
chalk.
|
|
214
|
-
"
|
|
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
|
-
|
|
219
|
-
|
|
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\/?$/, '') || '
|
|
339
|
+
const platformUrl = apiBaseUrl.replace(/\/api\/?$/, '') || 'https://reshot.dev';
|
|
223
340
|
|
|
224
|
-
|
|
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;
|