snapdrift 0.3.0 → 0.4.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 - 2026-05-26
4
+
5
+ ### Features
6
+
7
+ - **VisualProvider interface** — new `VisualProvider` interface (`capture`, `diff`, `publishBaseline`, `fetchLatestBaseline`, `buildCommentBody`) with `LocalProvider` and `SnapProvider` implementations. Config `provider` field (`"local"` | `"snap"`) selects the backend; defaults to `"local"` for zero-config backward compatibility.
8
+ - **SnapProvider** — calls Snap's `/v1/visual/*` API for capture, diff, and baseline management. Retries on 5xx with exponential backoff (3 attempts, 30 s cap). Honors `snap.onUnavailable` (`"fail"` | `"warn-and-skip"` | `"fallback-local"`). 4xx errors never retry or fall back.
9
+ - **Snap config** — new `snap` config block (`apiUrl`, `apiKeyEnv`/`apiKey`, `projectId`, `onUnavailable`) in `snapdrift.json`. Exactly one of `apiKeyEnv` or `apiKey` is required when `provider: "snap"`.
10
+ - **PR comment unification** — both providers now produce identical PR comment markdown via a shared `buildCommentBody(summary, meta?)` method. `SnapProvider` appends a "View in dashboard →" link when `dashboardUrl` is present; `LocalProvider` omits it. Comment steps route through the provider instead of calling `buildReportCommentBody` directly, with a defensive fallback to `LocalProvider` if config loading fails.
11
+ - **`migrate-baselines` command** — one-shot CLI command (`snapdrift migrate-baselines --to snap` / `--to local`) to upload local baselines to Snap or export Snap baselines back to a local directory. Supports `--accept-cross-engine` for engine-mismatch scenarios.
12
+ - **`init --from-snap-action` codemod** — translates a Snap `github-action` workflow YAML into `snapdrift.json` with `provider: "snap"`. Emits structured warnings for features with no direct equivalent (jpeg format, baseline tags, single-shot artifact upload).
13
+
14
+ ### Infrastructure
15
+
16
+ - **`@snapdrift/manifest` v1.1.0** — added `ProviderCommentMeta`, `dashboardUrl` on `VisualDiffSummary`, `buildCommentBody` on `VisualProvider`, `SnapConfig` re-export.
17
+ - **`@snapdrift/adapter-report-md` v1.1.0** — `buildReportCommentBody` now accepts `dashboardUrl` in meta; renders "View in dashboard →" link when present and valid.
18
+ - **Action contract tests** — `snapdrift-actions-contract` test suite now asserts that comment and pr-diff steps include `createProvider` and `buildCommentBody` calls.
19
+ - **Provider unification tests** — new `buildCommentBody — provider unification` test block verifying identical markdown output across providers, dashboard link inclusion/exclusion, and URL validation.
20
+
3
21
  ## 0.3.0 - 2026-05-26
4
22
 
5
23
  ### Infrastructure
@@ -20,6 +20,10 @@ inputs:
20
20
  description: Artifact label to include in the comment.
21
21
  required: false
22
22
  default: ''
23
+ repo-config-path:
24
+ description: Path to the SnapDrift config file (needed for provider-aware comment rendering).
25
+ required: false
26
+ default: .github/snapdrift.json
23
27
  max-changed-rows:
24
28
  description: Maximum number of changed-route rows shown in the PR comment before truncation.
25
29
  required: false
@@ -42,6 +46,7 @@ runs:
42
46
  VISUAL_DIFF_RUN_URL: ${{ inputs.run-url }}
43
47
  VISUAL_DIFF_ARTIFACT_NAME: ${{ inputs.artifact-name }}
44
48
  INPUT_PR_NUMBER: ${{ inputs.pr-number }}
49
+ REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
45
50
  MAX_CHANGED_ROWS: ${{ inputs.max-changed-rows }}
46
51
  MAX_ERROR_ROWS: ${{ inputs.max-error-rows }}
47
52
  with:
@@ -63,15 +68,29 @@ runs:
63
68
  return;
64
69
  }
65
70
 
