snapdrift 0.8.1 → 0.9.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/lib/cli.mjs CHANGED
@@ -1,13 +1,13 @@
1
1
  // @ts-check
2
2
 
3
- import { execSync } from 'node:child_process';
3
+ import { spawnSync } from 'node:child_process';
4
4
  import fs from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
 
7
7
  import { loadSnapdriftConfig } from '@snapdrift/adapter-fs';
8
8
  import { shouldFailDriftCheck } from '@snapdrift/manifest';
9
9
  import { createProvider } from './provider.mjs';
10
- import { SnapFallbackError, SnapSkipError } from './snap-provider.mjs';
10
+ import { captureWithPolicy, diffWithPolicy, publishBaselineWithPolicy } from './outage-policy.mjs';
11
11
  import { generateHtmlReport } from './report.mjs';
12
12
 
13
13
  /** @typedef {import('../types/visual-diff-types').VisualDiffSummary} DriftSummary */
@@ -39,7 +39,8 @@ export const LOCAL_DIFF_SUBDIR = 'diff';
39
39
  * Supported flags:
40
40
  * --open Open the HTML report after diff
41
41
  * --config <path> Path to snapdrift.json (default: .github/snapdrift.json)
42
- * --routes <ids> Comma-separated route IDs to run
42
+ * --routes <ids> Comma-separated route IDs (diff/capture/local baseline;
43
+ * hosted baselines require the complete configured set)
43
44
  * --baseline-dir <p> Override local baseline directory (default: .snapdrift/baseline)
44
45
  * --current-dir <p> Override local current-capture directory (default: .snapdrift/current)
45
46
  * --diff-dir <p> Override local diff-output directory (default: .snapdrift/diff)
@@ -48,6 +49,11 @@ export const LOCAL_DIFF_SUBDIR = 'diff';
48
49
  * --accept-cross-engine Allow importing baselines from a different capture engine
49
50
  * --from-snap-action <path> Snap action workflow YAML path (init command)
50
51
  *
52
+ * Values may be given inline as `--flag=value` (so values beginning with a hyphen,
53
+ * e.g. `--routes=-mobile`, work) or after the `--` end-of-options marker. Unknown
54
+ * flags and missing option values are rejected with a non-zero error; invalid
55
+ * *values* for --to/--from remain silently ignored (validated downstream).
56
+ *
51
57
  * @param {string[]} argv - process.argv
52
58
  * @returns {CliOptions}
53
59
  */
