@controlfront/detect 0.0.1

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 (35) hide show
  1. package/bin/cfb.js +202 -0
  2. package/package.json +64 -0
  3. package/src/commands/baseline.js +198 -0
  4. package/src/commands/init.js +309 -0
  5. package/src/commands/login.js +71 -0
  6. package/src/commands/logout.js +44 -0
  7. package/src/commands/scan.js +1547 -0
  8. package/src/commands/snapshot.js +191 -0
  9. package/src/commands/sync.js +127 -0
  10. package/src/config/baseUrl.js +49 -0
  11. package/src/data/tailwind-core-spec.js +149 -0
  12. package/src/engine/runRules.js +210 -0
  13. package/src/lib/collectDeclaredTokensAuto.js +67 -0
  14. package/src/lib/collectTokenMatches.js +330 -0
  15. package/src/lib/collectTokenMatches.js.regex +252 -0
  16. package/src/lib/loadRules.js +73 -0
  17. package/src/rules/core/no-hardcoded-colors.js +28 -0
  18. package/src/rules/core/no-hardcoded-spacing.js +29 -0
  19. package/src/rules/core/no-inline-styles.js +28 -0
  20. package/src/utils/authorId.js +106 -0
  21. package/src/utils/buildAIContributions.js +224 -0
  22. package/src/utils/buildBlameData.js +388 -0
  23. package/src/utils/buildDeclaredCssVars.js +185 -0
  24. package/src/utils/buildDeclaredJson.js +214 -0
  25. package/src/utils/buildFileChanges.js +372 -0
  26. package/src/utils/buildRuntimeUsage.js +337 -0
  27. package/src/utils/detectDeclaredDrift.js +59 -0
  28. package/src/utils/extractImports.js +178 -0
  29. package/src/utils/fileExtensions.js +65 -0
  30. package/src/utils/generateInsights.js +332 -0
  31. package/src/utils/getAllFiles.js +63 -0
  32. package/src/utils/getCommitMetaData.js +102 -0
  33. package/src/utils/getLine.js +14 -0
  34. package/src/utils/resolveProjectForFolder/index.js +47 -0
  35. package/src/utils/twClassify.js +138 -0