66
- const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/pr-comment.mjs');
67
- const { buildReportCommentBody, PR_COMMENT_MARKERS } = await import(pathToFileURL(modulePath));
71
+ const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
72
+ const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
73
+ const prCommentModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/pr-comment.mjs');
74
+ const { createProvider } = await import(pathToFileURL(providerModulePath));
75
+ const { loadSnapdriftConfig } = await import(pathToFileURL(configModulePath));
76
+ const { PR_COMMENT_MARKERS } = await import(pathToFileURL(prCommentModulePath));
77
+
78
+ // Provider-loading block duplicated in actions/pr-diff/action.yml — keep in sync.
79
+ let provider;
80
+ try {
81
+ const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
82
+ provider = createProvider(config.provider || 'local', config);
83
+ } catch (err) {
84
+ core.warning(`Could not load SnapDrift config; falling back to LocalProvider. ${err.message}`);
85
+ provider = createProvider('local');
86
+ }
68
87
 
69
88
  const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
70
89
  const runUrl = process.env.VISUAL_DIFF_RUN_URL || `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
71
90
 
72
91
  const maxChangedRows = Number(process.env.MAX_CHANGED_ROWS) || 20;
73
92
  const maxErrorRows = Number(process.env.MAX_ERROR_ROWS) || 10;
74
- const body = buildReportCommentBody(summary, {
93
+ const body = provider.buildCommentBody(summary, {
75
94
  artifactName: process.env.VISUAL_DIFF_ARTIFACT_NAME || '',
76
95
  runUrl,
77
96
  maxChangedRows,
@@ -498,6 +498,7 @@ runs:
498
498
  SUMMARY_PATH: ${{ steps.compare.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path }}
499
499
  ARTIFACT_NAME: ${{ steps.context.outputs.artifact_name }}
500
500
  RUN_URL: ${{ steps.context.outputs.run_url }}
501
+ REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
501
502
  MAX_CHANGED_ROWS: ${{ inputs.max-changed-rows }}
502
503
  MAX_ERROR_ROWS: ${{ inputs.max-error-rows }}
503
504
  with:
@@ -531,13 +532,25 @@ runs:
531
532
  return;
532
533
  }
533
534
 
534
- const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/pr-comment.mjs');
535
- const { buildReportCommentBody } = await import(pathToFileURL(modulePath));
535
+ const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
536
+ const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
537
+ const { createProvider } = await import(pathToFileURL(providerModulePath));
538
+ const { loadSnapdriftConfig } = await import(pathToFileURL(configModulePath));
539
+
540
+ // Provider-loading block duplicated in actions/comment/action.yml — keep in sync.
541
+ let provider;
542
+ try {
543
+ const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
544
+ provider = createProvider(config.provider || 'local', config);
545
+ } catch (err) {
546
+ core.warning(`Could not load SnapDrift config; falling back to LocalProvider. ${err.message}`);
547
+ provider = createProvider('local');
548
+ }
536
549
 
537
550
  const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
538
551
  const maxChangedRows = Number(process.env.MAX_CHANGED_ROWS) || 20;
539
552
  const maxErrorRows = Number(process.env.MAX_ERROR_ROWS) || 10;
540
- const body = buildReportCommentBody(summary, {
553
+ const body = provider.buildCommentBody(summary, {
541
554
  artifactName: process.env.ARTIFACT_NAME || '',
542
555
  runUrl: process.env.RUN_URL || '',
543
556
  maxChangedRows,
package/docs/contracts.md CHANGED
@@ -34,6 +34,12 @@ SnapDrift reads runtime behavior from `.github/snapdrift.json` by default.
34
34
  |:------|:-----|:------------|
35
35
  | `selection.sharedPrefixes` | `string[]` | Prefixes that force the full route set |
36
36
  | `selection.sharedExact` | `string[]` | Exact files that force the full route set |
37
+ | `provider` | `string` | `"local"` (default) or `"snap"` for hosted backend |
38
+ | `snap.apiUrl` | `string` | Snap API base URL (default: `https://snap.i2dev.com`) |
39
+ | `snap.apiKeyEnv` | `string` | Env var name for API key (mutually exclusive with `snap.apiKey`) |
40
+ | `snap.apiKey` | `string` | Inline API key with `${VAR}` interpolation (mutually exclusive with `snap.apiKeyEnv`) |
41
+ | `snap.projectId` | `string` | Snap project ID or `"auto"` (default: `"auto"`, derives from `GITHUB_REPOSITORY`) |
42
+ | `snap.onUnavailable` | `string` | Behavior when Snap is unreachable: `"fail"` (default), `"warn-and-skip"`, or `"fallback-local"` |
37
43
 