@@ -70,41 +76,105 @@ export function parseArgs(argv) {
70
76
  /** @type {string | undefined} */
71
77
  let fromSnapAction;
72
78
 
73
- for (let i = 1; i < args.length; i++) {
74
- const arg = args[i];
75
- if (arg === '--open') {
76
- open = true;
77
- } else if (arg === '--config' && i + 1 < args.length) {
78
- configPath = args[++i];
79
- } else if (arg === '--routes' && i + 1 < args.length) {
80
- routes.push(
81
- ...args[++i]
82
- .split(',')
83
- .map((r) => r.trim())
84
- .filter(Boolean)
85
- );
86
- } else if (arg === '--baseline-dir' && i + 1 < args.length) {
87
- baselineDir = path.resolve(args[++i]);
88
- } else if (arg === '--current-dir' && i + 1 < args.length) {
89
- currentDir = path.resolve(args[++i]);
90
- } else if (arg === '--diff-dir' && i + 1 < args.length) {
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];
106
- }
107
- }
79
+ // Flags that consume the following token as their value. Unknown flags and
80
+ // missing option values are rejected rather than silently ignored, so a typo
81
+ // cannot fall back to the default config/directories. Invalid *values* for
82
+ // --to/--from stay silently ignored to preserve the migrate-baselines contract
83
+ // (their validation happens downstream). Values that begin with a hyphen (e.g.
84
+ // a route id like `-mobile`) may be supplied inline as `--flag=value` or bare.
85
+ const VALUE_FLAGS = new Set([
86
+ '--config',
87
+ '--routes',
88
+ '--baseline-dir',
89
+ '--current-dir',
90
+ '--diff-dir',
91
+ '--to',
92
+ '--from',
93
+ '--from-snap-action'
94
+ ]);
95
+ const BOOLEAN_FLAGS = new Set(['--open', '--accept-cross-engine']);
96
+
97
+ /**
98
+ * Assign a parsed value (from an inline `--flag=value` or a following token)
99
+ * to the option named by `flag`.
100
+ *
101
+ * @param {string} flag
102
+ * @param {string} value
103
+ */
104
+ function assignFlag(flag, value) {
105
+ if (flag === '--routes') {
106
+ routes.push(...value.split(',').map((r) => r.trim()).filter(Boolean));
107
+ } else if (flag === '--config') {
108
+ configPath = value;
109
+ } else if (flag === '--baseline-dir') {
110
+ baselineDir = path.resolve(value);
111
+ } else if (flag === '--current-dir') {
112
+ currentDir = path.resolve(value);
113
+ } else if (flag === '--diff-dir') {
114
+ diffDir = path.resolve(value);
115
+ } else if (flag === '--to') {
116
+ if (value === 'snap' || value === 'local') to = value;
117
+ } else if (flag === '--from') {
118
+ if (value === 'snap') from = value;
119
+ } else if (flag === '--from-snap-action') {
120
+ fromSnapAction = value;
121
+ }
122
+ }
123
+
124
+ let endOfOptions = false;
125
+
126
+ for (let i = 1; i < args.length; i++) {
127
+ const raw = args[i];
128
+
129
+ if (raw === '--') {
130
+ endOfOptions = true;
131
+ continue;
132
+ }
133
+
134
+ // End-of-options: after `--`, tokens are values/positionals, never flags.
135
+ if (endOfOptions) {
136
+ if (VALUE_FLAGS.has(raw)) {
137
+ const value = args[i + 1];
138
+ if (value === undefined) {
139
+ throw new Error(`Missing value for flag ${raw}`);
140
+ }
141
+ assignFlag(raw, value);
142
+ i++; // consume the following token so it is not reprocessed as a flag
143
+ }
144
+ continue;
145
+ }
146
+
147
+ // Inline `--flag=value` form (also handles values that begin with a hyphen).
148
+ const eq = raw.indexOf('=');
149
+ if (raw.startsWith('--') && eq > 0) {
150
+ const flag = raw.slice(0, eq);
151
+ if (!VALUE_FLAGS.has(flag)) {
152
+ throw new Error(`Unknown flag: ${flag}`);
153
+ }
154
+ assignFlag(flag, raw.slice(eq + 1));
155
+ continue;
156
+ }
157
+
158
+ if (BOOLEAN_FLAGS.has(raw)) {
159
+ if (raw === '--open') {
160
+ open = true;
161
+ } else if (raw === '--accept-cross-engine') {
162
+ acceptCrossEngine = true;
163
+ }
164
+ } else if (VALUE_FLAGS.has(raw)) {
165
+ const value = args[i + 1];
166
+ // A hyphenated following token (e.g. a route id like `-mobile`) is a
167
+ // legitimate value; only a missing token, the `--` end-of-options marker,
168
+ // or a following *recognized flag* means the value is absent.
169
+ if (value === undefined || value === '--' || VALUE_FLAGS.has(value) || BOOLEAN_FLAGS.has(value)) {
170
+ throw new Error(`Missing value for flag ${raw}`);
171
+ }
172
+ assignFlag(raw, value);
173
+ i++; // consume the following token so it is not reprocessed as a flag
174
+ } else if (raw.startsWith('-')) {
175
+ throw new Error(`Unknown flag: ${raw}`);
176
+ }
177
+ }
108
178
 
109
179
  return { command, open, configPath, routes, baselineDir, currentDir, diffDir, to, from, acceptCrossEngine, fromSnapAction };
110
180
  }