@@ -0,0 +1,1547 @@
1
+ // --- Shared fetch helper for CI helpers ---
2
+ async function getFetch() {
3
+ if (globalThis.fetch) return globalThis.fetch;
4
+ const mod = await import("node-fetch");
5
+ return mod.default || mod;
6
+ }
7
+ import chalk from "chalk";
8
+ import { execSync, spawnSync } from "child_process";
9
+ import fs from "fs";
10
+ import jwt from "jsonwebtoken";
11
+ import micromatch from "micromatch";
12
+ import path from "path";
13
+ import { fileURLToPath } from "url";
14
+ import { resolveProjectForFolder } from "../utils/resolveProjectForFolder/index.js";
15
+ import { SCANNABLE_EXTENSIONS } from "../utils/fileExtensions.js";
16
+
17
+ // --- Git network ops memoization (avoid repeated fetch/maintenance during long backfills) ---
18
+ let _cfDidUnshallowFetch = false;
19
+ let _cfDidFetchDefaultBranchRef = false;
20
+
21
+ // --- Git ops metrics (helps diagnose rare hangs / best-effort fallbacks) ---
22
+ const _cfGitStats = {
23
+ unshallow: { attempted: 0, ok: 0, failed: 0, timed_out: 0, ms_total: 0 },
24
+ fetch_branch_ref: {
25
+ attempted: 0,
26
+ ok: 0,
27
+ failed: 0,
28
+ timed_out: 0,
29
+ ms_total: 0,
30
+ },
31
+ log_since: { attempted: 0, ok: 0, failed: 0, timed_out: 0, ms_total: 0 },
32
+ log_recent: { attempted: 0, ok: 0, failed: 0, timed_out: 0, ms_total: 0 },
33
+ best_effort_fallbacks: 0,
34
+ };
35
+
36
+ // --- Snapshot sync metrics (helps diagnose flaky localhost/network during long backfills) ---
37
+ const _cfSnapshotSyncStats = {
38
+ attempted: 0,
39
+ ok: 0,
40
+ failed: 0,
41
+ retried: 0,
42
+ // keep a small sample to avoid log spam
43
+ failure_samples: [],
44
+ };
45
+
46
+ function recordSnapshotSyncResult({ ok, attempt, totalAttempts, err } = {}) {
47
+ _cfSnapshotSyncStats.attempted += 1;
48
+ if (ok) _cfSnapshotSyncStats.ok += 1;
49
+ else _cfSnapshotSyncStats.failed += 1;
50
+
51
+ if (!ok && attempt && totalAttempts && attempt < totalAttempts) {
52
+ _cfSnapshotSyncStats.retried += 1;
53
+ }
54
+
55
+ if (!ok && err && _cfSnapshotSyncStats.failure_samples.length < 5) {
56
+ _cfSnapshotSyncStats.failure_samples.push({
57
+ message: err?.message || String(err),
58
+ status: err?.status || null,
59
+ code: err?.code || err?.errno || null,
60
+ name: err?.name || null,
61
+ });
62
+ }
63
+ }
64
+
65
+ function printSnapshotSyncSummary(prefix = "🪵") {
66
+ console.log(`${prefix} Snapshot sync summary`, {
67
+ attempted: _cfSnapshotSyncStats.attempted,
68
+ ok: _cfSnapshotSyncStats.ok,
69
+ failed: _cfSnapshotSyncStats.failed,
70
+ retried: _cfSnapshotSyncStats.retried,
71
+ failure_samples: _cfSnapshotSyncStats.failure_samples,
72
+ });
73
+ }
74
+
75
+ function isRetryableSnapshotError(err) {
76
+ const msg = (err?.message || "").toLowerCase();
77
+ const code = (err?.code || err?.errno || "").toString().toLowerCase();
78
+
79
+ // network / socket / DNS / fetch-layer issues
80
+ const retryableCodes = new Set([
81
+ "etimedout",
82
+ "econnreset",
83
+ "econnrefused",
84
+ "ehostunreach",
85
+ "enotfound",
86
+ "eai_again",
87
+ "socket hang up",
88
+ ]);
89
+
90
+ if (retryableCodes.has(code)) return true;
91
+
92
+ if (
93
+ msg.includes("fetch failed") ||
94
+ msg.includes("socket") ||
95
+ msg.includes("timed out") ||
96
+ msg.includes("timeout") ||
97
+ msg.includes("econnreset") ||
98
+ msg.includes("econnrefused") ||
99
+ msg.includes("enotfound")
100
+ ) {
101
+ return true;
102
+ }
103
+
104
+ // If we encoded an HTTP status into the error message, treat 429/5xx as retryable
105
+ if (
106
+ msg.includes(" 429 ") ||
107
+ msg.includes(" 500 ") ||
108
+ msg.includes(" 502 ") ||
109
+ msg.includes(" 503 ") ||
110
+ msg.includes(" 504 ")
111
+ ) {
112
+ return true;
113
+ }
114
+
115
+ return false;
116
+ }
117
+
118
+ function sleep(ms) {
119
+ return new Promise((r) => setTimeout(r, ms));
120
+ }
121
+
122
+ async function postSnapshotDirectWithRetry(
123
+ args,
124
+ { attempts = 3, baseDelayMs = 750, progressInfo = null } = {}
125
+ ) {
126
+ let lastErr = null;
127
+
128
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
129
+ try {
130
+ if (attempt > 1) {
131
+ console.log("🪵 postSnapshotDirect:retry", { attempt, attempts });
132
+ }
133
+ await postSnapshotDirect({ ...args, progressInfo });
134
+ recordSnapshotSyncResult({ ok: true, attempt, totalAttempts: attempts });
135
+ return;
136
+ } catch (e) {
137
+ lastErr = e;
138
+ recordSnapshotSyncResult({
139
+ ok: false,
140
+ attempt,
141
+ totalAttempts: attempts,
142
+ err: e,
143
+ });
144
+
145
+ const retryable = isRetryableSnapshotError(e);
146
+ const isLast = attempt === attempts;
147
+
148
+ console.warn("⚠️ postSnapshotDirect failed", {
149
+ attempt,
150
+ attempts,
151
+ retryable,
152
+ message: e?.message || String(e),
153
+ });
154
+
155
+ if (!retryable || isLast) break;
156
+
157
+ // backoff with a small cap
158
+ const delay = Math.min(baseDelayMs * attempt, 4000);
159
+ await sleep(delay);
160
+ }
161
+ }
162
+
163
+ throw lastErr;
164
+ }
165
+
166
+ function recordGitStat(bucket, { ok, ms = 0, errorCode = null } = {}) {
167
+ const b = _cfGitStats[bucket];
168
+ if (!b) return;
169
+ b.attempted += 1;
170
+ if (ok) b.ok += 1;
171
+ else b.failed += 1;
172
+ if (errorCode === "ETIMEDOUT") b.timed_out += 1;
173
+ if (typeof ms === "number" && ms > 0) b.ms_total += ms;
174
+ }
175
+
176
+ function printGitOpsSummary(prefix = "🪝") {
177
+ const fmt = (bucketName) => {
178
+ const b = _cfGitStats[bucketName];
179
+ if (!b) return null;
180
+ const avg = b.ok > 0 ? Math.round(b.ms_total / b.ok) : null;
181
+ return {
182
+ bucket: bucketName,
183
+ attempted: b.attempted,
184
+ ok: b.ok,
185
+ failed: b.failed,
186
+ timed_out: b.timed_out,
187
+ avg_ms_ok: avg,
188
+ };
189
+ };
190
+
191
+ const rows = [
192
+ fmt("unshallow"),
193
+ fmt("fetch_branch_ref"),
194
+ fmt("log_since"),
195
+ fmt("log_recent"),
196
+ ].filter(Boolean);
197
+
198
+ console.log(`${prefix} Git ops summary`, {
199
+ best_effort_fallbacks: _cfGitStats.best_effort_fallbacks,
200
+ rows,
201
+ });
202
+ }
203
+
204
+ // --- Helper: run git with a hard timeout to prevent rare hangs during long backfills ---
205
+ function runGit(
206
+ cmd,
207
+ { timeoutMs = 30000, stdio = "ignore", encoding = "utf8", env = {} } = {}
208
+ ) {
209
+ const start = Date.now();
210
+ const res = spawnSync(cmd, {
211
+ shell: true,
212
+ timeout: timeoutMs,
213
+ stdio,
214
+ encoding,
215
+ env: {
216
+ ...process.env,
217
+ // Never allow interactive prompts during CLI runs
218
+ GIT_TERMINAL_PROMPT: "0",
219
+ // Fail fast on SSH prompts / connectivity issues
220
+ GIT_SSH_COMMAND:
221
+ process.env.GIT_SSH_COMMAND ||
222
+ "ssh -o BatchMode=yes -o ConnectTimeout=10",
223
+ ...env,
224
+ },
225
+ maxBuffer: 50 * 1024 * 1024,
226
+ });
227
+
228
+ const ms = Date.now() - start;
229
+
230
+ if (res.error) {
231
+ // spawnSync sets error.code === 'ETIMEDOUT' when the timeout is hit
232
+ const msg = res.error?.message || String(res.error);
233
+ const err = new Error(`Command failed: ${cmd}`);
234
+ err.meta = { ms, message: msg, code: res.error?.code || null };
235
+ throw err;
236
+ }
237
+
238
+ if (res.status !== 0) {
239
+ const stderr = typeof res.stderr === "string" ? res.stderr.trim() : "";
240
+ const err = new Error(`Command failed: ${cmd}`);
241
+ err.meta = { ms, status: res.status, stderr: stderr || null };
242
+ throw err;
243
+ }
244
+
245
+ return { stdout: res.stdout, ms };
246
+ }
247
+
248
+ // detect CSS imports in TS/JS files
249
+ function findCssImports(filePath, content) {
250
+ const regex = /import\s+["']([^"']+\.(css|scss|less))["'];?/g;
251
+ const imports = [];
252
+ let match;
253
+ while ((match = regex.exec(content)) !== null) {
254
+ const rawImport = match[1];
255
+ let resolved;
256
+ if (rawImport.startsWith(".") || rawImport.startsWith("/")) {
257
+ resolved = path.resolve(path.dirname(filePath), rawImport);
258
+ } else if (rawImport.startsWith("@workspace/")) {
259
+ const parts = rawImport.split("/");
260
+ const pkg = parts[1];
261
+ const rest = parts.slice(2).join("/");
262
+ resolved = path.resolve(process.cwd(), "packages", pkg, rest);
263
+ }
264
+ if (resolved && fs.existsSync(resolved)) {
265
+ imports.push(resolved);
266
+ console.log(`📂 Including imported CSS: ${resolved}`);
267
+ }
268
+ }
269
+ return imports;
270
+ }
271
+
272
+ // --- CLI version helper ---
273
+ function getCliVersion() {
274
+ try {
275
+ const pkgPath = path.resolve(__dirname, "../../package.json");
276
+ const raw = fs.readFileSync(pkgPath, "utf8");
277
+ const json = JSON.parse(raw);
278
+ return json.version || "0.0.0";
279
+ } catch (e) {
280
+ return "0.0.0";
281
+ }
282
+ }
283
+
284
+ // --- Helper: Get ALL commits since N days ago (oldest -> newest) ---
285
+ function getCommitsSinceDays(days = 90) {
286
+ try {
287
+ // Determine the default branch name dynamically
288
+ let defaultBranch = "main";
289
+ try {
290
+ const remoteShow = execSync("git remote show origin", {
291
+ encoding: "utf8",
292
+ });
293
+ const match = remoteShow.match(/HEAD branch: (.+)/);
294
+ if (match && match[1]) defaultBranch = match[1].trim();
295
+ } catch {
296
+ defaultBranch = "main";
297
+ }
298
+
299
+ // Check if HEAD is detached
300
+ let isDetached = false;
301
+ try {
302
+ execSync("git symbolic-ref -q HEAD", { stdio: "ignore" });
303
+ isDetached = false;
304
+ } catch {
305
+ isDetached = true;
306
+ }
307
+
308
+ const since = `${days} days ago`;
309
+ let output;
310
+
311
+ if (isDetached) {
312
+ const headSha = (() => {
313
+ try {
314
+ return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
315
+ } catch {
316
+ return null;
317
+ }
318
+ })();
319
+
320
+ const start = Date.now();
321
+ console.log(
322
+ `🪝 Detached HEAD detected — retrieving commits from origin/${defaultBranch} (since ${since})`,
323
+ { headSha, days }
324
+ );
325
+
326
+ // Ensure we have full history where possible (only once per process)
327
+ if (!_cfDidUnshallowFetch) {
328
+ try {
329
+ const t0 = Date.now();
330
+ console.log("🪝 git(fetch --unshallow):start");
331
+ const r = runGit(
332
+ `git -c maintenance.auto=false -c gc.auto=0 fetch --unshallow`,
333
+ {
334
+ timeoutMs: 20000,
335
+ stdio: "ignore",
336
+ }
337
+ );
338
+ recordGitStat("unshallow", { ok: true, ms: r.ms });
339
+ console.log("🪝 git(fetch --unshallow):ok", { ms: r.ms });
340
+ } catch (e) {
341
+ recordGitStat("unshallow", {
342
+ ok: false,
343
+ ms: e?.meta?.ms ?? 0,
344
+ errorCode: e?.meta?.code ?? null,
345
+ });
346
+ _cfGitStats.best_effort_fallbacks += 1;
347
+ console.log("🪝 git(fetch --unshallow):best-effort-continue");
348
+ console.log("🪝 git(fetch --unshallow):skip/fail", {
349
+ ms: e?.meta?.ms ?? Date.now() - start,
350
+ code: e?.meta?.code ?? null,
351
+ message: e?.message || String(e),
352
+ });
353
+ } finally {
354
+ _cfDidUnshallowFetch = true;
355
+ }
356
+ }
357
+
358
+ // Ensure the remote branch ref exists locally (only once per process)
359
+ if (!_cfDidFetchDefaultBranchRef) {
360
+ try {
361
+ const t1 = Date.now();
362
+ const cmd = `git -c maintenance.auto=false -c gc.auto=0 fetch origin ${defaultBranch}:${defaultBranch}`;
363
+ console.log("🪝 git(fetch origin branch):start", { cmd });
364
+ const r = runGit(cmd, {
365
+ timeoutMs: 30000,
366
+ stdio: "ignore",
367
+ });
368
+ recordGitStat("fetch_branch_ref", { ok: true, ms: r.ms });
369
+ console.log("🪝 git(fetch origin branch):ok", { ms: r.ms });
370
+ } catch (e) {
371
+ recordGitStat("fetch_branch_ref", {
372
+ ok: false,
373
+ ms: e?.meta?.ms ?? 0,
374
+ errorCode: e?.meta?.code ?? null,
375
+ });
376
+ _cfGitStats.best_effort_fallbacks += 1;
377
+ console.log("🪝 git(fetch origin branch):best-effort-continue");
378
+ console.warn(
379
+ `⚠️ Could not fetch origin/${defaultBranch} as local ref; commit history may be incomplete.`,
380
+ {
381
+ message: e?.message || String(e),
382
+ }
383
+ );
384
+ } finally {
385
+ _cfDidFetchDefaultBranchRef = true;
386
+ }
387
+ }
388
+
389
+ const t2 = Date.now();
390
+ const cmd = `git log ${defaultBranch} --since='${since}' --pretty="format:%H|%cI|%an"`;
391
+ console.log("🪝 git(log defaultBranch since):start", { cmd });
392
+ try {
393
+ const r = runGit(cmd, { timeoutMs: 20000, stdio: "pipe" });
394
+ output = r.stdout;
395
+ recordGitStat("log_since", { ok: true, ms: r.ms });
396
+ console.log("🪝 git(log defaultBranch since):ok", {
397
+ ms: r.ms,
398
+ bytes: typeof output === "string" ? output.length : null,
399
+ });
400
+ } catch (e) {
401
+ recordGitStat("log_since", {
402
+ ok: false,
403
+ ms: e?.meta?.ms ?? 0,
404
+ errorCode: e?.meta?.code ?? null,
405
+ });
406
+ _cfGitStats.best_effort_fallbacks += 1;
407
+ console.warn(
408
+ "⚠️ git log failed in detached mode; continuing best-effort with empty commit list.",
409
+ {
410
+ message: e?.message || String(e),
411
+ }
412
+ );
413
+ output = "";
414
+ }
415
+
416
+ console.log("🪝 Detached HEAD commits since:done", {
417
+ ms: Date.now() - start,
418
+ });
419
+ } else {
420
+ const t = Date.now();
421
+ try {
422
+ output = execSync(
423
+ `git log ${defaultBranch} --since='${since}' --pretty="format:%H|%cI|%an"`,
424
+ { encoding: "utf8" }
425
+ );
426
+ recordGitStat("log_since", { ok: true, ms: Date.now() - t });
427
+ } catch (e) {
428
+ recordGitStat("log_since", {
429
+ ok: false,
430
+ ms: Date.now() - t,
431
+ errorCode: null,
432
+ });
433
+ _cfGitStats.best_effort_fallbacks += 1;
434
+ console.warn(
435
+ "⚠️ git log failed; continuing best-effort with empty commit list.",
436
+ {
437
+ message: e?.message || String(e),
438
+ }
439
+ );
440
+ output = "";
441
+ }
442
+ }
443
+
444
+ const commits = output
445
+ .split("\n")
446
+ .map((line) => line.trim())
447
+ .filter(Boolean)
448
+ .map((line) => {
449
+ const [sha, date, author] = line.split("|");
450
+ return { sha: sha?.trim(), date: date?.trim(), author: author?.trim() };
451
+ })
452
+ .filter((c) => c.sha && c.date);
453
+
454
+ // git log returns newest-first; backfill should run oldest -> newest
455
+ return commits.reverse();
456
+ } catch (err) {
457
+ console.warn("⚠️ Failed to get commits since days:", err?.message || err);
458
+ return [];
459
+ }
460
+ }
461
+
462
+ function getCurrentCommitSHA() {
463
+ try {
464
+ return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
465
+ } catch {
466
+ return null;
467
+ }
468
+ }
469
+
470
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
471
+
472
+ function getAllFiles(
473
+ dir,
474
+ exts = SCANNABLE_EXTENSIONS,
475
+ files = [],
476
+ excludePatterns = []
477
+ ) {
478
+ const baseDir = process.cwd();
479
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
480
+
481
+ for (const entry of entries) {
482
+ const fullPath = path.join(dir, entry.name);
483
+ const relativePath = path.relative(baseDir, fullPath).replace(/\\/g, "/");
484
+
485
+ if (excludePatterns.length) {
486
+ const isExcluded = micromatch.isMatch(relativePath, excludePatterns, {
487
+ nocase: true,
488
+ });
489
+ // console.log(`Checking ${relativePath}, excluded: ${isExcluded}`);
490
+ if (isExcluded) {
491
+ continue;
492
+ }
493
+ }
494
+
495
+ if (entry.isDirectory()) {
496
+ getAllFiles(fullPath, exts, files, excludePatterns);
497
+ } else if (exts.includes(path.extname(entry.name))) {
498
+ files.push(fullPath);
499
+ }
500
+ }
501
+
502
+ return files;
503
+ }
504
+
505
+ // --- CI direct baseline sync helper ---
506
+ async function postBaselineDirect({
507
+ baseline,
508
+ workspaceId,
509
+ projectId,
510
+ appUrl,
511
+ ingestToken,
512
+ targetCommit,
513
+ overwrite = false,
514
+ }) {
515
+ if (!projectId) {
516
+ throw new Error("CF_PROJECT_ID is required in CI mode");
517
+ }
518
+ if (!appUrl) {
519
+ throw new Error("CF_APP_URL or NEXT_PUBLIC_APP_URL is required in CI mode");
520
+ }
521
+ const fetchFn = await getFetch();
522
+ const payload = {
523
+ baseline: {
524
+ ...baseline,
525
+ workspace_id: workspaceId || null,
526
+ project_id: projectId,
527
+ project_root: baseline.projectRoot ?? baseline.project_root ?? null,
528
+ commit_sha: targetCommit || null,
529
+ ...(overwrite ? { overwrite: true } : {}),
530
+ },
531
+ };
532
+ console.log("🪵 postBaselineDirect using ingestToken:", ingestToken);
533
+ console.log(
534
+ "🪵 posting to:",
535
+ `${appUrl.replace(/\/$/, "")}/api/baselines${
536
+ overwrite ? "?overwrite=true" : ""
537
+ }`
538
+ );
539
+ const res = await fetchFn(
540
+ `${appUrl.replace(/\/$/, "")}/api/baselines${
541
+ overwrite ? "?overwrite=true" : ""
542
+ }`,
543
+ {
544
+ method: "POST",
545
+ headers: {
546
+ "content-type": "application/json",
547
+ ...(ingestToken ? { authorization: `Bearer ${ingestToken}` } : {}),
548
+ ...(overwrite ? { "x-cf-overwrite": "true" } : {}),
549
+ },
550
+ body: JSON.stringify(payload),
551
+ }
552
+ );
553
+ if (!res.ok) {
554
+ const text = await res.text().catch(() => "");
555
+ throw new Error(
556
+ `Baseline sync failed: ${res.status} ${res.statusText} ${text}`
557
+ );
558
+ }
559
+ }
560
+
561
+ // --- CI direct snapshot sync helper ---
562
+ async function postSnapshotDirect({
563
+ snapshot,
564
+ workspaceId,
565
+ projectId,
566
+ appUrl,
567
+ ingestToken,
568
+ targetCommit,
569
+ overwrite = false,
570
+ progressInfo = null,
571
+ }) {
572
+ if (!projectId) {
573
+ throw new Error("CF_PROJECT_ID is required in CI mode");
574
+ }
575
+ if (!appUrl) {
576
+ throw new Error("CF_APP_URL or NEXT_PUBLIC_APP_URL is required in CI mode");
577
+ }
578
+ const fetchFn = await getFetch();
579
+ const payload = {
580
+ snapshot: {
581
+ ...snapshot,
582
+ workspace_id: workspaceId || null,
583
+ project_id: projectId,
584
+ project_root: snapshot.projectRoot ?? snapshot.project_root ?? null,
585
+ commit_sha: targetCommit || null,
586
+ ...(overwrite ? { overwrite: true } : {}),
587
+ },
588
+ };
589
+ const base = appUrl.replace(/\/$/, "");
590
+ const url = `${base}/api/snapshots${overwrite ? "?overwrite=true" : ""}`;
591
+ console.log("🪵 postSnapshotDirect using ingestToken:", ingestToken);
592
+
593
+ let progressStr = "";
594
+ if (progressInfo) {
595
+ const { index, total, elapsedMin, elapsedSec } = progressInfo;
596
+ progressStr = ` (${index}/${total} - ${elapsedMin}m${elapsedSec}s elapsed)`;
597
+ }
598
+ console.log(`🪵 posting to: ${url}${progressStr}`);
599
+ const res = await fetchFn(url, {
600
+ method: "POST",
601
+ headers: {
602
+ "content-type": "application/json",
603
+ ...(ingestToken ? { authorization: `Bearer ${ingestToken}` } : {}),
604
+ ...(overwrite ? { "x-cf-overwrite": "true" } : {}),
605
+ },
606
+ body: JSON.stringify(payload),
607
+ });
608
+ if (!res.ok) {
609
+ const text = await res.text().catch(() => "");
610
+ throw new Error(
611
+ `Snapshot sync failed: ${res.status} ${res.statusText} ${text}`
612
+ );
613
+ }
614
+ }
615
+
616
+ function formatElapsed(startMs) {
617
+ const elapsedMs = Date.now() - startMs;
618
+ const elapsedMin = Math.floor(elapsedMs / 60000);
619
+ const elapsedSec = Math.floor((elapsedMs % 60000) / 1000);
620
+ return { elapsedMs, elapsedMin, elapsedSec };
621
+ }
622
+
623
+ // Helper: get commits between two SHAs (inclusive, oldest → newest)
624
+ function getCommitsInRange(fromSha, toSha) {
625
+ try {
626
+ // Get all commits between fromSha and toSha (inclusive)
627
+ const output = execSync(
628
+ `git log ${fromSha}^..${toSha} --pretty="format:%H|%cI|%an" --reverse`,
629
+ { encoding: "utf8" }
630
+ );
631
+ return output
632
+ .split("\n")
633
+ .map((line) => line.trim())
634
+ .filter(Boolean)
635
+ .map((line) => {
636
+ const [sha, date, author] = line.split("|");
637
+ return { sha: sha?.trim(), date: date?.trim(), author: author?.trim() };
638
+ })
639
+ .filter((c) => c.sha && c.date);
640
+ } catch (err) {
641
+ console.warn("⚠️ Failed to get commits in range:", err?.message || err);
642
+ return [];
643
+ }
644
+ }
645
+
646
+ // Helper: get commits between two dates (inclusive, oldest → newest)
647
+ function getCommitsInDateRange(fromDate, toDate) {
648
+ try {
649
+ // Determine the default branch name dynamically
650
+ let defaultBranch = "main";
651
+ try {
652
+ const remoteShow = execSync("git remote show origin", {
653
+ encoding: "utf8",
654
+ });
655
+ const match = remoteShow.match(/HEAD branch: (.+)/);
656
+ if (match && match[1]) defaultBranch = match[1].trim();
657
+ } catch {
658
+ defaultBranch = "main";
659
+ }
660
+
661
+ console.log(`🔍 Fetching latest commits from origin/${defaultBranch}...`);
662
+
663
+ // Fetch latest commits to ensure we have the full history
664
+ try {
665
+ execSync(`git fetch origin ${defaultBranch}`, { stdio: 'inherit' });
666
+ } catch (e) {
667
+ console.warn("⚠️ Failed to fetch latest commits:", e.message);
668
+ }
669
+
670
+ // Get commits using ISO date format for precise matching
671
+ // Use YYYY-MM-DD format which git handles reliably
672
+ console.log(`🔍 Searching for commits between ${fromDate} and ${toDate} on ${defaultBranch}`);
673
+
674
+ const output = execSync(
675
+ `git log origin/${defaultBranch} --since="${fromDate} 00:00:00" --until="${toDate} 23:59:59" --pretty="format:%H|%cI|%an" --reverse`,
676
+ { encoding: "utf8" }
677
+ );
678
+
679
+ const commits = output
680
+ .split("\n")
681
+ .map((line) => line.trim())
682
+ .filter(Boolean)
683
+ .map((line) => {
684
+ const [sha, date, author] = line.split("|");
685
+ return { sha: sha?.trim(), date: date?.trim(), author: author?.trim() };
686
+ })
687
+ .filter((c) => c.sha && c.date);
688
+
689
+ console.log(`📊 Found ${commits.length} commits in date range`);
690
+
691
+ return commits;
692
+ } catch (err) {
693
+ console.warn("⚠️ Failed to get commits in date range:", err?.message || err);
694
+ return [];
695
+ }
696
+ }
697
+
698
+ // DRY backfill for days or range
699
+ async function runBackfill({
700
+ days,
701
+ syncBackfill,
702
+ slugBackfill,
703
+ rangeFromSha,
704
+ rangeToSha,
705
+ rangeFromDate,
706
+ rangeToDate,
707
+ }) {
708
+ let commits = [];
709
+ let rangeDescription = "";
710
+
711
+ if (days) {
712
+ commits = getCommitsSinceDays(days);
713
+ rangeDescription = `in the last ${days} days`;
714
+ } else if (rangeFromSha && rangeToSha) {
715
+ commits = getCommitsInRange(rangeFromSha, rangeToSha);
716
+ rangeDescription = `in range ${rangeFromSha}..${rangeToSha}`;
717
+ } else if (rangeFromDate && rangeToDate) {
718
+ commits = getCommitsInDateRange(rangeFromDate, rangeToDate);
719
+ rangeDescription = `between ${rangeFromDate} and ${rangeToDate}`;
720
+ }
721
+
722
+ if (!commits.length) {
723
+ console.log(`ℹ️ No commits found ${rangeDescription}.`);
724
+ printGitOpsSummary();
725
+ printSnapshotSyncSummary();
726
+ process.exit(0);
727
+ }
728
+
729
+ console.log(`🧾 Found ${commits.length} commits ${rangeDescription}.`);
730
+
731
+ let backfillIndex = 0;
732
+ const backfillTotal = commits.length;
733
+ const backfillStart = Date.now();
734
+
735
+ for (const c of commits) {
736
+ backfillIndex += 1;
737
+ const { elapsedMin, elapsedSec } = formatElapsed(backfillStart);
738
+ const day = c.date.slice(0, 10);
739
+
740
+ console.log(
741
+ `🕒 Backfill scan ${backfillIndex}/${backfillTotal} ` +
742
+ `(${elapsedMin}m ${elapsedSec}s elapsed) ` +
743
+ `for ${day} @ ${c.sha}`
744
+ );
745
+
746
+ try {
747
+ execSync(`git checkout ${c.sha}`, { stdio: "inherit" });
748
+ } catch (err) {
749
+ console.warn(`⚠️ Failed to checkout commit ${c.sha}:`, err.message);
750
+ continue;
751
+ }
752
+
753
+ await runScan({
754
+ sync: syncBackfill,
755
+ openWeb: false,
756
+ slug: slugBackfill ?? "historical",
757
+ snapshot: true,
758
+ makeBaseline: false,
759
+ effectiveDate: c.date,
760
+ forceOverwrite: true,
761
+ commitOverride: c.sha,
762
+ isBackfillChild: true,
763
+ backfillProgress: {
764
+ index: backfillIndex,
765
+ total: backfillTotal,
766
+ elapsedMin,
767
+ elapsedSec,
768
+ },
769
+ });
770
+ }
771
+
772
+ let completionMessage = "";
773
+ if (days) {
774
+ completionMessage = `--last-${days}-days-all`;
775
+ } else if (rangeFromSha && rangeToSha) {
776
+ completionMessage = `--range-sha ${rangeFromSha},${rangeToSha}`;
777
+ } else if (rangeFromDate && rangeToDate) {
778
+ completionMessage = `--range-date ${rangeFromDate},${rangeToDate}`;
779
+ }
780
+
781
+ console.log(`✅ Completed ${completionMessage} backfill.`);
782
+ printGitOpsSummary();
783
+ printSnapshotSyncSummary();
784
+ process.exit(0);
785
+ }
786
+
787
+ export async function runScan({
788
+ sync = false,
789
+ openWeb = true,
790
+ slug,
791
+ snapshot,
792
+ makeBaseline = false,
793
+ baseline,
794
+ effectiveDate, // ISO string – used for historical backfill
795
+ forceOverwrite = false,
796
+ commitOverride,
797
+ isBackfillChild = false,
798
+ rangeFromSha,
799
+ rangeToSha,
800
+ rangeFromDate,
801
+ rangeToDate,
802
+ backfillProgress = null,
803
+ // CI shorthand: snapshot HEAD (GITHUB_SHA) + sync, backfills blocked
804
+ lastCommit = false,
805
+ } = {}) {
806
+ const ciMode =
807
+ process.env.CF_CI_MODE === "true" || process.env.GITHUB_ACTIONS === "true";
808
+ const sessionToken = process.env.CF_SESSION_TOKEN || "";
809
+ const ingestToken = process.env.CF_INGEST_TOKEN || "";
810
+ let ciProjectId = process.env.CF_PROJECT_ID || "";
811
+ let ciWorkspaceId = process.env.CF_WORKSPACE_ID || "";
812
+
813
+ // Import baseUrl helpers
814
+ const { resolveBaseUrl, getStoredEnvironment } = await import("../config/baseUrl.js");
815
+ const appUrlEnv = resolveBaseUrl();
816
+
817
+ // --- DRY backfill dispatch (blocked in --last-commit CI mode) ---
818
+ if (!isBackfillChild && !lastCommit && process.argv.includes("--last-90-days-all")) {
819
+ const syncBackfill = process.argv.includes("--sync");
820
+ let slugBackfill;
821
+ for (const arg of process.argv) {
822
+ if (arg.startsWith("--slug=")) slugBackfill = arg.split("=")[1];
823
+ }
824
+ await runBackfill({ days: 90, syncBackfill, slugBackfill });
825
+ }
826
+
827
+ if (!isBackfillChild && !lastCommit && process.argv.includes("--last-180-days-all")) {
828
+ const syncBackfill = process.argv.includes("--sync");
829
+ let slugBackfill;
830
+ for (const arg of process.argv) {
831
+ if (arg.startsWith("--slug=")) slugBackfill = arg.split("=")[1];
832
+ }
833
+ await runBackfill({ days: 180, syncBackfill, slugBackfill });
834
+ }
835
+
836
+ // --- Backfill over a commit SHA range ---
837
+ if (
838
+ !isBackfillChild &&
839
+ !lastCommit &&
840
+ typeof rangeFromSha === "string" &&
841
+ typeof rangeToSha === "string"
842
+ ) {
843
+ const syncBackfill = process.argv.includes("--sync");
844
+ let slugBackfill;
845
+ for (const arg of process.argv) {
846
+ if (arg.startsWith("--slug=")) slugBackfill = arg.split("=")[1];
847
+ }
848
+ await runBackfill({
849
+ syncBackfill,
850
+ slugBackfill,
851
+ rangeFromSha,
852
+ rangeToSha,
853
+ });
854
+ }
855
+
856
+ // --- Backfill over a date range ---
857
+ if (
858
+ !isBackfillChild &&
859
+ !lastCommit &&
860
+ typeof rangeFromDate === "string" &&
861
+ typeof rangeToDate === "string"
862
+ ) {
863
+ const syncBackfill = process.argv.includes("--sync");
864
+ let slugBackfill;
865
+ for (const arg of process.argv) {
866
+ if (arg.startsWith("--slug=")) slugBackfill = arg.split("=")[1];
867
+ }
868
+ await runBackfill({
869
+ syncBackfill,
870
+ slugBackfill,
871
+ rangeFromDate,
872
+ rangeToDate,
873
+ });
874
+ }
875
+
876
+ // ---------------------------
877
+ // Determine target commit properly
878
+ // ---------------------------
879
+ // ----------------------------------------
880
+ // Commit argument parser: supports BOTH:
881
+ // --commit=SHA and --commit SHA
882
+ // ----------------------------------------
883
+ let argCommit = null;
884
+
885
+ for (let i = 0; i < process.argv.length; i++) {
886
+ const arg = process.argv[i];
887
+
888
+ // Format 1: --commit=SHA
889
+ if (arg.startsWith("--commit=")) {
890
+ argCommit = arg.split("=")[1];
891
+ break;
892
+ }
893
+
894
+ // Format 2: --commit SHA
895
+ if (arg === "--commit" && process.argv[i + 1]) {
896
+ argCommit = process.argv[i + 1];
897
+ break;
898
+ }
899
+ }
900
+
901
+ const envCommit =
902
+ process.env.CF_COMMIT_SHA ||
903
+ process.env.INPUT_COMMIT_SHA ||
904
+ process.env.GITHUB_SHA;
905
+
906
+ let targetCommit = null;
907
+ const hasCommitArg = !!argCommit;
908
+
909
+ // --- commitOverride logic for backfill ---
910
+ if (commitOverride) {
911
+ targetCommit = commitOverride;
912
+ console.log("🎯 Target commit (override):", targetCommit);
913
+ } else {
914
+ if (hasCommitArg) {
915
+ // Respect user-supplied commit always
916
+ targetCommit = argCommit;
917
+ try {
918
+ execSync(`git cat-file -e ${targetCommit}`, { stdio: "ignore" });
919
+ console.log(`✔ Commit ${targetCommit} exists locally`);
920
+ } catch {
921
+ console.log(
922
+ `🔄 Commit ${targetCommit} not found locally — attempting targeted fetch...`
923
+ );
924
+ try {
925
+ execSync(`git fetch origin ${targetCommit}`, { stdio: "inherit" });
926
+ console.log(`✔ Commit ${targetCommit} fetched successfully`);
927
+ } catch (err) {
928
+ console.error(
929
+ `❌ Failed to fetch commit ${targetCommit}:`,
930
+ err.message
931
+ );
932
+ process.exit(1);
933
+ }
934
+ }
935
+ } else {
936
+ // No CLI arg → fall back to env or HEAD
937
+ targetCommit = envCommit || getCurrentCommitSHA();
938
+ if (!targetCommit) {
939
+ console.error("❌ No commit could be determined.");
940
+ process.exit(1);
941
+ }
942
+
943
+ try {
944
+ execSync(`git cat-file -e ${targetCommit}`, { stdio: "ignore" });
945
+ console.log(`✔ Commit ${targetCommit} exists locally`);
946
+ } catch {
947
+ console.log(
948
+ `🔄 Commit ${targetCommit} not found locally — attempting targeted fetch...`
949
+ );
950
+ try {
951
+ execSync(`git fetch origin ${targetCommit}`, { stdio: "inherit" });
952
+ console.log(`✔ Commit ${targetCommit} fetched successfully`);
953
+ } catch (err) {
954
+ console.error(
955
+ `❌ Failed to fetch commit ${targetCommit}:`,
956
+ err.message
957
+ );
958
+ process.exit(1);
959
+ }
960
+ }
961
+ }
962
+ }
963
+ // Do NOT override targetCommit later based on recent commits.
964
+ console.log("🎯 Target commit for scan:", targetCommit);
965
+ // --- Commit checkout logic (safe: stash before checkout) ---
966
+ let originalRef = null;
967
+ let hadLocalChanges = false;
968
+ let switchedToCommit = false;
969
+
970
+ if (hasCommitArg) {
971
+ try {
972
+ // Detect uncommitted changes
973
+ const status = execSync("git status --porcelain", { encoding: "utf8" });
974
+ if (status.trim().length > 0) {
975
+ hadLocalChanges = true;
976
+ console.log("💾 Stashing local changes before checkout...");
977
+ execSync(`git stash push -u -m "cf-scan-temp-stash"`, {
978
+ stdio: "inherit",
979
+ });
980
+ }
981
+ } catch (e) {
982
+ console.warn("⚠️ Could not determine git status:", e.message);
983
+ }
984
+
985
+ try {
986
+ originalRef = execSync("git rev-parse --abbrev-ref HEAD", {
987
+ encoding: "utf8",
988
+ }).trim();
989
+
990
+ console.log(`🕒 Checking out commit ${targetCommit} for scan...`);
991
+ execSync(`git checkout ${targetCommit}`, { stdio: "inherit" });
992
+ switchedToCommit = true;
993
+ } catch (err) {
994
+ console.warn("⚠️ Failed to checkout target commit:", err.message);
995
+ }
996
+ }
997
+
998
+ // Derive project/workspace IDs from session token if needed in CI mode
999
+ if (ciMode && !ciProjectId && sessionToken) {
1000
+ try {
1001
+ const decoded = jwt.decode(sessionToken);
1002
+ if (decoded && typeof decoded === "object") {
1003
+ if (decoded.project_id) ciProjectId = decoded.project_id;
1004
+ if (decoded.workspace_id) ciWorkspaceId = decoded.workspace_id;
1005
+ console.log(
1006
+ "🪵 Derived project/workspace IDs from session token:",
1007
+ "project_id =",
1008
+ ciProjectId,
1009
+ "workspace_id =",
1010
+ ciWorkspaceId
1011
+ );
1012
+ }
1013
+ } catch (e) {
1014
+ console.warn(
1015
+ "⚠️ Failed to decode session token for project/workspace IDs:",
1016
+ e?.message || e
1017
+ );
1018
+ }
1019
+ }
1020
+
1021
+ const cwd = process.cwd();
1022
+ const cliVersion = getCliVersion();
1023
+
1024
+ // Read environment directly from config file
1025
+ const configPath = path.join(process.env.HOME || process.env.USERPROFILE, ".cf", "config.json");
1026
+ let currentEnv = "dev";
1027
+ if (fs.existsSync(configPath)) {
1028
+ try {
1029
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
1030
+ currentEnv = config.environment || "dev";
1031
+ } catch (e) {
1032
+ // Silently fall back to dev
1033
+ }
1034
+ }
1035
+
1036
+ const envDisplay = currentEnv === 'prod' ? '🌐 Production' : '🏠 Development';
1037
+
1038
+ console.log(`ControlFront CLI v${cliVersion}`);
1039
+ console.log(`Environment: ${envDisplay} - ${appUrlEnv}`);
1040
+
1041
+ // Print engine info
1042
+ if (ciMode) {
1043
+ // In CI mode, print bundle engine version (if available)
1044
+ let bundleEngineVersion = null;
1045
+ if (process.env.CF_BUNDLE_ENGINE_VERSION) {
1046
+ bundleEngineVersion = process.env.CF_BUNDLE_ENGINE_VERSION;
1047
+ }
1048
+ console.log(
1049
+ `Engine: server bundle v${bundleEngineVersion || "unspecified"}`
1050
+ );
1051
+ } else {
1052
+ // Local mode
1053
+ console.log(`Engine: local (core rules)`);
1054
+ }
1055
+
1056
+ // Default exclude patterns (since no YAML)
1057
+ const excludePatterns = [
1058
+ // build + vendor dirs
1059
+ "**/node_modules/**",
1060
+ "**/dist/**",
1061
+ "**/build/**",
1062
+ "**/.next/**",
1063
+ "**/.turbo/**",
1064
+ "**/coverage/**",
1065
+ "**/.git/**",
1066
+ "**/public/**",
1067
+ "**/.cache/**",
1068
+ "**/out/**",
1069
+
1070
+ // tests + stories
1071
+ "**/*.test.*",
1072
+ "**/*.spec.*",
1073
+ "**/*.stories.*",
1074
+ "**/*.d.ts",
1075
+ "**/__mocks__/**",
1076
+ "**/__tests__/**",
1077
+
1078
+ // CF artifacts
1079
+ "scan-report.json",
1080
+ "insights.json",
1081
+ "cf-violations.json",
1082
+ ".cf/**",
1083
+
1084
+ // logs + env
1085
+ "*.log",
1086
+ ".env",
1087
+ ".env.*",
1088
+
1089
+ // lockfiles
1090
+ "package-lock.json",
1091
+ "yarn.lock",
1092
+ "pnpm-lock.yaml",
1093
+
1094
+ // big config JSONs (skip, not relevant to UI/CD)
1095
+ "turbo.json",
1096
+ "tsconfig*.json",
1097
+ "package.json",
1098
+
1099
+ // misc config not relevant
1100
+ "vite.config.*",
1101
+ "next.config.*",
1102
+ "tailwind.config.*",
1103
+ "babel.config.*",
1104
+ "postcss.config.*",
1105
+ ".eslintrc.*",
1106
+ ".prettierrc.*",
1107
+ ".stylelintrc.*",
1108
+ ".npmrc",
1109
+ ".nvmrc",
1110
+ ".DS_Store",
1111
+ ];
1112
+ console.log("📂 Excluding patterns:", excludePatterns);
1113
+ const files = getAllFiles(
1114
+ cwd,
1115
+ SCANNABLE_EXTENSIONS,
1116
+ [],
1117
+ excludePatterns
1118
+ );
1119
+ console.log(`📄 Scanned ${files.length} files`);
1120
+ // Detect CSS imports inside JS/TS files and include them
1121
+ const cssImports = [];
1122
+ for (const f of files) {
1123
+ if (/\.(t|j)sx?$/.test(f)) {
1124
+ const content = fs.readFileSync(f, "utf8");
1125
+ cssImports.push(...findCssImports(f, content));
1126
+ }
1127
+ }
1128
+
1129
+ // Deduplicate
1130
+ const allFiles = Array.from(new Set([...files, ...cssImports]));
1131
+ console.log(
1132
+ `📄 Final file list includes ${allFiles.length} files (with CSS imports)`
1133
+ );
1134
+
1135
+ if (baseline) {
1136
+ console.error(
1137
+ "❌ Legacy baseline scans are no longer supported. Use `cf scan --snapshot --make-baseline` instead."
1138
+ );
1139
+ process.exit(1);
1140
+ }
1141
+
1142
+ // --- Standalone snapshot mode (Option A) ---
1143
+ if (snapshot) {
1144
+ const { runSnapshot } = await import("./snapshot.js");
1145
+
1146
+ // Run snapshot analysis on the same file list used for scans
1147
+ const snapshotResult = await runSnapshot({ files: allFiles });
1148
+
1149
+ // Optional sync
1150
+ if (sync) {
1151
+ if (ciMode) {
1152
+ if (effectiveDate) {
1153
+ snapshotResult.commit_timestamp = effectiveDate;
1154
+ }
1155
+ try {
1156
+ await postSnapshotDirectWithRetry(
1157
+ {
1158
+ snapshot: snapshotResult,
1159
+ workspaceId: ciWorkspaceId || null,
1160
+ projectId: ciProjectId,
1161
+ appUrl: appUrlEnv,
1162
+ ingestToken: sessionToken || ingestToken,
1163
+ targetCommit,
1164
+ overwrite: forceOverwrite === true,
1165
+ },
1166
+ { attempts: 3, progressInfo: backfillProgress }
1167
+ );
1168
+ } catch (e) {
1169
+ const msg = e?.message || String(e);
1170
+ console.error("❌ Snapshot sync failed:", msg);
1171
+
1172
+ // Never fail the entire 90-day backfill because of a transient network/API failure.
1173
+ if (isBackfillChild) {
1174
+ console.warn(
1175
+ "⚠️ Continuing backfill despite snapshot sync failure (best-effort)."
1176
+ );
1177
+ return snapshotResult;
1178
+ }
1179
+
1180
+ process.exit(1);
1181
+ }
1182
+ } else {
1183
+ const configPath = path.join(
1184
+ process.env.HOME || process.env.USERPROFILE,
1185
+ ".cf",
1186
+ "config.json"
1187
+ );
1188
+ if (!fs.existsSync(configPath)) {
1189
+ console.log("🔐 You are not logged in. Run `cf login`.");
1190
+ process.exit(1);
1191
+ }
1192
+ const localToken = JSON.parse(
1193
+ fs.readFileSync(configPath, "utf8")
1194
+ ).token;
1195
+ let projectInfo = await resolveProjectForFolder();
1196
+ if (!projectInfo?.workspace?.id || !projectInfo?.project?.id) {
1197
+ console.error(
1198
+ "❌ This folder is not linked to a ControlFront project. Please run `cf init`."
1199
+ );
1200
+ process.exit(1);
1201
+ }
1202
+ try {
1203
+ if (effectiveDate) {
1204
+ snapshotResult.commit_timestamp = effectiveDate;
1205
+ }
1206
+ await postSnapshotDirectWithRetry(
1207
+ {
1208
+ snapshot: snapshotResult,
1209
+ workspaceId: projectInfo.workspace.id,
1210
+ projectId: projectInfo.project.id,
1211
+ appUrl: appUrlEnv,
1212
+ ingestToken: localToken,
1213
+ targetCommit,
1214
+ overwrite: forceOverwrite === true,
1215
+ },
1216
+ { attempts: 3, progressInfo: backfillProgress }
1217
+ );
1218
+
1219
+ // Success path for initial snapshot sync
1220
+ console.log(
1221
+ `✅ Snapshot synced successfully for commit ${targetCommit}.`
1222
+ );
1223
+ if (makeBaseline) {
1224
+ console.log("📌 Promoting this snapshot to active baseline…");
1225
+
1226
+ const promoteUrl = `${appUrlEnv.replace(/\/$/, "")}/api/baselines`;
1227
+
1228
+ const promotePayload = {
1229
+ project_id: ciProjectId || projectInfo?.project?.id,
1230
+ snapshot_commit: targetCommit,
1231
+ };
1232
+
1233
+ let fetchFn = globalThis.fetch;
1234
+ if (!fetchFn) {
1235
+ const mod = await import("node-fetch");
1236
+ fetchFn = mod.default || mod;
1237
+ }
1238
+
1239
+ const promoteRes = await fetchFn(promoteUrl, {
1240
+ method: "POST",
1241
+ headers: {
1242
+ "content-type": "application/json",
1243
+ authorization: `Bearer ${
1244
+ sessionToken || ingestToken || localToken
1245
+ }`,
1246
+ },
1247
+ body: JSON.stringify(promotePayload),
1248
+ });
1249
+
1250
+ if (!promoteRes.ok) {
1251
+ const text = await promoteRes.text().catch(() => "");
1252
+ console.error("❌ Failed to promote baseline:", text);
1253
+ process.exit(1);
1254
+ }
1255
+
1256
+ console.log("✅ Snapshot promoted to active baseline.");
1257
+ }
1258
+ } catch (e) {
1259
+ const msg = e?.message || "";
1260
+
1261
+ const isDuplicate =
1262
+ msg.includes("409") ||
1263
+ msg.toLowerCase().includes("duplicate") ||
1264
+ msg.toLowerCase().includes("already exists");
1265
+
1266
+ if (isDuplicate && !forceOverwrite) {
1267
+ // If forceOverwrite, skip prompt and immediately overwrite
1268
+ if (forceOverwrite === true) {
1269
+ console.log(
1270
+ chalk.blue("🔁 Overwriting existing snapshot (forced)…")
1271
+ );
1272
+ try {
1273
+ if (effectiveDate) {
1274
+ snapshotResult.commit_timestamp = effectiveDate;
1275
+ }
1276
+ await postSnapshotDirectWithRetry(
1277
+ {
1278
+ snapshot: snapshotResult,
1279
+ workspaceId: projectInfo.workspace.id,
1280
+ projectId: projectInfo.project.id,
1281
+ appUrl: appUrlEnv,
1282
+ ingestToken: localToken,
1283
+ targetCommit,
1284
+ overwrite: true,
1285
+ },
1286
+ { attempts: 3, progressInfo: backfillProgress }
1287
+ );
1288
+ console.log("✅ Snapshot overwritten successfully.");
1289
+ if (makeBaseline) {
1290
+ console.log("📌 Promoting this snapshot to active baseline…");
1291
+
1292
+ const promoteUrl = `${appUrlEnv.replace(
1293
+ /\/$/,
1294
+ ""
1295
+ )}/api/baselines`;
1296
+
1297
+ const promotePayload = {
1298
+ project_id: projectInfo.project.id,
1299
+ snapshot_commit: targetCommit,
1300
+ };
1301
+
1302
+ let fetchFn = globalThis.fetch;
1303
+ if (!fetchFn) {
1304
+ const mod = await import("node-fetch");
1305
+ fetchFn = mod.default || mod;
1306
+ }
1307
+
1308
+ const promoteRes = await fetchFn(promoteUrl, {
1309
+ method: "POST",
1310
+ headers: {
1311
+ "content-type": "application/json",
1312
+ authorization: `Bearer ${localToken}`,
1313
+ },
1314
+ body: JSON.stringify(promotePayload),
1315
+ });
1316
+
1317
+ if (!promoteRes.ok) {
1318
+ const text = await promoteRes.text().catch(() => "");
1319
+ console.error("❌ Failed to promote baseline:", text);
1320
+ process.exit(1);
1321
+ }
1322
+
1323
+ console.log("✅ Snapshot promoted to active baseline.");
1324
+ }
1325
+ } catch (e2) {
1326
+ console.error("❌ Overwrite failed:", e2?.message || e2);
1327
+ if (isBackfillChild) {
1328
+ console.warn(
1329
+ "⚠️ Continuing backfill despite snapshot overwrite failure (best-effort)."
1330
+ );
1331
+ return snapshotResult;
1332
+ }
1333
+ process.exit(1);
1334
+ }
1335
+ } else {
1336
+ // Interactive prompt (legacy behavior)
1337
+ if (!forceOverwrite) {
1338
+ console.log(
1339
+ chalk.yellow(
1340
+ `⚠️ Snapshot for commit ${targetCommit} already exists on server.`
1341
+ )
1342
+ );
1343
+
1344
+ const readline = await import("readline");
1345
+ const rl = readline.createInterface({
1346
+ input: process.stdin,
1347
+ output: process.stdout,
1348
+ });
1349
+
1350
+ const ask = (q) =>
1351
+ new Promise((resolve) =>
1352
+ rl.question(q, (ans) => resolve(ans.trim()))
1353
+ );
1354
+
1355
+ const ans = await ask(
1356
+ chalk.yellow("Overwrite existing snapshot? (y/N): ")
1357
+ );
1358
+ rl.close();
1359
+
1360
+ if (ans.toLowerCase() === "y") {
1361
+ console.log(chalk.blue("🔁 Overwriting existing snapshot…"));
1362
+
1363
+ try {
1364
+ if (effectiveDate) {
1365
+ snapshotResult.commit_timestamp = effectiveDate;
1366
+ }
1367
+ await postSnapshotDirectWithRetry(
1368
+ {
1369
+ snapshot: snapshotResult,
1370
+ workspaceId: projectInfo.workspace.id,
1371
+ projectId: projectInfo.project.id,
1372
+ appUrl: appUrlEnv,
1373
+ ingestToken: localToken,
1374
+ targetCommit,
1375
+ overwrite: true,
1376
+ },
1377
+ { attempts: 3, progressInfo: backfillProgress }
1378
+ );
1379
+ console.log("✅ Snapshot overwritten successfully.");
1380
+ if (makeBaseline) {
1381
+ console.log(
1382
+ "📌 Promoting this snapshot to active baseline…"
1383
+ );
1384
+
1385
+ const promoteUrl = `${appUrlEnv.replace(
1386
+ /\/$/,
1387
+ ""
1388
+ )}/api/baselines`;
1389
+
1390
+ const promotePayload = {
1391
+ project_id: projectInfo.project.id,
1392
+ snapshot_commit: targetCommit,
1393
+ };
1394
+
1395
+ let fetchFn = globalThis.fetch;
1396
+ if (!fetchFn) {
1397
+ const mod = await import("node-fetch");
1398
+ fetchFn = mod.default || mod;
1399
+ }
1400
+
1401
+ const promoteRes = await fetchFn(promoteUrl, {
1402
+ method: "POST",
1403
+ headers: {
1404
+ "content-type": "application/json",
1405
+ authorization: `Bearer ${localToken}`,
1406
+ },
1407
+ body: JSON.stringify(promotePayload),
1408
+ });
1409
+
1410
+ if (!promoteRes.ok) {
1411
+ const text = await promoteRes.text().catch(() => "");
1412
+ console.error("❌ Failed to promote baseline:", text);
1413
+ process.exit(1);
1414
+ }
1415
+
1416
+ console.log("✅ Snapshot promoted to active baseline.");
1417
+ }
1418
+ } catch (e2) {
1419
+ console.error("❌ Overwrite failed:", e2?.message || e2);
1420
+ if (isBackfillChild) {
1421
+ console.warn(
1422
+ "⚠️ Continuing backfill despite snapshot overwrite failure (best-effort)."
1423
+ );
1424
+ return snapshotResult;
1425
+ }
1426
+ process.exit(1);
1427
+ }
1428
+ } else {
1429
+ console.log("❌ Snapshot not overwritten. Exiting.");
1430
+ process.exit(1);
1431
+ }
1432
+ } else {
1433
+ // Should never reach here, but guarantees no prompt
1434
+ throw e;
1435
+ }
1436
+ }
1437
+ } else {
1438
+ console.error("❌ Snapshot sync failed:", msg);
1439
+ if (isBackfillChild) {
1440
+ console.warn(
1441
+ "⚠️ Continuing backfill despite snapshot sync failure (best-effort)."
1442
+ );
1443
+ return snapshotResult;
1444
+ }
1445
+ process.exit(1);
1446
+ }
1447
+ }
1448
+ }
1449
+
1450
+ // Restore git state just like baseline mode
1451
+ if (hasCommitArg && switchedToCommit) {
1452
+ try {
1453
+ console.log(
1454
+ "↩️ Restoring your previous branch with `git checkout -`…"
1455
+ );
1456
+ execSync("git checkout -", { stdio: "inherit" });
1457
+
1458
+ if (hadLocalChanges) {
1459
+ console.log("💾 Restoring stashed changes…");
1460
+ try {
1461
+ execSync("git stash pop", { stdio: "inherit" });
1462
+ } catch (e) {
1463
+ console.warn("⚠️ Failed to pop stash:", e.message);
1464
+ }
1465
+ }
1466
+ } catch (err) {
1467
+ console.warn("⚠️ Failed to restore previous branch:", err.message);
1468
+ }
1469
+ }
1470
+
1471
+ return snapshotResult;
1472
+ }
1473
+
1474
+ // --- CI direct sync path ---
1475
+ if (ciMode) {
1476
+ if (!ciProjectId) {
1477
+ console.error("❌ CF_PROJECT_ID is required in CI mode");
1478
+ process.exit(1);
1479
+ }
1480
+ if (!appUrlEnv) {
1481
+ console.error(
1482
+ "❌ CF_APP_URL or NEXT_PUBLIC_APP_URL is required in CI mode"
1483
+ );
1484
+ process.exit(1);
1485
+ }
1486
+
1487
+ if (!sessionToken && !ingestToken) {
1488
+ console.warn(
1489
+ "⚠️ No session or ingest token provided. Scan will not be posted to ControlFront."
1490
+ );
1491
+ } else {
1492
+ try {
1493
+ console.log(
1494
+ "\n📡 CI mode detected. Posting scan directly to ControlFront..."
1495
+ );
1496
+ if (effectiveDate) {
1497
+ console.log("📅 Setting commit timestamp for snapshot:", effectiveDate);
1498
+ }
1499
+ const payload = {
1500
+ scan: {
1501
+ ...report,
1502
+ // Normalize names expected by the API/DB
1503
+ workspace_id: ciWorkspaceId || null,
1504
+ project_id: ciProjectId,
1505
+ project_root: report.projectRoot ?? report.project_root ?? null,
1506
+ slug: report.slug || "ci",
1507
+ github: {
1508
+ run_id: process.env.GITHUB_RUN_ID || null,
1509
+ run_number: process.env.GITHUB_RUN_NUMBER || null,
1510
+ repository: process.env.GITHUB_REPOSITORY || null,
1511
+ ref: report.commit_branch || process.env.GITHUB_REF || null,
1512
+ sha: report.commit_sha || process.env.GITHUB_SHA || null,
1513
+ actor: report.commit_author || process.env.GITHUB_ACTOR || null,
1514
+ workflow: process.env.GITHUB_WORKFLOW || null,
1515
+ },
1516
+ },
1517
+ };
1518
+
1519
+ const url = `${appUrlEnv.replace(/\/$/, "")}/api/scans`;
1520
+ const fetchFn = await getFetch();
1521
+ const res = await fetchFn(url, {
1522
+ method: "POST",
1523
+ headers: {
1524
+ "content-type": "application/json",
1525
+ ...(ingestToken ? { authorization: `Bearer ${ingestToken}` } : {}),
1526
+ },
1527
+ body: JSON.stringify(payload),
1528
+ });
1529
+
1530
+ if (!res.ok) {
1531
+ const text = await res.text().catch(() => "");
1532
+ throw new Error(
1533
+ `Scan sync failed: ${res.status} ${res.statusText} ${text}`
1534
+ );
1535
+ }
1536
+
1537
+ console.log("✅ Scan posted successfully.");
1538
+ } catch (e) {
1539
+ console.error("❌ CI scan sync failed:", e.message);
1540
+ process.exit(1);
1541
+ }
1542
+ }
1543
+
1544
+ return;
1545
+ }
1546
+ }
1547
+ }