38
44
  ### Example
39
45
 
@@ -129,6 +135,12 @@ The pull request drift bundle contains:
129
135
  }
130
136
  ```
131
137
 
138
+ ### Optional summary fields
139
+
140
+ | Field | Type | Description |
141
+ |:------|:-----|:------------|
142
+ | `dashboardUrl` | `string?` | Snap dashboard URL for the run (set by `SnapProvider`; omitted by `LocalProvider`) |
143
+
132
144
  ### Status values
133
145
 
134
146
  | Status | Meaning |
@@ -208,6 +220,47 @@ When using the `snapdrift` CLI, outputs are written to `.snapdrift/` by default:
208
220
 
209
221
  All three directories can be overridden with `--baseline-dir`, `--current-dir`, and `--diff-dir`. See the [Local CLI guide](local-cli.md) for details.
210
222
 
223
+ ## Migration commands
224
+
225
+ ### migrate-baselines
226
+
227
+ Migrate baselines between local storage and Snap.
228
+
229
+ **Upload local baselines to Snap:**
230
+
231
+ ```
232
+ snapdrift migrate-baselines --to snap [--config <path>] [--baseline-dir <dir>]
233
+ ```
234
+
235
+ - Reads local manifest + baseline images from the baseline directory.
236
+ - Uploads as the initial accepted baseline on Snap via `POST /v1/visual/projects/:id/baselines`.
237
+ - Idempotent: skips upload if a baseline already exists with the matching commit SHA.
238
+ - Requires snapdrift.json with `provider: "snap"` (or snap config).
239
+
240
+ **Download Snap baselines to local:**
241
+
242
+ ```
243
+ snapdrift migrate-baselines --to local --from snap [--accept-cross-engine] [--config <path>] [--baseline-dir <dir>]
244
+ ```
245
+
246
+ - Downloads baselines from Snap's export endpoint.
247
+ - Writes to the local baseline directory.
248
+ - Without `--accept-cross-engine`: hard-errors if the exported baselines were captured by a different engine.
249
+ - With `--accept-cross-engine`: overrides the engine name to `snapdrift-local` in the imported manifest (visual differences may occur).
250
+
251
+ ### init
252
+
253
+ **Translate a Snap action workflow to snapdrift.json:**
254
+
255
+ ```
256
+ snapdrift init --from-snap-action <workflow-yaml-path>
257
+ ```
258
+
259
+ - Reads an existing Snap `github-action/` workflow YAML.
260
+ - Translates Snap action inputs into `.github/snapdrift.json`.
261
+ - Emits `.github/MIGRATION_NOTES.md` listing warnings and deferred decisions.
262
+ - Idempotent: refuses to overwrite an existing `snapdrift.json`.
263
+
211
264
  ## Primary entrypoints
212
265
 
213
266
  - `actions/baseline`
@@ -235,3 +288,72 @@ These are for custom orchestration only. Wrapper actions set them automatically.
235
288
  | `SNAPDRIFT_BASELINE_SOURCE_SHA` | `compare-results.mjs` | Baseline source SHA to embed in the report |
236
289
  | `SNAPDRIFT_ENFORCE_OUTCOME` | `compare-results.mjs` | Set to `0` to disable enforcement in direct CLI usage |
237
290
  | `SNAPDRIFT_CAPTURE_CONCURRENCY` | `capture-routes.mjs` | Max concurrent route captures per viewport context (positive integer, default `5`). Set to `1` to restore serial behaviour for apps with shared session/auth state. |
291
+
292
+ ## PR comment markdown shape
293
+
294
+ The canonical PR comment is produced by `provider.buildCommentBody(summary, meta)` in `@snapdrift/adapter-report-md`. Both `LocalProvider` and `SnapProvider` emit byte-identical markdown for the same summary — the only provider-specific difference is the optional **"View in dashboard →"** link appended by `SnapProvider`.
295
+
296
+ Snap's server-side notification posting should render the same template from the run summary JSON so that reviewers see a consistent format regardless of which provider produced the run.
297
+
298
+ ### Structure
299
+
300
+ ```
301
+ <!-- snapdrift-report -->
302
+ ## {status-icon} SnapDrift Report — {status-label}
303
+
304
+ | Signal | Count |
305
+ |:-------|------:|
306
+ | Drift signals | N |
307
+ | Missing in baseline | N |
308
+ | Missing in current capture | N |
309
+ | Dimension shifts | N |
310
+
311
+ > **Note:** {message} ← optional, when summary.message is set
312
+
313
+ <details><summary>Error details</summary> ← collapsible, only when errors exist
314
+ | Route | Viewport | Error |
315
+ |:------|:---------|:------|
316
+ | route-id | viewport | error message |
317
+ *...and N more* — [View full report →]({runUrl}) ← when errors exceed maxErrorRows
318
+ </details>
319
+
320
+ <details><summary>Drift signals</summary> ← collapsible, only when changed routes exist
321
+ | Route | Viewport | Mismatch |
322
+ |:------|:---------|:---------|
323
+ | route-id | viewport | X.XX% |
324
+ *...and N more* — [View full report →]({runUrl}) ← when routes exceed maxChangedRows
325
+ </details>
326
+
327
+ <details open><summary>Dimension shifts — comparison skipped</summary> ← auto-expanded, only when dimension shifts exist
328
+ > SnapDrift detected a dimension shift …
329
+ | Route | Viewport | Baseline | Current |
330
+ |:------|:---------|:---------|:--------|
331
+ | route-id | viewport | WxH | WxH |
332
+ </details>
333
+
334
+ <sub>SnapDrift · artifact `name` · baseline `name` · sha `abc1234` · [View run](url) · [View in dashboard →](url)</sub>
335
+ ↑ LocalProvider omits "View in dashboard"
336
+
337
+ <div align="right"><sub>Powered by <a href="…">SnapDrift</a></sub></div>
338
+ ```
339
+
340
+ ### Meta parameters
341
+
342
+ | Field | Type | Description |
343
+ |:------|:-----|:------------|
344
+ | `artifactName` | `string?` | PR diff artifact label |
345
+ | `runUrl` | `string?` | GitHub Actions run URL (adds `[View run]` link) |
346
+ | `dashboardUrl` | `string?` | Snap dashboard URL (adds `[View in dashboard →]` link; SnapProvider only) |
347
+ | `maxChangedRows` | `number` | Max drift-signal rows before truncation (default 20) |
348
+ | `maxErrorRows` | `number` | Max error rows before truncation (default 10) |
349
+
350
+ ### Update-in-place semantics
351
+
352
+ Comments are identified by the `<!-- snapdrift-report -->` HTML marker. On re-run, the action finds the most recent matching comment and updates it in place. Duplicate markers from earlier runs are deleted.
353
+
354
+ ### Provider contract
355
+
356
+ The `VisualProvider` interface requires `buildCommentBody(summary, meta?)`:
357
+
358
+ - **`LocalProvider`** — delegates to `buildReportCommentBody` without a `dashboardUrl`.
359
+ - **`SnapProvider`** — constructs `dashboardUrl` from `{apiUrl}/projects/{projectId}/runs/{lastRunId}` and passes it through.
package/lib/cli.mjs CHANGED
@@ -4,8 +4,10 @@ import { execSync } from 'node:child_process';
4
4
  import fs from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
 
7
- import { runBaselineCapture, generateDriftReport, loadSnapdriftConfig } from '@snapdrift/adapter-fs';
7
+ import { loadSnapdriftConfig } from '@snapdrift/adapter-fs';
8
8
  import { shouldFailDriftCheck } from '@snapdrift/manifest';
9
+ import { createProvider } from './provider.mjs';
10
+ import { SnapFallbackError, SnapSkipError } from './snap-provider.mjs';
9
11
  import { generateHtmlReport } from './report.mjs';
10
12
 
11
13
  /** @typedef {import('../types/visual-diff-types').VisualDiffSummary} DriftSummary */
@@ -23,13 +25,17 @@ export const LOCAL_DIFF_SUBDIR = 'diff';
23
25
  * routes: string[],
24
26
  * baselineDir: string,
25
27
  * currentDir: string,
26
- * diffDir: string
28
+ * diffDir: string,
29
+ * to?: 'snap' | 'local',
30
+ * from?: 'snap',
31
+ * acceptCrossEngine?: boolean,
32
+ * fromSnapAction?: string
27
33
  * }} CliOptions
28
34
  */
29
35
 
30
36
  /**
31
37
  * Parse CLI arguments from process.argv (or a provided argv array).
32
- * Supported commands: capture, diff
38
+ * Supported commands: capture, diff, migrate-baselines, init
33
39
  * Supported flags:
34
40
  * --open Open the HTML report after diff
35
41
  * --config <path> Path to snapdrift.json (default: .github/snapdrift.json)
@@ -37,6 +43,10 @@ export const LOCAL_DIFF_SUBDIR = 'diff';
37
43
  * --baseline-dir <p> Override local baseline directory (default: .snapdrift/baseline)
38
44
  * --current-dir <p> Override local current-capture directory (default: .snapdrift/current)
39
45
  * --diff-dir <p> Override local diff-output directory (default: .snapdrift/diff)
46
+ * --to <snap|local> Migration target (migrate-baselines command)
47
+ * --from <snap> Migration source (migrate-baselines --to local)
48
+ * --accept-cross-engine Allow importing baselines from a different capture engine
49
+ * --from-snap-action <path> Snap action workflow YAML path (init command)
40
50
  *
41
51
  * @param {string[]} argv - process.argv
42
52
  * @returns {CliOptions}
@@ -52,6 +62,13 @@ export function parseArgs(argv) {
52
62
  let baselineDir = path.resolve(LOCAL_SNAPDRIFT_DIR, LOCAL_BASELINE_SUBDIR);
53
63
  let currentDir = path.resolve(LOCAL_SNAPDRIFT_DIR, LOCAL_CURRENT_SUBDIR);
54
64
  let diffDir = path.resolve(LOCAL_SNAPDRIFT_DIR, LOCAL_DIFF_SUBDIR);
65
+ /** @type {'snap' | 'local' | undefined} */
66
+ let to;
67
+ /** @type {'snap' | undefined} */
68
+ let from;
69
+ let acceptCrossEngine = false;
70
+ /** @type {string | undefined} */
71
+ let fromSnapAction;
55
72
 
56
73
  for (let i = 1; i < args.length; i++) {
57
74
  const arg = args[i];
@@ -72,10 +89,24 @@ export function parseArgs(argv) {
72
89
  currentDir = path.resolve(args[++i]);
73
90
  } else if (arg === '--diff-dir' && i + 1 < args.length) {
74
91
  diffDir = path.resolve(args[++i]);
92
+ } else if (arg === '--to' && i + 1 < args.length) {
93
+ const toVal = args[++i];
94
+ if (toVal === 'snap' || toVal === 'local') {
95
+ to = toVal;
96
+ }
97
+ } else if (arg === '--from' && i + 1 < args.length) {
98
+ const fromVal = args[++i];
99
+ if (fromVal === 'snap') {
100
+ from = fromVal;
101
+ }
102
+ } else if (arg === '--accept-cross-engine') {
103
+ acceptCrossEngine = true;
104
+ } else if (arg === '--from-snap-action' && i + 1 < args.length) {
105
+ fromSnapAction = args[++i];
75
106
  }
76
107
  }
77
108
 
78
- return { command, open, configPath, routes, baselineDir, currentDir, diffDir };
109
+ return { command, open, configPath, routes, baselineDir, currentDir, diffDir, to, from, acceptCrossEngine, fromSnapAction };
79
110
  }
80
111
 
81
112
  /**
@@ -148,12 +179,29 @@ function openFile(filePath) {
148
179
  */
149
180
  export async function runCaptureCommand(opts) {
150
181
  process.stdout.write(`Capturing baseline to ${opts.baselineDir} ...\n`);
151
- const { selectedRouteIds } = await runBaselineCapture({
152
- configPath: opts.configPath,
153
- routeIds: opts.routes.length > 0 ? opts.routes : undefined,
154
- outDir: opts.baselineDir
155
- });
156
- process.stdout.write(`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
182
+ const { config } = await loadSnapdriftConfig(opts.configPath);
183
+ const provider = createProvider(config.provider ?? 'local', config);
184
+ try {
185
+ const { selectedRouteIds } = await provider.capture({
186
+ configPath: opts.configPath,
187
+ routeIds: opts.routes.length > 0 ? opts.routes : undefined,
188
+ outDir: opts.baselineDir
189
+ });
190
+ process.stdout.write(`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
191
+ } catch (error) {
192
+ if (error instanceof SnapFallbackError) {
193
+ process.stdout.write('Falling back to local provider for capture...\n');
194
+ const localProvider = createProvider('local');
195
+ const { selectedRouteIds } = await localProvider.capture({
196
+ configPath: opts.configPath,
197
+ routeIds: opts.routes.length > 0 ? opts.routes : undefined,
198
+ outDir: opts.baselineDir
199
+ });
200
+ process.stdout.write(`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
201
+ return;
202
+ }
203
+ throw error;
204
+ }
157
205
  }