@@ -162,15 +232,30 @@ export function printSummary(summary) {
162
232
  * @returns {void}
163
233
  */
164
234
  function openFile(filePath) {
165
- const cmd =
166
- process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
167
- try {
168
- execSync(`${cmd} "${filePath}"`);
169
- } catch {
235
+ const { cmd, args } = buildOpenCommand(filePath);
236
+ const result = spawnSync(cmd, args);
237
+ if (result.error) {
170
238
  process.stderr.write(`Could not open file: ${filePath}\n`);
171
239
  }
172
240
  }
173
241
 
242
+ /**
243
+ * Build the platform-correct invocation to open a file with the OS default
244
+ * viewer. The path is passed as a separate argument (never shell-interpolated)
245
+ * so metacharacters in a local path cannot execute commands. On Windows, `start`
246
+ * is a cmd.exe built-in rather than an executable, so spawnSync (which does not
247
+ * use a shell by default) returns ENOENT; open the registered handler through
248
+ * rundll32, a real executable, keeping the path as a separate argument.
249
+ *
250
+ * @param {string} filePath
251
+ * @returns {{ cmd: string, args: string[] }}
252
+ */
253
+ export function buildOpenCommand(filePath, platform = process.platform) {
254
+ if (platform === 'darwin') return { cmd: 'open', args: [filePath] };
255
+ if (platform === 'linux') return { cmd: 'xdg-open', args: [filePath] };
256
+ return { cmd: 'rundll32', args: ['url.dll,FileProtocolHandler', filePath] };
257
+ }
258
+
174
259
  /**
175
260
  * Run the `capture` command — capture screenshots to a local baseline directory.
176
261
  *
@@ -180,36 +265,33 @@ function openFile(filePath) {
180
265
  export async function runCaptureCommand(opts) {
181
266
  process.stdout.write(`Capturing baseline to ${opts.baselineDir} ...\n`);
182
267
  const { config } = await loadSnapdriftConfig(opts.configPath);
183
- const provider = createProvider(config.provider ?? 'local', config);
184
- try {
185
- const { selectedRouteIds } = await provider.capture({
268
+ const providerName = config.provider ?? 'local';
269
+ const provider = createProvider(providerName, config);
270
+
271
+ // onUnavailable: warn-and-skip — the user asked not to fail the build when
272
+ // Snap is down. `diff` and `baseline` both honour this; `capture` used to
273
+ // let SnapSkipError reach bin/snapdrift.mjs, which exits 1. See #108.
274
+ const captured = await captureWithPolicy({
275
+ provider,
276
+ providerName,
277
+ config,
278
+ captureOptions: {
186
279
  configPath: opts.configPath,
187
280
  routeIds: opts.routes.length > 0 ? opts.routes : undefined,
188
281
  outDir: opts.baselineDir,
189
- purpose: 'baseline'
190
- });
191
- process.stdout.write(`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
192
- } catch (error) {
193
- // onUnavailable: warn-and-skip — the user asked not to fail the build when
194
- // Snap is down. `diff` and `baseline` both honour this; `capture` used to
195
- // let SnapSkipError reach bin/snapdrift.mjs, which exits 1. See #108.
196
- if (error instanceof SnapSkipError) {
197
- process.stdout.write('Capture skipped (Snap unavailable, warn-and-skip mode).\n');
198
- return;
199
- }
200
- if (error instanceof SnapFallbackError) {
201
- process.stdout.write('Falling back to local provider for capture...\n');
202
- const localProvider = createProvider('local');
203
- const { selectedRouteIds } = await localProvider.capture({
204
- configPath: opts.configPath,
205
- routeIds: opts.routes.length > 0 ? opts.routes : undefined,
206
- outDir: opts.baselineDir
207
- });
208
- process.stdout.write(`Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
209
- return;
210
- }
211
- throw error;
282
+ purpose: 'capture'
283
+ },
284
+ onSkip: () => process.stdout.write('Capture skipped (Snap unavailable, warn-and-skip mode).\n'),
285
+ onFallback: () => process.stdout.write('Falling back to local provider for capture...\n')
286
+ });
287
+
288
+ if (captured.outcome === 'skipped') {
289
+ return;
212
290
  }
291
+
292
+ process.stdout.write(
293
+ `Captured ${captured.result.selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`
294
+ );
213
295
  }
214
296
 
215
297
  /**
@@ -240,79 +322,65 @@ export async function runBaselineCommand(opts) {
240
322
  : `Capturing baseline to ${opts.baselineDir} ...\n`
241
323
  );
242
324
 
243
- /** Capture locally so `fallback-local` still leaves the user with a baseline. */
244
- async function captureLocalFallback() {
245
- process.stdout.write('Falling back to local provider for capture...\n');
246
- const localProvider = createProvider('local');
247
- const { selectedRouteIds } = await localProvider.capture({
248
- configPath: opts.configPath,
249
- routeIds: opts.routes.length > 0 ? opts.routes : undefined,
250
- outDir: opts.baselineDir
251
- });
325
+ /** @type {import('../types/visual-diff-types').ProviderCaptureOptions} */
326
+ const captureOptions = {
327
+ configPath: opts.configPath,
328
+ routeIds: opts.routes.length > 0 ? opts.routes : undefined,
329
+ outDir: opts.baselineDir,
330
+ purpose: 'baseline'
331
+ };
332
+
333
+ const onFallback = () => process.stdout.write('Falling back to local provider for capture...\n');
334
+
335
+ // Snap can go down during *either* phase — the capture submission or the
336
+ // publish that follows it — and the user's `onUnavailable` choice must hold
337
+ // for both, otherwise `warn-and-skip` still exits 1 and `fallback-local`
338
+ // still leaves them with no baseline.
339
+ const captured = await captureWithPolicy({
340
+ provider,
341
+ providerName,
342
+ config,
343
+ captureOptions,
344
+ onSkip: () => process.stdout.write('Baseline skipped (Snap unavailable, warn-and-skip mode).\n'),
345
+ onFallback
346
+ });
347
+
348
+ if (captured.outcome === 'skipped') {
349
+ return;
350
+ }
351
+
352
+ if (captured.providerName !== 'snap') {
353
+ const suffix = providerName === 'snap' ? ' (local fallback — nothing published to Snap)' : '';
252
354
  process.stdout.write(
253
- `Captured ${selectedRouteIds.length} route(s) to ${opts.baselineDir} (local fallback — nothing published to Snap)\n`
355
+ `Captured ${captured.result.selectedRouteIds.length} route(s) to ${opts.baselineDir}${suffix}\n`
254
356
  );
357
+ return;
255
358
  }
256
359
 
257
- /**
258
- * Apply the configured `onUnavailable` behaviour. Snap can go down during
259
- * *either* phase — the capture submission or the publish that follows it — and
260
- * the user's choice must hold for both, otherwise `warn-and-skip` still exits 1
261
- * and `fallback-local` still leaves them with no baseline.
262
- *
263
- * @param {unknown} error
264
- * @param {string} skipMessage
265
- * @returns {Promise<boolean>} true when the error was absorbed
266
- */
267
- async function handleUnavailable(error, skipMessage) {
268
- if (error instanceof SnapSkipError) {
269
- process.stdout.write(skipMessage);
270
- return true;
271
- }
272
- if (error instanceof SnapFallbackError) {
273
- await captureLocalFallback();
274
- return true;
275
- }
276
- return false;
277
- }
360
+ process.stdout.write(
361
+ `Captured ${captured.result.selectedRouteIds.length} route(s); waiting for Snap to finish rendering ...\n`
362
+ );
278
363
 
279
- /** @type {{ resultsPath?: string, selectedRouteIds: string[] }} */
280
- let captured;
281
- try {
282
- captured = await provider.capture({
283
- configPath: opts.configPath,
284
- routeIds: opts.routes.length > 0 ? opts.routes : undefined,
285
- outDir: opts.baselineDir,
286
- purpose: 'baseline'
287
- });
288
- } catch (error) {
289
- if (await handleUnavailable(error, 'Baseline skipped (Snap unavailable, warn-and-skip mode).\n')) {
290
- return;
291
- }
292
- throw error;
293
- }
364
+ const published = await publishBaselineWithPolicy({
365
+ provider,
366
+ publishOptions: { resultsPath: captured.result.resultsPath },
367
+ captureOptions,
368
+ onSkip: () =>
369
+ process.stdout.write('Baseline publish skipped (Snap unavailable, warn-and-skip mode).\n'),
370
+ onFallback
371
+ });
294
372
 
295
- if (providerName !== 'snap') {
296
- process.stdout.write(`Captured ${captured.selectedRouteIds.length} route(s) to ${opts.baselineDir}\n`);
373
+ if (published.outcome === 'skipped') {
297
374
  return;
298
375
  }
299
376
 
300
- process.stdout.write(
301
- `Captured ${captured.selectedRouteIds.length} route(s); waiting for Snap to finish rendering ...\n`
302
- );
303
- try {
304
- await provider.publishBaseline({ resultsPath: captured.resultsPath });
305
- } catch (error) {
306
- if (
307
- await handleUnavailable(
308
- error,
309
- 'Baseline publish skipped (Snap unavailable, warn-and-skip mode).\n'
310
- )
311
- ) {
312
- return;
313
- }
314
- throw error;
377
+ if (published.outcome === 'fell-back') {
378
+ process.stdout.write(
379
+ `Captured ${published.recapture?.selectedRouteIds.length ?? 0} route(s) to ${opts.baselineDir} (local fallback — nothing published to Snap)\n`
380
+ );
381
+ return;
315
382
  }
383
+
316
384
  process.stdout.write('Snap baseline published successfully.\n');
317
385
  }
318
386
 
@@ -326,31 +394,32 @@ export async function runBaselineCommand(opts) {
326
394
  export async function runDiffCommand(opts) {
327
395
  process.stdout.write(`Capturing current screenshots to ${opts.currentDir} ...\n`);
328
396
  const { config } = await loadSnapdriftConfig(opts.configPath);
329
- const provider = createProvider(config.provider ?? 'local', config);
397
+ const providerName = config.provider ?? 'local';
398
+ const provider = createProvider(providerName, config);
330
399
 
331
- let currentCapture;
332
- let usedLocalFallback = false;
333
- try {
334
- currentCapture = await provider.capture({
335
- configPath: opts.configPath,
336
- routeIds: opts.routes.length > 0 ? opts.routes : undefined,
337
- outDir: opts.currentDir
338
- });
339
- } catch (error) {
340
- if (error instanceof SnapFallbackError) {
341
- process.stdout.write('Falling back to local provider for capture...\n');
342
- const localProvider = createProvider('local');
343
- currentCapture = await localProvider.capture({
344
- configPath: opts.configPath,
345
- routeIds: opts.routes.length > 0 ? opts.routes : undefined,
346
- outDir: opts.currentDir
347
- });
348
- usedLocalFallback = true;
349
- } else {
350
- throw error;
351
- }
400
+ /** @type {import('../types/visual-diff-types').ProviderCaptureOptions} */
401
+ const captureOptions = {
402
+ configPath: opts.configPath,
403
+ routeIds: opts.routes.length > 0 ? opts.routes : undefined,
404
+ outDir: opts.currentDir
405
+ };
406
+
407
+ const captured = await captureWithPolicy({
408
+ provider,
409
+ providerName,
410
+ config,
411
+ captureOptions,
412
+ onSkip: () =>
413
+ process.stdout.write('Visual regression skipped (Snap unavailable, warn-and-skip mode).\n'),
414
+ onFallback: () => process.stdout.write('Falling back to local provider for capture...\n')
415
+ });
416
+
417
+ if (captured.outcome === 'skipped') {
418
+ return;
352
419
  }
353
420
 
421
+ const currentCapture = captured.result;
422
+
354
423
  process.stdout.write('Comparing against baseline ...\n');
355
424
 
356
425
  const baselineResultsPath = path.join(opts.baselineDir, path.basename(config.resultsFile));
@@ -361,10 +430,12 @@ export async function runDiffCommand(opts) {
361
430
  const markdownPath = path.join(opts.diffDir, 'summary.md');
362
431
  const htmlPath = path.join(opts.diffDir, 'report.html');
363
432
 
364
- const diffProvider = usedLocalFallback ? createProvider('local') : provider;
365
- let diffResult;
366
- try {
367
- diffResult = await diffProvider.diff({
433
+ const diffed = await diffWithPolicy({
434
+ provider: captured.providerName === 'local' ? createProvider('local') : provider,
435
+ providerName: captured.providerName,
436
+ localScreenshots: captured.localScreenshots,
437
+ captureOptions,
438
+ diffOptions: {
368
439
  configPath: opts.configPath,
369
440
  routeIds: opts.routes.length > 0 ? opts.routes : undefined,
370
441
  baselineResultsPath,
@@ -373,20 +444,24 @@ export async function runDiffCommand(opts) {
373
444
  currentManifestPath: currentCapture.manifestPath,
374
445
  baselineRunDir: opts.baselineDir,
375
446
  currentRunDir: currentCapture.screenshotsRoot
376
- });
377
- } catch (error) {
378
- if (error instanceof SnapSkipError) {
379
- process.stdout.write('Visual regression skipped (Snap unavailable, warn-and-skip mode).\n');
380
- return;
381
- }
382
- throw error;
447
+ },
448
+ onSkip: () =>
449
+ process.stdout.write('Visual regression skipped (Snap unavailable, warn-and-skip mode).\n'),
450
+ onFallback: () => process.stdout.write('Falling back to local provider for the diff...\n'),
451
+ onRecapture: () =>
452
+ process.stdout.write('Recapturing locally so the local diff has screenshots to compare...\n')
453
+ });
454
+
455
+ if (diffed.outcome !== 'diffed') {
456
+ return;
383
457
  }
384
458
 
385
- const { summary, markdown } = diffResult;
459
+ const { summary, markdown } = diffed.result;
460
+ const currentRunDir = diffed.recapture?.screenshotsRoot ?? currentCapture.screenshotsRoot;
386
461
 
387
462
  const html = await generateHtmlReport(summary, {
388
463
  baselineRunDir: opts.baselineDir,
389
- currentRunDir: currentCapture.screenshotsRoot
464
+ currentRunDir
390
465
  });
391
466
 
392
467
  await Promise.all([
@@ -485,4 +560,4 @@ export async function main(argv) {
485
560
  );
486
561
  process.exitCode = 1;
487
562
  }
488
- }
563
+ }
@@ -87,19 +87,30 @@ function translateStepToConfig(step) {
87
87
  });
88
88
  }
89
89
 
90
- // Snap provider config (if present in inputs)
91
- /** @type {JsonObject | undefined} */
92
- let snap;
93
- const snapApiUrl = inputs['snap-api-url'] || inputs.snap_api_url;
94
- const snapApiKeyEnv = inputs['snap-api-key-env'] || inputs.snap_api_key_env;
95
- const snapProjectId = inputs['snap-project-id'] || inputs.snap_project_id;
96
-
97
- if (snapApiKeyEnv || snapProjectId) {
98
- snap = {};
99
- if (snapApiUrl) snap.apiUrl = snapApiUrl;
100
- if (snapApiKeyEnv) snap.apiKeyEnv = snapApiKeyEnv;
101
- if (snapProjectId) snap.projectId = snapProjectId;
102
- }
90
+ // Snap provider config (if present in inputs). A hosted Snap provider requires
91
+ // an API-key source; only enable provider: "snap" when one is present. When a
92
+ // project ID exists without an API-key source, keep a valid (local) config and
93
+ // emit a clear migration warning rather than emitting provider: "snap" with no
94
+ // API-key source, which later fails validation.
95
+ /** @type {JsonObject | undefined} */
96
+ let snap;
97
+ const snapApiUrl = inputs['snap-api-url'] || inputs.snap_api_url;
98
+ const snapApiKeyEnv = inputs['snap-api-key-env'] || inputs.snap_api_key_env;
99
+ const snapProjectId = inputs['snap-project-id'] || inputs.snap_project_id;
100
+
101
+ if (snapApiKeyEnv) {
102
+ snap = {};
103
+ if (snapApiUrl) snap.apiUrl = snapApiUrl;
104
+ snap.apiKeyEnv = snapApiKeyEnv;
105
+ if (snapProjectId) snap.projectId = snapProjectId;
106
+ } else if (snapProjectId) {
107
+ warnings.push({
108
+ field: 'snap.projectId',
109
+ originalValue: snapProjectId,
110
+ message: `Snap project ID "${snapProjectId}" is present but no API-key source (snap-api-key-env) was configured. SnapDrift keeps the local provider; add a Snap API key source to enable hosted baselines.`,
111
+ severity: 'warning'
112
+ });
113
+ }
103
114
 
104
115
  const config = {
105
116
  baselineArtifactName: 'snapdrift-baseline',