@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
@@ -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
- await setupWizard(options);
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.scenarios
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", "approved")
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("Authenticate with Reshot Cloud (or set RESHOT_API_KEY + RESHOT_PROJECT_ID env vars)")
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("Compatibility alias for `reshot auth`")
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);
@@ -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
- const response = await axios.post(`${baseUrl}/v1/publish/batch`, payload, {
653
- headers: {
654
- "Content-Type": "application/json",
655
- Authorization: `Bearer ${apiKey}`,
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
- timeout: 60000,
658
- maxBodyLength: Infinity,
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
- return withRetry(async () => {
813
- const params = new URLSearchParams();
814
- if (options.status) params.set("status", options.status);
815
- if (options.journeyKey) params.set("journeyKey", options.journeyKey);
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
- const response = await axios.get(
818
- `${baseUrl}/v1/projects/${projectId}/drifts?${params.toString()}`,
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
- return response.data;
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
- return withRetry(async () => {
833
- const response = await axios.post(
834
- `${baseUrl}/v1/projects/${projectId}/sync-jobs`,
835
- {
836
- limit: options.limit || 10,
837
- status: options.status,
838
- },
839
- {
840
- headers: { Authorization: `Bearer ${apiKey}` },
841
- timeout: 30000,
842
- },
843
- );
844
- return response.data;
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
  };