158
206
 
159
207
  /**
@@ -166,12 +214,30 @@ export async function runCaptureCommand(opts) {
166
214
  export async function runDiffCommand(opts) {
167
215
  process.stdout.write(`Capturing current screenshots to ${opts.currentDir} ...\n`);
168
216
  const { config } = await loadSnapdriftConfig(opts.configPath);
217
+ const provider = createProvider(config.provider ?? 'local', config);
169
218
 
170
- const currentCapture = await runBaselineCapture({
171
- configPath: opts.configPath,
172
- routeIds: opts.routes.length > 0 ? opts.routes : undefined,
173
- outDir: opts.currentDir
174
- });
219
+ let currentCapture;
220
+ let usedLocalFallback = false;
221
+ try {
222
+ currentCapture = await provider.capture({
223
+ configPath: opts.configPath,
224
+ routeIds: opts.routes.length > 0 ? opts.routes : undefined,
225
+ outDir: opts.currentDir
226
+ });
227
+ } catch (error) {
228
+ if (error instanceof SnapFallbackError) {
229
+ process.stdout.write('Falling back to local provider for capture...\n');
230
+ const localProvider = createProvider('local');
231
+ currentCapture = await localProvider.capture({
232
+ configPath: opts.configPath,
233
+ routeIds: opts.routes.length > 0 ? opts.routes : undefined,
234
+ outDir: opts.currentDir
235
+ });
236
+ usedLocalFallback = true;
237
+ } else {
238
+ throw error;
239
+ }
240
+ }
175
241
 
176
242
  process.stdout.write('Comparing against baseline ...\n');
177
243
 
@@ -183,16 +249,28 @@ export async function runDiffCommand(opts) {
183
249
  const markdownPath = path.join(opts.diffDir, 'summary.md');
184
250
  const htmlPath = path.join(opts.diffDir, 'report.html');
185
251
 
186
- const { summary, markdown } = await generateDriftReport({
187
- configPath: opts.configPath,
188
- routeIds: opts.routes.length > 0 ? opts.routes : undefined,
189
- baselineResultsPath,
190
- baselineManifestPath,
191
- currentResultsPath: currentCapture.resultsPath,
192
- currentManifestPath: currentCapture.manifestPath,
193
- baselineRunDir: opts.baselineDir,
194
- currentRunDir: currentCapture.screenshotsRoot
195
- });
252
+ const diffProvider = usedLocalFallback ? createProvider('local') : provider;
253
+ let diffResult;
254
+ try {
255
+ diffResult = await diffProvider.diff({
256
+ configPath: opts.configPath,
257
+ routeIds: opts.routes.length > 0 ? opts.routes : undefined,
258
+ baselineResultsPath,
259
+ baselineManifestPath,
260
+ currentResultsPath: currentCapture.resultsPath,
261
+ currentManifestPath: currentCapture.manifestPath,
262
+ baselineRunDir: opts.baselineDir,
263
+ currentRunDir: currentCapture.screenshotsRoot
264
+ });
265
+ } catch (error) {
266
+ if (error instanceof SnapSkipError) {
267
+ process.stdout.write('Visual regression skipped (Snap unavailable, warn-and-skip mode).\n');
268
+ return;
269
+ }
270
+ throw error;
271
+ }
272
+
273
+ const { summary, markdown } = diffResult;
196
274
 
197
275
  const html = await generateHtmlReport(summary, {
198
276
  baselineRunDir: opts.baselineDir,
@@ -218,6 +296,58 @@ export async function runDiffCommand(opts) {
218
296
  }
219
297
  }
220
298
 
299
+ /**
300
+ * Run the `migrate-baselines` command — migrate baselines between local and Snap.
301
+ *
302
+ * @param {CliOptions} opts
303
+ * @returns {Promise<void>}
304
+ */
305
+ export async function runMigrateBaselinesCommand(opts) {
306
+ if (!opts.to || (opts.to !== 'snap' && opts.to !== 'local')) {
307
+ process.stderr.write('Usage: snapdrift migrate-baselines --to <snap|local> [--from snap] [--accept-cross-engine] [--config <path>] [--baseline-dir <dir>]\n');
308
+ process.exitCode = 1;
309
+ return;
310
+ }
311
+
312
+ if (opts.to === 'local' && opts.from !== 'snap') {
313
+ process.stderr.write('Error: --to local requires --from snap\n');
314
+ process.exitCode = 1;
315
+ return;
316
+ }
317
+
318
+ if (opts.acceptCrossEngine && opts.to !== 'local') {
319
+ process.stderr.write('Error: --accept-cross-engine is only valid with --to local\n');
320
+ process.exitCode = 1;
321
+ return;
322
+ }
323
+
324
+ const { runMigrateToSnap, runMigrateToLocal } = await import('./migrate-baselines.mjs');
325
+ const { config } = await loadSnapdriftConfig(opts.configPath);
326
+
327
+ if (opts.to === 'snap') {
328
+ await runMigrateToSnap(config, opts);
329
+ } else {
330
+ await runMigrateToLocal(config, opts);
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Run the `init` command — initialize snapdrift config from a Snap action workflow.
336
+ *
337
+ * @param {CliOptions} opts
338
+ * @returns {Promise<void>}
339
+ */
340
+ export async function runInitCommand(opts) {
341
+ if (!opts.fromSnapAction) {
342
+ process.stderr.write('Usage: snapdrift init --from-snap-action <workflow-yaml-path>\n');
343
+ process.exitCode = 1;
344
+ return;
345
+ }
346
+
347
+ const { runInitFromAction } = await import('./init-from-action.mjs');
348
+ await runInitFromAction(opts.fromSnapAction);
349
+ }
350
+
221
351
  /**
222
352
  * Main CLI entry point — parse args and dispatch to the appropriate command.
223
353
  *
@@ -231,9 +361,13 @@ export async function main(argv) {
231
361
  await runCaptureCommand(opts);
232
362
  } else if (opts.command === 'diff') {
233
363
  await runDiffCommand(opts);
364
+ } else if (opts.command === 'migrate-baselines') {
365
+ await runMigrateBaselinesCommand(opts);
366
+ } else if (opts.command === 'init') {
367
+ await runInitCommand(opts);
234
368
  } else {
235
369
  process.stderr.write(
236
- `Unknown command: ${opts.command}\nUsage: snapdrift <capture|diff> [options]\n`
370
+ `Unknown command: ${opts.command}\nUsage: snapdrift <capture|diff|migrate-baselines|init> [options]\n`
237
371
  );
238
372
  process.exitCode = 1;
239
373
  }