@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.
- package/README.md +67 -22
- package/package.json +18 -14
- package/src/commands/auth.js +37 -7
- package/src/commands/capture-dom.js +50 -0
- package/src/commands/compose.js +220 -0
- package/src/commands/doctor-release.js +7 -0
- package/src/commands/doctor-target.js +36 -4
- package/src/commands/drifts.js +13 -1
- package/src/commands/publish.js +183 -21
- package/src/commands/pull.js +9 -4
- package/src/commands/refresh.js +166 -0
- package/src/commands/setup-wizard.js +57 -3
- package/src/commands/status.js +22 -2
- package/src/commands/variation.js +194 -0
- package/src/index.js +190 -10
- package/src/lib/api-client.js +61 -35
- 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 +76 -2
- package/src/lib/capture-script-runner.js +289 -138
- package/src/lib/certification.js +23 -1
- 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 +2 -2
- package/src/lib/dom-capture.js +64 -0
- package/src/lib/ensure-browser.js +147 -0
- package/src/lib/record-clip.js +83 -3
- package/src/lib/record-config.js +0 -4
- package/src/lib/release-doctor.js +11 -3
- package/src/lib/resolve-targets.js +60 -0
- package/src/lib/run-manifest.js +45 -0
- package/src/lib/ui-api-helpers.js +118 -0
- package/src/lib/ui-api.js +28 -820
- 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-CvleJUur.js → index-D0S2otug.js} +56 -56
- package/web/manager/dist/index.html +1 -1
- package/src/commands/ingest.js +0 -458
- package/src/commands/setup.js +0 -165
- package/src/lib/playwright-runner.js +0 -252
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// variation.js - Render variations from a captured DOM scene.
|
|
2
|
+
//
|
|
3
|
+
// Workflow:
|
|
4
|
+
// 1. Resolve the source MHTML — either from the local .reshot/output tree
|
|
5
|
+
// or by downloading from the platform CDN (R2 public URL stored on
|
|
6
|
+
// the VisualVersion as domSceneS3Path).
|
|
7
|
+
// 2. Open the MHTML in a fresh headless Chromium.
|
|
8
|
+
// 3. Apply the variation manifest (JS mutations + viewport).
|
|
9
|
+
// 4. Screenshot.
|
|
10
|
+
//
|
|
11
|
+
// This is the v1 marketing-operator entry point. The dashboard UI on top
|
|
12
|
+
// of it is the next deliverable; the CLI exists so the loop is usable
|
|
13
|
+
// the moment Phase 1 ships.
|
|
14
|
+
//
|
|
15
|
+
// Manifest format (JSON):
|
|
16
|
+
//
|
|
17
|
+
// {
|
|
18
|
+
// "viewport": { "width": 1440, "height": 900, "deviceScaleFactor": 2 },
|
|
19
|
+
// "colorScheme": "light" | "dark",
|
|
20
|
+
// "mutations": [
|
|
21
|
+
// { "remove": "aside, nav" }, // delete elements
|
|
22
|
+
// { "replaceText": [
|
|
23
|
+
// { "find": "ari@tempo.example", "with": "alice@acme.com" }
|
|
24
|
+
// ]},
|
|
25
|
+
// { "limit": { "selector": "tbody tr", "count": 5 } }, // keep first N
|
|
26
|
+
// { "setText": { "selector": "[data-author]", "text": "alice@acme.com" } },
|
|
27
|
+
// { "setAttr": { "selector": "img.logo", "name": "src", "value": "..." } },
|
|
28
|
+
// { "evaluate": "/* arbitrary page.evaluate script */" }
|
|
29
|
+
// ]
|
|
30
|
+
// }
|
|
31
|
+
|
|
32
|
+
const fs = require("fs-extra");
|
|
33
|
+
const path = require("node:path");
|
|
34
|
+
const chalk = require("chalk");
|
|
35
|
+
const { chromium } = require("playwright");
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Runs in the browser context. Pure function — no closures over Node
|
|
39
|
+
* state. `manifestJson` is the only input. Keep this synchronous; if
|
|
40
|
+
* the page has React rehydration scripts, give them time before calling
|
|
41
|
+
* by waiting in the caller, then apply mutations as the *last* DOM
|
|
42
|
+
* write so they aren't immediately overwritten.
|
|
43
|
+
*/
|
|
44
|
+
function applyMutationsInBrowser(manifestJson) {
|
|
45
|
+
const m = JSON.parse(manifestJson);
|
|
46
|
+
for (const mut of m.mutations || []) {
|
|
47
|
+
if (mut.remove) {
|
|
48
|
+
document.querySelectorAll(mut.remove).forEach((el) => el.remove());
|
|
49
|
+
}
|
|
50
|
+
if (mut.replaceText) {
|
|
51
|
+
for (const r of mut.replaceText) {
|
|
52
|
+
document.body.innerHTML = document.body.innerHTML
|
|
53
|
+
.split(r.find)
|
|
54
|
+
.join(r.with);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (mut.limit) {
|
|
58
|
+
const els = document.querySelectorAll(mut.limit.selector);
|
|
59
|
+
els.forEach((el, i) => {
|
|
60
|
+
if (i >= mut.limit.count) el.remove();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (mut.setText) {
|
|
64
|
+
document.querySelectorAll(mut.setText.selector).forEach((el) => {
|
|
65
|
+
el.textContent = mut.setText.text;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (mut.setAttr) {
|
|
69
|
+
document.querySelectorAll(mut.setAttr.selector).forEach((el) => {
|
|
70
|
+
el.setAttribute(mut.setAttr.name, mut.setAttr.value);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (mut.evaluate) {
|
|
74
|
+
// eslint-disable-next-line no-new-func
|
|
75
|
+
new Function(mut.evaluate)();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function resolveSourceMhtml({ source, scenarioKey, captureKey, theme }) {
|
|
81
|
+
// 1) Explicit HTTPS URL → download to tmp, return local path. The local
|
|
82
|
+
// file MUST end in .mhtml — Chromium detects MHTML by extension, not by
|
|
83
|
+
// sniffing magic bytes, and our CDN serves under .related (the extension
|
|
84
|
+
// derived from multipart/related contentType).
|
|
85
|
+
if (source && /^https?:\/\//.test(source)) {
|
|
86
|
+
const tmpDir = path.join(require("os").tmpdir(), "reshot-variation");
|
|
87
|
+
fs.ensureDirSync(tmpDir);
|
|
88
|
+
const localPath = path.join(tmpDir, `${Date.now()}-variation.mhtml`);
|
|
89
|
+
const res = await fetch(source);
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
throw new Error(`Failed to fetch source MHTML: ${res.status} ${res.statusText}`);
|
|
92
|
+
}
|
|
93
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
94
|
+
await fs.writeFile(localPath, buf);
|
|
95
|
+
return { kind: "remote", path: localPath };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 2) Explicit local path
|
|
99
|
+
if (source && fs.existsSync(source)) {
|
|
100
|
+
return { kind: "local", path: source };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 3) Look in .reshot/output tree
|
|
104
|
+
if (scenarioKey && captureKey) {
|
|
105
|
+
const root = path.join(process.cwd(), ".reshot", "output", scenarioKey);
|
|
106
|
+
if (fs.existsSync(root)) {
|
|
107
|
+
const runs = fs.readdirSync(root)
|
|
108
|
+
.filter((d) => /^\d{4}-\d{2}-\d{2}/.test(d))
|
|
109
|
+
.sort()
|
|
110
|
+
.reverse();
|
|
111
|
+
for (const run of runs) {
|
|
112
|
+
const candidate = path.join(
|
|
113
|
+
root,
|
|
114
|
+
run,
|
|
115
|
+
theme ? `theme-${theme}` : "default",
|
|
116
|
+
`${captureKey}.mhtml`,
|
|
117
|
+
);
|
|
118
|
+
if (fs.existsSync(candidate)) {
|
|
119
|
+
return { kind: "local", path: candidate };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function variationCommand(options) {
|
|
129
|
+
const {
|
|
130
|
+
source,
|
|
131
|
+
scenario,
|
|
132
|
+
capture,
|
|
133
|
+
theme = "light",
|
|
134
|
+
manifest: manifestPath,
|
|
135
|
+
output,
|
|
136
|
+
headless = true,
|
|
137
|
+
} = options;
|
|
138
|
+
|
|
139
|
+
if (!manifestPath || !fs.existsSync(manifestPath)) {
|
|
140
|
+
throw new Error(`Manifest not found: ${manifestPath}`);
|
|
141
|
+
}
|
|
142
|
+
if (!output) {
|
|
143
|
+
throw new Error("--output <path.png> is required");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
147
|
+
const viewport = manifest.viewport || {
|
|
148
|
+
width: 1440,
|
|
149
|
+
height: 900,
|
|
150
|
+
deviceScaleFactor: 2,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
console.log(chalk.cyan("🎨 Variation render"));
|
|
154
|
+
console.log(chalk.gray(` manifest: ${manifestPath}`));
|
|
155
|
+
|
|
156
|
+
const sourceRef = await resolveSourceMhtml({
|
|
157
|
+
source,
|
|
158
|
+
scenarioKey: scenario,
|
|
159
|
+
captureKey: capture,
|
|
160
|
+
theme,
|
|
161
|
+
});
|
|
162
|
+
if (!sourceRef) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
"Could not resolve source MHTML. Provide --source <path> or --scenario <key> --capture <key>.",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
console.log(chalk.gray(` source: ${sourceRef.path}`));
|
|
168
|
+
|
|
169
|
+
const browser = await chromium.launch({ headless });
|
|
170
|
+
const ctx = await browser.newContext({
|
|
171
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
172
|
+
deviceScaleFactor: viewport.deviceScaleFactor || 2,
|
|
173
|
+
colorScheme: manifest.colorScheme === "dark" ? "dark" : "light",
|
|
174
|
+
javaScriptEnabled: true,
|
|
175
|
+
});
|
|
176
|
+
const page = await ctx.newPage();
|
|
177
|
+
|
|
178
|
+
const fileUrl = `file://${path.resolve(sourceRef.path)}`;
|
|
179
|
+
await page.goto(fileUrl, { waitUntil: "domcontentloaded" });
|
|
180
|
+
await page.waitForLoadState("load");
|
|
181
|
+
await page.waitForTimeout(400);
|
|
182
|
+
|
|
183
|
+
if (manifest.mutations?.length) {
|
|
184
|
+
await page.evaluate(applyMutationsInBrowser, JSON.stringify(manifest));
|
|
185
|
+
await page.waitForTimeout(200);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
fs.ensureDirSync(path.dirname(output));
|
|
189
|
+
await page.screenshot({ path: output });
|
|
190
|
+
console.log(chalk.green(`✔ Rendered: ${output}`));
|
|
191
|
+
await browser.close();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = variationCommand;
|
package/src/index.js
CHANGED
|
@@ -8,6 +8,8 @@ require("dotenv").config({
|
|
|
8
8
|
|
|
9
9
|
const { Command } = require("commander");
|
|
10
10
|
const chalk = require("chalk");
|
|
11
|
+
const fs = require("fs-extra");
|
|
12
|
+
const path = require("path");
|
|
11
13
|
const pkg = require("../package.json");
|
|
12
14
|
|
|
13
15
|
const program = new Command();
|
|
@@ -26,18 +28,59 @@ program
|
|
|
26
28
|
.command("setup")
|
|
27
29
|
.description("Set up the local or hosted workflow from scratch")
|
|
28
30
|
.option("--offline", "Stay local-only and skip hosted authentication")
|
|
31
|
+
.option("--project <id>", "Connect setup to an existing Reshot project")
|
|
32
|
+
.option("--token <token>", "Publish token for non-interactive project linking")
|
|
29
33
|
.option("--no-studio", "Skip offering to launch Studio after setup")
|
|
30
34
|
.option("--force", "Force re-initialization even if already set up")
|
|
31
35
|
.action(async (options) => {
|
|
32
36
|
try {
|
|
33
37
|
const setupWizard = require("./commands/setup-wizard");
|
|
34
|
-
|
|
38
|
+
// Commander stores `--no-studio` as `options.studio === false`, not
|
|
39
|
+
// `options.noStudio`. Normalize it so the flag is honored end-to-end.
|
|
40
|
+
await setupWizard({ ...options, noStudio: options.studio === false });
|
|
35
41
|
} catch (error) {
|
|
36
42
|
console.error(chalk.red("Error:"), error.message);
|
|
37
43
|
process.exit(1);
|
|
38
44
|
}
|
|
39
45
|
});
|
|
40
46
|
|
|
47
|
+
program
|
|
48
|
+
.command("record-clip [target]")
|
|
49
|
+
.description("Record a scenario as a summary MP4 (alias for `reshot run --format summary-video`)")
|
|
50
|
+
.option("-s, --scenarios <keys>", "Comma-separated scenario keys")
|
|
51
|
+
.option("--out <dir>", "Copy the generated MP4 and metadata into this directory")
|
|
52
|
+
.option("--no-headless", "Run browser in visible mode")
|
|
53
|
+
.option("--debug", "Enable verbose debug logging")
|
|
54
|
+
.action(async (target, options) => {
|
|
55
|
+
if (options.debug) {
|
|
56
|
+
process.env.RESHOT_DEBUG = "1";
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const runCommand = require("./commands/run");
|
|
60
|
+
const scenarioKeys = resolveRecordClipScenarioKeys(target, options);
|
|
61
|
+
const result = await runCommand({
|
|
62
|
+
scenarioKeys,
|
|
63
|
+
headless: options.headless,
|
|
64
|
+
format: "summary-video",
|
|
65
|
+
noExit: true,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (options.out && result?.success !== false) {
|
|
69
|
+
await copyRecordClipOutputs(result, target, options.out);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (result?.success === false) {
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error(chalk.red("Error:"), error.message);
|
|
77
|
+
if (options.debug && error.stack) {
|
|
78
|
+
console.error(chalk.gray(error.stack));
|
|
79
|
+
}
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
41
84
|
program.addHelpText(
|
|
42
85
|
"after",
|
|
43
86
|
`
|
|
@@ -113,7 +156,7 @@ program
|
|
|
113
156
|
|
|
114
157
|
// Run: Execute scenarios from config (automated visual capture)
|
|
115
158
|
program
|
|
116
|
-
.command("run")
|
|
159
|
+
.command("run [target]")
|
|
117
160
|
.description("Execute visual capture scenarios from config")
|
|
118
161
|
.option("-s, --scenarios <keys>", "Comma-separated list of scenario keys")
|
|
119
162
|
.option("--no-headless", "Run browser in visible mode")
|
|
@@ -135,15 +178,13 @@ program
|
|
|
135
178
|
.option("--no-style", "Disable style processing")
|
|
136
179
|
.option("--cloud", "Compare against cloud baselines")
|
|
137
180
|
.option("--debug", "Enable verbose debug logging")
|
|
138
|
-
.action(async (options) => {
|
|
181
|
+
.action(async (target, options) => {
|
|
139
182
|
if (options.debug) {
|
|
140
183
|
process.env.RESHOT_DEBUG = "1";
|
|
141
184
|
}
|
|
142
185
|
try {
|
|
143
186
|
const runCommand = require("./commands/run");
|
|
144
|
-
const scenarioKeys = options
|
|
145
|
-
? options.scenarios.split(",").map((s) => s.trim())
|
|
146
|
-
: null;
|
|
187
|
+
const scenarioKeys = resolveScenarioKeysFromTarget(target, options);
|
|
147
188
|
await runCommand({
|
|
148
189
|
scenarioKeys,
|
|
149
190
|
headless: options.headless,
|
|
@@ -297,6 +338,18 @@ program
|
|
|
297
338
|
}
|
|
298
339
|
});
|
|
299
340
|
|
|
341
|
+
// Compose: Render a local JSX composition into a video pack
|
|
342
|
+
const { registerCompose } = require("./commands/compose");
|
|
343
|
+
registerCompose(program);
|
|
344
|
+
|
|
345
|
+
// Capture-DOM: Capture a self-contained DOM reconstruction artifact from a URL
|
|
346
|
+
const { registerCaptureDom } = require("./commands/capture-dom");
|
|
347
|
+
registerCaptureDom(program);
|
|
348
|
+
|
|
349
|
+
// Refresh: Phase 5 auto-update loop — recapture, drift-check, re-publish or flag
|
|
350
|
+
const { registerRefresh } = require("./commands/refresh");
|
|
351
|
+
registerRefresh(program);
|
|
352
|
+
|
|
300
353
|
// ============================================================================
|
|
301
354
|
// PUBLISHING & INTEGRATION COMMANDS
|
|
302
355
|
// ============================================================================
|
|
@@ -317,12 +370,37 @@ program
|
|
|
317
370
|
.action(async (options) => {
|
|
318
371
|
try {
|
|
319
372
|
const publishCommand = require("./commands/publish");
|
|
320
|
-
await publishCommand({
|
|
373
|
+
const result = await publishCommand({
|
|
321
374
|
...options,
|
|
322
375
|
outputJson: options.outputJson,
|
|
323
376
|
autoApprove: options.autoApprove,
|
|
324
377
|
skipReleaseDoctor: options.skipReleaseDoctor,
|
|
325
378
|
});
|
|
379
|
+
if (result && result.success === false) {
|
|
380
|
+
process.exit(1);
|
|
381
|
+
}
|
|
382
|
+
} catch (error) {
|
|
383
|
+
console.error(chalk.red("Error:"), error.message);
|
|
384
|
+
process.exit(1);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// Variation: Render a variation from a captured DOM scene (MHTML).
|
|
389
|
+
// Beta — see docs/variation-pipeline.md.
|
|
390
|
+
program
|
|
391
|
+
.command("variation")
|
|
392
|
+
.description("Render a variation from a captured DOM scene (beta)")
|
|
393
|
+
.option("-s, --source <path>", "Path to source .mhtml (overrides --scenario/--capture)")
|
|
394
|
+
.option("--scenario <key>", "Scenario key under .reshot/output/")
|
|
395
|
+
.option("--capture <key>", "Capture key (e.g., 'observation-detail')")
|
|
396
|
+
.option("--theme <name>", "Theme variant: light | dark", "light")
|
|
397
|
+
.option("-m, --manifest <path>", "Path to variation manifest (.json)")
|
|
398
|
+
.option("-o, --output <path>", "Output PNG path")
|
|
399
|
+
.option("--no-headless", "Run browser visibly for debugging")
|
|
400
|
+
.action(async (options) => {
|
|
401
|
+
try {
|
|
402
|
+
const variationCommand = require("./commands/variation");
|
|
403
|
+
await variationCommand(options);
|
|
326
404
|
} catch (error) {
|
|
327
405
|
console.error(chalk.red("Error:"), error.message);
|
|
328
406
|
process.exit(1);
|
|
@@ -336,7 +414,7 @@ program
|
|
|
336
414
|
.option("-f, --format <format>", "Output format: json, ts, csv", "json")
|
|
337
415
|
.option("-o, --output <path>", "Output file path")
|
|
338
416
|
.option("--full", "Include full metadata in TypeScript output")
|
|
339
|
-
.option("--status <status>", "Filter: approved, pending, all", "
|
|
417
|
+
.option("--status <status>", "Filter: approved, pending, all", "all")
|
|
340
418
|
.action(async (options) => {
|
|
341
419
|
try {
|
|
342
420
|
const pullCommand = require("./commands/pull");
|
|
@@ -359,6 +437,7 @@ doctor
|
|
|
359
437
|
.command("target")
|
|
360
438
|
.description("Audit the certified target contract before capture")
|
|
361
439
|
.option("-s, --scenarios <keys>", "Comma-separated list of scenario keys")
|
|
440
|
+
.option("--timeout <ms>", "Per-step timeout in milliseconds (default 15000)")
|
|
362
441
|
.option("--json", "Output JSON report")
|
|
363
442
|
.action(async (options) => {
|
|
364
443
|
try {
|
|
@@ -469,7 +548,23 @@ Actions:
|
|
|
469
548
|
// Auth: Standalone authentication (for re-auth scenarios)
|
|
470
549
|
const auth = program
|
|
471
550
|
.command("auth")
|
|
472
|
-
.description(
|
|
551
|
+
.description(
|
|
552
|
+
"Link this CLI to a Reshot project. Opens a browser to approve the session and stores a project API key locally.",
|
|
553
|
+
)
|
|
554
|
+
.addHelpText(
|
|
555
|
+
"after",
|
|
556
|
+
`
|
|
557
|
+
Authentication paths:
|
|
558
|
+
Interactive (default) Opens your browser, you approve the session, and the
|
|
559
|
+
CLI saves a project API key to .reshot/settings.json.
|
|
560
|
+
Non-interactive (CI) Set RESHOT_API_KEY and RESHOT_PROJECT_ID and the CLI
|
|
561
|
+
links without any browser or prompt.
|
|
562
|
+
|
|
563
|
+
Examples:
|
|
564
|
+
reshot auth Browser-based login
|
|
565
|
+
RESHOT_API_KEY=… RESHOT_PROJECT_ID=… reshot auth Headless / CI login
|
|
566
|
+
`,
|
|
567
|
+
)
|
|
473
568
|
.action(async () => {
|
|
474
569
|
try {
|
|
475
570
|
const authCommand = require("./commands/auth");
|
|
@@ -482,7 +577,24 @@ const auth = program
|
|
|
482
577
|
|
|
483
578
|
auth
|
|
484
579
|
.command("login")
|
|
485
|
-
.description(
|
|
580
|
+
.description(
|
|
581
|
+
"Alias for `reshot auth` — opens the browser approval flow (or uses RESHOT_API_KEY in CI).",
|
|
582
|
+
)
|
|
583
|
+
.action(async () => {
|
|
584
|
+
try {
|
|
585
|
+
const authCommand = require("./commands/auth");
|
|
586
|
+
await authCommand();
|
|
587
|
+
} catch (error) {
|
|
588
|
+
console.error(chalk.red("Error:"), error.message);
|
|
589
|
+
process.exit(1);
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
program
|
|
594
|
+
.command("login")
|
|
595
|
+
.description(
|
|
596
|
+
"Alias for `reshot auth` — link this CLI to a project via browser approval (or RESHOT_API_KEY in CI).",
|
|
597
|
+
)
|
|
486
598
|
.action(async () => {
|
|
487
599
|
try {
|
|
488
600
|
const authCommand = require("./commands/auth");
|
|
@@ -523,4 +635,72 @@ program
|
|
|
523
635
|
}
|
|
524
636
|
});
|
|
525
637
|
|
|
638
|
+
function resolveRecordClipScenarioKeys(target, options = {}) {
|
|
639
|
+
return resolveScenarioKeysFromTarget(target, options);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function resolveScenarioKeysFromTarget(target, options = {}) {
|
|
643
|
+
if (options.scenarios) {
|
|
644
|
+
return options.scenarios.split(",").map((value) => value.trim()).filter(Boolean);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (!target) {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const absoluteTarget = path.resolve(process.cwd(), target);
|
|
652
|
+
if (!fs.existsSync(absoluteTarget)) {
|
|
653
|
+
return [target];
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const source = fs.readFileSync(absoluteTarget, "utf8");
|
|
657
|
+
const config = require("./lib/config").readConfig();
|
|
658
|
+
const scenarios = config.scenarios || [];
|
|
659
|
+
const mentioned = scenarios.find((scenario) => source.includes(scenario.key));
|
|
660
|
+
if (mentioned) {
|
|
661
|
+
return [mentioned.key];
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const basename = path.basename(target).replace(/\.(spec\.)?[cm]?[tj]sx?$/i, "");
|
|
665
|
+
const byName = scenarios.find((scenario) => {
|
|
666
|
+
const normalizedKey = String(scenario.key || "").replace(/^dogfood-/, "");
|
|
667
|
+
return scenario.key === basename || normalizedKey === basename;
|
|
668
|
+
});
|
|
669
|
+
if (byName) {
|
|
670
|
+
return [byName.key];
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
throw new Error(
|
|
674
|
+
`Could not map ${target} to a configured scenario. Add the scenario key to the spec file or pass --scenarios <key>.`,
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function copyRecordClipOutputs(result, target, outDir) {
|
|
679
|
+
const firstScenario = (result.results || []).find((item) => item?.success !== false);
|
|
680
|
+
const outputDir = firstScenario?.outputDir;
|
|
681
|
+
if (!outputDir) {
|
|
682
|
+
throw new Error("No summary-video output directory was produced.");
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const slug = target
|
|
686
|
+
? path.basename(target).replace(/\.(spec\.)?[cm]?[tj]sx?$/i, "")
|
|
687
|
+
: firstScenario.key || "summary-video";
|
|
688
|
+
const destinationDir = path.resolve(process.cwd(), outDir);
|
|
689
|
+
await fs.ensureDir(destinationDir);
|
|
690
|
+
|
|
691
|
+
const copies = [
|
|
692
|
+
["summary-video.mp4", `${slug}.mp4`],
|
|
693
|
+
["summary-video.metadata.json", `${slug}.metadata.json`],
|
|
694
|
+
["sentinels.json", `${slug}.sentinels.json`],
|
|
695
|
+
];
|
|
696
|
+
|
|
697
|
+
for (const [fromName, toName] of copies) {
|
|
698
|
+
const sourcePath = path.join(outputDir, fromName);
|
|
699
|
+
if (await fs.pathExists(sourcePath)) {
|
|
700
|
+
await fs.copy(sourcePath, path.join(destinationDir, toName));
|
|
701
|
+
console.log(chalk.gray(` copied ${toName}`));
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
526
706
|
program.parse(process.argv);
|
package/src/lib/api-client.js
CHANGED
|
@@ -119,6 +119,17 @@ async function withRetry(fn, options = {}) {
|
|
|
119
119
|
throw error;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// Respect Retry-After header on 429 rate limit responses
|
|
123
|
+
const retryAfterHeader = error.response?.headers?.["retry-after"];
|
|
124
|
+
if (retryAfterHeader && statusCode === 429) {
|
|
125
|
+
const retryMs = (parseInt(retryAfterHeader, 10) || 5) * 1000;
|
|
126
|
+
console.log(
|
|
127
|
+
` ⚠ Rate limited (attempt ${attempt}/${maxRetries}), retrying in ${retryMs / 1000}s...`,
|
|
128
|
+
);
|
|
129
|
+
await sleep(retryMs);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
122
133
|
// Calculate delay with exponential backoff + jitter
|
|
123
134
|
const delay = Math.min(
|
|
124
135
|
initialDelay * Math.pow(2, attempt - 1) + Math.random() * 1000,
|
|
@@ -649,17 +660,21 @@ async function publishBatch(apiKey, payload) {
|
|
|
649
660
|
throw new Error("API key is required to publish");
|
|
650
661
|
}
|
|
651
662
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
663
|
+
return withRetry(
|
|
664
|
+
async () => {
|
|
665
|
+
const response = await axios.post(`${baseUrl}/v1/publish/batch`, payload, {
|
|
666
|
+
headers: {
|
|
667
|
+
"Content-Type": "application/json",
|
|
668
|
+
Authorization: `Bearer ${apiKey}`,
|
|
669
|
+
},
|
|
670
|
+
timeout: 60000,
|
|
671
|
+
maxBodyLength: Infinity,
|
|
672
|
+
maxContentLength: Infinity,
|
|
673
|
+
});
|
|
674
|
+
return response.data;
|
|
656
675
|
},
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
maxContentLength: Infinity,
|
|
660
|
-
});
|
|
661
|
-
|
|
662
|
-
return response.data;
|
|
676
|
+
{ maxRetries: 2, retryOn: [500, 502, 503, 504, 429, "ECONNRESET", "ETIMEDOUT"] },
|
|
677
|
+
);
|
|
663
678
|
}
|
|
664
679
|
|
|
665
680
|
/**
|
|
@@ -809,40 +824,49 @@ async function commitIngest(apiKey, projectId, uploadResults, git, cli) {
|
|
|
809
824
|
* Get drift records for a project
|
|
810
825
|
*/
|
|
811
826
|
async function getDrifts(apiKey, projectId, options = {}) {
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
827
|
+
const params = new URLSearchParams();
|
|
828
|
+
if (options.status) params.set("status", options.status);
|
|
829
|
+
if (options.journeyKey) params.set("journeyKey", options.journeyKey);
|
|
830
|
+
const endpoint = `${baseUrl}/v1/projects/${projectId}/drifts?${params.toString()}`;
|
|
816
831
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
{
|
|
832
|
+
try {
|
|
833
|
+
return await withRetry(async () => {
|
|
834
|
+
const response = await axios.get(endpoint, {
|
|
820
835
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
821
836
|
timeout: 30000,
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
})
|
|
837
|
+
});
|
|
838
|
+
return response.data;
|
|
839
|
+
});
|
|
840
|
+
} catch (error) {
|
|
841
|
+
// Surface HTTP status + endpoint + server message instead of axios's
|
|
842
|
+
// bare "Request failed with status code N".
|
|
843
|
+
throw createApiError("Drifts request failed", endpoint, error);
|
|
844
|
+
}
|
|
826
845
|
}
|
|
827
846
|
|
|
828
847
|
/**
|
|
829
848
|
* Get sync jobs for a project
|
|
830
849
|
*/
|
|
831
850
|
async function getSyncJobs(apiKey, projectId, options = {}) {
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
851
|
+
const endpoint = `${baseUrl}/v1/projects/${projectId}/sync-jobs`;
|
|
852
|
+
try {
|
|
853
|
+
return await withRetry(async () => {
|
|
854
|
+
const response = await axios.post(
|
|
855
|
+
endpoint,
|
|
856
|
+
{
|
|
857
|
+
limit: options.limit || 10,
|
|
858
|
+
status: options.status,
|
|
859
|
+
},
|
|
860
|
+
{
|
|
861
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
862
|
+
timeout: 30000,
|
|
863
|
+
},
|
|
864
|
+
);
|
|
865
|
+
return response.data;
|
|
866
|
+
});
|
|
867
|
+
} catch (error) {
|
|
868
|
+
throw createApiError("Sync jobs request failed", endpoint, error);
|
|
869
|
+
}
|
|
846
870
|
}
|
|
847
871
|
|
|
848
872
|
/**
|
|
@@ -898,4 +922,6 @@ module.exports = {
|
|
|
898
922
|
getDrifts,
|
|
899
923
|
getSyncJobs,
|
|
900
924
|
driftAction,
|
|
925
|
+
// Testing
|
|
926
|
+
withRetry,
|
|
901
927
|
};
|