getseatbelt 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.
package/dist/cli.js ADDED
@@ -0,0 +1,4735 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/init.ts
7
+ import path13 from "path";
8
+
9
+ // src/utils/paths.ts
10
+ import path from "path";
11
+ var CONFIG_FILENAME = "seatbelt.config.yaml";
12
+ var SEATBELT_DIRNAME = ".seatbelt";
13
+ var STATE_FILENAME = "state.json";
14
+ var ARTIFACTS_DIRNAME = "artifacts";
15
+ var GITIGNORE_FILENAME = ".gitignore";
16
+ function resolvePaths(cwd = process.cwd()) {
17
+ const root = path.resolve(cwd);
18
+ const seatbeltDir = path.join(root, SEATBELT_DIRNAME);
19
+ return {
20
+ cwd: root,
21
+ configFile: path.join(root, CONFIG_FILENAME),
22
+ seatbeltDir,
23
+ stateFile: path.join(seatbeltDir, STATE_FILENAME),
24
+ artifactsDir: path.join(seatbeltDir, ARTIFACTS_DIRNAME),
25
+ gitignore: path.join(root, GITIGNORE_FILENAME)
26
+ };
27
+ }
28
+ function artifactDir(paths, runId) {
29
+ return path.join(paths.artifactsDir, runId);
30
+ }
31
+ function displayPath(from, to) {
32
+ const rel = path.relative(from, to);
33
+ return rel.split(path.sep).join("/");
34
+ }
35
+
36
+ // src/utils/fs.ts
37
+ import fs from "fs";
38
+ import fsp from "fs/promises";
39
+ import path2 from "path";
40
+ function exists(target) {
41
+ return fs.existsSync(target);
42
+ }
43
+ async function ensureDir(dir) {
44
+ await fsp.mkdir(dir, { recursive: true });
45
+ }
46
+ async function readText(target) {
47
+ return fsp.readFile(target, "utf8");
48
+ }
49
+ async function writeText(target, content) {
50
+ await ensureDir(path2.dirname(target));
51
+ await fsp.writeFile(target, content, "utf8");
52
+ }
53
+ async function readJson(target) {
54
+ return JSON.parse(await readText(target));
55
+ }
56
+ async function writeJson(target, data) {
57
+ await writeText(target, JSON.stringify(data, null, 2) + "\n");
58
+ }
59
+ async function readBase64(target) {
60
+ const buf = await fsp.readFile(target);
61
+ return buf.toString("base64");
62
+ }
63
+
64
+ // src/config/schema.ts
65
+ import { z } from "zod";
66
+ var viewportSchema = z.object({
67
+ width: z.number().int().positive().default(1280),
68
+ height: z.number().int().positive().default(800)
69
+ }).default({});
70
+ var appSchema = z.object({
71
+ name: z.string().default("My app"),
72
+ type: z.enum(["saas", "ecommerce", "web", "api"]).optional(),
73
+ framework: z.string().optional(),
74
+ // auto-detected later
75
+ packageManager: z.string().optional()
76
+ // auto-detected later
77
+ }).default({});
78
+ var serverSchema = z.object({
79
+ baseUrl: z.string().url().default("http://localhost:3000"),
80
+ port: z.number().int().positive().optional(),
81
+ devCommand: z.string().optional(),
82
+ startTimeoutMs: z.number().int().positive().default(6e4),
83
+ reuseExistingServer: z.boolean().default(true)
84
+ }).default({});
85
+ var servicesSchema = z.object({
86
+ startCommand: z.string().optional(),
87
+ waitFor: z.array(z.string()).default([]),
88
+ startTimeoutMs: z.number().int().positive().default(9e4),
89
+ reuseExisting: z.boolean().default(true),
90
+ stopOnFinish: z.boolean().default(false)
91
+ // NEVER tear services down by default
92
+ }).default({});
93
+ var checksSchema = z.object({
94
+ lint: z.string().optional(),
95
+ typecheck: z.string().optional(),
96
+ build: z.string().optional(),
97
+ test: z.string().optional()
98
+ }).default({});
99
+ var resetSchema = z.object({
100
+ command: z.string().optional(),
101
+ // Default false so a brand-new project (no reset yet) still runs its first flows.
102
+ // `seatbelt setup-reset` recommends setting this to true once a reset command exists.
103
+ required: z.boolean().default(false),
104
+ allowNonLocal: z.boolean().default(false),
105
+ // safety: refuse to reset a non-localhost baseUrl
106
+ timeoutMs: z.number().int().positive().default(12e4),
107
+ verify: z.object({
108
+ expectStdoutContains: z.string().optional(),
109
+ // After the reset command succeeds, hit this URL and require a 2xx. Absolute URL, or a
110
+ // path (e.g. /api/qa/health) resolved against server.baseUrl. Localhost-only by default.
111
+ httpOk: z.string().optional()
112
+ }).optional()
113
+ }).default({});
114
+ var browserSchema = z.object({
115
+ headed: z.boolean().default(true),
116
+ // DEFAULT: visible. This is the trust layer.
117
+ freshSession: z.boolean().default(true),
118
+ // DEFAULT: fresh context, cleared storage each run
119
+ slowMo: z.number().int().nonnegative().default(150),
120
+ viewport: viewportSchema,
121
+ video: z.boolean().default(true),
122
+ trace: z.boolean().default(false)
123
+ }).default({});
124
+ var aiSchema = z.object({
125
+ enabled: z.boolean().default(true),
126
+ // false behaves like --local-only
127
+ backend: z.string().url().default("https://api.getseatbelt.ai")
128
+ }).default({});
129
+ var flowsSchema = z.object({
130
+ dir: z.string().default("qa-flows"),
131
+ run: z.array(z.string()).default([]),
132
+ autoRefresh: z.enum(["prompt", "off", "always"]).default("prompt")
133
+ }).default({});
134
+ var reportSchema = z.object({
135
+ formats: z.array(z.enum(["html", "md"])).default(["html"]),
136
+ openOnFinish: z.boolean().default(true)
137
+ // auto-open report unless --no-open / CI / headless
138
+ }).default({});
139
+ var envSchema = z.object({
140
+ file: z.string().default(".env.local")
141
+ }).default({});
142
+ var configSchema = z.object({
143
+ version: z.number().default(1),
144
+ app: appSchema,
145
+ server: serverSchema,
146
+ services: servicesSchema,
147
+ checks: checksSchema,
148
+ reset: resetSchema,
149
+ browser: browserSchema,
150
+ ai: aiSchema,
151
+ flows: flowsSchema,
152
+ report: reportSchema,
153
+ env: envSchema
154
+ }).default({});
155
+
156
+ // src/config/defaults.ts
157
+ var STARTER_CONFIG_YAML = `version: 1
158
+ app:
159
+ name: My app
160
+
161
+ server:
162
+ baseUrl: http://localhost:3000
163
+ reuseExistingServer: true
164
+
165
+ browser:
166
+ headed: true
167
+ freshSession: true
168
+ video: true
169
+ trace: false
170
+
171
+ report:
172
+ formats:
173
+ - html
174
+ openOnFinish: true
175
+
176
+ flows:
177
+ dir: qa-flows
178
+ autoRefresh: prompt
179
+
180
+ # Clean test user (optional but recommended). Run \`seatbelt setup-reset\` to generate
181
+ # a prompt that adds a safe reset script to your app, then uncomment and set required: true.
182
+ # reset:
183
+ # command: npm run qa:reset-user
184
+ # required: true
185
+ # timeoutMs: 30000
186
+ # allowNonLocal: false
187
+ `;
188
+
189
+ // src/flows/load.ts
190
+ import path4 from "path";
191
+ import fs3 from "fs";
192
+ import YAML from "yaml";
193
+
194
+ // src/flows/schema.ts
195
+ import { z as z2 } from "zod";
196
+ var gotoStep = z2.object({ goto: z2.string() }).strict();
197
+ var clickStep = z2.object({ click: z2.string() }).strict();
198
+ var fillStep = z2.object({
199
+ fill: z2.object({
200
+ selector: z2.string(),
201
+ value: z2.string().optional(),
202
+ // Pull the value from an env var (e.g. SEATBELT_TEST_PASSWORD) so secrets never live
203
+ // in the flow file. Exactly one of `value` / `valueFromEnv` must be set.
204
+ valueFromEnv: z2.string().optional()
205
+ }).strict().refine((v) => v.value !== void 0 !== (v.valueFromEnv !== void 0), {
206
+ message: "fill needs exactly one of `value` or `valueFromEnv`"
207
+ })
208
+ }).strict();
209
+ var pressStep = z2.object({
210
+ // `press: Enter` or `press: { key: Enter, selector: "input[name=q]" }`
211
+ press: z2.union([
212
+ z2.string(),
213
+ z2.object({ key: z2.string(), selector: z2.string().optional() }).strict()
214
+ ])
215
+ }).strict();
216
+ var selectOptionStep = z2.object({
217
+ selectOption: z2.object({ selector: z2.string(), value: z2.string() }).strict()
218
+ }).strict();
219
+ var uploadStep = z2.object({
220
+ // `path` is resolved relative to the project root; the file must exist when the step runs.
221
+ upload: z2.object({ selector: z2.string(), path: z2.string() }).strict()
222
+ }).strict();
223
+ var waitForUrlStep = z2.object({ waitForUrlContains: z2.string() }).strict();
224
+ var waitForTextStep = z2.object({ waitForText: z2.string() }).strict();
225
+ var waitTimeoutStep = z2.object({ waitForTimeoutMs: z2.number().int().nonnegative() }).strict();
226
+ var expectUrlStep = z2.object({ expectUrlContains: z2.string() }).strict();
227
+ var expectTextStep = z2.object({ expectText: z2.string() }).strict();
228
+ var expectVisibleStep = z2.object({ expectVisible: z2.string() }).strict();
229
+ var expectNotVisibleStep = z2.object({ expectNotVisible: z2.string() }).strict();
230
+ var expectTitleStep = z2.object({ expectTitleContains: z2.string() }).strict();
231
+ var screenshotStep = z2.object({ screenshot: z2.string() }).strict();
232
+ var flowStepSchema = z2.union([
233
+ gotoStep,
234
+ clickStep,
235
+ fillStep,
236
+ pressStep,
237
+ selectOptionStep,
238
+ uploadStep,
239
+ waitForUrlStep,
240
+ waitForTextStep,
241
+ waitTimeoutStep,
242
+ expectUrlStep,
243
+ expectTextStep,
244
+ expectVisibleStep,
245
+ expectNotVisibleStep,
246
+ expectTitleStep,
247
+ screenshotStep
248
+ ]);
249
+ var flowDependenciesSchema = z2.object({
250
+ files: z2.array(z2.string()).default([]),
251
+ routes: z2.array(z2.string()).default([]),
252
+ services: z2.array(z2.string()).default([])
253
+ }).default({});
254
+ var flowRequiresSchema = z2.object({
255
+ env: z2.array(z2.string()).default([])
256
+ }).default({});
257
+ var flowDeclaredStatusSchema = z2.enum(["grey", "green", "yellow", "red", "blocked"]);
258
+ var flowSchema = z2.object({
259
+ id: z2.string().regex(/^[a-z0-9][a-z0-9-]*$/, "flow id must be lowercase letters, numbers and dashes"),
260
+ name: z2.string().min(1),
261
+ description: z2.string().default(""),
262
+ critical: z2.boolean().default(true),
263
+ /**
264
+ * Declared status. Optional. `yellow` means "template — needs setup" and is skipped in
265
+ * bulk `seatbelt run` / `seatbelt certify --run` (still shown in the map). Run it explicitly
266
+ * (`seatbelt run <id>`) or remove the line once it's configured to include it in bulk runs.
267
+ */
268
+ status: flowDeclaredStatusSchema.optional(),
269
+ tags: z2.array(z2.string()).default([]),
270
+ dependencies: flowDependenciesSchema,
271
+ requires: flowRequiresSchema,
272
+ /**
273
+ * Prerequisite flow ids that must pass first (e.g. a dashboard flow `needs: [login]`).
274
+ * Prereqs run before the dependent, once per run, and share its browser context so login
275
+ * carries into the dependent. A failed prereq blocks the dependent.
276
+ */
277
+ needs: z2.array(z2.string()).default([]),
278
+ startUrl: z2.string().optional(),
279
+ steps: z2.array(flowStepSchema).min(1, "a flow needs at least one step")
280
+ }).strict();
281
+ function isNeedsSetup(flow) {
282
+ return flow.status === "yellow";
283
+ }
284
+
285
+ // src/flows/templates.ts
286
+ import fs2 from "fs";
287
+ import path3 from "path";
288
+ var HOMEPAGE = `id: homepage-smoke
289
+ name: Homepage loads
290
+ description: Open the home page and confirm it loads without crashing.
291
+ critical: true
292
+ tags:
293
+ - smoke
294
+ dependencies:
295
+ routes:
296
+ - /
297
+ steps:
298
+ - goto: /
299
+ - expectTitleContains: "" # empty string = "any title" (just proves the page renders)
300
+ - screenshot: homepage
301
+ `;
302
+ var LOGIN = `id: login-smoke
303
+ name: User logs in
304
+ description: Log in as the QA test user from a fresh browser session.
305
+ critical: true
306
+ status: yellow # needs setup \u2014 skipped in bulk runs. Remove this line to enable.
307
+ tags:
308
+ - auth
309
+ - critical
310
+ dependencies:
311
+ routes:
312
+ - /login
313
+ - /dashboard
314
+ requires:
315
+ env:
316
+ - SEATBELT_TEST_EMAIL
317
+ - SEATBELT_TEST_PASSWORD
318
+ # TODO(setup): confirm the login route + selectors for YOUR app, then delete the "status: yellow"
319
+ # line above. Prefer stable selectors (type / placeholder / role) over brittle CSS.
320
+ # Example: Syllawise's Next.js app uses /signin with placeholder-based email/password inputs.
321
+ steps:
322
+ - goto: /login
323
+ - fill:
324
+ selector: "input[type=email]"
325
+ valueFromEnv: SEATBELT_TEST_EMAIL
326
+ - fill:
327
+ selector: "input[type=password]"
328
+ valueFromEnv: SEATBELT_TEST_PASSWORD
329
+ - click: "button[type=submit]"
330
+ - expectUrlContains: "/dashboard"
331
+ - screenshot: after-login
332
+ `;
333
+ var DASHBOARD = `id: dashboard-smoke
334
+ name: Dashboard loads after login
335
+ description: After signing in, the dashboard/home area loads and shows the signed-in workspace.
336
+ critical: true
337
+ status: yellow # needs setup \u2014 skipped in bulk runs. Remove this line to enable.
338
+ tags:
339
+ - dashboard
340
+ dependencies:
341
+ routes:
342
+ - /login
343
+ - /dashboard
344
+ requires:
345
+ env:
346
+ - SEATBELT_TEST_EMAIL
347
+ - SEATBELT_TEST_PASSWORD
348
+ # TODO(setup): each flow runs in a FRESH session, so this logs in first. Update the route and the
349
+ # expected text below to match YOUR authenticated landing page.
350
+ steps:
351
+ - goto: /login
352
+ - fill:
353
+ selector: "input[type=email]"
354
+ valueFromEnv: SEATBELT_TEST_EMAIL
355
+ - fill:
356
+ selector: "input[type=password]"
357
+ valueFromEnv: SEATBELT_TEST_PASSWORD
358
+ - click: "button[type=submit]"
359
+ - expectUrlContains: "/dashboard"
360
+ - expectVisible: "text=Dashboard" # TODO: change to real text on your dashboard
361
+ - screenshot: dashboard
362
+ `;
363
+ var SIGNUP = `id: signup-smoke
364
+ name: New user signs up
365
+ description: Template only \u2014 a new user creates an account. Edit before enabling.
366
+ critical: false
367
+ status: yellow # template only \u2014 skipped until you configure it.
368
+ tags:
369
+ - auth
370
+ - signup
371
+ dependencies:
372
+ routes:
373
+ - /signup
374
+ requires:
375
+ env:
376
+ - SEATBELT_TEST_EMAIL
377
+ - SEATBELT_TEST_PASSWORD
378
+ # TODO(setup): signup usually needs a UNIQUE email per run (plus-addressing) and may send a
379
+ # verification email. Configure a QA bypass or a disposable inbox first, and confirm selectors.
380
+ steps:
381
+ - goto: /signup
382
+ - fill:
383
+ selector: "input[type=email]"
384
+ valueFromEnv: SEATBELT_TEST_EMAIL
385
+ - fill:
386
+ selector: "input[type=password]"
387
+ valueFromEnv: SEATBELT_TEST_PASSWORD
388
+ - click: "button[type=submit]"
389
+ - screenshot: after-signup
390
+ `;
391
+ var UPLOAD = `id: upload-smoke
392
+ name: User uploads a file
393
+ description: Template only \u2014 upload a file and confirm it is accepted. Edit before enabling.
394
+ critical: false
395
+ status: yellow # template only \u2014 skipped until you configure it.
396
+ tags:
397
+ - upload
398
+ dependencies:
399
+ routes:
400
+ - /upload
401
+ requires:
402
+ env:
403
+ - SEATBELT_TEST_EMAIL
404
+ - SEATBELT_TEST_PASSWORD
405
+ # TODO(setup): add a fixture file at qa-fixtures/sample.pdf (or change the path), confirm the
406
+ # file-input selector, and set the success text your app shows after an upload.
407
+ steps:
408
+ - goto: /upload
409
+ - upload:
410
+ selector: "input[type=file]"
411
+ path: "qa-fixtures/sample.pdf"
412
+ - click: "button[type=submit]"
413
+ - expectText: "Upload complete" # TODO: change to your real success text
414
+ - screenshot: after-upload
415
+ `;
416
+ var CHECKOUT = `id: checkout-smoke
417
+ name: Revenue / access flow (checkout example)
418
+ description: Template only \u2014 an example revenue/access flow (checkout, billing, entitlements, or trial unlock). Edit before enabling.
419
+ critical: false
420
+ status: yellow # template only \u2014 this flow is app-specific. Keep disabled until wired.
421
+ tags:
422
+ - checkout
423
+ - billing
424
+ dependencies:
425
+ routes:
426
+ - /pricing
427
+ - /checkout
428
+ - /dashboard
429
+ requires:
430
+ env:
431
+ - SEATBELT_TEST_EMAIL
432
+ - SEATBELT_TEST_PASSWORD
433
+ # TODO(setup): this is an EXAMPLE of a critical flow \u2014 checkout, billing, entitlements, a trial
434
+ # unlock, or whatever revenue/access flow your app has. Not every app has one; delete this file
435
+ # if yours doesn't. Every app differs (Stripe or not, hosted vs embedded fields, local vs a
436
+ # preview/staging URL). Wire your real pricing/upgrade selectors and expected unlock state. If you
437
+ # use Stripe test mode, its test card is 4242 4242 4242 4242. Keep disabled until intentionally set.
438
+ steps:
439
+ - goto: /pricing
440
+ - click: "text=Upgrade" # TODO: your real upgrade button text/selector
441
+ - screenshot: checkout-page
442
+ `;
443
+ var STARTER_FLOW_TEMPLATES = [
444
+ { id: "homepage-smoke", yaml: HOMEPAGE },
445
+ { id: "login-smoke", yaml: LOGIN },
446
+ { id: "dashboard-smoke", yaml: DASHBOARD },
447
+ { id: "signup-smoke", yaml: SIGNUP },
448
+ { id: "upload-smoke", yaml: UPLOAD },
449
+ { id: "checkout-smoke", yaml: CHECKOUT }
450
+ ];
451
+ function hasAnyFlowFiles(dir) {
452
+ try {
453
+ return fs2.readdirSync(dir).some((f) => f.endsWith(".yaml") || f.endsWith(".yml"));
454
+ } catch {
455
+ return false;
456
+ }
457
+ }
458
+ async function ensureStarterFlows(flowsDir) {
459
+ await ensureDir(flowsDir);
460
+ if (hasAnyFlowFiles(flowsDir)) return [];
461
+ const created = [];
462
+ for (const tpl of STARTER_FLOW_TEMPLATES) {
463
+ await writeText(path3.join(flowsDir, `${tpl.id}.yaml`), tpl.yaml);
464
+ created.push(tpl.id);
465
+ }
466
+ return created;
467
+ }
468
+
469
+ // src/flows/load.ts
470
+ function listFlowFiles(dir) {
471
+ if (!exists(dir)) return [];
472
+ return fs3.readdirSync(dir).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")).sort();
473
+ }
474
+ async function ensureFlowsDir(flowsDir) {
475
+ return ensureStarterFlows(flowsDir);
476
+ }
477
+ async function loadFlows(cwd, flowsDirName, options = {}) {
478
+ const flowsDir = path4.join(cwd, flowsDirName);
479
+ const createdFlowIds = options.create === false ? [] : await ensureFlowsDir(flowsDir);
480
+ const createdStarter = createdFlowIds.length > 0;
481
+ const files2 = listFlowFiles(flowsDir);
482
+ const flows = [];
483
+ const errors = [];
484
+ const seen = /* @__PURE__ */ new Set();
485
+ for (const fileName of files2) {
486
+ const full = path4.join(flowsDir, fileName);
487
+ let parsed;
488
+ try {
489
+ parsed = YAML.parse(await readText(full));
490
+ } catch (e) {
491
+ const first = e instanceof Error ? e.message.split("\n")[0] ?? e.message : String(e);
492
+ errors.push({ file: fileName, message: `not valid YAML (${first})` });
493
+ continue;
494
+ }
495
+ const result = flowSchema.safeParse(parsed);
496
+ if (!result.success) {
497
+ const issue = result.error.issues[0];
498
+ const where = issue && issue.path.length ? issue.path.join(".") : "(flow)";
499
+ errors.push({ file: fileName, message: `${where}: ${issue?.message ?? "invalid flow"}` });
500
+ continue;
501
+ }
502
+ const flow = result.data;
503
+ if (seen.has(flow.id)) {
504
+ errors.push({ file: fileName, message: `duplicate flow id "${flow.id}"` });
505
+ continue;
506
+ }
507
+ seen.add(flow.id);
508
+ flows.push(flow);
509
+ }
510
+ flows.sort((a, b) => a.id.localeCompare(b.id));
511
+ return { flowsDir, flows, createdStarter, createdFlowIds, errors };
512
+ }
513
+
514
+ // src/state/state.ts
515
+ var HOMEPAGE_SMOKE_ID = "homepage-smoke";
516
+ function normalizeFlow(f) {
517
+ return {
518
+ id: f.id,
519
+ name: f.name,
520
+ status: f.status ?? "grey",
521
+ description: f.description ?? "",
522
+ tags: f.tags ?? [],
523
+ lastRunId: f.lastRunId ?? null,
524
+ lastRunAt: f.lastRunAt ?? null,
525
+ lastReportPath: f.lastReportPath ?? null,
526
+ lastFailureSummary: f.lastFailureSummary ?? null,
527
+ dependencies: {
528
+ files: f.dependencies?.files ?? [],
529
+ routes: f.dependencies?.routes ?? [],
530
+ services: f.dependencies?.services ?? []
531
+ },
532
+ requires: { env: f.requires?.env ?? [] },
533
+ needs: f.needs ?? [],
534
+ certification: {
535
+ lastCertifiedAt: f.certification?.lastCertifiedAt ?? null,
536
+ lastCertifiedRunId: f.certification?.lastCertifiedRunId ?? null
537
+ }
538
+ };
539
+ }
540
+ function seedState(projectName) {
541
+ return {
542
+ version: 1,
543
+ project: { name: projectName },
544
+ flows: [
545
+ normalizeFlow({
546
+ id: HOMEPAGE_SMOKE_ID,
547
+ name: "Homepage loads",
548
+ status: "grey",
549
+ description: "Open the configured app URL and confirm the page loads.",
550
+ tags: ["smoke"]
551
+ })
552
+ ]
553
+ };
554
+ }
555
+ async function loadState(stateFile) {
556
+ if (!exists(stateFile)) return null;
557
+ const raw = await readJson(stateFile);
558
+ return {
559
+ version: raw.version ?? 1,
560
+ project: raw.project ?? { name: "My app" },
561
+ flows: (raw.flows ?? []).map((f) => normalizeFlow(f))
562
+ };
563
+ }
564
+ async function saveState(stateFile, state) {
565
+ await writeJson(stateFile, state);
566
+ }
567
+ async function loadOrSeedState(stateFile, projectName) {
568
+ return await loadState(stateFile) ?? seedState(projectName);
569
+ }
570
+ function upsertFlow(state, patch) {
571
+ const index = state.flows.findIndex((f) => f.id === patch.id);
572
+ if (index === -1) {
573
+ return { ...state, flows: [...state.flows, normalizeFlow(patch)] };
574
+ }
575
+ const flows = state.flows.map((f, i) => i === index ? { ...f, ...patch } : f);
576
+ return { ...state, flows };
577
+ }
578
+ function syncFlowsFromYaml(state, inputs) {
579
+ let next = state;
580
+ for (const f of inputs) {
581
+ const existing = next.flows.find((x) => x.id === f.id);
582
+ if (existing) {
583
+ next = upsertFlow(next, {
584
+ id: f.id,
585
+ name: f.name,
586
+ description: f.description,
587
+ tags: f.tags,
588
+ dependencies: f.dependencies,
589
+ requires: f.requires,
590
+ needs: f.needs
591
+ });
592
+ } else {
593
+ next = upsertFlow(next, {
594
+ id: f.id,
595
+ name: f.name,
596
+ status: f.declaredStatus ?? "grey",
597
+ description: f.description,
598
+ tags: f.tags,
599
+ dependencies: f.dependencies,
600
+ requires: f.requires,
601
+ needs: f.needs
602
+ });
603
+ }
604
+ }
605
+ return next;
606
+ }
607
+ function recordCertification(state, id, runId, at) {
608
+ return upsertFlow(state, {
609
+ id,
610
+ name: state.flows.find((f) => f.id === id)?.name ?? id,
611
+ certification: { lastCertifiedAt: at, lastCertifiedRunId: runId }
612
+ });
613
+ }
614
+
615
+ // src/utils/log.ts
616
+ import pc from "picocolors";
617
+ var log = {
618
+ plain(message = "") {
619
+ console.log(message);
620
+ },
621
+ step(message) {
622
+ console.log(pc.cyan("\u203A ") + message);
623
+ },
624
+ success(message) {
625
+ console.log(pc.green("\u2714 ") + message);
626
+ },
627
+ warn(message) {
628
+ console.log(pc.yellow("! ") + message);
629
+ },
630
+ info(message) {
631
+ console.log(pc.blue("i ") + message);
632
+ },
633
+ dim(message) {
634
+ console.log(pc.dim(message));
635
+ },
636
+ blank() {
637
+ console.log("");
638
+ }
639
+ };
640
+
641
+ // src/utils/prompt.ts
642
+ import readline from "readline/promises";
643
+ import { stdin as input, stdout as output } from "process";
644
+ async function offer(question, opts = {}) {
645
+ const { autoYes = false, defaultYes = true } = opts;
646
+ if (autoYes) return true;
647
+ if (!process.stdin.isTTY) return false;
648
+ const hint = defaultYes ? "[Y/n]" : "[y/N]";
649
+ const rl = readline.createInterface({ input, output });
650
+ try {
651
+ const answer = (await rl.question(`${question} ${hint} `)).trim().toLowerCase();
652
+ if (answer === "") return defaultYes;
653
+ return /^y(es)?$/.test(answer);
654
+ } finally {
655
+ rl.close();
656
+ }
657
+ }
658
+
659
+ // src/utils/errors.ts
660
+ import pc2 from "picocolors";
661
+ var SeatbeltError = class extends Error {
662
+ hint;
663
+ constructor(message, hint) {
664
+ super(message);
665
+ this.name = "SeatbeltError";
666
+ this.hint = hint;
667
+ }
668
+ };
669
+ function isDebug() {
670
+ return process.env.SEATBELT_DEBUG === "1" || process.argv.includes("--debug");
671
+ }
672
+ function printError(err) {
673
+ console.error("");
674
+ if (err instanceof SeatbeltError) {
675
+ console.error(pc2.red("\u2716 ") + pc2.red(err.message));
676
+ if (err.hint) {
677
+ console.error("");
678
+ console.error(err.hint);
679
+ }
680
+ } else {
681
+ const msg = err instanceof Error ? err.message : String(err);
682
+ console.error(pc2.red("\u2716 Something went wrong.") + " " + msg);
683
+ console.error("");
684
+ console.error(pc2.dim("Run again with SEATBELT_DEBUG=1 for technical details."));
685
+ }
686
+ if (isDebug() && err instanceof Error && err.stack) {
687
+ console.error("");
688
+ console.error(pc2.dim(err.stack));
689
+ }
690
+ }
691
+
692
+ // src/commands/run.ts
693
+ import path11 from "path";
694
+ import open from "open";
695
+ import { chromium } from "playwright";
696
+
697
+ // src/config/load.ts
698
+ import YAML2 from "yaml";
699
+
700
+ // src/config/detect.ts
701
+ import path5 from "path";
702
+ import fs4 from "fs";
703
+
704
+ // src/utils/net.ts
705
+ import net from "net";
706
+ function sleep(ms) {
707
+ return new Promise((resolve) => setTimeout(resolve, ms));
708
+ }
709
+ function parseHostPort(url) {
710
+ const u = new URL(url);
711
+ const port = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80;
712
+ return { host: u.hostname, port };
713
+ }
714
+ function isLocalhostUrl(url) {
715
+ try {
716
+ const host = new URL(url).hostname;
717
+ return host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0" || host === "::1" || host.endsWith(".local");
718
+ } catch {
719
+ return false;
720
+ }
721
+ }
722
+ function probePort(host, port, timeoutMs = 400) {
723
+ return new Promise((resolve) => {
724
+ const socket = new net.Socket();
725
+ let settled = false;
726
+ const done = (ok) => {
727
+ if (settled) return;
728
+ settled = true;
729
+ socket.destroy();
730
+ resolve(ok);
731
+ };
732
+ socket.setTimeout(timeoutMs);
733
+ socket.once("connect", () => done(true));
734
+ socket.once("timeout", () => done(false));
735
+ socket.once("error", () => done(false));
736
+ socket.connect(port, host === "localhost" ? "127.0.0.1" : host);
737
+ });
738
+ }
739
+ async function isUrlReachable(url, timeoutMs = 1e3) {
740
+ try {
741
+ const { host, port } = parseHostPort(url);
742
+ return await probePort(host, port, timeoutMs);
743
+ } catch {
744
+ return false;
745
+ }
746
+ }
747
+ async function firstReachablePort(host, ports, timeoutMs = 400) {
748
+ const checks = await Promise.all(
749
+ ports.map(async (port) => ({ port, ok: await probePort(host, port, timeoutMs) }))
750
+ );
751
+ for (const port of ports) {
752
+ if (checks.find((c) => c.port === port)?.ok) return port;
753
+ }
754
+ return null;
755
+ }
756
+ async function waitForUrlReachable(url, timeoutMs, intervalMs = 500) {
757
+ const deadline = Date.now() + timeoutMs;
758
+ if (await isUrlReachable(url, 1e3)) return true;
759
+ while (Date.now() < deadline) {
760
+ await sleep(intervalMs);
761
+ if (await isUrlReachable(url, 1e3)) return true;
762
+ }
763
+ return false;
764
+ }
765
+
766
+ // src/config/detect.ts
767
+ var FRAMEWORK_DEFAULT_PORT = {
768
+ nextjs: 3e3,
769
+ nuxt: 3e3,
770
+ vite: 5173,
771
+ quasar: 9e3,
772
+ node: 3e3
773
+ };
774
+ var COMMON_PORTS = [3e3, 3001, 5173, 8080, 9e3];
775
+ async function readPackageJson(cwd) {
776
+ const file = path5.join(cwd, "package.json");
777
+ if (!exists(file)) return null;
778
+ try {
779
+ return JSON.parse(await readText(file));
780
+ } catch {
781
+ return null;
782
+ }
783
+ }
784
+ function detectPackageManager(cwd) {
785
+ if (exists(path5.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
786
+ if (exists(path5.join(cwd, "yarn.lock"))) return "yarn";
787
+ if (exists(path5.join(cwd, "package-lock.json"))) return "npm";
788
+ return "npm";
789
+ }
790
+ function hasDep(pkg, name) {
791
+ if (!pkg) return false;
792
+ return Boolean(pkg.dependencies?.[name] || pkg.devDependencies?.[name]);
793
+ }
794
+ function hasConfigFile(cwd, base) {
795
+ return [".js", ".ts", ".mjs", ".cjs"].some((ext) => exists(path5.join(cwd, base + ext)));
796
+ }
797
+ function detectFramework(cwd, pkg) {
798
+ if (hasDep(pkg, "next") || hasConfigFile(cwd, "next.config")) return "nextjs";
799
+ if (hasDep(pkg, "nuxt") || hasDep(pkg, "nuxt3") || hasConfigFile(cwd, "nuxt.config")) return "nuxt";
800
+ if (hasDep(pkg, "quasar") || hasDep(pkg, "@quasar/app-vite") || hasDep(pkg, "@quasar/app-webpack") || hasConfigFile(cwd, "quasar.config")) {
801
+ return "quasar";
802
+ }
803
+ if (hasDep(pkg, "vite") || hasConfigFile(cwd, "vite.config")) return "vite";
804
+ return "node";
805
+ }
806
+ function runScript(pm2, script) {
807
+ return `${pm2} run ${script}`;
808
+ }
809
+ function pickScript(pkg, names) {
810
+ if (!pkg?.scripts) return void 0;
811
+ return names.find((n) => Boolean(pkg.scripts?.[n]));
812
+ }
813
+ var COMPOSE_FILES = ["docker-compose.yml", "docker-compose.yaml", "compose.yaml", "compose.yml"];
814
+ function detectComposeFile(cwd) {
815
+ return COMPOSE_FILES.find((f) => exists(path5.join(cwd, f))) ?? null;
816
+ }
817
+ function detectEnvFiles(cwd) {
818
+ try {
819
+ return fs4.readdirSync(cwd).filter((f) => f === ".env" || f.startsWith(".env.")).sort();
820
+ } catch {
821
+ return [];
822
+ }
823
+ }
824
+ function isPlaceholderTest(script) {
825
+ return /no test specified/i.test(script);
826
+ }
827
+ async function detect(paths) {
828
+ const cwd = paths.cwd;
829
+ const pkg = await readPackageJson(cwd);
830
+ const packageManager = detectPackageManager(cwd);
831
+ const framework = detectFramework(cwd, pkg);
832
+ const devScript = pickScript(pkg, ["dev", "start"]);
833
+ const devCommand = devScript ? runScript(packageManager, devScript) : void 0;
834
+ const buildScript = pickScript(pkg, ["build"]);
835
+ const lintScript = pickScript(pkg, ["lint"]);
836
+ const typecheckScript = pickScript(pkg, ["typecheck", "type-check"]);
837
+ const testScript = pickScript(pkg, ["test"]);
838
+ const defaultPort = FRAMEWORK_DEFAULT_PORT[framework];
839
+ const probeOrder = [defaultPort, ...COMMON_PORTS].filter((p, i, a) => a.indexOf(p) === i);
840
+ const livePort = await firstReachablePort("127.0.0.1", probeOrder, 350);
841
+ const port = livePort ?? defaultPort;
842
+ const checks = {};
843
+ if (buildScript) checks.build = runScript(packageManager, buildScript);
844
+ if (lintScript) checks.lint = runScript(packageManager, lintScript);
845
+ if (typecheckScript) checks.typecheck = runScript(packageManager, typecheckScript);
846
+ if (testScript && pkg?.scripts?.[testScript] && !isPlaceholderTest(pkg.scripts[testScript])) {
847
+ checks.test = runScript(packageManager, testScript);
848
+ }
849
+ return {
850
+ framework,
851
+ packageManager,
852
+ livePortFound: livePort !== null,
853
+ hasPackageJson: pkg !== null,
854
+ scripts: pkg?.scripts ? Object.keys(pkg.scripts).sort() : [],
855
+ composeFile: detectComposeFile(cwd),
856
+ envFiles: detectEnvFiles(cwd),
857
+ app: { framework, packageManager },
858
+ server: {
859
+ ...devCommand ? { devCommand } : {},
860
+ baseUrl: `http://localhost:${port}`,
861
+ port
862
+ },
863
+ ...Object.keys(checks).length ? { checks } : {}
864
+ };
865
+ }
866
+ async function detectConfig(paths) {
867
+ const summary = await detect(paths);
868
+ return {
869
+ app: summary.app,
870
+ server: summary.server,
871
+ ...summary.checks ? { checks: summary.checks } : {}
872
+ };
873
+ }
874
+
875
+ // src/config/load.ts
876
+ function isPlainObject(value) {
877
+ return typeof value === "object" && value !== null && !Array.isArray(value);
878
+ }
879
+ function deepMerge(target, ...sources) {
880
+ for (const source of sources) {
881
+ if (!isPlainObject(source)) continue;
882
+ for (const [key, value] of Object.entries(source)) {
883
+ if (value === void 0) continue;
884
+ const existing = target[key];
885
+ if (isPlainObject(existing) && isPlainObject(value)) {
886
+ target[key] = deepMerge({ ...existing }, value);
887
+ } else {
888
+ target[key] = value;
889
+ }
890
+ }
891
+ }
892
+ return target;
893
+ }
894
+ function formatZodError(err) {
895
+ return err.issues.map((issue) => {
896
+ const where = issue.path.length ? issue.path.join(".") : "(root)";
897
+ return ` \u2022 ${where}: ${issue.message}`;
898
+ }).join("\n");
899
+ }
900
+ async function loadConfig(opts = {}) {
901
+ const paths = resolvePaths(opts.cwd);
902
+ const detected = await detectConfig(paths);
903
+ let fileConfig = {};
904
+ const configFileExists = exists(paths.configFile);
905
+ if (configFileExists) {
906
+ let parsed;
907
+ try {
908
+ parsed = YAML2.parse(await readText(paths.configFile));
909
+ } catch {
910
+ throw new SeatbeltError(
911
+ "Your seatbelt.config.yaml could not be read as YAML.",
912
+ `Check ${paths.configFile} for a syntax slip \u2014 usually a bad indent or a missing colon.`
913
+ );
914
+ }
915
+ if (parsed !== null && parsed !== void 0) {
916
+ if (!isPlainObject(parsed)) {
917
+ throw new SeatbeltError(
918
+ "Your seatbelt.config.yaml must be a set of settings (a YAML mapping), not a list or single value.",
919
+ "Start from the block that `seatbelt on` generates and add settings under it."
920
+ );
921
+ }
922
+ fileConfig = parsed;
923
+ }
924
+ }
925
+ const merged = deepMerge({}, detected, fileConfig, opts.overrides);
926
+ const result = configSchema.safeParse(merged);
927
+ if (!result.success) {
928
+ throw new SeatbeltError(
929
+ "Your Seatbelt configuration has a problem:\n" + formatZodError(result.error),
930
+ "Fix the value(s) above in seatbelt.config.yaml, then run the command again."
931
+ );
932
+ }
933
+ return { config: result.data, configFileExists, paths };
934
+ }
935
+
936
+ // src/utils/runId.ts
937
+ function createRunId(date = /* @__PURE__ */ new Date()) {
938
+ const pad = (n) => String(n).padStart(2, "0");
939
+ const y = date.getFullYear();
940
+ const mo = pad(date.getMonth() + 1);
941
+ const d = pad(date.getDate());
942
+ const h = pad(date.getHours());
943
+ const mi = pad(date.getMinutes());
944
+ const s = pad(date.getSeconds());
945
+ return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
946
+ }
947
+
948
+ // src/version.ts
949
+ var VERSION = "0.0.1";
950
+
951
+ // src/server/lifecycle.ts
952
+ import path6 from "path";
953
+ import { spawn } from "child_process";
954
+ import { execa } from "execa";
955
+ var noopStop = async () => {
956
+ };
957
+ function portOf(config) {
958
+ try {
959
+ const u = new URL(config.server.baseUrl);
960
+ if (u.port) return Number(u.port);
961
+ } catch {
962
+ }
963
+ if (config.server.port) return config.server.port;
964
+ try {
965
+ return parseHostPort(config.server.baseUrl).port;
966
+ } catch {
967
+ return 3e3;
968
+ }
969
+ }
970
+ async function killTree(pid, sub) {
971
+ if (!pid) {
972
+ try {
973
+ sub.kill();
974
+ } catch {
975
+ }
976
+ return;
977
+ }
978
+ if (process.platform === "win32") {
979
+ await new Promise((resolve) => {
980
+ const t = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
981
+ t.on("exit", () => resolve());
982
+ t.on("error", () => resolve());
983
+ });
984
+ } else {
985
+ try {
986
+ sub.kill("SIGTERM");
987
+ } catch {
988
+ }
989
+ }
990
+ await Promise.race([sub.catch(() => void 0), sleep(2e3)]);
991
+ }
992
+ async function ensureServer(config, cwd, runDir) {
993
+ const baseUrl = config.server.baseUrl;
994
+ if (await isUrlReachable(baseUrl, 1500)) {
995
+ log.info(`Reusing the app already running at ${pc.cyan(baseUrl)}.`);
996
+ return { startedBySeatbelt: false, ready: true, logPath: null, stop: noopStop };
997
+ }
998
+ const devCommand = config.server.devCommand;
999
+ if (!devCommand) {
1000
+ return {
1001
+ startedBySeatbelt: false,
1002
+ ready: false,
1003
+ logPath: null,
1004
+ error: `Nothing is running at ${baseUrl}, and Seatbelt couldn't detect a dev command to start it.`,
1005
+ stop: noopStop
1006
+ };
1007
+ }
1008
+ if (!isLocalhostUrl(baseUrl)) {
1009
+ return {
1010
+ startedBySeatbelt: false,
1011
+ ready: false,
1012
+ logPath: null,
1013
+ error: `Refusing to auto-start a dev server for a non-localhost URL (${baseUrl}). Start it yourself, then run Seatbelt.`,
1014
+ stop: noopStop
1015
+ };
1016
+ }
1017
+ const logPath = path6.join(runDir, "server.log");
1018
+ const port = portOf(config);
1019
+ log.step(`Starting your dev server: ${pc.cyan(devCommand)}`);
1020
+ log.dim(` (logs \u2192 ${displayPath(cwd, logPath)})`);
1021
+ const sub = execa(devCommand, {
1022
+ shell: true,
1023
+ cwd,
1024
+ reject: false,
1025
+ stdout: { file: logPath },
1026
+ stderr: { file: logPath },
1027
+ env: { ...process.env, PORT: String(port), BROWSER: "none", SEATBELT_QA: "1" }
1028
+ });
1029
+ const pid = sub.pid;
1030
+ const stop = async () => {
1031
+ await killTree(pid, sub);
1032
+ };
1033
+ const ready = await waitForUrlReachable(baseUrl, config.server.startTimeoutMs, 500);
1034
+ if (!ready) {
1035
+ await stop();
1036
+ return {
1037
+ startedBySeatbelt: true,
1038
+ ready: false,
1039
+ logPath,
1040
+ error: `Your dev server didn't become reachable at ${baseUrl} within ${Math.round(
1041
+ config.server.startTimeoutMs / 1e3
1042
+ )}s. Check the server log for the reason.`,
1043
+ stop: noopStop
1044
+ };
1045
+ }
1046
+ log.success(`Dev server is up at ${pc.cyan(baseUrl)}.`);
1047
+ return { startedBySeatbelt: true, ready: true, logPath, stop };
1048
+ }
1049
+
1050
+ // src/reset/run.ts
1051
+ import path7 from "path";
1052
+ import { execa as execa2 } from "execa";
1053
+ var RESET_BASE = {
1054
+ verifyHttpUrl: null,
1055
+ verifyHttpOk: null
1056
+ };
1057
+ function resetBlocksRun(r) {
1058
+ return r.required && (r.status === "missing" || r.status === "failed" || r.status === "refused");
1059
+ }
1060
+ function resetIsCaution(r) {
1061
+ return !r.required && (r.status === "failed" || r.status === "refused");
1062
+ }
1063
+ function dangerousReason(command) {
1064
+ if (/\brm\s+-rf\b/i.test(command)) return "it contains `rm -rf`";
1065
+ if (/\bdropdb\b/i.test(command)) return "it contains `dropdb`";
1066
+ if (/\bdrop\s+database\b/i.test(command)) return "it drops a database";
1067
+ if (/prisma\s+migrate\s+reset/i.test(command) && /--force\b/i.test(command)) {
1068
+ return "it runs `prisma migrate reset --force`";
1069
+ }
1070
+ if (/\bseatbelt\s+run\b/i.test(command)) return "it would recursively invoke `seatbelt run`";
1071
+ return null;
1072
+ }
1073
+ function resolveVerifyUrl(httpOk2, baseUrl) {
1074
+ if (/^https?:\/\//i.test(httpOk2)) return httpOk2;
1075
+ return baseUrl.replace(/\/+$/, "") + "/" + httpOk2.replace(/^\/+/, "");
1076
+ }
1077
+ async function httpOk(url, timeoutMs) {
1078
+ const controller = new AbortController();
1079
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1080
+ let fetchUrl = url;
1081
+ try {
1082
+ const u = new URL(url);
1083
+ if (u.hostname === "localhost") {
1084
+ u.hostname = "127.0.0.1";
1085
+ fetchUrl = u.toString();
1086
+ }
1087
+ } catch {
1088
+ }
1089
+ try {
1090
+ const res = await fetch(fetchUrl, { signal: controller.signal, redirect: "follow" });
1091
+ return { ok: res.status >= 200 && res.status < 300, detail: `HTTP ${res.status}` };
1092
+ } catch (e) {
1093
+ const msg = e instanceof Error ? e.message.split("\n")[0] ?? e.message : String(e);
1094
+ return { ok: false, detail: msg };
1095
+ } finally {
1096
+ clearTimeout(timer);
1097
+ }
1098
+ }
1099
+ function resetNotRun(config, reason) {
1100
+ return {
1101
+ ...RESET_BASE,
1102
+ configured: Boolean(config.reset.command),
1103
+ required: config.reset.required,
1104
+ command: config.reset.command ?? null,
1105
+ status: "not-run",
1106
+ exitCode: null,
1107
+ durationMs: 0,
1108
+ logFile: null,
1109
+ summary: reason
1110
+ };
1111
+ }
1112
+ async function runReset(config, cwd, runDir, baseUrl) {
1113
+ const command = config.reset.command ?? null;
1114
+ const required = config.reset.required;
1115
+ if (!command) {
1116
+ return {
1117
+ ...RESET_BASE,
1118
+ configured: false,
1119
+ required,
1120
+ command: null,
1121
+ status: required ? "missing" : "skipped",
1122
+ exitCode: null,
1123
+ durationMs: 0,
1124
+ logFile: null,
1125
+ summary: required ? "Reset is required, but no reset command is configured. Run `seatbelt setup-reset`." : "No clean-test-user reset configured (skipped)."
1126
+ };
1127
+ }
1128
+ if (!isLocalhostUrl(baseUrl) && !config.reset.allowNonLocal) {
1129
+ return {
1130
+ ...RESET_BASE,
1131
+ configured: true,
1132
+ required,
1133
+ command,
1134
+ status: "refused",
1135
+ exitCode: null,
1136
+ durationMs: 0,
1137
+ logFile: null,
1138
+ summary: `Refused to run reset against a non-localhost URL (${baseUrl}). Set reset.allowNonLocal: true to override.`
1139
+ };
1140
+ }
1141
+ const danger = dangerousReason(command);
1142
+ if (danger) {
1143
+ return {
1144
+ ...RESET_BASE,
1145
+ configured: true,
1146
+ required,
1147
+ command,
1148
+ status: "refused",
1149
+ exitCode: null,
1150
+ durationMs: 0,
1151
+ logFile: null,
1152
+ summary: `Refused to auto-run the reset command because ${danger}. Run it yourself if that's intentional.`
1153
+ };
1154
+ }
1155
+ const logPath = path7.join(runDir, "reset.log");
1156
+ const start = Date.now();
1157
+ const result = await execa2(command, {
1158
+ shell: true,
1159
+ cwd,
1160
+ reject: false,
1161
+ timeout: config.reset.timeoutMs,
1162
+ // SEATBELT_QA=1 lets the app enable QA-only reset behavior. We never override NODE_ENV.
1163
+ env: { ...process.env, SEATBELT_QA: "1" }
1164
+ });
1165
+ const durationMs = Date.now() - start;
1166
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
1167
+ const stderr = typeof result.stderr === "string" ? result.stderr : "";
1168
+ const timedOut = result.timedOut === true;
1169
+ const exitCode = typeof result.exitCode === "number" ? result.exitCode : timedOut ? null : 1;
1170
+ const expect = config.reset.verify?.expectStdoutContains;
1171
+ const stdoutFailed = Boolean(expect) && !stdout.includes(expect);
1172
+ let verifyHttpUrl = null;
1173
+ let verifyHttpOk = null;
1174
+ let httpDetail = "";
1175
+ const commandOk = !timedOut && exitCode === 0 && !stdoutFailed;
1176
+ const httpOkTarget = config.reset.verify?.httpOk;
1177
+ if (commandOk && httpOkTarget) {
1178
+ verifyHttpUrl = resolveVerifyUrl(httpOkTarget, baseUrl);
1179
+ if (!isLocalhostUrl(verifyHttpUrl) && !config.reset.allowNonLocal) {
1180
+ verifyHttpOk = false;
1181
+ httpDetail = "refused to check a non-localhost health URL (set reset.allowNonLocal: true)";
1182
+ } else {
1183
+ const res = await httpOk(verifyHttpUrl, Math.min(config.reset.timeoutMs, 15e3));
1184
+ verifyHttpOk = res.ok;
1185
+ httpDetail = res.detail;
1186
+ }
1187
+ }
1188
+ await writeText(
1189
+ logPath,
1190
+ `$ ${command}
1191
+
1192
+ [stdout]
1193
+ ${stdout}
1194
+
1195
+ [stderr]
1196
+ ${stderr}
1197
+
1198
+ [exit] ${exitCode ?? "killed"}${timedOut ? " (timed out)" : ""}
1199
+ ${verifyHttpUrl ? `
1200
+ [verify httpOk] ${verifyHttpUrl} -> ${verifyHttpOk ? "ok" : "FAILED"} (${httpDetail})
1201
+ ` : ""}`
1202
+ );
1203
+ const logFile = displayPath(cwd, logPath);
1204
+ const passed = commandOk && verifyHttpOk !== false;
1205
+ if (passed) {
1206
+ return {
1207
+ configured: true,
1208
+ required,
1209
+ command,
1210
+ status: "passed",
1211
+ exitCode,
1212
+ durationMs,
1213
+ logFile,
1214
+ verifyHttpUrl,
1215
+ verifyHttpOk,
1216
+ summary: `Clean test user ready (${command}, ${(durationMs / 1e3).toFixed(1)}s${verifyHttpUrl ? `, health ${httpDetail}` : ""}).`
1217
+ };
1218
+ }
1219
+ const reason = timedOut ? `timed out after ${Math.round(config.reset.timeoutMs / 1e3)}s` : stdoutFailed ? `did not print the expected text "${expect}"` : verifyHttpOk === false ? `health check failed at ${verifyHttpUrl} (${httpDetail})` : `exited with code ${exitCode}`;
1220
+ return {
1221
+ configured: true,
1222
+ required,
1223
+ command,
1224
+ status: "failed",
1225
+ exitCode,
1226
+ durationMs,
1227
+ logFile,
1228
+ verifyHttpUrl,
1229
+ verifyHttpOk,
1230
+ summary: `Reset command ${reason}. See ${logFile}.`
1231
+ };
1232
+ }
1233
+
1234
+ // src/flows/runner.ts
1235
+ import fs5 from "fs";
1236
+ import path8 from "path";
1237
+ var GOTO_TIMEOUT_MS = 3e4;
1238
+ var STEP_TIMEOUT_MS = 15e3;
1239
+ function firstLine(err) {
1240
+ const msg = err instanceof Error ? err.message : String(err);
1241
+ return msg.split("\n")[0] ?? msg;
1242
+ }
1243
+ function joinUrl(baseUrl, target) {
1244
+ if (/^https?:\/\//i.test(target)) return target;
1245
+ return baseUrl.replace(/\/+$/, "") + "/" + target.replace(/^\/+/, "");
1246
+ }
1247
+ function stepLabel(step) {
1248
+ if ("goto" in step) return `goto ${step.goto}`;
1249
+ if ("click" in step) return `click ${step.click}`;
1250
+ if ("fill" in step)
1251
+ return `fill ${step.fill.selector}${step.fill.valueFromEnv ? ` (env ${step.fill.valueFromEnv})` : ""}`;
1252
+ if ("press" in step)
1253
+ return typeof step.press === "string" ? `press ${step.press}` : `press ${step.press.key}`;
1254
+ if ("selectOption" in step) return `selectOption ${step.selectOption.selector}`;
1255
+ if ("upload" in step) return `upload ${step.upload.path}`;
1256
+ if ("waitForUrlContains" in step) return `waitForUrlContains ${step.waitForUrlContains}`;
1257
+ if ("waitForText" in step) return `waitForText ${step.waitForText}`;
1258
+ if ("waitForTimeoutMs" in step) return `waitForTimeoutMs ${step.waitForTimeoutMs}`;
1259
+ if ("expectUrlContains" in step) return `expectUrlContains ${step.expectUrlContains}`;
1260
+ if ("expectText" in step) return `expectText ${step.expectText}`;
1261
+ if ("expectVisible" in step) return `expectVisible ${step.expectVisible}`;
1262
+ if ("expectNotVisible" in step) return `expectNotVisible ${step.expectNotVisible}`;
1263
+ if ("expectTitleContains" in step)
1264
+ return step.expectTitleContains === "" ? "expectTitleContains (any)" : `expectTitleContains ${step.expectTitleContains}`;
1265
+ if ("screenshot" in step) return `screenshot ${step.screenshot}`;
1266
+ return "step";
1267
+ }
1268
+ async function runStep(page, step, ctx) {
1269
+ if ("goto" in step) {
1270
+ await page.goto(joinUrl(ctx.baseUrl, step.goto), { waitUntil: "load", timeout: GOTO_TIMEOUT_MS });
1271
+ return;
1272
+ }
1273
+ if ("click" in step) {
1274
+ await page.click(step.click, { timeout: STEP_TIMEOUT_MS });
1275
+ return;
1276
+ }
1277
+ if ("fill" in step) {
1278
+ let value;
1279
+ if (step.fill.valueFromEnv) {
1280
+ const v = ctx.env[step.fill.valueFromEnv];
1281
+ if (v === void 0 || v === "") {
1282
+ throw new Error(`env var ${step.fill.valueFromEnv} is not set (needed to fill this field)`);
1283
+ }
1284
+ value = v;
1285
+ } else {
1286
+ value = step.fill.value ?? "";
1287
+ }
1288
+ await page.fill(step.fill.selector, value, { timeout: STEP_TIMEOUT_MS });
1289
+ return;
1290
+ }
1291
+ if ("press" in step) {
1292
+ if (typeof step.press === "string") {
1293
+ await page.keyboard.press(step.press);
1294
+ } else if (step.press.selector) {
1295
+ await page.locator(step.press.selector).first().press(step.press.key, { timeout: STEP_TIMEOUT_MS });
1296
+ } else {
1297
+ await page.keyboard.press(step.press.key);
1298
+ }
1299
+ return;
1300
+ }
1301
+ if ("selectOption" in step) {
1302
+ await page.selectOption(step.selectOption.selector, step.selectOption.value, {
1303
+ timeout: STEP_TIMEOUT_MS
1304
+ });
1305
+ return;
1306
+ }
1307
+ if ("upload" in step) {
1308
+ const abs = path8.isAbsolute(step.upload.path) ? step.upload.path : path8.join(ctx.cwd, step.upload.path);
1309
+ if (!fs5.existsSync(abs)) {
1310
+ throw new Error(`fixture file not found: ${step.upload.path} (add it, or fix the path)`);
1311
+ }
1312
+ await page.setInputFiles(step.upload.selector, abs, { timeout: STEP_TIMEOUT_MS });
1313
+ return;
1314
+ }
1315
+ if ("waitForUrlContains" in step) {
1316
+ const needle = step.waitForUrlContains;
1317
+ await page.waitForURL((url) => String(url).includes(needle), { timeout: STEP_TIMEOUT_MS });
1318
+ return;
1319
+ }
1320
+ if ("waitForText" in step) {
1321
+ await page.getByText(step.waitForText).first().waitFor({ state: "visible", timeout: STEP_TIMEOUT_MS });
1322
+ return;
1323
+ }
1324
+ if ("waitForTimeoutMs" in step) {
1325
+ await page.waitForTimeout(step.waitForTimeoutMs);
1326
+ return;
1327
+ }
1328
+ if ("expectUrlContains" in step) {
1329
+ const needle = step.expectUrlContains;
1330
+ try {
1331
+ await page.waitForURL((url) => String(url).includes(needle), { timeout: STEP_TIMEOUT_MS });
1332
+ } catch {
1333
+ throw new Error(`expected the URL to contain "${needle}", but it is "${page.url()}"`);
1334
+ }
1335
+ return;
1336
+ }
1337
+ if ("expectText" in step) {
1338
+ try {
1339
+ await page.getByText(step.expectText).first().waitFor({ state: "visible", timeout: STEP_TIMEOUT_MS });
1340
+ } catch {
1341
+ throw new Error(`expected to see the text "${step.expectText}", but it wasn't visible`);
1342
+ }
1343
+ return;
1344
+ }
1345
+ if ("expectVisible" in step) {
1346
+ try {
1347
+ await page.locator(step.expectVisible).first().waitFor({ state: "visible", timeout: STEP_TIMEOUT_MS });
1348
+ } catch {
1349
+ throw new Error(`expected "${step.expectVisible}" to be visible, but it wasn't`);
1350
+ }
1351
+ return;
1352
+ }
1353
+ if ("expectNotVisible" in step) {
1354
+ const loc = page.locator(step.expectNotVisible).first();
1355
+ const visible = await loc.isVisible().catch(() => false);
1356
+ if (visible) {
1357
+ throw new Error(`expected "${step.expectNotVisible}" to be hidden, but it is visible`);
1358
+ }
1359
+ return;
1360
+ }
1361
+ if ("expectTitleContains" in step) {
1362
+ const needle = step.expectTitleContains;
1363
+ if (needle === "") return;
1364
+ const title = await page.title();
1365
+ if (!title.includes(needle)) {
1366
+ throw new Error(`page title "${title}" does not contain "${needle}"`);
1367
+ }
1368
+ return;
1369
+ }
1370
+ if ("screenshot" in step) {
1371
+ await ensureDir(ctx.shotDir);
1372
+ const file = path8.join(ctx.shotDir, `${step.screenshot || "screenshot"}.png`);
1373
+ await page.screenshot({ path: file, fullPage: true });
1374
+ ctx.screenshots.push(file);
1375
+ return;
1376
+ }
1377
+ }
1378
+ async function runFlow(page, flow, baseUrl, runDir, env, cwd) {
1379
+ const consoleErrors = [];
1380
+ const networkFailures = [];
1381
+ page.on("console", (m) => {
1382
+ if (m.type() === "error") consoleErrors.push(m.text());
1383
+ });
1384
+ page.on("pageerror", (e) => consoleErrors.push(e.message));
1385
+ page.on("requestfailed", (r) => {
1386
+ const f = r.failure();
1387
+ networkFailures.push(`${r.method()} ${r.url()} \u2014 ${f?.errorText ?? "failed"}`);
1388
+ });
1389
+ page.on("response", (res) => {
1390
+ if (res.status() >= 400) {
1391
+ networkFailures.push(`${res.request().method()} ${res.url()} \u2192 ${res.status()}`);
1392
+ }
1393
+ });
1394
+ const shotDir = path8.join(runDir, "flows", flow.id);
1395
+ const screenshots = [];
1396
+ const ctx = { baseUrl, cwd, env, shotDir, screenshots };
1397
+ const steps = [];
1398
+ let failureSummary = null;
1399
+ const start = Date.now();
1400
+ const firstStep = flow.steps[0];
1401
+ const beginsWithGoto = firstStep !== void 0 && "goto" in firstStep;
1402
+ if (flow.startUrl && !beginsWithGoto) {
1403
+ try {
1404
+ await page.goto(joinUrl(baseUrl, flow.startUrl), {
1405
+ waitUntil: "load",
1406
+ timeout: GOTO_TIMEOUT_MS
1407
+ });
1408
+ } catch (e) {
1409
+ const msg = firstLine(e);
1410
+ steps.push({ index: 0, label: `goto ${flow.startUrl} (startUrl)`, ok: false, error: msg });
1411
+ failureSummary = `startUrl ${flow.startUrl}: ${msg}`;
1412
+ }
1413
+ }
1414
+ if (!failureSummary) {
1415
+ for (let i = 0; i < flow.steps.length; i++) {
1416
+ const step = flow.steps[i];
1417
+ const label = stepLabel(step);
1418
+ try {
1419
+ await runStep(page, step, ctx);
1420
+ steps.push({ index: i, label, ok: true, error: null });
1421
+ } catch (e) {
1422
+ const msg = firstLine(e);
1423
+ steps.push({ index: i, label, ok: false, error: msg });
1424
+ failureSummary = `step ${i + 1} (${label}): ${msg}`;
1425
+ try {
1426
+ await ensureDir(shotDir);
1427
+ const file = path8.join(shotDir, "failure.png");
1428
+ await page.screenshot({ path: file, fullPage: true });
1429
+ screenshots.push(file);
1430
+ } catch {
1431
+ }
1432
+ break;
1433
+ }
1434
+ }
1435
+ }
1436
+ let finalUrl = null;
1437
+ let pageTitle = null;
1438
+ try {
1439
+ finalUrl = page.url();
1440
+ } catch {
1441
+ }
1442
+ try {
1443
+ pageTitle = await page.title();
1444
+ } catch {
1445
+ }
1446
+ return {
1447
+ id: flow.id,
1448
+ name: flow.name,
1449
+ description: flow.description,
1450
+ critical: flow.critical,
1451
+ status: failureSummary ? "red" : "green",
1452
+ finalUrl,
1453
+ pageTitle,
1454
+ steps,
1455
+ screenshots,
1456
+ consoleErrors,
1457
+ networkFailures,
1458
+ failureSummary,
1459
+ durationMs: Date.now() - start
1460
+ };
1461
+ }
1462
+
1463
+ // src/flows/graph.ts
1464
+ function validateNeeds(flows) {
1465
+ const ids = new Set(flows.map((f) => f.id));
1466
+ const errors = [];
1467
+ for (const f of flows) {
1468
+ for (const n of f.needs) {
1469
+ if (!ids.has(n)) {
1470
+ errors.push({ message: `flow "${f.id}" needs "${n}", which doesn't exist in your flows` });
1471
+ }
1472
+ }
1473
+ }
1474
+ return errors;
1475
+ }
1476
+ function findCycle(flows) {
1477
+ const byId = new Map(flows.map((f) => [f.id, f]));
1478
+ const state = /* @__PURE__ */ new Map();
1479
+ const stack = [];
1480
+ const visit = (id) => {
1481
+ const s = state.get(id);
1482
+ if (s === "done") return null;
1483
+ if (s === "visiting") {
1484
+ const start = stack.indexOf(id);
1485
+ return [...stack.slice(start), id];
1486
+ }
1487
+ const flow = byId.get(id);
1488
+ if (!flow) return null;
1489
+ state.set(id, "visiting");
1490
+ stack.push(id);
1491
+ for (const n of flow.needs) {
1492
+ const cyc = visit(n);
1493
+ if (cyc) return cyc;
1494
+ }
1495
+ stack.pop();
1496
+ state.set(id, "done");
1497
+ return null;
1498
+ };
1499
+ for (const f of flows) {
1500
+ const cyc = visit(f.id);
1501
+ if (cyc) return cyc;
1502
+ }
1503
+ return null;
1504
+ }
1505
+ function expandWithPrereqs(rootIds, byId) {
1506
+ const out = /* @__PURE__ */ new Set();
1507
+ const walk2 = (id) => {
1508
+ if (out.has(id)) return;
1509
+ const flow = byId.get(id);
1510
+ if (!flow) return;
1511
+ out.add(id);
1512
+ for (const n of flow.needs) walk2(n);
1513
+ };
1514
+ for (const id of rootIds) walk2(id);
1515
+ return out;
1516
+ }
1517
+ function topoSort(ids, byId) {
1518
+ const result = [];
1519
+ const done = /* @__PURE__ */ new Set();
1520
+ const visit = (id) => {
1521
+ if (done.has(id) || !ids.has(id)) return;
1522
+ done.add(id);
1523
+ const flow = byId.get(id);
1524
+ if (flow) for (const n of flow.needs) visit(n);
1525
+ result.push(id);
1526
+ };
1527
+ for (const id of ids) visit(id);
1528
+ return result;
1529
+ }
1530
+ function journeys(ids, byId) {
1531
+ const parent = /* @__PURE__ */ new Map();
1532
+ const find = (x) => {
1533
+ let r = x;
1534
+ while (parent.get(r) !== r) r = parent.get(r);
1535
+ let c = x;
1536
+ while (parent.get(c) !== r) {
1537
+ const next = parent.get(c);
1538
+ parent.set(c, r);
1539
+ c = next;
1540
+ }
1541
+ return r;
1542
+ };
1543
+ const union = (a, b) => {
1544
+ const ra = find(a);
1545
+ const rb = find(b);
1546
+ if (ra !== rb) parent.set(ra, rb);
1547
+ };
1548
+ for (const id of ids) parent.set(id, id);
1549
+ for (const id of ids) {
1550
+ const flow = byId.get(id);
1551
+ if (!flow) continue;
1552
+ for (const n of flow.needs) if (ids.has(n)) union(id, n);
1553
+ }
1554
+ const groups = /* @__PURE__ */ new Map();
1555
+ for (const id of ids) {
1556
+ const root = find(id);
1557
+ if (!groups.has(root)) groups.set(root, /* @__PURE__ */ new Set());
1558
+ groups.get(root).add(id);
1559
+ }
1560
+ return [...groups.values()].map((set) => topoSort(set, byId));
1561
+ }
1562
+
1563
+ // src/flows/env.ts
1564
+ import fs6 from "fs";
1565
+ import path9 from "path";
1566
+ function parseEnvFile(contents) {
1567
+ const out = {};
1568
+ for (const rawLine of contents.split(/\r?\n/)) {
1569
+ const line2 = rawLine.trim();
1570
+ if (!line2 || line2.startsWith("#")) continue;
1571
+ const eq = line2.indexOf("=");
1572
+ if (eq === -1) continue;
1573
+ const key = line2.slice(0, eq).trim().replace(/^export\s+/, "");
1574
+ if (!key) continue;
1575
+ let value = line2.slice(eq + 1).trim();
1576
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
1577
+ value = value.slice(1, -1);
1578
+ }
1579
+ out[key] = value;
1580
+ }
1581
+ return out;
1582
+ }
1583
+ function envFileExists(cwd, envFile) {
1584
+ try {
1585
+ return fs6.existsSync(path9.join(cwd, envFile));
1586
+ } catch {
1587
+ return false;
1588
+ }
1589
+ }
1590
+ function loadEnvValues(cwd, envFile) {
1591
+ const values = {};
1592
+ try {
1593
+ const file = path9.join(cwd, envFile);
1594
+ if (fs6.existsSync(file)) {
1595
+ Object.assign(values, parseEnvFile(fs6.readFileSync(file, "utf8")));
1596
+ }
1597
+ } catch {
1598
+ }
1599
+ for (const [k, v] of Object.entries(process.env)) {
1600
+ if (typeof v === "string") values[k] = v;
1601
+ }
1602
+ return values;
1603
+ }
1604
+ function requiredEnvForFlow(flow) {
1605
+ const names = new Set(flow.requires.env);
1606
+ for (const step of flow.steps) {
1607
+ if ("fill" in step && step.fill.valueFromEnv) names.add(step.fill.valueFromEnv);
1608
+ }
1609
+ return [...names];
1610
+ }
1611
+ function missingEnvForFlow(flow, values) {
1612
+ return requiredEnvForFlow(flow).filter((name) => {
1613
+ const v = values[name];
1614
+ return v === void 0 || v.trim() === "";
1615
+ });
1616
+ }
1617
+
1618
+ // src/flows/freshness.ts
1619
+ import crypto from "crypto";
1620
+ import fs7 from "fs";
1621
+ import path10 from "path";
1622
+ var META_FILENAME = "flow-meta.json";
1623
+ var META_VERSION = 1;
1624
+ function metaPath(paths) {
1625
+ return path10.join(paths.seatbeltDir, META_FILENAME);
1626
+ }
1627
+ function readFlowMeta(paths) {
1628
+ try {
1629
+ const raw = JSON.parse(fs7.readFileSync(metaPath(paths), "utf8"));
1630
+ return { version: raw.version ?? META_VERSION, flows: raw.flows ?? {} };
1631
+ } catch {
1632
+ return { version: META_VERSION, flows: {} };
1633
+ }
1634
+ }
1635
+ async function writeFlowMeta(paths, meta) {
1636
+ await fs7.promises.mkdir(paths.seatbeltDir, { recursive: true });
1637
+ await fs7.promises.writeFile(metaPath(paths), JSON.stringify(meta, null, 2) + "\n", "utf8");
1638
+ }
1639
+ function fingerprintDependencies(cwd, deps) {
1640
+ const out = {};
1641
+ for (const rel of deps.files) {
1642
+ if (rel.includes("*")) continue;
1643
+ try {
1644
+ const abs = path10.join(cwd, rel);
1645
+ const stat = fs7.statSync(abs);
1646
+ if (!stat.isFile()) continue;
1647
+ out[rel] = crypto.createHash("sha1").update(fs7.readFileSync(abs)).digest("hex");
1648
+ } catch {
1649
+ }
1650
+ }
1651
+ return out;
1652
+ }
1653
+ function detectStale(current, baseline) {
1654
+ if (!baseline || Object.keys(baseline).length === 0) return { stale: false, changedFiles: [] };
1655
+ const changed = /* @__PURE__ */ new Set();
1656
+ for (const [file, hash] of Object.entries(current)) {
1657
+ if (baseline[file] !== void 0 && baseline[file] !== hash) changed.add(file);
1658
+ }
1659
+ for (const file of Object.keys(baseline)) {
1660
+ if (!(file in current)) changed.add(file);
1661
+ }
1662
+ const changedFiles = [...changed];
1663
+ return { stale: changedFiles.length > 0, changedFiles };
1664
+ }
1665
+
1666
+ // src/report/verdict.ts
1667
+ function verdictLabel(v) {
1668
+ if (v === "merge") return "MERGE";
1669
+ if (v === "caution") return "MERGE WITH CAUTION";
1670
+ return "DO NOT MERGE";
1671
+ }
1672
+ function verdictEmoji(v) {
1673
+ if (v === "merge") return "\u2705";
1674
+ if (v === "caution") return "\u26A0";
1675
+ return "\u26D4";
1676
+ }
1677
+ function verdictExitCode(v) {
1678
+ return v === "do_not_merge" ? 1 : 0;
1679
+ }
1680
+ function verdictClass(v) {
1681
+ if (v === "merge") return "pass";
1682
+ if (v === "caution") return "warn";
1683
+ return "fail";
1684
+ }
1685
+ function computeVerdict(flows, reset, context = {}) {
1686
+ const reasons = [];
1687
+ let doNotMerge = false;
1688
+ let caution = false;
1689
+ if (context.serverUnavailable) {
1690
+ doNotMerge = true;
1691
+ reasons.push("The app/dev server could not be reached, so no flow could run.");
1692
+ }
1693
+ if (resetBlocksRun(reset)) {
1694
+ doNotMerge = true;
1695
+ reasons.push(`Required clean-test-user reset ${reset.status} \u2014 browser QA can't be trusted.`);
1696
+ } else if (resetIsCaution(reset)) {
1697
+ caution = true;
1698
+ reasons.push("Clean-test-user reset did not succeed (not required) \u2014 results may be contaminated.");
1699
+ }
1700
+ for (const f of flows) {
1701
+ if (f.status === "red") {
1702
+ if (f.critical) {
1703
+ doNotMerge = true;
1704
+ reasons.push(`Critical flow "${f.id}" failed.`);
1705
+ } else {
1706
+ caution = true;
1707
+ reasons.push(`Non-critical flow "${f.id}" failed.`);
1708
+ }
1709
+ } else if (f.status === "blocked") {
1710
+ const why = f.blockedByPrereq ? `prerequisite "${f.blockedByPrereq}" did not pass` : f.missingEnv.length ? `missing env: ${f.missingEnv.join(", ")}` : "could not run";
1711
+ if (f.critical) {
1712
+ doNotMerge = true;
1713
+ reasons.push(`Critical flow "${f.id}" blocked \u2014 ${why}.`);
1714
+ } else {
1715
+ caution = true;
1716
+ reasons.push(`Flow "${f.id}" blocked \u2014 ${why}.`);
1717
+ }
1718
+ } else if (f.status === "yellow") {
1719
+ caution = true;
1720
+ reasons.push(`Flow "${f.id}" needs setup \u2014 not tested.`);
1721
+ } else if (f.status === "grey") {
1722
+ caution = true;
1723
+ reasons.push(`Flow "${f.id}" has not been tested yet.`);
1724
+ } else if (f.status === "green") {
1725
+ if (f.stale) {
1726
+ caution = true;
1727
+ reasons.push(`Flow "${f.id}" passed but may be stale (dependency files changed).`);
1728
+ }
1729
+ if (f.consoleErrors.length || f.networkFailures.length) {
1730
+ caution = true;
1731
+ reasons.push(`Flow "${f.id}" passed but logged console/network errors.`);
1732
+ }
1733
+ }
1734
+ }
1735
+ const verdict = doNotMerge ? "do_not_merge" : caution ? "caution" : "merge";
1736
+ return { verdict, reasons };
1737
+ }
1738
+
1739
+ // src/report/html.ts
1740
+ function escapeHtml(input3) {
1741
+ return input3.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1742
+ }
1743
+ function countStatuses(flows) {
1744
+ const counts = { green: 0, yellow: 0, red: 0, blocked: 0, grey: 0 };
1745
+ for (const f of flows) counts[f.status] += 1;
1746
+ return counts;
1747
+ }
1748
+ function buildFixPrompt(report) {
1749
+ const reset = report.reset;
1750
+ if (reset.status === "missing") {
1751
+ return `Seatbelt needs a "clean test user" reset before it can trust browser QA, but no reset command is configured. Run \`seatbelt setup-reset\` to generate a prompt that adds a safe local reset script (\`qa:reset-user\`) to this app, then set reset.command in seatbelt.config.yaml and re-run \`seatbelt run\`.`;
1752
+ }
1753
+ if (reset.status === "failed") {
1754
+ return `Seatbelt's clean-test-user reset failed before browser QA. The command \`${reset.command}\` ${reset.summary.replace(/^Reset command /, "")} Inspect the reset script (it should create/reset a fixed QA user and clear their state, gated on SEATBELT_QA=1 or NODE_ENV!=="production"), fix why it failed${reset.logFile ? ` (see ${reset.logFile})` : ""}, then re-run \`seatbelt run\`.`;
1755
+ }
1756
+ if (reset.status === "refused") {
1757
+ return `Seatbelt refused to auto-run the configured reset command: ${reset.summary} Make the reset command safe (a scoped \`qa:reset-user\` that only touches the QA user), or run it yourself, then re-run \`seatbelt run\`.`;
1758
+ }
1759
+ const failed = report.flows.filter((f) => f.status === "red" || f.status === "blocked");
1760
+ if (failed.length === 0) {
1761
+ const needsSetup = report.flows.filter((f) => f.needsSetup);
1762
+ const stale = report.flows.filter((f) => f.stale);
1763
+ if (needsSetup.length || stale.length) {
1764
+ const lines2 = [];
1765
+ lines2.push(
1766
+ `Seatbelt's tested flows passed against ${report.baseUrl} in a real browser, but some flows still need attention before this is fully certified:`
1767
+ );
1768
+ if (needsSetup.length) {
1769
+ lines2.push("");
1770
+ lines2.push(`Needs setup (skipped \u2014 configure, then remove "status: yellow"):`);
1771
+ for (const f of needsSetup) lines2.push(` - ${f.id}: ${f.note ?? "template not configured"}`);
1772
+ }
1773
+ if (stale.length) {
1774
+ lines2.push("");
1775
+ lines2.push(`Possibly stale (dependency files changed since last certified):`);
1776
+ for (const f of stale) lines2.push(` - ${f.id}: ${f.staleFiles.join(", ")}`);
1777
+ }
1778
+ lines2.push("");
1779
+ lines2.push(
1780
+ `Configure the needs-setup flows (test creds + real selectors) and re-run \`seatbelt certify --run\` to certify the full map.`
1781
+ );
1782
+ return lines2.join("\n");
1783
+ }
1784
+ return `All ${report.flows.length} Seatbelt flow(s) passed against ${report.baseUrl} in a real browser. Nothing to fix. Add more critical flows (login, checkout, dashboard) to widen coverage before your next merge.`;
1785
+ }
1786
+ const lines = [];
1787
+ lines.push(
1788
+ `Seatbelt ran browser flows against ${report.baseUrl} and ${failed.length} did not pass. Fix the app so these pass:`
1789
+ );
1790
+ for (const f of failed) {
1791
+ lines.push("");
1792
+ lines.push(`Flow "${f.name}" (${f.id}) \u2014 ${f.status.toUpperCase()}`);
1793
+ if (f.missingEnv.length) {
1794
+ lines.push(` Missing env: ${f.missingEnv.join(", ")} (add to your env file, e.g. .env.local)`);
1795
+ }
1796
+ if (f.failureSummary) lines.push(` Failed at: ${f.failureSummary}`);
1797
+ if (f.finalUrl) lines.push(` Final URL: ${f.finalUrl}`);
1798
+ if (f.consoleErrors.length) {
1799
+ lines.push(` Console errors:`);
1800
+ for (const e of f.consoleErrors.slice(0, 6)) lines.push(` - ${e}`);
1801
+ }
1802
+ if (f.networkFailures.length) {
1803
+ lines.push(` Network failures:`);
1804
+ for (const n of f.networkFailures.slice(0, 6)) lines.push(` - ${n}`);
1805
+ }
1806
+ }
1807
+ lines.push("");
1808
+ lines.push(
1809
+ `Inspect the routes/components/handlers behind each failing flow, use the console/network signals above to find the root cause, and fix it. Then I'll re-run \`seatbelt run\`.`
1810
+ );
1811
+ return lines.join("\n");
1812
+ }
1813
+ var STYLES = `
1814
+ :root { color-scheme: light dark; }
1815
+ * { box-sizing: border-box; }
1816
+ body { margin:0; padding:0 0 64px; font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; background:#0f1115; color:#e6e8eb; }
1817
+ @media (prefers-color-scheme: light){ body{ background:#f5f6f8; color:#1b1f24; } }
1818
+ .wrap { max-width:940px; margin:0 auto; padding:24px; }
1819
+ .brand { display:flex; align-items:center; gap:10px; font-weight:700; font-size:18px; margin-bottom:4px; }
1820
+ .brand .dot { width:12px; height:12px; border-radius:50%; background:#3b82f6; box-shadow:0 0 0 4px rgba(59,130,246,.2); }
1821
+ .sub { opacity:.7; font-size:13px; margin-bottom:20px; }
1822
+ .banner { border-radius:12px; padding:18px 20px; font-size:20px; font-weight:700; margin:18px 0 12px; }
1823
+ .banner.pass { background:linear-gradient(90deg,#0f2f1d,#14532d); color:#c9f7d8; border:1px solid #16a34a; }
1824
+ .banner.warn { background:linear-gradient(90deg,#3a2f0d,#5c4813); color:#ffe9a8; border:1px solid #d9a406; }
1825
+ .banner.fail { background:linear-gradient(90deg,#3a1113,#5b1418); color:#ffd7d9; border:1px solid #dc2626; }
1826
+ @media (prefers-color-scheme: light){ .banner.pass{background:#dcfce7;color:#14532d;} .banner.warn{background:#fef3c7;color:#7c5a05;} .banner.fail{background:#fee2e2;color:#7f1d1d;} }
1827
+ .reasons { margin:0 0 20px; padding:12px 16px; border-radius:10px; background:rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.08); font-size:13px; }
1828
+ .reasons ul { margin:6px 0 0; padding-left:18px; } .reasons li { margin:3px 0; }
1829
+ .card { background:rgba(255,255,255,.03); border:1px solid rgba(255,255,255,.08); border-radius:12px; padding:16px 18px; margin:14px 0; }
1830
+ @media (prefers-color-scheme: light){ .card{ background:#fff; border-color:#e5e7eb; } }
1831
+ .card h2 { margin:0 0 12px; font-size:15px; }
1832
+ .card h3 { margin:16px 0 6px; font-size:12px; text-transform:uppercase; letter-spacing:.6px; opacity:.7; }
1833
+ .kv { display:grid; grid-template-columns:150px 1fr; gap:6px 16px; font-size:14px; }
1834
+ .kv div:nth-child(odd){ opacity:.6; }
1835
+ .mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; word-break:break-all; }
1836
+ .dim2 { opacity:.5; font-size:12px; }
1837
+ .err { color:#ff9ea2; }
1838
+ @media (prefers-color-scheme: light){ .err{ color:#b91c1c; } }
1839
+ .counts { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:12px; }
1840
+ .count { padding:3px 10px; border-radius:999px; font-size:12px; font-weight:700; border:1px solid rgba(255,255,255,.14); }
1841
+ .pill { display:inline-block; padding:2px 10px; border-radius:999px; font-size:12px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; }
1842
+ .pill.green{background:#14532d;color:#c9f7d8;} .pill.red{background:#5b1418;color:#ffd7d9;}
1843
+ .pill.grey{background:#3f4652;color:#dfe3e8;} .pill.yellow{background:#5c4813;color:#ffe9a8;} .pill.blocked{background:#4a3b12;color:#ffe9a8;}
1844
+ .tag { display:inline-block; padding:1px 8px; margin:0 4px 4px 0; border-radius:6px; font-size:11px; background:rgba(255,255,255,.08); }
1845
+ table { width:100%; border-collapse:collapse; font-size:14px; }
1846
+ th,td { text-align:left; padding:8px 10px; border-bottom:1px solid rgba(255,255,255,.08); vertical-align:top; }
1847
+ @media (prefers-color-scheme: light){ th,td{ border-color:#eceef1; } }
1848
+ th { font-size:12px; text-transform:uppercase; letter-spacing:.5px; opacity:.6; }
1849
+ ul.steps { list-style:none; margin:0; padding:0; } ul.steps li { padding:3px 0; font-size:13px; }
1850
+ ul.errs { margin:0; padding-left:18px; } ul.errs li { margin:4px 0; font-family:ui-monospace,monospace; font-size:13px; }
1851
+ .shots { display:flex; flex-wrap:wrap; gap:12px; } .shots figure { margin:0; max-width:280px; }
1852
+ img.shot { width:100%; border-radius:8px; border:1px solid rgba(255,255,255,.12); display:block; }
1853
+ figcaption { font-size:12px; opacity:.6; margin-top:4px; }
1854
+ .vid { width:100%; max-width:680px; border-radius:10px; border:1px solid rgba(255,255,255,.14); background:#000; display:block; margin:2px 0 8px; }
1855
+ .evhead { display:flex; align-items:center; gap:8px; }
1856
+ .tracecmd { display:block; margin-top:6px; }
1857
+ .empty { opacity:.55; font-style:italic; font-size:13px; }
1858
+ pre { background:#0b0d10; border:1px solid rgba(255,255,255,.1); border-radius:10px; padding:14px; overflow:auto; font-size:13px; color:#e6e8eb; }
1859
+ .copybtn { float:right; cursor:pointer; border:1px solid rgba(255,255,255,.18); background:transparent; color:inherit; border-radius:8px; padding:4px 10px; font-size:12px; }
1860
+ .copybtn:hover{ background:rgba(255,255,255,.08); }
1861
+ .note { font-size:13px; opacity:.72; margin-top:8px; }
1862
+ .warnnote { color:#ffce74; }
1863
+ .footer { opacity:.5; font-size:12px; margin-top:28px; text-align:center; }
1864
+ `;
1865
+ function resultPill(status) {
1866
+ if (status === "green") return `<span class="pill green">PASS</span>`;
1867
+ if (status === "blocked") return `<span class="pill blocked">BLOCKED</span>`;
1868
+ if (status === "red") return `<span class="pill red">FAIL</span>`;
1869
+ if (status === "yellow") return `<span class="pill yellow">NEEDS SETUP</span>`;
1870
+ return `<span class="pill grey">${status.toUpperCase()}</span>`;
1871
+ }
1872
+ function resetPill(status) {
1873
+ const cls = status === "passed" ? "green" : status === "failed" ? "red" : status === "missing" || status === "refused" ? "blocked" : "grey";
1874
+ const label = status === "not-run" ? "NOT RUN" : status.toUpperCase();
1875
+ return `<span class="pill ${cls}">${label}</span>`;
1876
+ }
1877
+ function renderResetSection(reset) {
1878
+ return `<div class="card">
1879
+ <h2>\u{1F527} Clean test user ${resetPill(reset.status)}</h2>
1880
+ <div class="kv" style="margin-top:10px">
1881
+ <div>Configured</div><div>${reset.configured ? "yes" : "no"}</div>
1882
+ <div>Required</div><div>${reset.required ? "yes" : "no"}</div>
1883
+ <div>Command</div><div class="mono">${reset.command ? escapeHtml(reset.command) : "\u2014"}</div>
1884
+ ${reset.exitCode !== null ? `<div>Exit code</div><div class="mono">${reset.exitCode}</div>` : ""}
1885
+ ${reset.durationMs ? `<div>Duration</div><div>${(reset.durationMs / 1e3).toFixed(1)}s</div>` : ""}
1886
+ ${reset.verifyHttpUrl ? `<div>Health check</div><div class="mono">${reset.verifyHttpOk ? "\u2705" : "\u26D4"} ${escapeHtml(reset.verifyHttpUrl)}</div>` : ""}
1887
+ ${reset.logFile ? `<div>Log</div><div class="mono">${escapeHtml(reset.logFile)}</div>` : ""}
1888
+ </div>
1889
+ <div class="note ${reset.status === "failed" || reset.status === "missing" || reset.status === "refused" ? "err" : ""}">${escapeHtml(reset.summary)}</div>
1890
+ ${reset.status === "missing" ? `<div class="note">Run <span class="mono">seatbelt setup-reset</span> to generate a prompt that adds a safe reset script to your app.</div>` : ""}
1891
+ </div>`;
1892
+ }
1893
+ function renderStep(s) {
1894
+ const icon = s.ok ? "\u2705" : "\u26D4";
1895
+ return `<li>${icon} <span class="mono">${escapeHtml(s.label)}</span>${s.error ? ` \u2014 <span class="err">${escapeHtml(s.error)}</span>` : ""}</li>`;
1896
+ }
1897
+ function depsLine(f) {
1898
+ const parts = [];
1899
+ if (f.dependencies.routes.length) parts.push(`routes: ${f.dependencies.routes.join(", ")}`);
1900
+ if (f.dependencies.files.length) parts.push(`files: ${f.dependencies.files.join(", ")}`);
1901
+ if (f.dependencies.services.length) parts.push(`services: ${f.dependencies.services.join(", ")}`);
1902
+ if (f.requiresEnv.length) parts.push(`env: ${f.requiresEnv.join(", ")}`);
1903
+ return parts.length ? escapeHtml(parts.join(" \xB7 ")) : "\u2014";
1904
+ }
1905
+ function renderFlowCard(f) {
1906
+ const shots = f.screenshots.length ? f.screenshots.map(
1907
+ (s) => `<figure><img class="shot" src="${s.dataUri}" alt="${escapeHtml(s.name)}"/><figcaption>${escapeHtml(s.name)}</figcaption></figure>`
1908
+ ).join("") : `<div class="empty">No screenshots.</div>`;
1909
+ const consoleBlock = f.consoleErrors.length ? `<ul class="errs">${f.consoleErrors.map((e) => `<li>${escapeHtml(e)}</li>`).join("")}</ul>` : `<div class="empty">None.</div>`;
1910
+ const netBlock = f.networkFailures.length ? `<ul class="errs">${f.networkFailures.map((e) => `<li>${escapeHtml(e)}</li>`).join("")}</ul>` : `<div class="empty">None.</div>`;
1911
+ const stepsBlock = f.steps.length ? `<ul class="steps">${f.steps.map(renderStep).join("")}</ul>` : `<div class="empty">Not run.</div>`;
1912
+ const tagsHtml = f.tags.length ? f.tags.map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join("") : "";
1913
+ const videoBlock = f.videoFile ? `<video class="vid" controls preload="metadata" src="${escapeHtml(f.videoFile)}"></video>
1914
+ <div class="dim2">\u{1F3A5} Video: <a href="${escapeHtml(f.videoFile)}">${escapeHtml(f.videoFile)}</a></div>` : `<div class="empty">No video recorded.</div>`;
1915
+ const traceBlock = f.traceFile ? `<div class="note">\u{1F50D} Trace: <span class="mono">${escapeHtml(f.traceFile)}</span>
1916
+ <span class="mono tracecmd">npx playwright show-trace ${escapeHtml(f.traceFile)}</span></div>` : "";
1917
+ const evidence = `<h3>Evidence${f.status === "red" ? " \u2014 failure" : ""}</h3>
1918
+ ${videoBlock}
1919
+ <div class="shots">${shots}</div>
1920
+ ${traceBlock}`;
1921
+ return `<div class="card">
1922
+ <h2>${escapeHtml(f.name)} ${resultPill(f.status)} ${f.critical ? "" : '<span class="dim2">non-critical</span>'}</h2>
1923
+ ${tagsHtml ? `<div style="margin:2px 0 8px">${tagsHtml}</div>` : ""}
1924
+ ${f.description ? `<div class="note">${escapeHtml(f.description)}</div>` : ""}
1925
+ ${f.needs.length ? `<div class="note">Needs: ${escapeHtml(f.needs.join(", "))}</div>` : ""}
1926
+ ${f.blockedByPrereq ? `<div class="note err">\u26D4 Blocked \u2014 prerequisite "${escapeHtml(f.blockedByPrereq)}" did not pass.</div>` : ""}
1927
+ ${f.note ? `<div class="note warnnote">\u26A0 ${escapeHtml(f.note)}</div>` : ""}
1928
+ ${f.stale ? `<div class="note warnnote">\u26A0 Possibly stale \u2014 these dependency files changed since last certified: ${escapeHtml(f.staleFiles.join(", "))}</div>` : ""}
1929
+ ${f.missingEnv.length ? `<div class="note err">Missing env: ${escapeHtml(f.missingEnv.join(", "))} \u2014 add them to your env file (e.g. .env.local).</div>` : ""}
1930
+ <div class="kv" style="margin-top:10px">
1931
+ <div>Depends on</div><div>${depsLine(f)}</div>
1932
+ <div>Final URL</div><div class="mono">${escapeHtml(f.finalUrl ?? "\u2014")}</div>
1933
+ <div>Page title</div><div>${escapeHtml(f.pageTitle ?? "\u2014")}</div>
1934
+ <div>Duration</div><div>${f.durationMs ? (f.durationMs / 1e3).toFixed(1) + "s" : "\u2014"}</div>
1935
+ </div>
1936
+ ${f.failureSummary ? `<div class="note err">\u26D4 ${escapeHtml(f.failureSummary)}</div>` : ""}
1937
+ ${f.status === "red" ? `${evidence}<h3>Steps</h3>${stepsBlock}` : `<h3>Steps</h3>${stepsBlock}${evidence}`}
1938
+ <h3>Console errors (${f.consoleErrors.length})</h3>${consoleBlock}
1939
+ <h3>Network failures (${f.networkFailures.length})</h3>${netBlock}
1940
+ </div>`;
1941
+ }
1942
+ function renderReportHtml(r) {
1943
+ const cls = verdictClass(r.verdict);
1944
+ const verdictText = `${verdictEmoji(r.verdict)} ${verdictLabel(r.verdict)}`;
1945
+ const fix = buildFixPrompt(r);
1946
+ const counts = countStatuses(r.flows);
1947
+ const reasonsHtml = r.verdict !== "merge" && r.verdictReasons.length ? `<div class="reasons"><strong>Why:</strong><ul>${r.verdictReasons.map((x) => `<li>${escapeHtml(x)}</li>`).join("")}</ul></div>` : "";
1948
+ const countsHtml = `<div class="counts">
1949
+ <span class="count pill green">${counts.green} pass</span>
1950
+ <span class="count pill yellow">${counts.yellow} needs setup</span>
1951
+ <span class="count pill red">${counts.red} fail</span>
1952
+ <span class="count pill blocked">${counts.blocked} blocked</span>
1953
+ ${counts.grey ? `<span class="count pill grey">${counts.grey} untested</span>` : ""}
1954
+ </div>`;
1955
+ const rows = r.flows.map(
1956
+ (f) => `<tr>
1957
+ <td>${escapeHtml(f.name)}<div class="mono dim2">${escapeHtml(f.id)}</div></td>
1958
+ <td style="text-align:center">${f.critical ? "\u2713" : "\u2014"}</td>
1959
+ <td style="text-align:center">${resultPill(f.status)}</td>
1960
+ <td>${f.tags.map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join("") || "\u2014"}</td>
1961
+ <td>${f.failureSummary ? escapeHtml(f.failureSummary) : f.note ? escapeHtml(f.note) : f.stale ? '<span class="warnnote">may be stale</span>' : "\u2014"}</td>
1962
+ <td style="text-align:right">${f.durationMs ? (f.durationMs / 1e3).toFixed(1) + "s" : "\u2014"}</td>
1963
+ </tr>`
1964
+ ).join("");
1965
+ return `<!DOCTYPE html>
1966
+ <html lang="en">
1967
+ <head>
1968
+ <meta charset="utf-8" />
1969
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1970
+ <title>SeatbeltAI Report \u2014 ${verdictLabel(r.verdict)}</title>
1971
+ <style>${STYLES}</style>
1972
+ </head>
1973
+ <body>
1974
+ <div class="wrap">
1975
+ <div class="brand"><span class="dot"></span> SeatbeltAI</div>
1976
+ <div class="sub">${r.mode === "certify" ? "Certification run" : "Run"} ${escapeHtml(r.runId)} \xB7 ${escapeHtml(r.generatedAt)} \xB7 session: ${r.sessionFresh ? "fresh" : "reused"} \xB7 seatbelt v${escapeHtml(r.seatbeltVersion)}</div>
1977
+
1978
+ <div class="banner ${cls}">${verdictText}</div>
1979
+ ${reasonsHtml}
1980
+
1981
+ ${renderResetSection(r.reset)}
1982
+
1983
+ <div class="card">
1984
+ <h2>Run</h2>
1985
+ <div class="kv">
1986
+ <div>Mode</div><div>${r.mode === "certify" ? "certify (QA-certified flow map)" : "run"}</div>
1987
+ <div>Base URL</div><div class="mono">${escapeHtml(r.baseUrl)}</div>
1988
+ <div>Framework</div><div>${escapeHtml(r.framework ?? "\u2014")}</div>
1989
+ <div>Package manager</div><div>${escapeHtml(r.packageManager ?? "\u2014")}</div>
1990
+ <div>Dev server</div><div>${r.serverStartedBySeatbelt ? "started by Seatbelt" : "reused (already running)"}</div>
1991
+ ${r.serverLogFile ? `<div>Server log</div><div class="mono">${escapeHtml(r.serverLogFile)}</div>` : ""}
1992
+ <div>Artifacts</div><div class="mono">${escapeHtml(r.artifactDir)}</div>
1993
+ </div>
1994
+ </div>
1995
+
1996
+ <div class="card">
1997
+ <h2>Flow map (${r.flows.length})</h2>
1998
+ ${countsHtml}
1999
+ <table>
2000
+ <thead><tr><th>Flow</th><th style="text-align:center">Critical</th><th style="text-align:center">Result</th><th>Tags</th><th>Failed step / note</th><th style="text-align:right">Duration</th></tr></thead>
2001
+ <tbody>${rows}</tbody>
2002
+ </table>
2003
+ </div>
2004
+
2005
+ ${r.flows.map(renderFlowCard).join("")}
2006
+
2007
+ <div class="card">
2008
+ <h2>Fix prompt \u2014 paste into Claude / Cursor / Codex
2009
+ <button class="copybtn" onclick="copyFix()">Copy</button>
2010
+ </h2>
2011
+ <pre id="fix">${escapeHtml(fix)}</pre>
2012
+ </div>
2013
+
2014
+ <div class="note">
2015
+ Day 5 scope: <strong>deterministic local QA-certified flow map</strong> \u2014 clean-test-user reset
2016
+ (with optional health check), real YAML flows with prerequisites replayed in a visible browser,
2017
+ prominent video/screenshot/trace evidence, and a tri-state merge verdict. Still to come:
2018
+ AI-generated/refreshed flows and full affected-flow reruns.
2019
+ </div>
2020
+
2021
+ <div class="footer">
2022
+ Seatbelt ran against your current local working tree \u2014 pull or rebase first if you want the verdict to include the latest changes on main.<br>
2023
+ Generated locally by SeatbeltAI \xB7 no data left your machine
2024
+ </div>
2025
+ </div>
2026
+ <script>
2027
+ function copyFix(){
2028
+ var el = document.getElementById('fix');
2029
+ navigator.clipboard.writeText(el.innerText).then(function(){
2030
+ var b = document.querySelector('.copybtn'); if(!b) return;
2031
+ var t = b.textContent; b.textContent = 'Copied!'; setTimeout(function(){ b.textContent = t; }, 1400);
2032
+ });
2033
+ }
2034
+ </script>
2035
+ </body>
2036
+ </html>
2037
+ `;
2038
+ }
2039
+ function renderReportMarkdown(r) {
2040
+ const counts = countStatuses(r.flows);
2041
+ const lines = [];
2042
+ lines.push(`# SeatbeltAI Report \u2014 ${verdictEmoji(r.verdict)} ${verdictLabel(r.verdict)}`);
2043
+ lines.push(
2044
+ `_${r.mode === "certify" ? "certification run" : "run"} \xB7 ${r.generatedAt} \xB7 ${r.baseUrl} \xB7 ${r.framework ?? "\u2014"} \xB7 session: ${r.sessionFresh ? "fresh" : "reused"} \xB7 seatbelt v${r.seatbeltVersion}_`
2045
+ );
2046
+ lines.push("");
2047
+ if (r.verdict !== "merge" && r.verdictReasons.length) {
2048
+ lines.push(`**Why:**`);
2049
+ for (const reason of r.verdictReasons) lines.push(`- ${reason}`);
2050
+ lines.push("");
2051
+ }
2052
+ const resetIcon = r.reset.status === "passed" ? "\u2705" : r.reset.status === "skipped" || r.reset.status === "not-run" ? "\u2796" : "\u26D4";
2053
+ lines.push(`## \u{1F527} Clean test user ${resetIcon} ${r.reset.status.toUpperCase()}`);
2054
+ lines.push(`- Configured: ${r.reset.configured ? "yes" : "no"} \xB7 Required: ${r.reset.required ? "yes" : "no"}`);
2055
+ if (r.reset.command) lines.push(`- Command: \`${r.reset.command}\``);
2056
+ if (r.reset.verifyHttpUrl)
2057
+ lines.push(`- Health check: ${r.reset.verifyHttpOk ? "\u2705" : "\u26D4"} \`${r.reset.verifyHttpUrl}\``);
2058
+ if (r.reset.logFile) lines.push(`- Log: \`${r.reset.logFile}\``);
2059
+ lines.push(`- ${r.reset.summary}`);
2060
+ lines.push("");
2061
+ lines.push(
2062
+ `## Flow map \u2014 ${counts.green} pass \xB7 ${counts.yellow} needs setup \xB7 ${counts.red} fail \xB7 ${counts.blocked} blocked${counts.grey ? ` \xB7 ${counts.grey} untested` : ""}`
2063
+ );
2064
+ lines.push(`| Flow | Critical | Result | Tags | Failed step / note | Duration |`);
2065
+ lines.push(`|------|:--:|:--:|------|------|--:|`);
2066
+ for (const f of r.flows) {
2067
+ const result = f.status === "green" ? "\u2705 PASS" : f.status === "blocked" ? "\u26A0 BLOCKED" : f.status === "yellow" ? "\u{1F7E1} NEEDS SETUP" : f.status === "red" ? "\u26D4 FAIL" : "\u25FB " + f.status.toUpperCase();
2068
+ const detail = f.failureSummary ?? f.note ?? (f.stale ? "may be stale" : "\u2014");
2069
+ lines.push(
2070
+ `| ${f.name} (\`${f.id}\`) | ${f.critical ? "\u2713" : "\u2014"} | ${result} | ${f.tags.join(", ") || "\u2014"} | ${detail} | ${f.durationMs ? (f.durationMs / 1e3).toFixed(1) + "s" : "\u2014"} |`
2071
+ );
2072
+ }
2073
+ lines.push("");
2074
+ for (const f of r.flows) {
2075
+ lines.push(`### ${f.name} (\`${f.id}\`) \u2014 ${f.status}`);
2076
+ if (f.tags.length) lines.push(`- Tags: ${f.tags.join(", ")}`);
2077
+ if (f.needs.length) lines.push(`- Needs: ${f.needs.join(", ")}`);
2078
+ if (f.blockedByPrereq) lines.push(`- \u26D4 Blocked \u2014 prerequisite \`${f.blockedByPrereq}\` did not pass`);
2079
+ if (f.dependencies.routes.length) lines.push(`- Routes: ${f.dependencies.routes.join(", ")}`);
2080
+ if (f.dependencies.files.length) lines.push(`- Files: ${f.dependencies.files.join(", ")}`);
2081
+ if (f.dependencies.services.length) lines.push(`- Services: ${f.dependencies.services.join(", ")}`);
2082
+ if (f.requiresEnv.length) lines.push(`- Requires env: ${f.requiresEnv.join(", ")}`);
2083
+ if (f.missingEnv.length) lines.push(`- \u26A0 Missing env: ${f.missingEnv.join(", ")}`);
2084
+ if (f.stale) lines.push(`- \u26A0 Possibly stale \u2014 changed: ${f.staleFiles.join(", ")}`);
2085
+ if (f.note) lines.push(`- ${f.note}`);
2086
+ if (f.finalUrl) lines.push(`- Final URL: ${f.finalUrl}`);
2087
+ if (f.failureSummary) lines.push(`- \u26D4 ${f.failureSummary}`);
2088
+ if (f.videoFile) lines.push(`- \u{1F3A5} Video: \`${f.videoFile}\``);
2089
+ if (f.traceFile) lines.push(`- \u{1F50D} Trace: \`npx playwright show-trace ${f.traceFile}\``);
2090
+ if (f.consoleErrors.length) lines.push(`- Console errors: ${f.consoleErrors.length}`);
2091
+ if (f.networkFailures.length) lines.push(`- Network failures: ${f.networkFailures.length}`);
2092
+ lines.push("");
2093
+ }
2094
+ if (r.serverLogFile) {
2095
+ lines.push(`Server log: \`${r.serverLogFile}\``);
2096
+ lines.push("");
2097
+ }
2098
+ lines.push(`## Fix prompt (paste into Claude / Cursor / Codex)`);
2099
+ lines.push("```");
2100
+ lines.push(buildFixPrompt(r));
2101
+ lines.push("```");
2102
+ lines.push("");
2103
+ lines.push(
2104
+ `> Day 5 scope: deterministic local QA-certified flow map (reset + health check + flow prerequisites + video/trace evidence + tri-state verdict + freshness seed).`
2105
+ );
2106
+ return lines.join("\n") + "\n";
2107
+ }
2108
+
2109
+ // src/commands/run.ts
2110
+ function chromiumLaunchError(err) {
2111
+ const raw = err instanceof Error ? err.message : String(err);
2112
+ const firstLine2 = (raw.split("\n")[0] ?? raw).trim();
2113
+ if (/Executable doesn't exist|playwright install/i.test(raw)) {
2114
+ return new SeatbeltError(
2115
+ "Seatbelt couldn't start its browser (Chromium isn't installed yet).",
2116
+ `Install it once with:
2117
+ ${pc.green("npx playwright install chromium")}
2118
+
2119
+ Then run ${pc.green("seatbelt run")} again.`
2120
+ );
2121
+ }
2122
+ if (/spawn (UNKNOWN|EACCES|ENOENT|EPERM)/i.test(raw)) {
2123
+ return new SeatbeltError(
2124
+ "Seatbelt couldn't start its browser \u2014 the system blocked the browser from launching.",
2125
+ [
2126
+ "This usually means the browser can't open in the current environment (no desktop",
2127
+ "session, a sandbox, or security software blocking chrome.exe). Run Seatbelt from a",
2128
+ "normal terminal on your desktop. If it persists, reinstall the browser with:",
2129
+ ` ${pc.green("npx playwright install chromium")}`,
2130
+ "",
2131
+ pc.dim(`Details: ${firstLine2}`)
2132
+ ].join("\n")
2133
+ );
2134
+ }
2135
+ return new SeatbeltError("Seatbelt couldn't start its browser.", firstLine2);
2136
+ }
2137
+ function baseFlowReport(flow, stale) {
2138
+ return {
2139
+ id: flow.id,
2140
+ name: flow.name,
2141
+ description: flow.description,
2142
+ critical: flow.critical,
2143
+ status: "grey",
2144
+ tags: flow.tags,
2145
+ dependencies: flow.dependencies,
2146
+ requiresEnv: requiredEnvForFlow(flow),
2147
+ missingEnv: [],
2148
+ needsSetup: false,
2149
+ needs: flow.needs,
2150
+ blockedByPrereq: null,
2151
+ stale: stale.stale,
2152
+ staleFiles: stale.changedFiles,
2153
+ finalUrl: null,
2154
+ pageTitle: null,
2155
+ failureSummary: null,
2156
+ note: null,
2157
+ durationMs: 0,
2158
+ steps: [],
2159
+ screenshots: [],
2160
+ videoFile: null,
2161
+ traceFile: null,
2162
+ consoleErrors: [],
2163
+ networkFailures: []
2164
+ };
2165
+ }
2166
+ async function mergeRunResult(base, res, videoFile, traceFile) {
2167
+ const screenshots = [];
2168
+ for (const abs of res.screenshots) {
2169
+ try {
2170
+ const name = path11.basename(abs).replace(/\.png$/i, "");
2171
+ screenshots.push({ name, dataUri: `data:image/png;base64,${await readBase64(abs)}` });
2172
+ } catch {
2173
+ }
2174
+ }
2175
+ return {
2176
+ ...base,
2177
+ status: res.status,
2178
+ // green | red
2179
+ finalUrl: res.finalUrl,
2180
+ pageTitle: res.pageTitle,
2181
+ failureSummary: res.failureSummary,
2182
+ durationMs: res.durationMs,
2183
+ steps: res.steps,
2184
+ screenshots,
2185
+ videoFile,
2186
+ traceFile,
2187
+ consoleErrors: res.consoleErrors,
2188
+ networkFailures: res.networkFailures
2189
+ };
2190
+ }
2191
+ async function runCommand(opts = {}) {
2192
+ const { config, paths, configFileExists } = await loadConfig({ cwd: opts.cwd });
2193
+ if (opts.headed !== void 0) config.browser.headed = opts.headed;
2194
+ const mode = opts.mode ?? "run";
2195
+ if (!configFileExists) {
2196
+ log.info(
2197
+ `No ${pc.cyan("seatbelt.config.yaml")} found \u2014 using detected + default settings. Run ${pc.green("seatbelt on")} to save a config.`
2198
+ );
2199
+ }
2200
+ const runId = createRunId();
2201
+ const dir = artifactDir(paths, runId);
2202
+ await ensureDir(dir);
2203
+ const baseUrl = config.server.baseUrl;
2204
+ const framework = config.app.framework ?? null;
2205
+ const packageManager = config.app.packageManager ?? null;
2206
+ const relReportPath = displayPath(paths.cwd, path11.join(dir, "report.html"));
2207
+ log.blank();
2208
+ log.step(`Seatbelt ${mode === "certify" ? "certify" : "run"} ${pc.bold(runId)}`);
2209
+ log.info(`Detected: ${pc.bold(framework ?? "node")} \xB7 ${packageManager ?? "npm"} \xB7 ${pc.cyan(baseUrl)}`);
2210
+ const { flows: allFlows, flowsDir, createdFlowIds, errors: flowErrors } = await loadFlows(
2211
+ paths.cwd,
2212
+ config.flows.dir
2213
+ );
2214
+ if (createdFlowIds.length) {
2215
+ log.info(
2216
+ `No flows yet \u2014 created ${createdFlowIds.length} starter flow${createdFlowIds.length > 1 ? "s" : ""} in ${pc.cyan(displayPath(paths.cwd, flowsDir))} (${createdFlowIds.join(", ")}).`
2217
+ );
2218
+ }
2219
+ for (const e of flowErrors) log.warn(`Skipping ${e.file}: ${e.message}`);
2220
+ const byId = new Map(allFlows.map((f) => [f.id, f]));
2221
+ for (const e of validateNeeds(allFlows)) log.warn(e.message);
2222
+ const cycle = findCycle(allFlows);
2223
+ if (cycle) {
2224
+ throw new SeatbeltError(
2225
+ `Flow dependencies contain a loop: ${cycle.join(" \u2192 ")}`,
2226
+ "Remove one of the `needs:` links so the prerequisites form a chain, not a circle."
2227
+ );
2228
+ }
2229
+ const target = (opts.target ?? "").trim();
2230
+ const targeted = Boolean(target) && target !== "all";
2231
+ let represent = allFlows;
2232
+ if (targeted) {
2233
+ if (!byId.has(target)) {
2234
+ throw new SeatbeltError(
2235
+ `No flow with id "${target}" was found in ${config.flows.dir}/.`,
2236
+ allFlows.length ? `Available flows: ${allFlows.map((f) => f.id).join(", ")}` : `Run ${pc.green("seatbelt run")} to create starter flows.`
2237
+ );
2238
+ }
2239
+ const ids = expandWithPrereqs([target], byId);
2240
+ represent = allFlows.filter((f) => ids.has(f.id));
2241
+ }
2242
+ if (represent.length === 0) {
2243
+ throw new SeatbeltError(
2244
+ `No runnable flows were found in ${config.flows.dir}/.`,
2245
+ `Add a flow YAML file, or run ${pc.green("seatbelt on")} again to scaffold the starter set.`
2246
+ );
2247
+ }
2248
+ const meta = readFlowMeta(paths);
2249
+ const staleByFlow = /* @__PURE__ */ new Map();
2250
+ for (const f of represent) {
2251
+ const current = fingerprintDependencies(paths.cwd, f.dependencies);
2252
+ staleByFlow.set(f.id, detectStale(current, meta.flows[f.id]?.fingerprint));
2253
+ }
2254
+ const roots = targeted ? [target] : allFlows.filter((f) => !isNeedsSetup(f)).map((f) => f.id);
2255
+ const execIds = expandWithPrereqs(roots, byId);
2256
+ const envValues = loadEnvValues(paths.cwd, config.env.file);
2257
+ const reports = /* @__PURE__ */ new Map();
2258
+ let anyRunnable = false;
2259
+ for (const f of represent) {
2260
+ const base = baseFlowReport(f, staleByFlow.get(f.id) ?? { stale: false, changedFiles: [] });
2261
+ if (!execIds.has(f.id)) {
2262
+ base.status = "yellow";
2263
+ base.needsSetup = true;
2264
+ base.note = `Needs setup \u2014 configure this flow${base.requiresEnv.length ? ` (env: ${base.requiresEnv.join(", ")})` : ""}, then remove "status: yellow". Skipped in bulk runs; run it explicitly with "seatbelt run ${f.id}".`;
2265
+ reports.set(f.id, base);
2266
+ continue;
2267
+ }
2268
+ const missing = missingEnvForFlow(f, envValues);
2269
+ if (missing.length) {
2270
+ base.status = "blocked";
2271
+ base.missingEnv = missing;
2272
+ base.failureSummary = `missing env: ${missing.join(", ")}`;
2273
+ base.note = `Add ${missing.join(", ")} to ${config.env.file} (or your shell), then re-run.`;
2274
+ reports.set(f.id, base);
2275
+ continue;
2276
+ }
2277
+ anyRunnable = true;
2278
+ reports.set(f.id, base);
2279
+ }
2280
+ const needsAnyEnv = represent.some((f) => reports.get(f.id).requiresEnv.length > 0);
2281
+ if (needsAnyEnv && !envFileExists(paths.cwd, config.env.file)) {
2282
+ log.warn(
2283
+ `No ${pc.cyan(config.env.file)} found \u2014 some flows need test credentials. Create it with e.g. ${pc.cyan("SEATBELT_TEST_EMAIL=\u2026")} / ${pc.cyan("SEATBELT_TEST_PASSWORD=\u2026")} (values are never printed or uploaded).`
2284
+ );
2285
+ }
2286
+ const finalize = async (resetReport2, serverLogFile2, serverStartedBySeatbelt, serverUnavailable) => {
2287
+ const flowReports = represent.map((f) => reports.get(f.id));
2288
+ const { verdict, reasons } = computeVerdict(flowReports, resetReport2, { serverUnavailable });
2289
+ const report = {
2290
+ runId,
2291
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2292
+ mode,
2293
+ verdict,
2294
+ verdictReasons: reasons,
2295
+ baseUrl,
2296
+ framework,
2297
+ packageManager,
2298
+ serverStartedBySeatbelt,
2299
+ serverLogFile: serverLogFile2,
2300
+ artifactDir: displayPath(paths.cwd, dir),
2301
+ sessionFresh: config.browser.freshSession,
2302
+ seatbeltVersion: VERSION,
2303
+ reset: resetReport2,
2304
+ flows: flowReports
2305
+ };
2306
+ await writeText(path11.join(dir, "report.html"), renderReportHtml(report));
2307
+ await writeJson(path11.join(dir, "run.json"), report);
2308
+ if (config.report.formats.includes("md")) {
2309
+ await writeText(path11.join(dir, "report.md"), renderReportMarkdown(report));
2310
+ }
2311
+ let state = await loadOrSeedState(paths.stateFile, config.app.name);
2312
+ state = syncFlowsFromYaml(
2313
+ state,
2314
+ allFlows.map((f) => ({
2315
+ id: f.id,
2316
+ name: f.name,
2317
+ description: f.description,
2318
+ tags: f.tags,
2319
+ dependencies: f.dependencies,
2320
+ requires: f.requires,
2321
+ needs: f.needs,
2322
+ declaredStatus: f.status
2323
+ }))
2324
+ );
2325
+ const at = report.generatedAt;
2326
+ for (const f of flowReports) {
2327
+ if (f.needsSetup) continue;
2328
+ state = upsertFlow(state, {
2329
+ id: f.id,
2330
+ name: f.name,
2331
+ status: f.status,
2332
+ lastRunId: runId,
2333
+ lastRunAt: at,
2334
+ lastReportPath: relReportPath,
2335
+ lastFailureSummary: f.failureSummary
2336
+ });
2337
+ if (mode === "certify" && (f.status === "green" || f.status === "red")) {
2338
+ state = recordCertification(state, f.id, runId, at);
2339
+ const flow = byId.get(f.id);
2340
+ meta.flows[f.id] = {
2341
+ dependencies: flow.dependencies,
2342
+ fingerprint: fingerprintDependencies(paths.cwd, flow.dependencies),
2343
+ lastCertifiedRunId: runId,
2344
+ lastCertifiedAt: at
2345
+ };
2346
+ }
2347
+ }
2348
+ await saveState(paths.stateFile, state);
2349
+ if (mode === "certify") await writeFlowMeta(paths, meta);
2350
+ printSummary(report);
2351
+ const shouldOpen = config.report.openOnFinish && opts.open !== false && !process.env.CI;
2352
+ if (shouldOpen) {
2353
+ try {
2354
+ await open(path11.join(dir, "report.html"));
2355
+ } catch {
2356
+ log.dim("(Could not auto-open the report \u2014 open the path above in your browser.)");
2357
+ }
2358
+ }
2359
+ return verdictExitCode(verdict);
2360
+ };
2361
+ const printSummary = (report) => {
2362
+ const counts = countStatuses(report.flows);
2363
+ log.blank();
2364
+ const label = `${verdictEmoji(report.verdict)} Verdict: ${verdictLabel(report.verdict)}`;
2365
+ if (report.verdict === "merge") log.success(pc.bold(label));
2366
+ else log.warn(pc.bold(label));
2367
+ if (report.verdict !== "merge" && report.verdictReasons.length) {
2368
+ for (const r of report.verdictReasons.slice(0, 6)) log.plain(` \u2022 ${r}`);
2369
+ }
2370
+ log.blank();
2371
+ log.plain(
2372
+ pc.dim(
2373
+ `${counts.green} pass \xB7 ${counts.yellow} needs setup \xB7 ${counts.red} fail \xB7 ${counts.blocked} blocked${counts.grey ? ` \xB7 ${counts.grey} untested` : ""}`
2374
+ )
2375
+ );
2376
+ for (const f of report.flows) {
2377
+ const tag = f.status === "green" ? pc.green("PASS ") : f.status === "yellow" ? pc.yellow("SETUP ") : f.status === "blocked" ? pc.yellow("BLOCKED") : f.status === "red" ? pc.red("FAIL ") : pc.dim("UNTESTD");
2378
+ const detail = f.failureSummary && f.status !== "green" ? pc.dim(" \u2014 " + f.failureSummary) : f.stale ? pc.yellow(" \u2014 may be stale") : "";
2379
+ log.plain(` ${tag} ${f.id}${detail}`);
2380
+ }
2381
+ log.blank();
2382
+ log.plain(`Report: ${pc.cyan(relReportPath)}`);
2383
+ if (report.serverLogFile) log.plain(`Server log: ${pc.cyan(report.serverLogFile)}`);
2384
+ log.blank();
2385
+ if (report.verdict === "do_not_merge") {
2386
+ log.plain(
2387
+ pc.bold("Next:") + " copy the fix prompt from the report into Claude / Cursor / Codex, fix, then re-run."
2388
+ );
2389
+ } else {
2390
+ log.plain(
2391
+ pc.bold("Next:") + " configure any needs-setup flows and re-run " + pc.green(`seatbelt ${mode === "certify" ? "certify --run" : "run"}`) + "."
2392
+ );
2393
+ }
2394
+ log.blank();
2395
+ };
2396
+ if (!anyRunnable) {
2397
+ log.dim("No flows are eligible to run right now (all need setup or are missing env).");
2398
+ return await finalize(
2399
+ resetNotRun(config, "No flows were eligible to run, so reset was not needed."),
2400
+ null,
2401
+ false,
2402
+ false
2403
+ );
2404
+ }
2405
+ const server = await ensureServer(config, paths.cwd, dir);
2406
+ const serverLogFile = server.logPath ? displayPath(paths.cwd, server.logPath) : null;
2407
+ if (!server.ready) {
2408
+ log.blank();
2409
+ if (server.startedBySeatbelt) {
2410
+ log.plain(server.error ?? `Seatbelt could not reach ${baseUrl}.`);
2411
+ log.plain("");
2412
+ log.dim("Check the server log for the underlying error, then run seatbelt run again.");
2413
+ } else {
2414
+ log.plain(`Seatbelt could not open ${pc.cyan(baseUrl)}.`);
2415
+ log.plain("");
2416
+ log.plain("Your app may not be running, and Seatbelt couldn't find a dev command to start it.");
2417
+ log.plain(`Start your app (or set ${pc.cyan("server.devCommand")} in seatbelt.config.yaml), then run:`);
2418
+ log.plain(` ${pc.green("seatbelt run")}`);
2419
+ }
2420
+ for (const f of represent) {
2421
+ const r = reports.get(f.id);
2422
+ if (r.status === "blocked" || r.needsSetup) continue;
2423
+ r.status = "blocked";
2424
+ r.failureSummary = server.error ?? "app not reachable";
2425
+ r.note = "The app wasn't reachable, so this flow could not run.";
2426
+ }
2427
+ return await finalize(
2428
+ resetNotRun(config, "Skipped \u2014 the app was unreachable, so reset did not run."),
2429
+ serverLogFile,
2430
+ server.startedBySeatbelt,
2431
+ true
2432
+ );
2433
+ }
2434
+ const resetReport = await runReset(config, paths.cwd, dir, baseUrl);
2435
+ if (resetReport.status === "passed") {
2436
+ log.success(`Clean test user ready ${pc.dim("(" + resetReport.command + ")")}`);
2437
+ } else if (resetReport.status === "skipped") {
2438
+ log.dim("No clean-test-user reset configured \u2014 running without one.");
2439
+ } else {
2440
+ log.warn(resetReport.summary);
2441
+ }
2442
+ if (resetBlocksRun(resetReport)) {
2443
+ log.blank();
2444
+ if (resetReport.status === "missing") {
2445
+ log.plain(`Run ${pc.green("seatbelt setup-reset")} to add a safe clean-test-user reset, then run again.`);
2446
+ } else {
2447
+ log.plain("Browser QA was stopped because a clean test user is required and reset did not succeed.");
2448
+ if (resetReport.logFile) log.dim(`See ${resetReport.logFile} for details.`);
2449
+ }
2450
+ for (const f of represent) {
2451
+ const r = reports.get(f.id);
2452
+ if (r.status === "blocked" || r.needsSetup) continue;
2453
+ r.status = "blocked";
2454
+ r.failureSummary = `reset ${resetReport.status}`;
2455
+ r.note = "Blocked: a required clean test user could not be prepared.";
2456
+ }
2457
+ if (server.startedBySeatbelt) await server.stop();
2458
+ return await finalize(resetReport, serverLogFile, server.startedBySeatbelt, false);
2459
+ }
2460
+ const videoDir = path11.join(dir, "media");
2461
+ const groups = journeys(execIds, byId);
2462
+ let browser;
2463
+ try {
2464
+ try {
2465
+ browser = await chromium.launch({
2466
+ headless: !config.browser.headed,
2467
+ slowMo: config.browser.slowMo
2468
+ });
2469
+ } catch (err) {
2470
+ throw chromiumLaunchError(err);
2471
+ }
2472
+ for (const group of groups) {
2473
+ if (group.every((id) => reports.get(id).status === "blocked")) continue;
2474
+ const context = await browser.newContext({
2475
+ viewport: config.browser.viewport,
2476
+ storageState: void 0,
2477
+ recordVideo: config.browser.video ? { dir: videoDir } : void 0
2478
+ });
2479
+ await context.clearCookies();
2480
+ if (config.browser.trace) {
2481
+ await context.tracing.start({ screenshots: true, snapshots: true, sources: false });
2482
+ }
2483
+ for (const id of group) {
2484
+ const flow = byId.get(id);
2485
+ const rep = reports.get(id);
2486
+ if (rep.status === "blocked") continue;
2487
+ const failedPrereq = flow.needs.find(
2488
+ (n) => execIds.has(n) && reports.get(n).status !== "green"
2489
+ );
2490
+ if (failedPrereq) {
2491
+ rep.status = "blocked";
2492
+ rep.blockedByPrereq = failedPrereq;
2493
+ rep.failureSummary = `prerequisite "${failedPrereq}" did not pass`;
2494
+ rep.note = `Blocked: prerequisite "${failedPrereq}" did not pass, so this flow was not run.`;
2495
+ log.warn(`BLOCKED \u2014 ${id} (needs ${failedPrereq})`);
2496
+ continue;
2497
+ }
2498
+ log.step(`Flow: ${pc.bold(flow.name)} ${pc.dim("(" + flow.id + ")")}`);
2499
+ if (config.browser.trace) await context.tracing.startChunk({ title: flow.id });
2500
+ const page = await context.newPage();
2501
+ const result = await runFlow(page, flow, baseUrl, dir, envValues, paths.cwd);
2502
+ const video = page.video();
2503
+ await page.close();
2504
+ let traceFile = null;
2505
+ if (config.browser.trace) {
2506
+ const traceAbs = path11.join(dir, "flows", flow.id, "trace.zip");
2507
+ try {
2508
+ await context.tracing.stopChunk({ path: traceAbs });
2509
+ traceFile = displayPath(dir, traceAbs);
2510
+ } catch {
2511
+ traceFile = null;
2512
+ }
2513
+ }
2514
+ let videoFile = null;
2515
+ if (video) {
2516
+ try {
2517
+ videoFile = path11.relative(dir, await video.path()).split(path11.sep).join("/");
2518
+ } catch {
2519
+ videoFile = null;
2520
+ }
2521
+ }
2522
+ reports.set(flow.id, await mergeRunResult(rep, result, videoFile, traceFile));
2523
+ if (result.status === "green") log.success(`PASS \u2014 ${flow.id}`);
2524
+ else log.warn(`FAIL \u2014 ${flow.id}${result.failureSummary ? " \u2014 " + result.failureSummary : ""}`);
2525
+ }
2526
+ if (config.browser.trace) await context.tracing.stop().catch(() => {
2527
+ });
2528
+ await context.close();
2529
+ }
2530
+ await browser.close();
2531
+ browser = void 0;
2532
+ } finally {
2533
+ if (browser) await browser.close().catch(() => {
2534
+ });
2535
+ if (server.startedBySeatbelt) await server.stop();
2536
+ }
2537
+ return await finalize(resetReport, serverLogFile, server.startedBySeatbelt, false);
2538
+ }
2539
+
2540
+ // src/commands/setupReset.ts
2541
+ import path12 from "path";
2542
+
2543
+ // src/reset/prompt.ts
2544
+ function pm(context) {
2545
+ return context.packageManager || "npm";
2546
+ }
2547
+ function resetRunCommand(context) {
2548
+ const p = pm(context);
2549
+ return p === "npm" ? "npm run qa:reset-user" : `${p} qa:reset-user`;
2550
+ }
2551
+ function resetConfigSnippet(context) {
2552
+ return [
2553
+ "reset:",
2554
+ ` command: ${resetRunCommand(context)}`,
2555
+ " required: true",
2556
+ " timeoutMs: 30000",
2557
+ " allowNonLocal: false"
2558
+ ].join("\n");
2559
+ }
2560
+ function buildResetPrompt(context) {
2561
+ const p = pm(context);
2562
+ const frameworkNote = context.framework ? `This project looks like a ${context.framework} app using ${p}. ` : `This project uses ${p}. `;
2563
+ return `You are working in this repository. Add a safe "clean test user" reset script so an
2564
+ external QA tool (SeatbeltAI) can start browser tests from a known-good state every run.
2565
+
2566
+ ${frameworkNote}Do the following:
2567
+
2568
+ 1. Inspect this app's auth model, database/ORM, and how it stores user, session, onboarding,
2569
+ cart, uploads, progress, and entitlement/subscription state. Briefly report what you found
2570
+ BEFORE writing code.
2571
+
2572
+ 2. Create a script runnable as \`${resetRunCommand(context)}\` that:
2573
+ - Creates or resets a single FIXED QA test user, using env vars SEATBELT_TEST_EMAIL and
2574
+ SEATBELT_TEST_PASSWORD (default them to safe local values if unset).
2575
+ - Resets that user's per-user state: onboarding/"first run" flags back to the start, cart
2576
+ emptied, uploads/progress cleared, entitlements/subscription set to free/none, any cached
2577
+ unlocks relocked.
2578
+ - Is IDEMPOTENT and fast \u2014 running it twice in a row gives the same clean state.
2579
+ - Only ever touches the QA user's data. Never other users, never global/shared data.
2580
+
2581
+ 3. Safety (this is critical):
2582
+ - Refuse to run when NODE_ENV === "production".
2583
+ - Only perform resets when SEATBELT_QA === "1" OR NODE_ENV !== "production".
2584
+ - Do NOT drop databases, run destructive migrations, or \`rm -rf\` anything.
2585
+ - Use the app's existing database connection/ORM. Don't add heavy new dependencies.
2586
+
2587
+ 4. Wire it up:
2588
+ - Add the "qa:reset-user" script to package.json.
2589
+ - Document any required env vars (SEATBELT_TEST_EMAIL, SEATBELT_TEST_PASSWORD) in the README
2590
+ or a comment.
2591
+
2592
+ 5. Print a clear success line at the end, e.g. "QA reset complete: user <email> reset", and a
2593
+ clear error (non-zero exit) if it fails, so the outcome is obvious.
2594
+
2595
+ Show me the diff before applying. Keep it minimal, idempotent, and local/dev only.`;
2596
+ }
2597
+
2598
+ // src/commands/setupReset.ts
2599
+ async function setupResetCommand(opts = {}) {
2600
+ const { config, paths } = await loadConfig({ cwd: opts.cwd });
2601
+ const context = {
2602
+ framework: config.app.framework ?? null,
2603
+ packageManager: config.app.packageManager ?? null
2604
+ };
2605
+ const prompt = buildResetPrompt(context);
2606
+ const snippet = resetConfigSnippet(context);
2607
+ log.blank();
2608
+ log.step("Set up a clean test user for Seatbelt");
2609
+ log.blank();
2610
+ log.plain("Before Seatbelt tests your app, it needs to start from the same place every time \u2014");
2611
+ log.plain("a known QA user with a predictable, clean state (onboarding not done, cart empty,");
2612
+ log.plain("no paid access, uploads/progress cleared). Seatbelt can't guess your database, so");
2613
+ log.plain("it writes a prompt that tells Claude / Cursor / Codex to add this safely in your app.");
2614
+ log.plain("");
2615
+ log.dim("Seatbelt runs this command for you before each `seatbelt run` \u2014 you won't run it by hand.");
2616
+ log.blank();
2617
+ log.plain(pc.bold("1. Paste this prompt into Claude Code / Cursor / Codex:"));
2618
+ log.blank();
2619
+ log.plain(pc.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
2620
+ log.plain(prompt);
2621
+ log.plain(pc.dim("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
2622
+ log.blank();
2623
+ log.plain(pc.bold("2. Then add this to ") + pc.cyan("seatbelt.config.yaml") + pc.bold(":"));
2624
+ log.blank();
2625
+ log.plain(snippet);
2626
+ const promptFile = path12.join(paths.seatbeltDir, "reset-prompt.md");
2627
+ await ensureDir(paths.seatbeltDir);
2628
+ await writeText(
2629
+ promptFile,
2630
+ `# Seatbelt \u2014 clean test user prompt
2631
+
2632
+ Paste the prompt below into your coding agent.
2633
+
2634
+ \`\`\`
2635
+ ${prompt}
2636
+ \`\`\`
2637
+
2638
+ ## Then add to seatbelt.config.yaml
2639
+
2640
+ \`\`\`yaml
2641
+ ${snippet}
2642
+ \`\`\`
2643
+ `
2644
+ );
2645
+ log.blank();
2646
+ log.success(`Saved a copy to ${pc.cyan(displayPath(paths.cwd, promptFile))}`);
2647
+ log.blank();
2648
+ log.plain(pc.bold("Next:"));
2649
+ log.plain(` \u2022 Once your agent adds ${pc.cyan(resetRunCommand(context))}, run ${pc.green("seatbelt run")}.`);
2650
+ log.plain(` \u2022 Seatbelt will run your reset automatically before the browser flows.`);
2651
+ log.blank();
2652
+ log.dim("This command only generated a prompt \u2014 it did not run or change anything in your app.");
2653
+ log.blank();
2654
+ return 0;
2655
+ }
2656
+
2657
+ // src/commands/init.ts
2658
+ var FLOWS_DIRNAME = "qa-flows";
2659
+ async function ensureGitignore(gitignorePath, entry) {
2660
+ if (!exists(gitignorePath)) {
2661
+ await writeText(gitignorePath, `${entry}
2662
+ `);
2663
+ return "created";
2664
+ }
2665
+ const current = await readText(gitignorePath);
2666
+ const lines = current.split(/\r?\n/).map((l) => l.trim());
2667
+ if (lines.includes(entry) || lines.includes(entry.replace(/\/$/, ""))) {
2668
+ return "present";
2669
+ }
2670
+ const needsNewline = current.length > 0 && !current.endsWith("\n");
2671
+ await writeText(gitignorePath, current + (needsNewline ? "\n" : "") + `${entry}
2672
+ `);
2673
+ return "appended";
2674
+ }
2675
+ async function initCommand(opts = {}) {
2676
+ const paths = resolvePaths(opts.cwd);
2677
+ const created = [];
2678
+ const kept = [];
2679
+ let configCreated = false;
2680
+ log.blank();
2681
+ log.step("Setting up Seatbelt in this project\u2026");
2682
+ if (exists(paths.configFile)) {
2683
+ kept.push(CONFIG_FILENAME);
2684
+ } else {
2685
+ await writeText(paths.configFile, STARTER_CONFIG_YAML);
2686
+ created.push(CONFIG_FILENAME);
2687
+ configCreated = true;
2688
+ }
2689
+ await ensureDir(paths.seatbeltDir);
2690
+ const flowsDir = path13.join(paths.cwd, FLOWS_DIRNAME);
2691
+ const createdFlowIds = await ensureFlowsDir(flowsDir);
2692
+ if (createdFlowIds.length) {
2693
+ created.push(`${FLOWS_DIRNAME}/ (${createdFlowIds.length} starter flows: ${createdFlowIds.join(", ")})`);
2694
+ } else {
2695
+ kept.push(`${FLOWS_DIRNAME}/ (already has flows)`);
2696
+ }
2697
+ if (exists(paths.stateFile)) {
2698
+ kept.push(".seatbelt/state.json");
2699
+ } else {
2700
+ const { flows } = await loadFlows(paths.cwd, FLOWS_DIRNAME, { create: false });
2701
+ let state = seedState("My app");
2702
+ state = syncFlowsFromYaml(
2703
+ state,
2704
+ flows.map((f) => ({
2705
+ id: f.id,
2706
+ name: f.name,
2707
+ description: f.description,
2708
+ tags: f.tags,
2709
+ dependencies: f.dependencies,
2710
+ requires: f.requires,
2711
+ needs: f.needs,
2712
+ declaredStatus: f.status
2713
+ }))
2714
+ );
2715
+ await saveState(paths.stateFile, state);
2716
+ created.push(".seatbelt/state.json");
2717
+ }
2718
+ const gi = await ensureGitignore(paths.gitignore, ".seatbelt/");
2719
+ log.blank();
2720
+ if (created.length) {
2721
+ for (const f of created) log.success(`Created ${pc.bold(f)}`);
2722
+ }
2723
+ if (kept.length) {
2724
+ for (const f of kept) log.info(`Kept existing ${pc.bold(f)} (not overwritten)`);
2725
+ }
2726
+ if (gi === "created") log.success("Created .gitignore with .seatbelt/");
2727
+ else if (gi === "appended") log.success("Added .seatbelt/ to .gitignore");
2728
+ else log.info(".seatbelt/ already in .gitignore");
2729
+ log.blank();
2730
+ log.success("Seatbelt is set up in this repo.");
2731
+ log.dim("By using Seatbelt you agree to the Terms and Privacy Policy at https://getseatbelt.ai/terms.");
2732
+ log.blank();
2733
+ const shouldRun = opts.run === true || opts.run !== false && await offer("Run Seatbelt now to watch your first browser check?", { autoYes: opts.yes });
2734
+ if (shouldRun) {
2735
+ try {
2736
+ await runCommand({ cwd: opts.cwd, yes: opts.yes });
2737
+ } catch (err) {
2738
+ printError(err);
2739
+ log.blank();
2740
+ log.warn("Setup is done, but that first run didn't finish. Fix the issue above, then run `seatbelt run`.");
2741
+ }
2742
+ await maybeOfferReset(opts, configCreated);
2743
+ log.blank();
2744
+ log.plain(`Run ${pc.green("seatbelt run")} again after your next AI change.`);
2745
+ log.blank();
2746
+ log.dim("Seatbelt runs locally. Nothing leaves your machine.");
2747
+ log.blank();
2748
+ return 0;
2749
+ }
2750
+ await maybeOfferReset(opts, configCreated);
2751
+ log.blank();
2752
+ log.plain(pc.bold("You're set. Next steps:"));
2753
+ log.plain("");
2754
+ log.plain(` \u2022 Run ${pc.green("npx seatbelt run")} to watch your first browser check and open a report.`);
2755
+ log.plain(` \u2022 Configure the "needs setup" flows in ${pc.cyan(FLOWS_DIRNAME + "/")} (confirm routes/selectors,`);
2756
+ log.plain(` add ${pc.cyan("SEATBELT_TEST_EMAIL")}/${pc.cyan("SEATBELT_TEST_PASSWORD")}); see the map with ${pc.green("seatbelt certify")}.`);
2757
+ log.plain(` \u2022 Set up a clean test user any time with ${pc.green("seatbelt setup-reset")}.`);
2758
+ log.blank();
2759
+ log.dim("Seatbelt runs locally. Nothing leaves your machine.");
2760
+ log.blank();
2761
+ return 0;
2762
+ }
2763
+ async function maybeOfferReset(opts, configCreated) {
2764
+ if (!configCreated) return;
2765
+ const want = await offer(
2766
+ "Set up a clean test user, so every run starts from a known-clean account?",
2767
+ { autoYes: opts.yes }
2768
+ );
2769
+ if (want) {
2770
+ await setupResetCommand({ cwd: opts.cwd });
2771
+ }
2772
+ }
2773
+
2774
+ // src/commands/certify.ts
2775
+ function statusTag(status) {
2776
+ switch (status) {
2777
+ case "green":
2778
+ return pc.green("green ");
2779
+ case "red":
2780
+ return pc.red("red ");
2781
+ case "yellow":
2782
+ return pc.yellow("yellow ");
2783
+ case "blocked":
2784
+ return pc.yellow("blocked");
2785
+ default:
2786
+ return pc.dim("grey ");
2787
+ }
2788
+ }
2789
+ function depsSummary(f, requiresEnv) {
2790
+ const parts = [];
2791
+ if (f.dependencies.routes.length) parts.push(`routes: ${f.dependencies.routes.join(", ")}`);
2792
+ if (requiresEnv.length) parts.push(`env: ${requiresEnv.join(", ")}`);
2793
+ if (f.dependencies.services.length) parts.push(`services: ${f.dependencies.services.join(", ")}`);
2794
+ return parts.join(" \xB7 ") || pc.dim("\u2014");
2795
+ }
2796
+ function lastRunLabel(f) {
2797
+ if (!f.lastRunAt) return pc.dim("never");
2798
+ const when = f.lastRunAt.slice(0, 16).replace("T", " ");
2799
+ const cert = f.certification.lastCertifiedAt ? " \u2713certified" : "";
2800
+ return `${when}${pc.dim(cert)}`;
2801
+ }
2802
+ async function certifyCommand(opts = {}) {
2803
+ const { config, paths } = await loadConfig({ cwd: opts.cwd });
2804
+ const { flows, flowsDir, createdFlowIds, errors } = await loadFlows(paths.cwd, config.flows.dir);
2805
+ log.blank();
2806
+ log.step("Seatbelt certify \u2014 QA-certified flow map");
2807
+ if (createdFlowIds.length) {
2808
+ log.info(
2809
+ `Created ${createdFlowIds.length} starter flow${createdFlowIds.length > 1 ? "s" : ""} in ${pc.cyan(displayPath(paths.cwd, flowsDir))} (${createdFlowIds.join(", ")}).`
2810
+ );
2811
+ }
2812
+ for (const e of errors) log.warn(`Skipping ${e.file}: ${e.message}`);
2813
+ let state = await loadOrSeedState(paths.stateFile, config.app.name);
2814
+ state = syncFlowsFromYaml(
2815
+ state,
2816
+ flows.map((f) => ({
2817
+ id: f.id,
2818
+ name: f.name,
2819
+ description: f.description,
2820
+ tags: f.tags,
2821
+ dependencies: f.dependencies,
2822
+ requires: f.requires,
2823
+ needs: f.needs,
2824
+ declaredStatus: f.status
2825
+ }))
2826
+ );
2827
+ await saveState(paths.stateFile, state);
2828
+ const meta = readFlowMeta(paths);
2829
+ const staleNotes = [];
2830
+ for (const f of flows) {
2831
+ const current = fingerprintDependencies(paths.cwd, f.dependencies);
2832
+ const { stale, changedFiles } = detectStale(current, meta.flows[f.id]?.fingerprint);
2833
+ if (stale) staleNotes.push(`${f.id}: ${changedFiles.join(", ")}`);
2834
+ meta.flows[f.id] = {
2835
+ dependencies: f.dependencies,
2836
+ fingerprint: current,
2837
+ // Preserve any prior certification stamps; --run will set fresh ones.
2838
+ lastCertifiedRunId: meta.flows[f.id]?.lastCertifiedRunId ?? null,
2839
+ lastCertifiedAt: meta.flows[f.id]?.lastCertifiedAt ?? null
2840
+ };
2841
+ }
2842
+ await writeFlowMeta(paths, meta);
2843
+ const byId = new Map(state.flows.map((s) => [s.id, s]));
2844
+ const reqEnvById = new Map(flows.map((f) => [f.id, requiredEnvForFlow(f)]));
2845
+ log.blank();
2846
+ log.plain(pc.bold(" Status Flow Last run Depends on"));
2847
+ for (const f of flows) {
2848
+ const s = byId.get(f.id);
2849
+ if (!s) continue;
2850
+ const id = f.id.padEnd(24).slice(0, 24);
2851
+ log.plain(
2852
+ ` ${statusTag(s.status)} ${id} ${lastRunLabel(s).padEnd(18)} ${depsSummary(s, reqEnvById.get(f.id) ?? [])}`
2853
+ );
2854
+ }
2855
+ log.blank();
2856
+ const needsSetup = flows.filter((f) => isNeedsSetup(f)).map((f) => f.id);
2857
+ if (needsSetup.length) {
2858
+ log.dim(`Needs setup (skipped in bulk runs): ${needsSetup.join(", ")} \u2014 configure, then remove "status: yellow".`);
2859
+ }
2860
+ if (staleNotes.length) {
2861
+ log.warn(`Possibly stale since last certified: ${staleNotes.join(" \xB7 ")}`);
2862
+ }
2863
+ const shouldRun = opts.run === true || opts.run !== false && opts.yes === true;
2864
+ if (!shouldRun) {
2865
+ log.blank();
2866
+ log.plain(
2867
+ `${pc.bold("Map synced.")} To verify every eligible flow in a real browser and certify them, run ${pc.green("seatbelt certify --run")}.`
2868
+ );
2869
+ log.blank();
2870
+ return 0;
2871
+ }
2872
+ log.blank();
2873
+ log.step("Certifying \u2014 running all eligible flows\u2026");
2874
+ return runCommand({
2875
+ cwd: opts.cwd,
2876
+ target: "all",
2877
+ open: opts.open,
2878
+ headed: opts.headed,
2879
+ yes: opts.yes,
2880
+ mode: "certify"
2881
+ });
2882
+ }
2883
+
2884
+ // src/commands/generateFlows.ts
2885
+ import fs12 from "fs";
2886
+ import path18 from "path";
2887
+ import readline2 from "readline/promises";
2888
+ import { stdin as input2, stdout as output2 } from "process";
2889
+
2890
+ // src/inspect/app.ts
2891
+ import fs10 from "fs";
2892
+ import path16 from "path";
2893
+ import YAML3 from "yaml";
2894
+
2895
+ // src/inspect/routes.ts
2896
+ import fs8 from "fs";
2897
+ import path14 from "path";
2898
+ var ROUTE_EXT_RE = /\.(tsx|ts|jsx|js|vue|svelte)$/i;
2899
+ var SOURCE_EXT_RE = /\.(tsx|ts|jsx|js|vue|svelte|html)$/i;
2900
+ var SKIP_DIRS = /* @__PURE__ */ new Set([
2901
+ ".git",
2902
+ ".next",
2903
+ ".seatbelt",
2904
+ "node_modules",
2905
+ "dist",
2906
+ "build",
2907
+ "coverage",
2908
+ "playwright-report",
2909
+ "test-results"
2910
+ ]);
2911
+ function walk(dir, cwd, maxFiles = 500) {
2912
+ const out = [];
2913
+ const visit = (current) => {
2914
+ if (out.length >= maxFiles) return;
2915
+ let entries;
2916
+ try {
2917
+ entries = fs8.readdirSync(current, { withFileTypes: true });
2918
+ } catch {
2919
+ return;
2920
+ }
2921
+ for (const entry of entries) {
2922
+ if (out.length >= maxFiles) return;
2923
+ if (entry.isDirectory()) {
2924
+ if (!SKIP_DIRS.has(entry.name)) visit(path14.join(current, entry.name));
2925
+ } else if (entry.isFile()) {
2926
+ out.push(path14.relative(cwd, path14.join(current, entry.name)).split(path14.sep).join("/"));
2927
+ }
2928
+ }
2929
+ };
2930
+ if (exists(dir)) visit(dir);
2931
+ return out;
2932
+ }
2933
+ function normalizeRoute(route) {
2934
+ let out = route.replace(/\\/g, "/").replace(/\/index$/i, "");
2935
+ out = out.replace(/\(([^/]+)\)\//g, "").replace(/\/\(([^/]+)\)/g, "");
2936
+ out = out.replace(/\/@[^/]+/g, "");
2937
+ out = out.replace(/\[\.\.\.([^\]]+)\]/g, ":$1*").replace(/\[([^\]]+)\]/g, ":$1");
2938
+ out = out.replace(/\/+/g, "/");
2939
+ if (!out.startsWith("/")) out = "/" + out;
2940
+ return out === "" ? "/" : out;
2941
+ }
2942
+ function addRoute(routes, cwd, file, route, source) {
2943
+ const normalized = normalizeRoute(route);
2944
+ if (!routes.has(normalized)) routes.set(normalized, { route: normalized, file, source });
2945
+ }
2946
+ function nextAppRoute(cwd, file) {
2947
+ const parts = file.split("/");
2948
+ const appIndex = parts.indexOf("app");
2949
+ if (appIndex === -1 || !/^page\./i.test(parts[parts.length - 1] ?? "")) return null;
2950
+ return parts.slice(appIndex + 1, -1).join("/") || "/";
2951
+ }
2952
+ function nextPagesRoute(cwd, file) {
2953
+ const parts = file.split("/");
2954
+ const pagesIndex = parts.indexOf("pages");
2955
+ if (pagesIndex === -1) return null;
2956
+ const last = parts[parts.length - 1] ?? "";
2957
+ if (!ROUTE_EXT_RE.test(last) || last.startsWith("_") || last.startsWith("api.")) return null;
2958
+ const stem = last.replace(ROUTE_EXT_RE, "");
2959
+ return [...parts.slice(pagesIndex + 1, -1), stem].join("/") || "/";
2960
+ }
2961
+ function nuxtRoute(file) {
2962
+ const parts = file.split("/");
2963
+ const pagesIndex = parts.indexOf("pages");
2964
+ if (pagesIndex === -1) return null;
2965
+ const last = parts[parts.length - 1] ?? "";
2966
+ if (!ROUTE_EXT_RE.test(last)) return null;
2967
+ const stem = last.replace(ROUTE_EXT_RE, "");
2968
+ return [...parts.slice(pagesIndex + 1, -1), stem].join("/") || "/";
2969
+ }
2970
+ function routeHintsFromSource(text) {
2971
+ const routes = /* @__PURE__ */ new Set();
2972
+ const patterns = [
2973
+ /(?:href|to|path)\s*=\s*["'`]((?:\/[a-zA-Z0-9@:%_+~#?&=.,/-]+)+)["'`]/g,
2974
+ /(?:router\.push|navigate|redirect)\(\s*["'`]((?:\/[a-zA-Z0-9@:%_+~#?&=.,/-]+)+)["'`]/g
2975
+ ];
2976
+ for (const re of patterns) {
2977
+ for (const match of text.matchAll(re)) {
2978
+ const route = match[1];
2979
+ if (route && !route.startsWith("//")) routes.add(route.split("?")[0] ?? route);
2980
+ }
2981
+ }
2982
+ return [...routes].filter((r) => r.length < 80);
2983
+ }
2984
+ function detectRoutes(cwd) {
2985
+ const routes = /* @__PURE__ */ new Map();
2986
+ for (const file of walk(path14.join(cwd, "app"), cwd)) {
2987
+ const route = nextAppRoute(cwd, file);
2988
+ if (route) addRoute(routes, cwd, file, route, "next-app");
2989
+ }
2990
+ for (const file of walk(path14.join(cwd, "pages"), cwd)) {
2991
+ const nextRoute = nextPagesRoute(cwd, file);
2992
+ if (nextRoute) addRoute(routes, cwd, file, nextRoute, "next-pages");
2993
+ const nuxt = nuxtRoute(file);
2994
+ if (nuxt) addRoute(routes, cwd, file, nuxt, "nuxt");
2995
+ }
2996
+ const sourceRoots = ["src", "components", "routes"].filter((d) => exists(path14.join(cwd, d)));
2997
+ for (const root of sourceRoots) {
2998
+ for (const file of walk(path14.join(cwd, root), cwd, 250).filter((f) => SOURCE_EXT_RE.test(f))) {
2999
+ let text = "";
3000
+ try {
3001
+ const abs = path14.join(cwd, file);
3002
+ const stat = fs8.statSync(abs);
3003
+ if (stat.size > 8e4) continue;
3004
+ text = fs8.readFileSync(abs, "utf8");
3005
+ } catch {
3006
+ continue;
3007
+ }
3008
+ for (const route of routeHintsFromSource(text)) {
3009
+ const source = /createRouter|createWebHistory|<Router|react-router|useRoutes/.test(text) ? file.endsWith(".vue") ? "vue-router" : "vite-react" : "generic";
3010
+ addRoute(routes, cwd, file, route, source);
3011
+ }
3012
+ }
3013
+ }
3014
+ return [...routes.values()].sort((a, b) => a.route.localeCompare(b.route));
3015
+ }
3016
+
3017
+ // src/ai/privacy.ts
3018
+ import fs9 from "fs";
3019
+ import path15 from "path";
3020
+ var BLOCKED_DIRS = /* @__PURE__ */ new Set([
3021
+ ".git",
3022
+ "node_modules",
3023
+ "dist",
3024
+ "build",
3025
+ "coverage",
3026
+ ".next",
3027
+ ".nuxt",
3028
+ ".output",
3029
+ ".seatbelt/artifacts",
3030
+ "playwright-report",
3031
+ "test-results"
3032
+ ]);
3033
+ var BLOCKED_EXTS = /* @__PURE__ */ new Set([
3034
+ ".png",
3035
+ ".jpg",
3036
+ ".jpeg",
3037
+ ".gif",
3038
+ ".webp",
3039
+ ".ico",
3040
+ ".mp4",
3041
+ ".mov",
3042
+ ".webm",
3043
+ ".zip",
3044
+ ".gz",
3045
+ ".pdf",
3046
+ ".woff",
3047
+ ".woff2",
3048
+ ".ttf",
3049
+ ".eot",
3050
+ ".exe",
3051
+ ".dll",
3052
+ ".so",
3053
+ ".dylib"
3054
+ ]);
3055
+ var ALLOWED_EXTS = /* @__PURE__ */ new Set([
3056
+ ".ts",
3057
+ ".tsx",
3058
+ ".js",
3059
+ ".jsx",
3060
+ ".mjs",
3061
+ ".cjs",
3062
+ ".vue",
3063
+ ".svelte",
3064
+ ".html",
3065
+ ".css",
3066
+ ".scss",
3067
+ ".md",
3068
+ ".json",
3069
+ ".yaml",
3070
+ ".yml"
3071
+ ]);
3072
+ var LOCKFILES = /* @__PURE__ */ new Set(["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb"]);
3073
+ var DEFAULT_MAX_FILE_BYTES = 4e4;
3074
+ function norm(relPath) {
3075
+ return relPath.split(path15.sep).join("/");
3076
+ }
3077
+ function privacyDecision(relPath, sizeBytes = 0, maxBytes = DEFAULT_MAX_FILE_BYTES) {
3078
+ const rel = norm(relPath);
3079
+ const lower = rel.toLowerCase();
3080
+ const base = path15.posix.basename(lower);
3081
+ const ext = path15.posix.extname(lower);
3082
+ if (base === ".env" || base.startsWith(".env.")) return { allowed: false, reason: "env values are never sent" };
3083
+ if (base.endsWith(".pem") || base.endsWith(".key") || base.endsWith(".p12")) return { allowed: false, reason: "key material is never sent" };
3084
+ if (LOCKFILES.has(base)) return { allowed: false, reason: "lockfile excluded" };
3085
+ if (BLOCKED_EXTS.has(ext)) return { allowed: false, reason: "binary/media file excluded" };
3086
+ for (const dir of BLOCKED_DIRS) {
3087
+ if (lower === dir || lower.startsWith(dir + "/") || lower.includes("/" + dir + "/")) {
3088
+ return { allowed: false, reason: `${dir} excluded` };
3089
+ }
3090
+ }
3091
+ if (sizeBytes > maxBytes) return { allowed: false, reason: `larger than ${maxBytes} bytes` };
3092
+ if (!ALLOWED_EXTS.has(ext)) return { allowed: false, reason: "file type excluded" };
3093
+ return { allowed: true, reason: "allowed source file" };
3094
+ }
3095
+ function redactSecrets(input3) {
3096
+ let out = input3;
3097
+ const replacements = [
3098
+ [/(sk_live_[A-Za-z0-9_]+)/g, "[REDACTED_STRIPE_SECRET]"],
3099
+ [/(sk_test_[A-Za-z0-9_]+)/g, "[REDACTED_STRIPE_SECRET]"],
3100
+ [/(pk_live_[A-Za-z0-9_]+)/g, "[REDACTED_STRIPE_PUBLISHABLE]"],
3101
+ [/(OPENAI_API_KEY\s*[:=]\s*)["']?[^"'\s]+/gi, "$1[REDACTED]"],
3102
+ [/(api[_-]?key\s*[:=]\s*)["']?[^"'\s]+/gi, "$1[REDACTED]"],
3103
+ [/(token\s*[:=]\s*)["']?[^"'\s]+/gi, "$1[REDACTED]"],
3104
+ [/(password\s*[:=]\s*)["']?[^"'\n]+/gi, "$1[REDACTED]"],
3105
+ [/(jwt[_-]?secret\s*[:=]\s*)["']?[^"'\s]+/gi, "$1[REDACTED]"],
3106
+ [/(mongodb(?:\+srv)?:\/\/)[^\s"']+/gi, "$1[REDACTED]"],
3107
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "[REDACTED_PRIVATE_KEY]"]
3108
+ ];
3109
+ for (const [re, replacement] of replacements) out = out.replace(re, replacement);
3110
+ return out;
3111
+ }
3112
+ function collectEnvVarNames(cwd) {
3113
+ const names = /* @__PURE__ */ new Set();
3114
+ let files2 = [];
3115
+ try {
3116
+ files2 = fs9.readdirSync(cwd).filter((f) => f === ".env" || f.startsWith(".env."));
3117
+ } catch {
3118
+ return [];
3119
+ }
3120
+ for (const file of files2) {
3121
+ try {
3122
+ const text = fs9.readFileSync(path15.join(cwd, file), "utf8");
3123
+ for (const line2 of text.split(/\r?\n/)) {
3124
+ const match = line2.match(/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=/);
3125
+ if (match?.[1]) names.add(match[1]);
3126
+ }
3127
+ } catch {
3128
+ }
3129
+ }
3130
+ return [...names].sort();
3131
+ }
3132
+ function collectSourceFiles(cwd, relPaths, maxFiles = 30) {
3133
+ const included = [];
3134
+ const excluded = [];
3135
+ const seen = /* @__PURE__ */ new Set();
3136
+ for (const relPath of relPaths) {
3137
+ const rel = norm(relPath);
3138
+ if (seen.has(rel)) continue;
3139
+ seen.add(rel);
3140
+ const abs = path15.join(cwd, rel);
3141
+ let stat;
3142
+ try {
3143
+ stat = fs9.statSync(abs);
3144
+ } catch {
3145
+ excluded.push({ path: rel, reason: "file not found" });
3146
+ continue;
3147
+ }
3148
+ const decision = privacyDecision(rel, stat.size);
3149
+ if (!decision.allowed) {
3150
+ excluded.push({ path: rel, reason: decision.reason });
3151
+ continue;
3152
+ }
3153
+ if (included.length >= maxFiles) {
3154
+ excluded.push({ path: rel, reason: "source file budget reached" });
3155
+ continue;
3156
+ }
3157
+ try {
3158
+ included.push({ path: rel, content: redactSecrets(fs9.readFileSync(abs, "utf8")) });
3159
+ } catch {
3160
+ excluded.push({ path: rel, reason: "could not read file" });
3161
+ }
3162
+ }
3163
+ return { included, excluded };
3164
+ }
3165
+
3166
+ // src/inspect/app.ts
3167
+ function readPackageSummary(cwd) {
3168
+ const file = path16.join(cwd, "package.json");
3169
+ if (!exists(file)) return null;
3170
+ try {
3171
+ const raw = JSON.parse(fs10.readFileSync(file, "utf8"));
3172
+ return {
3173
+ name: raw.name ?? null,
3174
+ scripts: raw.scripts ?? {},
3175
+ dependencies: Object.keys(raw.dependencies ?? {}).sort(),
3176
+ devDependencies: Object.keys(raw.devDependencies ?? {}).sort()
3177
+ };
3178
+ } catch {
3179
+ return null;
3180
+ }
3181
+ }
3182
+ function listFlowFiles2(cwd, flowsDir) {
3183
+ const dir = path16.join(cwd, flowsDir);
3184
+ try {
3185
+ return fs10.readdirSync(dir).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")).sort().map((f) => path16.join(flowsDir, f).split(path16.sep).join("/"));
3186
+ } catch {
3187
+ return [];
3188
+ }
3189
+ }
3190
+ async function existingFlowSummaries(cwd, flowsDir) {
3191
+ const out = [];
3192
+ for (const rel of listFlowFiles2(cwd, flowsDir)) {
3193
+ const yaml = await readText(path16.join(cwd, rel)).catch(() => "");
3194
+ let parsed = {};
3195
+ try {
3196
+ parsed = YAML3.parse(yaml) ?? {};
3197
+ } catch {
3198
+ parsed = {};
3199
+ }
3200
+ out.push({
3201
+ id: typeof parsed.id === "string" ? parsed.id : path16.basename(rel).replace(/\.(ya?ml)$/i, ""),
3202
+ name: typeof parsed.name === "string" ? parsed.name : "",
3203
+ file: rel,
3204
+ status: typeof parsed.status === "string" ? parsed.status : null,
3205
+ yaml
3206
+ });
3207
+ }
3208
+ return out;
3209
+ }
3210
+ function routeSet(routes) {
3211
+ return new Set(routes.map((r) => r.route.toLowerCase()));
3212
+ }
3213
+ function hasAnyRoute(routes, parts) {
3214
+ return [...routes].some((r) => parts.some((p) => r.includes(p)));
3215
+ }
3216
+ function inferCandidateFlows(routes, pkg) {
3217
+ const routeNames = routeSet(routes);
3218
+ const deps = new Set([...pkg?.dependencies ?? [], ...pkg?.devDependencies ?? []].map((d) => d.toLowerCase()));
3219
+ const out = /* @__PURE__ */ new Set(["homepage-smoke"]);
3220
+ if (hasAnyRoute(routeNames, ["/login", "/signin", "/sign-in", "/auth"])) out.add("signin-smoke");
3221
+ if (hasAnyRoute(routeNames, ["/signup", "/register", "/sign-up"])) out.add("signup-smoke");
3222
+ if (hasAnyRoute(routeNames, ["/onboarding", "/corridor", "/welcome", "/setup"])) out.add("onboarding-smoke");
3223
+ if (hasAnyRoute(routeNames, ["/dashboard", "/app", "/account", "/overview", "/courses"])) out.add("dashboard-smoke");
3224
+ if (hasAnyRoute(routeNames, ["/upload", "/import", "/documents", "/files"])) out.add("upload-smoke");
3225
+ if (hasAnyRoute(routeNames, ["/pricing", "/checkout", "/billing", "/upgrade"]) || deps.has("stripe")) out.add("checkout-smoke");
3226
+ if (hasAnyRoute(routeNames, ["/pro", "/entitlement", "/unlock", "/billing"])) out.add("entitlement-smoke");
3227
+ return [...out];
3228
+ }
3229
+ function likelySourceFiles(cwd, routes, flowsDir, existing) {
3230
+ const files2 = /* @__PURE__ */ new Set();
3231
+ files2.add("package.json");
3232
+ files2.add("seatbelt.config.yaml");
3233
+ for (const route of routes.slice(0, 40)) files2.add(route.file);
3234
+ for (const flow of existing) files2.add(flow.file);
3235
+ const likelyNames = [
3236
+ "middleware.ts",
3237
+ "middleware.js",
3238
+ "src/router.ts",
3239
+ "src/router.js",
3240
+ "src/routes.tsx",
3241
+ "src/App.tsx",
3242
+ "src/App.jsx"
3243
+ ];
3244
+ for (const rel of likelyNames) files2.add(rel);
3245
+ return [...files2].filter((rel) => rel.startsWith(flowsDir + "/") || exists(path16.join(cwd, rel)));
3246
+ }
3247
+ async function inspectApp(paths, config) {
3248
+ const detection = await detect(paths);
3249
+ const packageJson = readPackageSummary(paths.cwd);
3250
+ const routes = detectRoutes(paths.cwd);
3251
+ const existingFlows = await existingFlowSummaries(paths.cwd, config.flows.dir);
3252
+ const flowLoad = await loadFlows(paths.cwd, config.flows.dir, { create: false });
3253
+ const meta = readFlowMeta(paths);
3254
+ const staleFlows = [];
3255
+ for (const flow of flowLoad.flows) {
3256
+ const current = fingerprintDependencies(paths.cwd, flow.dependencies);
3257
+ const stale = detectStale(current, meta.flows[flow.id]?.fingerprint);
3258
+ if (stale.stale) staleFlows.push({ id: flow.id, changedFiles: stale.changedFiles });
3259
+ }
3260
+ let stateFlowIds = [];
3261
+ try {
3262
+ const raw = JSON.parse(fs10.readFileSync(paths.stateFile, "utf8"));
3263
+ stateFlowIds = (raw.flows ?? []).map((f) => f.id).filter((id) => Boolean(id));
3264
+ } catch {
3265
+ stateFlowIds = [];
3266
+ }
3267
+ const candidates = inferCandidateFlows(routes, packageJson);
3268
+ const sourcePaths = [
3269
+ ...likelySourceFiles(paths.cwd, routes, config.flows.dir, existingFlows),
3270
+ ...staleFlows.flatMap((f) => f.changedFiles)
3271
+ ];
3272
+ const source = collectSourceFiles(paths.cwd, sourcePaths);
3273
+ return {
3274
+ cwd: paths.cwd,
3275
+ packageManager: detection.packageManager,
3276
+ framework: detection.framework,
3277
+ packageJson,
3278
+ routes,
3279
+ existingFlows,
3280
+ stateFlowIds,
3281
+ staleFlows,
3282
+ candidateFlows: candidates,
3283
+ envVarNames: collectEnvVarNames(paths.cwd),
3284
+ source
3285
+ };
3286
+ }
3287
+
3288
+ // src/ai/prompt.ts
3289
+ function formatInspectionForPrompt(inspection) {
3290
+ return JSON.stringify(buildHostedProjectPayload(inspection), null, 2);
3291
+ }
3292
+ function buildHostedProjectPayload(inspection) {
3293
+ return {
3294
+ packageManager: inspection.packageManager,
3295
+ framework: inspection.framework,
3296
+ packageJson: inspection.packageJson ? {
3297
+ name: inspection.packageJson.name,
3298
+ scripts: inspection.packageJson.scripts,
3299
+ dependencies: inspection.packageJson.dependencies,
3300
+ devDependencies: inspection.packageJson.devDependencies
3301
+ } : null,
3302
+ scripts: inspection.packageJson?.scripts ?? {},
3303
+ routes: inspection.routes.map((r) => ({ route: r.route, file: r.file, source: r.source })),
3304
+ existingFlows: inspection.existingFlows.map((f) => ({
3305
+ id: f.id,
3306
+ name: f.name,
3307
+ file: f.file,
3308
+ status: f.status
3309
+ })),
3310
+ stateFlowIds: inspection.stateFlowIds,
3311
+ staleFlows: inspection.staleFlows,
3312
+ candidateFlows: inspection.candidateFlows,
3313
+ envVarNames: inspection.envVarNames,
3314
+ envVarNamesOnly: inspection.envVarNames,
3315
+ includedFiles: inspection.source.included.map((f) => f.path),
3316
+ excludedFiles: inspection.source.excluded,
3317
+ sourceFiles: inspection.source.included.map((f) => ({ path: f.path, content: f.content })),
3318
+ redactionSummary: {
3319
+ envValuesSent: false,
3320
+ secretsRedacted: true,
3321
+ excludedCount: inspection.source.excluded.length
3322
+ }
3323
+ };
3324
+ }
3325
+ function buildFlowPrompt(inspection, mode, targetFlows = []) {
3326
+ const scope = targetFlows.length > 0 ? `Only generate or refresh these flow ids: ${targetFlows.join(", ")}.` : "Generate only flows supported by the detected routes and source evidence.";
3327
+ return `You are SeatbeltAI's flow authoring layer.
3328
+
3329
+ SeatbeltAI is QA for AI-coded apps before merge. AI writes deterministic Seatbelt YAML. Playwright runs that YAML later. Do not generate code. Do not invent unsupported app journeys.
3330
+
3331
+ Task: ${mode === "refresh" ? "refresh stale existing Seatbelt flow YAML" : "generate starter Seatbelt flow YAML"}.
3332
+ ${scope}
3333
+
3334
+ Return ONLY one or more YAML documents, fenced as yaml or separated by "---". No prose outside YAML.
3335
+
3336
+ Current Seatbelt flow schema:
3337
+ - Required: id, name, steps.
3338
+ - Optional/expected: description, critical, status, tags, dependencies, requires, needs.
3339
+ - id must be lowercase letters/numbers/dashes and match the intended file stem.
3340
+ - status may be grey, green, yellow, red, blocked. Use "yellow" when human review/setup is needed.
3341
+ - dependencies shape:
3342
+ dependencies:
3343
+ files: ["relative/source/file.tsx"]
3344
+ routes: ["/route"]
3345
+ services: []
3346
+ - requires shape:
3347
+ requires:
3348
+ env: [SEATBELT_TEST_EMAIL, SEATBELT_TEST_PASSWORD]
3349
+ - Use needs for authenticated flows that depend on sign-in, for example:
3350
+ needs:
3351
+ - signin-smoke
3352
+
3353
+ Supported step syntax in this CLI:
3354
+ - goto: /path
3355
+ - click: "text=Sign in" OR "button[type=submit]"
3356
+ - fill:
3357
+ selector: "input[type=email]"
3358
+ valueFromEnv: SEATBELT_TEST_EMAIL
3359
+ - press: Enter
3360
+ - selectOption: { selector: "select[name=plan]", value: "pro" }
3361
+ - upload: { selector: "input[type=file]", path: "qa-fixtures/sample.pdf" }
3362
+ - waitForUrlContains: "/dashboard"
3363
+ - waitForText: "Dashboard"
3364
+ - waitForTimeoutMs: 1000
3365
+ - expectUrlContains: "/dashboard"
3366
+ - expectText: "Dashboard"
3367
+ - expectVisible: "text=Dashboard"
3368
+ - expectNotVisible: "text=Upgrade"
3369
+ - expectTitleContains: ""
3370
+ - screenshot: after-login
3371
+
3372
+ Rules:
3373
+ - Use selector strings compatible with Playwright locator(), such as text=..., input[type=email], button[type=submit], [data-testid=...].
3374
+ - Prefer visible text/data-testid/input type/placeholder-derived CSS over brittle deep CSS.
3375
+ - Use valueFromEnv for credentials. Never include secret values.
3376
+ - Avoid destructive actions. Checkout flows should be cautious and status: yellow unless app evidence is clear.
3377
+ - Include comments only inside the YAML if they help human review.
3378
+ - Include dependencies.files and dependencies.routes for freshness.
3379
+ - Only include flows supported by routes/source evidence. If uncertain, create a cautious status: yellow template.
3380
+
3381
+ Sanitized app summary:
3382
+ ${formatInspectionForPrompt(inspection)}
3383
+ `;
3384
+ }
3385
+ function buildCopyPasteFallbackPrompt(inspection, targetFlows = []) {
3386
+ const target = targetFlows.length ? `Focus on these flow ids: ${targetFlows.join(", ")}.` : "";
3387
+ return `You are working in this repository. Create or refresh SeatbeltAI flow YAML files.
3388
+
3389
+ ${target}
3390
+ Use the current Seatbelt schema:
3391
+ - id, name, description, critical, status, tags, dependencies, requires, needs, steps.
3392
+ - Steps: goto, click, fill{selector,valueFromEnv}, press, selectOption, upload, waitForUrlContains, waitForText, waitForTimeoutMs, expectUrlContains, expectText, expectVisible, expectNotVisible, expectTitleContains, screenshot.
3393
+ - Use SEATBELT_TEST_EMAIL / SEATBELT_TEST_PASSWORD as env names only. Never write secret values.
3394
+ - Use status: yellow for anything that needs human review.
3395
+ - Use dependencies.files/routes so Seatbelt can detect stale flows.
3396
+
3397
+ Detected summary:
3398
+ ${formatInspectionForPrompt(inspection)}
3399
+
3400
+ Return only YAML flow documents.`;
3401
+ }
3402
+
3403
+ // src/ai/provider.ts
3404
+ function defaultModel(provider) {
3405
+ if (provider === "seatbelt") return "hosted";
3406
+ if (provider === "openai") return "gpt-4.1-mini";
3407
+ return "hosted";
3408
+ }
3409
+
3410
+ // src/ai/openai.ts
3411
+ var OpenAIFlowProvider = class {
3412
+ constructor(apiKey = process.env.OPENAI_API_KEY) {
3413
+ this.apiKey = apiKey;
3414
+ }
3415
+ apiKey;
3416
+ name = "openai";
3417
+ available() {
3418
+ return Boolean(this.apiKey);
3419
+ }
3420
+ async generate(request) {
3421
+ if (!this.apiKey) throw new Error("OPENAI_API_KEY is not set");
3422
+ const res = await fetch("https://api.openai.com/v1/responses", {
3423
+ method: "POST",
3424
+ headers: {
3425
+ "content-type": "application/json",
3426
+ authorization: `Bearer ${this.apiKey}`
3427
+ },
3428
+ body: JSON.stringify({
3429
+ model: request.model,
3430
+ input: request.prompt,
3431
+ store: false
3432
+ })
3433
+ });
3434
+ const text = await res.text();
3435
+ let body;
3436
+ try {
3437
+ body = JSON.parse(text);
3438
+ } catch {
3439
+ throw new Error(`OpenAI returned a non-JSON response (${res.status})`);
3440
+ }
3441
+ if (!res.ok) throw new Error(body.error?.message ?? `OpenAI request failed (${res.status})`);
3442
+ if (body.output_text) return body.output_text;
3443
+ const chunks = [];
3444
+ for (const item of body.output ?? []) {
3445
+ for (const part of item.content ?? []) {
3446
+ if (part.type === "output_text" && part.text) chunks.push(part.text);
3447
+ }
3448
+ }
3449
+ const out = chunks.join("\n").trim();
3450
+ if (!out) throw new Error("OpenAI returned no text output");
3451
+ return out;
3452
+ }
3453
+ };
3454
+
3455
+ // src/auth/store.ts
3456
+ import fs11 from "fs";
3457
+ import os from "os";
3458
+ import path17 from "path";
3459
+ function configDir() {
3460
+ if (process.env.SEATBELT_CONFIG_DIR) return process.env.SEATBELT_CONFIG_DIR;
3461
+ if (process.env.XDG_CONFIG_HOME) return path17.join(process.env.XDG_CONFIG_HOME, "seatbelt");
3462
+ if (process.platform === "win32" && process.env.APPDATA) {
3463
+ return path17.join(process.env.APPDATA, "seatbelt");
3464
+ }
3465
+ return path17.join(os.homedir(), ".config", "seatbelt");
3466
+ }
3467
+ function authFilePath() {
3468
+ return path17.join(configDir(), "auth.json");
3469
+ }
3470
+ function readAuth() {
3471
+ try {
3472
+ const raw = fs11.readFileSync(authFilePath(), "utf8");
3473
+ const data = JSON.parse(raw);
3474
+ if (typeof data.token === "string" && data.token.trim()) {
3475
+ return {
3476
+ token: data.token,
3477
+ email: data.email,
3478
+ plan: data.plan,
3479
+ savedAt: data.savedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
3480
+ origin: data.origin
3481
+ };
3482
+ }
3483
+ } catch {
3484
+ }
3485
+ return null;
3486
+ }
3487
+ function readStoredToken() {
3488
+ return readAuth()?.token;
3489
+ }
3490
+ function writeAuth(auth) {
3491
+ const dir = configDir();
3492
+ fs11.mkdirSync(dir, { recursive: true });
3493
+ const record = { ...auth, savedAt: auth.savedAt ?? (/* @__PURE__ */ new Date()).toISOString() };
3494
+ const file = authFilePath();
3495
+ fs11.writeFileSync(file, JSON.stringify(record, null, 2) + "\n", "utf8");
3496
+ try {
3497
+ fs11.chmodSync(file, 384);
3498
+ } catch {
3499
+ }
3500
+ return record;
3501
+ }
3502
+ function clearAuth() {
3503
+ const file = authFilePath();
3504
+ try {
3505
+ if (!fs11.existsSync(file)) return false;
3506
+ fs11.rmSync(file);
3507
+ return true;
3508
+ } catch {
3509
+ return false;
3510
+ }
3511
+ }
3512
+
3513
+ // src/ai/seatbelt.ts
3514
+ function trimTrailingSlash(value) {
3515
+ return value.replace(/\/+$/, "");
3516
+ }
3517
+ function seatbeltWebUrl() {
3518
+ return trimTrailingSlash(
3519
+ process.env.SEATBELT_WEB_URL ?? process.env.SEATBELT_APP_URL ?? "https://getseatbelt.ai"
3520
+ );
3521
+ }
3522
+ function seatbeltApiToken() {
3523
+ return process.env.SEATBELT_API_TOKEN ?? process.env.SEATBELT_API_KEY ?? readStoredToken();
3524
+ }
3525
+ function hostedAiDisabled() {
3526
+ return process.env.SEATBELT_DISABLE_HOSTED_AI === "1";
3527
+ }
3528
+ function hostedAiTimeoutMs() {
3529
+ const raw = Number(process.env.SEATBELT_API_TIMEOUT_MS);
3530
+ if (!Number.isFinite(raw) || raw <= 0) return 6e4;
3531
+ return Math.min(Math.max(raw, 5e3), 3e5);
3532
+ }
3533
+ var SeatbeltFlowProvider = class {
3534
+ name = "seatbelt";
3535
+ apiUrl;
3536
+ token;
3537
+ constructor(apiUrl, token = seatbeltApiToken()) {
3538
+ this.apiUrl = trimTrailingSlash(process.env.SEATBELT_API_URL ?? apiUrl ?? "https://api.getseatbelt.ai");
3539
+ this.token = token;
3540
+ }
3541
+ available() {
3542
+ return Boolean(this.token) && !hostedAiDisabled();
3543
+ }
3544
+ destination() {
3545
+ return this.apiUrl;
3546
+ }
3547
+ async generate(request) {
3548
+ if (hostedAiDisabled()) throw new Error("Hosted Seatbelt AI is disabled by SEATBELT_DISABLE_HOSTED_AI=1.");
3549
+ if (!this.token) throw new Error("SEATBELT_API_TOKEN is not set.");
3550
+ const controller = new AbortController();
3551
+ const timeoutMs = hostedAiTimeoutMs();
3552
+ let timedOut = false;
3553
+ const timeout = setTimeout(() => {
3554
+ timedOut = true;
3555
+ controller.abort();
3556
+ }, timeoutMs);
3557
+ let res;
3558
+ try {
3559
+ res = await fetch(`${this.apiUrl}/api/ai/generate-flows`, {
3560
+ method: "POST",
3561
+ signal: controller.signal,
3562
+ headers: {
3563
+ "content-type": "application/json",
3564
+ authorization: `Bearer ${this.token}`
3565
+ },
3566
+ body: JSON.stringify({
3567
+ mode: request.mode ?? "generate-flows",
3568
+ cliVersion: request.cliVersion,
3569
+ project: request.project,
3570
+ prompt: request.prompt
3571
+ })
3572
+ });
3573
+ } catch (err) {
3574
+ if (timedOut) {
3575
+ throw new Error(`Seatbelt backend did not respond within ${Math.round(timeoutMs / 1e3)}s (set SEATBELT_API_TIMEOUT_MS to wait longer).`);
3576
+ }
3577
+ const reason = err instanceof Error ? err.message : String(err);
3578
+ throw new Error(`Could not reach the Seatbelt backend at ${this.apiUrl}: ${reason}`);
3579
+ } finally {
3580
+ clearTimeout(timeout);
3581
+ }
3582
+ const body = await res.json().catch(() => null);
3583
+ if (!res.ok || !body?.ok) {
3584
+ const code = body?.error?.code ? `${body.error.code}: ` : "";
3585
+ throw new Error(code + (body?.error?.message ?? `Seatbelt backend request failed (${res.status}).`));
3586
+ }
3587
+ if (!body.yaml?.trim()) throw new Error("Seatbelt backend returned no YAML.");
3588
+ return body.yaml;
3589
+ }
3590
+ };
3591
+
3592
+ // src/commands/login.ts
3593
+ import http from "http";
3594
+ import crypto2 from "crypto";
3595
+ import open2 from "open";
3596
+ async function verifyToken(webUrl, token) {
3597
+ try {
3598
+ const res = await fetch(`${webUrl}/api/access/me`, {
3599
+ headers: { authorization: `Bearer ${token}` }
3600
+ });
3601
+ const body = await res.json().catch(() => null);
3602
+ return {
3603
+ status: res.status,
3604
+ active: body?.access?.active,
3605
+ plan: body?.access?.plan,
3606
+ email: body?.access?.email
3607
+ };
3608
+ } catch {
3609
+ return { status: 0, networkError: true };
3610
+ }
3611
+ }
3612
+ function successPage() {
3613
+ return `<!doctype html><html><head><meta charset="utf-8"><title>Seatbelt connected</title>
3614
+ <style>
3615
+ body{margin:0;height:100vh;display:flex;align-items:center;justify-content:center;
3616
+ font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;background:#0b1220;color:#fff}
3617
+ .card{text-align:center;max-width:420px;padding:40px}
3618
+ .badge{width:56px;height:56px;border-radius:14px;background:#2563EB;display:inline-flex;
3619
+ align-items:center;justify-content:center;font-size:28px;margin-bottom:20px}
3620
+ h1{font-size:22px;margin:0 0 8px} p{color:#94a3b8;line-height:1.5;margin:0}
3621
+ </style></head><body><div class="card">
3622
+ <div class="badge">\u{1F512}</div>
3623
+ <h1>You're connected to Seatbelt</h1>
3624
+ <p>You can close this tab and return to your terminal. Future <code>seatbelt run</code> commands will just work.</p>
3625
+ </div></body></html>`;
3626
+ }
3627
+ function errorPage(message) {
3628
+ return `<!doctype html><html><head><meta charset="utf-8"><title>Seatbelt login</title></head>
3629
+ <body style="font-family:sans-serif;padding:40px;color:#b42318">${message} \u2014 return to your terminal and run <code>seatbelt login</code> again.</body></html>`;
3630
+ }
3631
+ function waitForBrowserToken(state, timeoutMs) {
3632
+ return new Promise((resolve, reject) => {
3633
+ const server = http.createServer((req, res) => {
3634
+ const url = new URL(req.url ?? "/", `http://127.0.0.1`);
3635
+ if (url.pathname !== "/callback") {
3636
+ res.writeHead(404, { "content-type": "text/plain" });
3637
+ res.end("Not found");
3638
+ return;
3639
+ }
3640
+ const token = url.searchParams.get("token");
3641
+ const gotState = url.searchParams.get("state");
3642
+ if (!token || gotState !== state) {
3643
+ res.writeHead(400, { "content-type": "text/html" });
3644
+ res.end(errorPage("That login link didn't match this session"));
3645
+ return;
3646
+ }
3647
+ res.writeHead(200, { "content-type": "text/html" });
3648
+ res.end(successPage());
3649
+ cleanup();
3650
+ resolve({ token, port });
3651
+ });
3652
+ let port = 0;
3653
+ const timer = setTimeout(() => {
3654
+ cleanup();
3655
+ reject(
3656
+ new SeatbeltError(
3657
+ "Login timed out waiting for the browser.",
3658
+ `Run ${pc.green("seatbelt login")} again, or paste a token with ${pc.green("seatbelt login --token <token>")}.`
3659
+ )
3660
+ );
3661
+ }, timeoutMs);
3662
+ function cleanup() {
3663
+ clearTimeout(timer);
3664
+ server.close();
3665
+ }
3666
+ server.on("error", (err) => {
3667
+ cleanup();
3668
+ reject(err);
3669
+ });
3670
+ server.listen(0, "127.0.0.1", () => {
3671
+ port = server.address().port;
3672
+ const webUrl = seatbeltWebUrl();
3673
+ const loginUrl = `${webUrl}/login/cli?port=${port}&state=${state}`;
3674
+ log.blank();
3675
+ log.step("Opening your browser to connect your Seatbelt account\u2026");
3676
+ log.plain(` ${pc.cyan(loginUrl)}`);
3677
+ log.dim(" If it didn't open, copy the link above into your browser.");
3678
+ open2(loginUrl).catch(() => {
3679
+ log.warn("Could not open a browser automatically. Open the link above manually.");
3680
+ });
3681
+ });
3682
+ });
3683
+ }
3684
+ async function storeAndReport(token, verify, origin) {
3685
+ writeAuth({ token, email: verify.email, plan: verify.plan, origin });
3686
+ log.blank();
3687
+ log.success("Logged in to Seatbelt.");
3688
+ if (verify.email || verify.plan) {
3689
+ log.plain(` Account: ${pc.bold(verify.email ?? "(unknown)")}${verify.plan ? pc.dim(` \xB7 ${verify.plan}`) : ""}`);
3690
+ }
3691
+ log.dim(` Token stored in ${authFilePath()}`);
3692
+ log.blank();
3693
+ log.plain(`Next: ${pc.green("seatbelt run")}`);
3694
+ log.blank();
3695
+ }
3696
+ async function loginCommand(opts = {}) {
3697
+ const webUrl = seatbeltWebUrl();
3698
+ if (opts.token) {
3699
+ const verify2 = await verifyToken(webUrl, opts.token);
3700
+ if (verify2.status === 401 || verify2.status === 403) {
3701
+ throw new SeatbeltError("That token was not accepted.", "Check the token and try again, or run `seatbelt login` without --token.");
3702
+ }
3703
+ if (verify2.networkError) {
3704
+ log.warn(`Couldn't reach ${webUrl} to verify the token \u2014 storing it anyway.`);
3705
+ } else if (verify2.active === false) {
3706
+ log.warn("That token's subscription is not active. Storing it, but paid features may be skipped.");
3707
+ }
3708
+ await storeAndReport(opts.token, verify2, webUrl);
3709
+ return 0;
3710
+ }
3711
+ const state = crypto2.randomBytes(16).toString("hex");
3712
+ const { token } = await waitForBrowserToken(state, 5 * 6e4);
3713
+ const verify = await verifyToken(webUrl, token);
3714
+ if (verify.status === 401 || verify.status === 403) {
3715
+ throw new SeatbeltError("The website returned a token, but it was rejected on verification.", "Try `seatbelt login` again.");
3716
+ }
3717
+ await storeAndReport(token, verify, webUrl);
3718
+ return 0;
3719
+ }
3720
+ async function ensureHostedToken(opts = {}) {
3721
+ const existing = seatbeltApiToken();
3722
+ if (existing) return existing;
3723
+ if (opts.localOnly) return void 0;
3724
+ if (hostedAiDisabled()) return void 0;
3725
+ if (process.env.SEATBELT_NO_AUTO_LOGIN === "1") return void 0;
3726
+ if (!process.stdin.isTTY) return void 0;
3727
+ log.blank();
3728
+ log.warn("You need to connect Seatbelt to continue.");
3729
+ log.plain("Opening your browser to log in\u2026");
3730
+ try {
3731
+ const state = crypto2.randomBytes(16).toString("hex");
3732
+ const { token } = await waitForBrowserToken(state, 5 * 6e4);
3733
+ const verify = await verifyToken(seatbeltWebUrl(), token);
3734
+ if (verify.status === 401 || verify.status === 403) {
3735
+ log.warn("Login didn't grant access. Continuing without hosted AI.");
3736
+ return void 0;
3737
+ }
3738
+ writeAuth({ token, email: verify.email, plan: verify.plan, origin: seatbeltWebUrl() });
3739
+ log.success(`Connected${verify.email ? " as " + verify.email : ""}.`);
3740
+ return token;
3741
+ } catch (err) {
3742
+ log.warn(`Couldn't complete login: ${err instanceof Error ? err.message : String(err)}`);
3743
+ log.dim("Continuing with local fallback. You can run `seatbelt login` any time.");
3744
+ return void 0;
3745
+ }
3746
+ }
3747
+ async function logoutCommand() {
3748
+ const had = clearAuth();
3749
+ log.blank();
3750
+ if (had) log.success("Logged out. Stored Seatbelt token removed.");
3751
+ else log.plain("You weren't logged in. Nothing to remove.");
3752
+ log.blank();
3753
+ return 0;
3754
+ }
3755
+ async function whoamiCommand() {
3756
+ log.blank();
3757
+ if (process.env.SEATBELT_API_TOKEN || process.env.SEATBELT_API_KEY) {
3758
+ log.plain(`${pc.bold("Auth source:")} environment variable (advanced/CI override).`);
3759
+ }
3760
+ const auth = readAuth();
3761
+ if (!auth) {
3762
+ log.plain("Not logged in.");
3763
+ log.dim(`Run ${pc.green("seatbelt login")} to connect your account in the browser.`);
3764
+ log.blank();
3765
+ return 0;
3766
+ }
3767
+ log.plain(`${pc.bold("Logged in:")} ${auth.email ?? "(unknown email)"}${auth.plan ? pc.dim(` \xB7 ${auth.plan}`) : ""}`);
3768
+ log.dim(` Stored ${new Date(auth.savedAt).toLocaleString()} in ${authFilePath()}`);
3769
+ log.blank();
3770
+ return 0;
3771
+ }
3772
+
3773
+ // src/ai/flowSuggest.ts
3774
+ import YAML5 from "yaml";
3775
+
3776
+ // src/ai/yamlExtract.ts
3777
+ import YAML4 from "yaml";
3778
+ function fencedBlocks(text) {
3779
+ const blocks = [];
3780
+ const re = /```(?:yaml|yml)?\s*([\s\S]*?)```/gi;
3781
+ for (const match of text.matchAll(re)) {
3782
+ if (match[1]?.trim()) blocks.push(match[1].trim());
3783
+ }
3784
+ return blocks;
3785
+ }
3786
+ function splitDocuments(text) {
3787
+ const doc = YAML4.parseAllDocuments(text);
3788
+ if (doc.length > 1) {
3789
+ return doc.map((d) => d.toString().trim()).filter(Boolean);
3790
+ }
3791
+ return [text.trim()].filter(Boolean);
3792
+ }
3793
+ function issueMessage(err) {
3794
+ if (err instanceof Error) return err.message.split("\n")[0] ?? err.message;
3795
+ return String(err);
3796
+ }
3797
+ function normalizeYaml(value) {
3798
+ return YAML4.stringify(value).trim() + "\n";
3799
+ }
3800
+ function extractFlowYaml(text) {
3801
+ const chunks = fencedBlocks(text);
3802
+ const candidates = chunks.length ? chunks.flatMap(splitDocuments) : splitDocuments(text);
3803
+ const valid = [];
3804
+ const rejected = [];
3805
+ const seen = /* @__PURE__ */ new Set();
3806
+ for (const candidate of candidates) {
3807
+ let parsed;
3808
+ try {
3809
+ parsed = YAML4.parse(candidate);
3810
+ } catch (err) {
3811
+ rejected.push({ yaml: candidate, error: `YAML parse failed: ${issueMessage(err)}` });
3812
+ continue;
3813
+ }
3814
+ const maybeFlows = Array.isArray(parsed?.flows) ? parsed.flows : [parsed];
3815
+ for (const item of maybeFlows) {
3816
+ const result = flowSchema.safeParse(item);
3817
+ const yaml = normalizeYaml(item);
3818
+ if (!result.success) {
3819
+ const issue = result.error.issues[0];
3820
+ const where = issue?.path.length ? issue.path.join(".") : "(flow)";
3821
+ rejected.push({ yaml, error: `${where}: ${issue?.message ?? "invalid flow"}` });
3822
+ continue;
3823
+ }
3824
+ if (seen.has(result.data.id)) {
3825
+ rejected.push({ yaml, error: `duplicate flow id ${result.data.id}` });
3826
+ continue;
3827
+ }
3828
+ seen.add(result.data.id);
3829
+ valid.push({ id: result.data.id, yaml, flow: result.data });
3830
+ }
3831
+ }
3832
+ return { valid, rejected };
3833
+ }
3834
+
3835
+ // src/ai/flowSuggest.ts
3836
+ function routeFor(inspection, parts, fallback) {
3837
+ const found = inspection.routes.find((r) => parts.some((p) => r.route.toLowerCase().includes(p)));
3838
+ return { route: found?.route ?? fallback, file: found?.file ?? null };
3839
+ }
3840
+ function files(...items) {
3841
+ return [...new Set(items.filter((x) => Boolean(x)))];
3842
+ }
3843
+ function yamlFor(flow) {
3844
+ return YAML5.stringify(flow).trim() + "\n";
3845
+ }
3846
+ function makeFlow(flow) {
3847
+ const parsed = extractFlowYaml(yamlFor(flow));
3848
+ if (!parsed.valid[0]) throw new Error("local flow template did not validate");
3849
+ return {
3850
+ id: parsed.valid[0].id,
3851
+ yaml: parsed.valid[0].yaml,
3852
+ flow: parsed.valid[0].flow,
3853
+ source: "local",
3854
+ assumptions: ["Generated locally from routes. Review selectors before certifying."]
3855
+ };
3856
+ }
3857
+ function localFlowSuggestions(inspection, requestedIds = []) {
3858
+ const requested = new Set(requestedIds);
3859
+ const shouldInclude = (id) => requested.size === 0 || requested.has(id);
3860
+ const suggestions = [];
3861
+ if (shouldInclude("homepage-smoke")) {
3862
+ const home = routeFor(inspection, ["/"], "/");
3863
+ suggestions.push(
3864
+ makeFlow({
3865
+ id: "homepage-smoke",
3866
+ name: "Homepage loads",
3867
+ description: "Open the home page and confirm it renders.",
3868
+ critical: true,
3869
+ tags: ["smoke"],
3870
+ dependencies: { files: files(home.file), routes: ["/"], services: [] },
3871
+ requires: { env: [] },
3872
+ steps: [{ goto: "/" }, { expectTitleContains: "" }, { screenshot: "homepage" }]
3873
+ })
3874
+ );
3875
+ }
3876
+ const login = routeFor(inspection, ["/signin", "/login", "/sign-in", "/auth"], "/login");
3877
+ const loginId = login.route.includes("signin") || login.route.includes("sign-in") ? "signin-smoke" : "login-smoke";
3878
+ if (shouldInclude(loginId) || shouldInclude("signin-smoke") || shouldInclude("login-smoke")) {
3879
+ suggestions.push(
3880
+ makeFlow({
3881
+ id: loginId,
3882
+ name: "User signs in",
3883
+ description: "Sign in as the QA test user from a fresh browser session.",
3884
+ critical: true,
3885
+ status: "yellow",
3886
+ tags: ["auth", "critical"],
3887
+ dependencies: { files: files(login.file), routes: [login.route], services: [] },
3888
+ requires: { env: ["SEATBELT_TEST_EMAIL", "SEATBELT_TEST_PASSWORD"] },
3889
+ steps: [
3890
+ { goto: login.route },
3891
+ { fill: { selector: "input[type=email]", valueFromEnv: "SEATBELT_TEST_EMAIL" } },
3892
+ { fill: { selector: "input[type=password]", valueFromEnv: "SEATBELT_TEST_PASSWORD" } },
3893
+ { click: "button[type=submit]" },
3894
+ { screenshot: "after-signin" }
3895
+ ]
3896
+ })
3897
+ );
3898
+ }
3899
+ const dashboard = routeFor(inspection, ["/dashboard", "/app", "/account", "/overview", "/courses"], "/dashboard");
3900
+ if (inspection.candidateFlows.includes("dashboard-smoke") && shouldInclude("dashboard-smoke")) {
3901
+ suggestions.push(
3902
+ makeFlow({
3903
+ id: "dashboard-smoke",
3904
+ name: "Dashboard loads",
3905
+ description: "Authenticated dashboard or workspace loads.",
3906
+ critical: true,
3907
+ status: "yellow",
3908
+ tags: ["dashboard"],
3909
+ dependencies: { files: files(login.file, dashboard.file), routes: [login.route, dashboard.route], services: [] },
3910
+ requires: { env: ["SEATBELT_TEST_EMAIL", "SEATBELT_TEST_PASSWORD"] },
3911
+ needs: [loginId],
3912
+ steps: [
3913
+ { goto: dashboard.route },
3914
+ { expectTitleContains: "" },
3915
+ { screenshot: "dashboard" }
3916
+ ]
3917
+ })
3918
+ );
3919
+ }
3920
+ const signup = routeFor(inspection, ["/signup", "/register", "/sign-up"], "/signup");
3921
+ if (inspection.candidateFlows.includes("signup-smoke") && shouldInclude("signup-smoke")) {
3922
+ suggestions.push(
3923
+ makeFlow({
3924
+ id: "signup-smoke",
3925
+ name: "New user signs up",
3926
+ description: "Template for creating a new QA user. Review before enabling.",
3927
+ critical: false,
3928
+ status: "yellow",
3929
+ tags: ["auth", "signup"],
3930
+ dependencies: { files: files(signup.file), routes: [signup.route], services: [] },
3931
+ requires: { env: ["SEATBELT_TEST_EMAIL", "SEATBELT_TEST_PASSWORD"] },
3932
+ steps: [
3933
+ { goto: signup.route },
3934
+ { fill: { selector: "input[type=email]", valueFromEnv: "SEATBELT_TEST_EMAIL" } },
3935
+ { fill: { selector: "input[type=password]", valueFromEnv: "SEATBELT_TEST_PASSWORD" } },
3936
+ { click: "button[type=submit]" },
3937
+ { screenshot: "after-signup" }
3938
+ ]
3939
+ })
3940
+ );
3941
+ }
3942
+ const onboarding = routeFor(inspection, ["/onboarding", "/corridor", "/welcome", "/setup"], "/onboarding");
3943
+ if (inspection.candidateFlows.includes("onboarding-smoke") && shouldInclude("onboarding-smoke")) {
3944
+ suggestions.push(
3945
+ makeFlow({
3946
+ id: "onboarding-smoke",
3947
+ name: "Onboarding starts",
3948
+ description: "Template for the first-run onboarding path.",
3949
+ critical: true,
3950
+ status: "yellow",
3951
+ tags: ["onboarding"],
3952
+ dependencies: { files: files(onboarding.file), routes: [onboarding.route], services: [] },
3953
+ requires: { env: ["SEATBELT_TEST_EMAIL", "SEATBELT_TEST_PASSWORD"] },
3954
+ needs: [loginId],
3955
+ steps: [{ goto: onboarding.route }, { expectTitleContains: "" }, { screenshot: "onboarding" }]
3956
+ })
3957
+ );
3958
+ }
3959
+ const upload = routeFor(inspection, ["/upload", "/import", "/documents", "/files"], "/upload");
3960
+ if (inspection.candidateFlows.includes("upload-smoke") && shouldInclude("upload-smoke")) {
3961
+ suggestions.push(
3962
+ makeFlow({
3963
+ id: "upload-smoke",
3964
+ name: "User uploads a file",
3965
+ description: "Template for uploading a fixture file. Review before enabling.",
3966
+ critical: false,
3967
+ status: "yellow",
3968
+ tags: ["upload"],
3969
+ dependencies: { files: files(upload.file), routes: [upload.route], services: [] },
3970
+ requires: { env: ["SEATBELT_TEST_EMAIL", "SEATBELT_TEST_PASSWORD"] },
3971
+ needs: [loginId],
3972
+ steps: [
3973
+ { goto: upload.route },
3974
+ { upload: { selector: "input[type=file]", path: "qa-fixtures/sample.pdf" } },
3975
+ { screenshot: "after-upload" }
3976
+ ]
3977
+ })
3978
+ );
3979
+ }
3980
+ const checkout = routeFor(inspection, ["/pricing", "/checkout", "/billing", "/upgrade"], "/pricing");
3981
+ if (inspection.candidateFlows.includes("checkout-smoke") && shouldInclude("checkout-smoke")) {
3982
+ suggestions.push(
3983
+ makeFlow({
3984
+ id: "checkout-smoke",
3985
+ name: "User upgrades",
3986
+ description: "Template for a revenue/access flow (checkout, billing, or entitlements). Keep disabled until your app's details are confirmed.",
3987
+ critical: false,
3988
+ status: "yellow",
3989
+ tags: ["checkout", "billing"],
3990
+ dependencies: { files: files(checkout.file), routes: [checkout.route], services: [] },
3991
+ requires: { env: ["SEATBELT_TEST_EMAIL", "SEATBELT_TEST_PASSWORD"] },
3992
+ needs: [loginId],
3993
+ steps: [{ goto: checkout.route }, { click: "text=Upgrade" }, { screenshot: "checkout" }]
3994
+ })
3995
+ );
3996
+ }
3997
+ return suggestions;
3998
+ }
3999
+ function aiFlowSuggestions(output3) {
4000
+ const extracted = extractFlowYaml(output3);
4001
+ return {
4002
+ suggestions: extracted.valid.map((v) => ({
4003
+ id: v.id,
4004
+ yaml: v.yaml,
4005
+ flow: v.flow,
4006
+ source: "ai",
4007
+ assumptions: ["Generated by AI from sanitized app summary. Review before certifying."]
4008
+ })),
4009
+ rejected: extracted.rejected
4010
+ };
4011
+ }
4012
+
4013
+ // src/commands/generateFlows.ts
4014
+ function providerFor(name, seatbeltBackend) {
4015
+ if (name === "seatbelt") return new SeatbeltFlowProvider(seatbeltBackend);
4016
+ if (name === "openai") return new OpenAIFlowProvider();
4017
+ return new SeatbeltFlowProvider(seatbeltBackend);
4018
+ }
4019
+ function chooseProvider(explicit) {
4020
+ if (explicit) return explicit;
4021
+ if (!hostedAiDisabled() && seatbeltApiToken()) return "seatbelt";
4022
+ if (process.env.OPENAI_API_KEY) return "openai";
4023
+ return hostedAiDisabled() ? "openai" : "seatbelt";
4024
+ }
4025
+ function targetPathFor(flowsDir, id) {
4026
+ return path18.join(flowsDir, `${id}.yaml`);
4027
+ }
4028
+ function suggestedPathFor(flowsDir, id) {
4029
+ return path18.join(flowsDir, `${id}.suggested.yaml`);
4030
+ }
4031
+ function backupPathFor(target) {
4032
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
4033
+ return `${target}.${stamp}.bak`;
4034
+ }
4035
+ function printInspectionSummary(inspection) {
4036
+ log.plain(pc.bold("Detected app"));
4037
+ log.plain(` Framework/package: ${pc.cyan(inspection.framework)} / ${pc.cyan(inspection.packageManager)}`);
4038
+ if (inspection.packageJson) {
4039
+ log.plain(` Scripts: ${Object.keys(inspection.packageJson.scripts).join(", ") || "(none)"}`);
4040
+ const deps = inspection.packageJson.dependencies.slice(0, 12).join(", ");
4041
+ log.plain(` Dependencies: ${deps || "(none)"}${inspection.packageJson.dependencies.length > 12 ? ", ..." : ""}`);
4042
+ }
4043
+ log.plain(` Routes: ${inspection.routes.map((r) => r.route).slice(0, 20).join(", ") || "(none detected)"}`);
4044
+ log.plain(` Existing flows: ${inspection.existingFlows.map((f) => f.id).join(", ") || "(none)"}`);
4045
+ log.plain(` Stale flows: ${inspection.staleFlows.map((f) => f.id).join(", ") || "(none)"}`);
4046
+ log.plain(` Candidate flows: ${inspection.candidateFlows.join(", ") || "(none)"}`);
4047
+ log.plain(` Env var names only: ${inspection.envVarNames.join(", ") || "(none detected)"}`);
4048
+ }
4049
+ function printPrivacySummary(inspection) {
4050
+ log.blank();
4051
+ log.plain(pc.bold("What would be sent to AI"));
4052
+ log.plain(" Framework/package summary, package scripts, dependency names, detected routes.");
4053
+ log.plain(` Existing flow ids: ${inspection.existingFlows.map((f) => f.id).join(", ") || "(none)"}`);
4054
+ log.plain(` Stale flow ids: ${inspection.staleFlows.map((f) => f.id).join(", ") || "(none)"}`);
4055
+ log.plain(` Source files included: ${inspection.source.included.map((f) => f.path).join(", ") || "(none)"}`);
4056
+ log.plain(` Source files excluded: ${inspection.source.excluded.map((f) => `${f.path} (${f.reason})`).slice(0, 12).join(", ") || "(none)"}`);
4057
+ log.plain(` Env vars: names only (${inspection.envVarNames.join(", ") || "none detected"}). Values are never sent.`);
4058
+ log.plain(" Redaction: likely API keys, tokens, passwords, Stripe secrets, Mongo URLs, JWT secrets, and private keys are replaced before sending.");
4059
+ }
4060
+ function printHostedDestination(provider) {
4061
+ if (provider.name !== "seatbelt") return;
4062
+ const destination = provider instanceof SeatbeltFlowProvider ? provider.destination() : "Seatbelt backend";
4063
+ log.plain(` API destination: ${pc.cyan(destination)}`);
4064
+ }
4065
+ async function consent(opts, inspection) {
4066
+ if (opts.yes) return true;
4067
+ if (!process.stdin.isTTY) return false;
4068
+ printPrivacySummary(inspection);
4069
+ const rl = readline2.createInterface({ input: input2, output: output2 });
4070
+ try {
4071
+ const answer = await rl.question("Send this sanitized summary to AI? [y/N] ");
4072
+ return /^y(es)?$/i.test(answer.trim());
4073
+ } finally {
4074
+ rl.close();
4075
+ }
4076
+ }
4077
+ async function saveFallbackPrompt(cwd, inspection, targetFlows) {
4078
+ const file = path18.join(cwd, ".seatbelt", "flow-authoring-prompt.md");
4079
+ await writeText(file, buildCopyPasteFallbackPrompt(inspection, targetFlows));
4080
+ return file;
4081
+ }
4082
+ function filterSuggestions(suggestions, targetFlows) {
4083
+ if (!targetFlows.length) return suggestions;
4084
+ const target = new Set(targetFlows);
4085
+ return suggestions.filter((s) => target.has(s.id));
4086
+ }
4087
+ async function writeSuggestions(cwd, flowsDirRel, suggestions, opts) {
4088
+ const flowsDir = path18.join(cwd, flowsDirRel);
4089
+ await ensureDir(flowsDir);
4090
+ const written = [];
4091
+ for (const suggestion of suggestions) {
4092
+ const target = targetPathFor(flowsDir, suggestion.id);
4093
+ const targetExists = exists(target);
4094
+ let outPath = target;
4095
+ let action2 = "write";
4096
+ if (targetExists && opts.mode === "refresh" && opts.apply) {
4097
+ action2 = "backup-and-overwrite";
4098
+ } else if (targetExists) {
4099
+ outPath = suggestedPathFor(flowsDir, suggestion.id);
4100
+ action2 = "suggested";
4101
+ }
4102
+ if (opts.dryRun) {
4103
+ written.push({ id: suggestion.id, path: outPath, action: "skip-dry-run" });
4104
+ continue;
4105
+ }
4106
+ if (action2 === "backup-and-overwrite") {
4107
+ await fs12.promises.copyFile(target, backupPathFor(target));
4108
+ }
4109
+ await writeText(outPath, suggestion.yaml);
4110
+ written.push({ id: suggestion.id, path: outPath, action: action2 });
4111
+ }
4112
+ return written;
4113
+ }
4114
+ async function syncWrittenFlows(cwd, paths, projectName, suggestions, writes) {
4115
+ const actualWrites = new Set(writes.filter((w) => w.action === "write" || w.action === "backup-and-overwrite").map((w) => w.id));
4116
+ if (!actualWrites.size) return;
4117
+ let state = await loadOrSeedState(paths.stateFile, projectName);
4118
+ state = syncFlowsFromYaml(
4119
+ state,
4120
+ suggestions.filter((s) => actualWrites.has(s.id)).map((s) => ({
4121
+ id: s.flow.id,
4122
+ name: s.flow.name,
4123
+ description: s.flow.description,
4124
+ tags: s.flow.tags,
4125
+ dependencies: s.flow.dependencies,
4126
+ requires: s.flow.requires,
4127
+ needs: s.flow.needs,
4128
+ declaredStatus: s.flow.status
4129
+ }))
4130
+ );
4131
+ await saveState(paths.stateFile, state);
4132
+ const meta = readFlowMeta(paths);
4133
+ for (const s of suggestions.filter((x) => actualWrites.has(x.id))) {
4134
+ meta.flows[s.id] = {
4135
+ dependencies: s.flow.dependencies,
4136
+ fingerprint: fingerprintDependencies(cwd, s.flow.dependencies),
4137
+ lastCertifiedAt: null,
4138
+ lastCertifiedRunId: null
4139
+ };
4140
+ }
4141
+ await writeFlowMeta(paths, meta);
4142
+ }
4143
+ async function runFlowGeneration(opts) {
4144
+ const { config, paths } = await loadConfig({ cwd: opts.cwd });
4145
+ const inspection = await inspectApp(paths, config);
4146
+ const targetFlows = opts.targetFlows ?? [];
4147
+ log.blank();
4148
+ log.step(`Seatbelt ${opts.mode === "refresh" ? "refresh-flows --ai" : "generate-flows"}`);
4149
+ printInspectionSummary(inspection);
4150
+ const localOnly = opts.localOnly || config.ai.enabled === false;
4151
+ if (!localOnly && opts.provider !== "openai") {
4152
+ await ensureHostedToken({ localOnly });
4153
+ }
4154
+ const providerName = chooseProvider(opts.provider);
4155
+ const model = opts.model ?? defaultModel(providerName);
4156
+ const provider = providerFor(providerName, config.ai.backend);
4157
+ let suggestions = [];
4158
+ let usedAi = false;
4159
+ const canCallProvider = !localOnly && provider.available() && (provider.name === "seatbelt" || await consent(opts, inspection));
4160
+ if (!localOnly && provider.name === "seatbelt") {
4161
+ log.blank();
4162
+ log.plain(pc.bold("Hosted AI transparency"));
4163
+ printHostedDestination(provider);
4164
+ printPrivacySummary(inspection);
4165
+ }
4166
+ if (canCallProvider) {
4167
+ try {
4168
+ const prompt = buildFlowPrompt(inspection, opts.mode, targetFlows);
4169
+ log.blank();
4170
+ log.step(`Calling ${providerName} (${model}) with sanitized app summary.`);
4171
+ const outputText = await provider.generate({
4172
+ prompt,
4173
+ model,
4174
+ mode: opts.mode === "refresh" ? "refresh-flows" : "generate-flows",
4175
+ cliVersion: VERSION,
4176
+ project: buildHostedProjectPayload(inspection)
4177
+ });
4178
+ const ai = aiFlowSuggestions(outputText);
4179
+ suggestions = filterSuggestions(ai.suggestions, targetFlows);
4180
+ usedAi = suggestions.length > 0;
4181
+ for (const rejected of ai.rejected) log.warn(`Rejected AI YAML: ${rejected.error}`);
4182
+ if (!suggestions.length) log.warn("AI did not return any valid Seatbelt flows. Falling back to local templates.");
4183
+ } catch (err) {
4184
+ const message = err instanceof Error ? err.message : String(err);
4185
+ log.warn(`AI generation was unavailable: ${message}`);
4186
+ }
4187
+ } else if (!localOnly && !provider.available()) {
4188
+ if (provider.name === "seatbelt") {
4189
+ const reason = hostedAiDisabled() ? "Hosted Seatbelt AI is disabled by SEATBELT_DISABLE_HOSTED_AI=1." : "You're not logged in to Seatbelt.";
4190
+ log.warn(`${reason} Using local-only fallback instead of failing.`);
4191
+ log.plain(` Run ${pc.green("seatbelt login")} to connect your account in the browser (no token to paste), or pass ${pc.green("--local-only")} to stay offline.`);
4192
+ log.dim(` Advanced/CI: set ${pc.cyan("SEATBELT_API_TOKEN")} instead of logging in.`);
4193
+ } else {
4194
+ log.warn(`Local OpenAI developer fallback is unavailable because ${pc.cyan("OPENAI_API_KEY")} is not set. Using local-only fallback.`);
4195
+ }
4196
+ } else if (!localOnly) {
4197
+ log.warn("AI consent was not granted. Using local-only fallback.");
4198
+ }
4199
+ if (!suggestions.length) {
4200
+ suggestions = filterSuggestions(localFlowSuggestions(inspection, targetFlows), targetFlows);
4201
+ if (opts.dryRun) {
4202
+ log.info("Dry run: would save a copy-paste flow authoring prompt in .seatbelt/flow-authoring-prompt.md.");
4203
+ } else {
4204
+ const promptFile = await saveFallbackPrompt(paths.cwd, inspection, targetFlows);
4205
+ log.info(`Saved a copy-paste flow authoring prompt: ${pc.cyan(displayPath(paths.cwd, promptFile))}`);
4206
+ }
4207
+ }
4208
+ if (!suggestions.length) {
4209
+ log.warn("No flow suggestions were produced from the detected app evidence.");
4210
+ return 0;
4211
+ }
4212
+ const writes = await writeSuggestions(paths.cwd, config.flows.dir, suggestions, opts);
4213
+ await syncWrittenFlows(paths.cwd, paths, config.app.name, suggestions, writes);
4214
+ log.blank();
4215
+ log.success(`${opts.dryRun ? "Prepared" : usedAi ? "Generated" : "Generated local fallback"} ${writes.length} flow suggestion${writes.length > 1 ? "s" : ""}:`);
4216
+ for (const w of writes) {
4217
+ const action2 = w.action === "skip-dry-run" ? "would write" : w.action === "suggested" ? "suggested" : w.action === "backup-and-overwrite" ? "applied with backup" : "wrote";
4218
+ log.plain(` ${pc.cyan(displayPath(paths.cwd, w.path))} ${pc.dim("(" + action2 + ")")}`);
4219
+ }
4220
+ log.blank();
4221
+ log.plain(pc.bold("Next:"));
4222
+ for (const w of writes.slice(0, 3)) {
4223
+ if (!w.path.endsWith(".suggested.yaml") && w.action !== "skip-dry-run") log.plain(` ${pc.green(`seatbelt run ${w.id}`)}`);
4224
+ }
4225
+ log.plain(` ${pc.green("seatbelt certify --run")}`);
4226
+ log.blank();
4227
+ return 0;
4228
+ }
4229
+ async function generateFlowsCommand(opts = {}) {
4230
+ return runFlowGeneration({ ...opts, mode: "generate" });
4231
+ }
4232
+
4233
+ // src/commands/refreshFlows.ts
4234
+ import path19 from "path";
4235
+ async function refreshFlowsCommand(opts = {}) {
4236
+ if (opts.ai) {
4237
+ return runFlowGeneration({
4238
+ cwd: opts.cwd,
4239
+ localOnly: opts.localOnly,
4240
+ dryRun: opts.dryRun,
4241
+ yes: opts.yes,
4242
+ provider: opts.provider,
4243
+ model: opts.model,
4244
+ mode: "refresh",
4245
+ apply: opts.apply,
4246
+ targetFlows: opts.flow ? [opts.flow] : void 0
4247
+ });
4248
+ }
4249
+ const { config, paths } = await loadConfig({ cwd: opts.cwd });
4250
+ const flowsDir = path19.join(paths.cwd, config.flows.dir);
4251
+ log.blank();
4252
+ log.step("Seatbelt refresh-flows " + pc.dim("(local)"));
4253
+ log.dim("Use --ai to ask for validated YAML suggestions. Without --ai, this stays local and only refreshes starter templates/stale warnings.");
4254
+ log.blank();
4255
+ const created = await ensureStarterFlows(flowsDir);
4256
+ if (created.length) {
4257
+ log.success(
4258
+ `Created ${created.length} starter flow${created.length > 1 ? "s" : ""} in ${pc.cyan(displayPath(paths.cwd, flowsDir))}: ${created.join(", ")}.`
4259
+ );
4260
+ }
4261
+ if (opts.localOnly && !created.length) {
4262
+ const restored = [];
4263
+ for (const tpl of STARTER_FLOW_TEMPLATES) {
4264
+ const file = path19.join(flowsDir, `${tpl.id}.yaml`);
4265
+ if (!exists(file)) {
4266
+ await writeText(file, tpl.yaml);
4267
+ restored.push(tpl.id);
4268
+ }
4269
+ }
4270
+ if (restored.length) {
4271
+ log.success(`Restored ${restored.length} missing starter template${restored.length > 1 ? "s" : ""}: ${restored.join(", ")}.`);
4272
+ } else {
4273
+ log.dim("All starter templates already present \u2014 nothing to restore.");
4274
+ }
4275
+ }
4276
+ const { flows, errors } = await loadFlows(paths.cwd, config.flows.dir, { create: false });
4277
+ for (const e of errors) log.warn(`Skipping ${e.file}: ${e.message}`);
4278
+ const meta = readFlowMeta(paths);
4279
+ const stale = [];
4280
+ for (const f of flows) {
4281
+ const current = fingerprintDependencies(paths.cwd, f.dependencies);
4282
+ const { stale: isStale, changedFiles } = detectStale(current, meta.flows[f.id]?.fingerprint);
4283
+ if (isStale) stale.push({ id: f.id, files: changedFiles });
4284
+ }
4285
+ log.blank();
4286
+ if (stale.length === 0) {
4287
+ log.success(pc.bold("No stale flows detected.") + " Your flows match the files last certified.");
4288
+ log.plain(` (Run ${pc.green("seatbelt certify --run")} after configuring flows to set the freshness baseline.)`);
4289
+ } else {
4290
+ log.warn(pc.bold(`${stale.length} flow${stale.length > 1 ? "s" : ""} may be stale (dependency files changed):`));
4291
+ for (const s of stale) log.plain(` \u2022 ${pc.cyan(s.id)} \u2014 changed: ${s.files.join(", ")}`);
4292
+ log.blank();
4293
+ log.plain(pc.bold("What to do (Seatbelt won't rewrite your flow files):"));
4294
+ log.plain(` 1. Review the changed files and update the affected flow YAML in ${pc.cyan(config.flows.dir + "/")}.`);
4295
+ log.plain(` 2. Re-certify to refresh the baseline: ${pc.green("seatbelt certify --run")}.`);
4296
+ log.dim(` (Or run ${pc.green("seatbelt refresh-flows --ai --dry-run")} to ask for validated YAML suggestions.)`);
4297
+ }
4298
+ log.blank();
4299
+ return 0;
4300
+ }
4301
+
4302
+ // src/commands/lint.ts
4303
+ import path20 from "path";
4304
+ import { execa as execa3 } from "execa";
4305
+ var CHECK_TIMEOUT_MS = 3e5;
4306
+ function renderLintMarkdown(model) {
4307
+ const lines = [];
4308
+ lines.push(`# SeatbeltAI Lint \u2014 ${model.passed ? "\u2705 PASS" : "\u26D4 FAIL"}`);
4309
+ lines.push(`_${model.generatedAt} \xB7 run ${model.runId}_`);
4310
+ lines.push("");
4311
+ lines.push(`| Check | Command | Result | Duration |`);
4312
+ lines.push(`|------|------|:--:|--:|`);
4313
+ for (const c of model.checks) {
4314
+ lines.push(
4315
+ `| ${c.name} | \`${c.command}\` | ${c.status === "pass" ? "\u2705 PASS" : "\u26D4 FAIL"} | ${(c.durationMs / 1e3).toFixed(1)}s |`
4316
+ );
4317
+ }
4318
+ lines.push("");
4319
+ for (const c of model.checks) {
4320
+ if (c.logFile) lines.push(`- ${c.name}: \`${c.logFile}\``);
4321
+ }
4322
+ lines.push("");
4323
+ lines.push(`> \`seatbelt lint\` runs your detected checks with no browser. Free and local.`);
4324
+ return lines.join("\n") + "\n";
4325
+ }
4326
+ async function lintCommand(opts = {}) {
4327
+ const { config, paths } = await loadConfig({ cwd: opts.cwd });
4328
+ const order = ["lint", "typecheck", "build", "test"];
4329
+ const checks = [];
4330
+ for (const key of order) {
4331
+ const command = config.checks[key];
4332
+ if (command) checks.push({ name: key, command });
4333
+ }
4334
+ if (checks.length === 0) {
4335
+ log.blank();
4336
+ log.warn("No checks were detected.");
4337
+ log.plain(
4338
+ `Seatbelt looks for ${pc.cyan("lint")}, ${pc.cyan("typecheck")}, ${pc.cyan("build")} and ${pc.cyan("test")} scripts in package.json.`
4339
+ );
4340
+ log.plain(
4341
+ "Add one or more of those scripts (or set them under `checks:` in seatbelt.config.yaml), then run " + pc.green("seatbelt lint") + " again."
4342
+ );
4343
+ log.blank();
4344
+ return 0;
4345
+ }
4346
+ const runId = createRunId();
4347
+ const dir = artifactDir(paths, runId);
4348
+ const lintDir = path20.join(dir, "lint");
4349
+ await ensureDir(lintDir);
4350
+ log.blank();
4351
+ log.step(`Seatbelt lint ${pc.bold(runId)}`);
4352
+ log.dim(`Running ${checks.length} check${checks.length > 1 ? "s" : ""} (no browser)\u2026`);
4353
+ const results = [];
4354
+ for (const check of checks) {
4355
+ log.blank();
4356
+ log.step(`${check.name}: ${pc.cyan(check.command)}`);
4357
+ const logPath = path20.join(lintDir, `${check.name}.log`);
4358
+ const start = Date.now();
4359
+ const r = await execa3(check.command, {
4360
+ shell: true,
4361
+ cwd: paths.cwd,
4362
+ reject: false,
4363
+ timeout: CHECK_TIMEOUT_MS,
4364
+ // CI=1 makes most runners (vitest/jest/react-scripts) run once and exit, not watch.
4365
+ env: { ...process.env, CI: "1" }
4366
+ });
4367
+ const durationMs = Date.now() - start;
4368
+ const stdout = typeof r.stdout === "string" ? r.stdout : "";
4369
+ const stderr = typeof r.stderr === "string" ? r.stderr : "";
4370
+ const timedOut = r.timedOut === true;
4371
+ const exitCode = typeof r.exitCode === "number" ? r.exitCode : timedOut ? null : 1;
4372
+ await writeText(
4373
+ logPath,
4374
+ `$ ${check.command}
4375
+
4376
+ [stdout]
4377
+ ${stdout}
4378
+
4379
+ [stderr]
4380
+ ${stderr}
4381
+
4382
+ [exit] ${exitCode ?? "killed"}${timedOut ? " (timed out)" : ""}
4383
+ `
4384
+ );
4385
+ const status = !timedOut && exitCode === 0 ? "pass" : "fail";
4386
+ results.push({
4387
+ name: check.name,
4388
+ command: check.command,
4389
+ status,
4390
+ exitCode,
4391
+ durationMs,
4392
+ logFile: displayPath(paths.cwd, logPath)
4393
+ });
4394
+ if (status === "pass") log.success(`${check.name} passed ${pc.dim((durationMs / 1e3).toFixed(1) + "s")}`);
4395
+ else log.warn(`${check.name} failed ${pc.dim("(exit " + (exitCode ?? "timeout") + ")")}`);
4396
+ }
4397
+ const passed = results.every((r) => r.status === "pass");
4398
+ const model = { runId, generatedAt: (/* @__PURE__ */ new Date()).toISOString(), passed, checks: results };
4399
+ await writeJson(path20.join(dir, "lint-run.json"), model);
4400
+ await writeText(path20.join(dir, "lint-report.md"), renderLintMarkdown(model));
4401
+ log.blank();
4402
+ log.plain(pc.bold("Checks"));
4403
+ for (const r of results) {
4404
+ const tag = r.status === "pass" ? pc.green("PASS") : pc.red("FAIL");
4405
+ log.plain(
4406
+ ` ${tag} ${r.name.padEnd(10)} ${pc.dim((r.durationMs / 1e3).toFixed(1) + "s").padEnd(6)} ${pc.dim(r.command)}`
4407
+ );
4408
+ }
4409
+ log.blank();
4410
+ log.plain(`Report: ${pc.cyan(displayPath(paths.cwd, path20.join(dir, "lint-report.md")))}`);
4411
+ log.blank();
4412
+ if (passed) {
4413
+ log.success(pc.bold("All checks passed."));
4414
+ } else {
4415
+ log.warn(pc.bold("Some checks failed.") + " Fix them, then re-run " + pc.green("seatbelt lint") + ".");
4416
+ }
4417
+ log.blank();
4418
+ return passed ? 0 : 1;
4419
+ }
4420
+
4421
+ // src/commands/doctor.ts
4422
+ import fs13 from "fs";
4423
+ import path21 from "path";
4424
+ import { chromium as chromium2 } from "playwright";
4425
+ function mark(status) {
4426
+ if (status === "ok") return pc.green("\u2713");
4427
+ if (status === "warn") return pc.yellow("\u26A0");
4428
+ return pc.red("\u2717");
4429
+ }
4430
+ function line(status, label, detail) {
4431
+ log.plain(` ${mark(status)} ${pc.bold(label.padEnd(10))} ${detail}`);
4432
+ }
4433
+ function chromiumInstalled() {
4434
+ try {
4435
+ return fs13.existsSync(chromium2.executablePath());
4436
+ } catch {
4437
+ return false;
4438
+ }
4439
+ }
4440
+ async function doctorCommand(opts = {}) {
4441
+ const { config, paths, configFileExists } = await loadConfig({ cwd: opts.cwd });
4442
+ const summary = await detect(paths);
4443
+ const baseUrl = config.server.baseUrl;
4444
+ const reachable = await isUrlReachable(baseUrl, 1200);
4445
+ const { flows, errors: flowErrors } = await loadFlows(paths.cwd, config.flows.dir, {
4446
+ create: false
4447
+ });
4448
+ const flowsDirExists = exists(path21.join(paths.cwd, config.flows.dir)) || flows.length > 0;
4449
+ const stateExists = exists(paths.stateFile);
4450
+ const hasChromium = chromiumInstalled();
4451
+ const resetConfigured = Boolean(config.reset.command);
4452
+ const blockers = [];
4453
+ log.blank();
4454
+ log.step("Seatbelt doctor");
4455
+ log.dim(configFileExists ? "Using seatbelt.config.yaml + detection." : "No config file \u2014 using detection + defaults.");
4456
+ log.blank();
4457
+ line(
4458
+ summary.hasPackageJson ? "ok" : "warn",
4459
+ "project",
4460
+ `${pc.cyan(summary.framework)} \xB7 ${summary.packageManager}${summary.hasPackageJson ? "" : pc.dim(" (no package.json found)")}`
4461
+ );
4462
+ const resolvedChecks = ["lint", "typecheck", "build", "test"].filter(
4463
+ (k) => config.checks[k]
4464
+ );
4465
+ line(
4466
+ summary.scripts.length ? "ok" : "warn",
4467
+ "scripts",
4468
+ summary.scripts.length ? `${summary.scripts.join(", ")}${resolvedChecks.length ? pc.dim(` \u2192 checks: ${resolvedChecks.join(", ")}`) : ""}` : "none detected"
4469
+ );
4470
+ if (reachable) {
4471
+ line("ok", "server", `${pc.cyan(baseUrl)} ${pc.green("(reachable now)")}`);
4472
+ } else if (config.server.devCommand) {
4473
+ line("warn", "server", `${pc.cyan(baseUrl)} not reachable \u2014 Seatbelt will start ${pc.cyan(config.server.devCommand)} on run`);
4474
+ } else {
4475
+ line("fail", "server", `${pc.cyan(baseUrl)} not reachable and no dev command detected`);
4476
+ blockers.push("Start your app, or set server.devCommand in seatbelt.config.yaml.");
4477
+ }
4478
+ if (summary.composeFile) {
4479
+ line("warn", "services", `${summary.composeFile} detected ${pc.dim("(Seatbelt does not start Docker yet)")}`);
4480
+ } else {
4481
+ line("ok", "services", "no Docker Compose file");
4482
+ }
4483
+ if (flows.length > 0) {
4484
+ line("ok", "flows", `${flows.length} valid in ${config.flows.dir}/${flowErrors.length ? pc.red(` \xB7 ${flowErrors.length} invalid`) : ""}`);
4485
+ } else if (flowsDirExists) {
4486
+ line("warn", "flows", `${config.flows.dir}/ has no valid flows`);
4487
+ } else {
4488
+ line("warn", "flows", `${config.flows.dir}/ not created yet \u2014 run ${pc.green("seatbelt on")} or ${pc.green("seatbelt run")}`);
4489
+ }
4490
+ for (const e of flowErrors) log.plain(` ${pc.red("\xB7")} ${e.file}: ${pc.dim(e.message)}`);
4491
+ line(stateExists ? "ok" : "warn", "state", stateExists ? ".seatbelt/state.json present" : "no state yet (run `seatbelt on`)");
4492
+ if (resetConfigured) {
4493
+ line("ok", "reset", `${pc.cyan(config.reset.command)} \xB7 required: ${config.reset.required ? "yes" : "no"}`);
4494
+ } else if (config.reset.required) {
4495
+ line("fail", "reset", "required but no command set \u2014 run `seatbelt setup-reset`");
4496
+ blockers.push("Reset is required but not configured. Run `seatbelt setup-reset`.");
4497
+ } else {
4498
+ line("warn", "reset", `no clean-test-user reset \u2014 run ${pc.green("seatbelt setup-reset")} to add one`);
4499
+ }
4500
+ line(
4501
+ summary.envFiles.length ? "ok" : "warn",
4502
+ "env",
4503
+ summary.envFiles.length ? summary.envFiles.join(", ") : `no .env files ${pc.dim("(add creds like SEATBELT_TEST_EMAIL)")}`
4504
+ );
4505
+ if (hasChromium) {
4506
+ line("ok", "browser", `Chromium installed \xB7 headed: ${config.browser.headed} \xB7 fresh: ${config.browser.freshSession} \xB7 video: ${config.browser.video} \xB7 trace: ${config.browser.trace}`);
4507
+ } else {
4508
+ line("fail", "browser", `Chromium not installed \u2014 run ${pc.green("npx playwright install chromium")}`);
4509
+ blockers.push("Install the browser: npx playwright install chromium");
4510
+ }
4511
+ line("ok", "report", `formats: ${config.report.formats.join(", ")} \xB7 opens automatically: ${config.report.openOnFinish}`);
4512
+ const envToken = Boolean(process.env.SEATBELT_API_TOKEN || process.env.SEATBELT_API_KEY);
4513
+ const storedAuth = readAuth();
4514
+ if (envToken) {
4515
+ line("ok", "account", `using ${pc.cyan("SEATBELT_API_TOKEN")} from the environment ${pc.dim("(advanced/CI override)")}`);
4516
+ } else if (storedAuth) {
4517
+ line("ok", "account", `logged in${storedAuth.email ? ` as ${storedAuth.email}` : ""}${storedAuth.plan ? pc.dim(` \xB7 ${storedAuth.plan}`) : ""}`);
4518
+ } else {
4519
+ line("warn", "account", `not logged in \u2014 run ${pc.green("seatbelt login")} to unlock hosted AI ${pc.dim("(local QA still works)")}`);
4520
+ }
4521
+ log.blank();
4522
+ if (blockers.length) {
4523
+ log.warn(pc.bold(`${blockers.length} thing${blockers.length > 1 ? "s" : ""} to fix before Seatbelt can run:`));
4524
+ for (const b of blockers) log.plain(` \u2022 ${b}`);
4525
+ } else if (!resetConfigured) {
4526
+ log.plain(pc.bold("Looks runnable.") + " For repeatable results, add a clean test user:");
4527
+ log.plain(` \u2022 ${pc.green("seatbelt setup-reset")}, then ${pc.green("seatbelt run")}`);
4528
+ } else {
4529
+ log.success(pc.bold("Everything looks ready. Run ") + pc.green("seatbelt run") + pc.bold("."));
4530
+ }
4531
+ log.blank();
4532
+ return blockers.length ? 1 : 0;
4533
+ }
4534
+
4535
+ // src/commands/report.ts
4536
+ import fs14 from "fs";
4537
+ import path22 from "path";
4538
+ import open3 from "open";
4539
+ function findLatestReport(artifactsDir) {
4540
+ if (!exists(artifactsDir)) return null;
4541
+ let subdirs;
4542
+ try {
4543
+ subdirs = fs14.readdirSync(artifactsDir).filter((d) => {
4544
+ try {
4545
+ return fs14.statSync(path22.join(artifactsDir, d)).isDirectory();
4546
+ } catch {
4547
+ return false;
4548
+ }
4549
+ }).sort().reverse();
4550
+ } catch {
4551
+ return null;
4552
+ }
4553
+ for (const d of subdirs) {
4554
+ const candidate = path22.join(artifactsDir, d, "report.html");
4555
+ if (exists(candidate)) return candidate;
4556
+ }
4557
+ return null;
4558
+ }
4559
+ async function reportCommand(opts = {}) {
4560
+ const paths = resolvePaths(opts.cwd);
4561
+ const latest = findLatestReport(paths.artifactsDir);
4562
+ if (!latest) {
4563
+ log.blank();
4564
+ log.warn("No Seatbelt report found yet.");
4565
+ log.plain(`Run ${pc.green("seatbelt run")} first \u2014 it captures evidence and writes a report.`);
4566
+ log.blank();
4567
+ return 0;
4568
+ }
4569
+ const rel = displayPath(paths.cwd, latest);
4570
+ if (opts.path) {
4571
+ log.plain(rel);
4572
+ return 0;
4573
+ }
4574
+ log.blank();
4575
+ log.step(`Opening ${pc.cyan(rel)}`);
4576
+ try {
4577
+ await open3(latest);
4578
+ } catch {
4579
+ log.dim("(Could not auto-open \u2014 open the path above in your browser.)");
4580
+ }
4581
+ log.blank();
4582
+ return 0;
4583
+ }
4584
+
4585
+ // src/commands/account.ts
4586
+ import open4 from "open";
4587
+ async function statusCommand() {
4588
+ const webUrl = seatbeltWebUrl();
4589
+ const token = seatbeltApiToken();
4590
+ const auth = readAuth();
4591
+ log.blank();
4592
+ log.step("Seatbelt status");
4593
+ if (process.env.SEATBELT_API_TOKEN || process.env.SEATBELT_API_KEY) {
4594
+ log.plain(` Auth: environment token ${pc.dim("(advanced/CI override)")}`);
4595
+ } else if (auth) {
4596
+ log.plain(` Auth: logged in ${pc.dim(`(stored ${new Date(auth.savedAt).toLocaleDateString()})`)}`);
4597
+ } else {
4598
+ log.plain(` Auth: ${pc.yellow("not logged in")}`);
4599
+ }
4600
+ if (!token) {
4601
+ log.blank();
4602
+ log.plain("You're not connected \u2014 local browser QA still works with no account.");
4603
+ log.plain(` Connect for hosted AI: ${pc.green("seatbelt login")}`);
4604
+ log.blank();
4605
+ return 0;
4606
+ }
4607
+ try {
4608
+ const res = await fetch(`${webUrl}/api/access/me`, {
4609
+ headers: { authorization: `Bearer ${token}` }
4610
+ });
4611
+ const body = await res.json().catch(() => null);
4612
+ if (res.status === 200 && body?.access) {
4613
+ log.plain(` Account: ${pc.bold(body.access.email ?? "(unknown)")}`);
4614
+ log.plain(
4615
+ ` Plan: ${body.access.plan ?? "(unknown)"} ${body.access.active ? pc.green("\xB7 active") : pc.yellow("\xB7 inactive")}`
4616
+ );
4617
+ log.plain(` Backend: ${pc.green("reachable")} ${pc.dim(webUrl)}`);
4618
+ } else if (res.status === 401) {
4619
+ log.plain(` Account: ${pc.yellow("token not recognized")} \u2014 run ${pc.green("seatbelt login")} again.`);
4620
+ } else if (res.status === 402) {
4621
+ log.plain(` Account: ${pc.yellow("subscription inactive")} \u2014 reactivate with ${pc.green("seatbelt billing")}.`);
4622
+ } else {
4623
+ log.plain(` Account: could not verify (status ${res.status}).`);
4624
+ }
4625
+ } catch {
4626
+ log.plain(` Backend: ${pc.yellow("unreachable")} ${pc.dim(webUrl)} ${pc.dim("\u2014 local QA still works")}`);
4627
+ }
4628
+ log.blank();
4629
+ log.dim("Manage billing: seatbelt billing \xB7 run quotas are honor-system for now");
4630
+ log.blank();
4631
+ return 0;
4632
+ }
4633
+ async function billingCommand() {
4634
+ const webUrl = seatbeltWebUrl();
4635
+ const token = seatbeltApiToken();
4636
+ log.blank();
4637
+ if (!token) {
4638
+ log.warn("You're not logged in.");
4639
+ log.plain(` Run ${pc.green("seatbelt login")} first, then ${pc.green("seatbelt billing")}.`);
4640
+ log.blank();
4641
+ return 0;
4642
+ }
4643
+ log.step("Opening your Stripe billing portal\u2026");
4644
+ let body = null;
4645
+ try {
4646
+ const res = await fetch(`${webUrl}/api/billing/portal`, {
4647
+ method: "POST",
4648
+ headers: { authorization: `Bearer ${token}` }
4649
+ });
4650
+ body = await res.json().catch(() => null);
4651
+ if (!res.ok || !body?.ok || !body?.url) {
4652
+ log.warn(body?.error?.message ?? `Could not open the billing portal (status ${res.status}).`);
4653
+ log.blank();
4654
+ return 1;
4655
+ }
4656
+ } catch (err) {
4657
+ log.warn(`Could not reach ${webUrl}: ${err instanceof Error ? err.message : String(err)}`);
4658
+ log.blank();
4659
+ return 1;
4660
+ }
4661
+ log.plain(` ${pc.cyan(body.url)}`);
4662
+ try {
4663
+ await open4(body.url);
4664
+ } catch {
4665
+ log.dim(" (Could not auto-open \u2014 open the link above in your browser.)");
4666
+ }
4667
+ log.blank();
4668
+ return 0;
4669
+ }
4670
+
4671
+ // src/cli.ts
4672
+ function headedFrom(opts) {
4673
+ if (opts.headless) return false;
4674
+ if (opts.headed) return true;
4675
+ return void 0;
4676
+ }
4677
+ function action(fn) {
4678
+ return async (...args) => {
4679
+ try {
4680
+ const code = await fn(...args);
4681
+ process.exitCode = code ?? 0;
4682
+ } catch (err) {
4683
+ printError(err);
4684
+ process.exitCode = 1;
4685
+ }
4686
+ };
4687
+ }
4688
+ var program = new Command();
4689
+ program.name("seatbelt").description("QA for AI-coded apps, before you merge. Watch a real browser check your app.").version(VERSION, "-v, --version", "print the Seatbelt version");
4690
+ program.command("init").alias("on").description("set up Seatbelt in this repo (write seatbelt.config.yaml + local .seatbelt state)").option("--cwd <path>", "run as if Seatbelt was started in <path>").option("--run", "run Seatbelt immediately after setup, skipping the prompt").option("--no-run", "set up only; do not run or prompt to run").option("--yes", "accept the run + clean-test-user offers without prompting").action(action((opts) => initCommand(opts)));
4691
+ program.command("run").argument("[target]", "flow id to run, or 'all' (default: run all flows)").description("detect + start your app, replay your flows in a visible browser, write + open a report").option("--cwd <path>", "run as if Seatbelt was started in <path>").option("--headed", "force a visible browser (this is the default)").option("--headless", "run the browser headless (for CI / automated checks)").option("--no-open", "do not auto-open the report when finished").option("--yes", "accept prompts / defaults without asking").action(
4692
+ action(
4693
+ (target, opts) => runCommand({
4694
+ cwd: opts.cwd,
4695
+ target,
4696
+ open: opts.open,
4697
+ yes: opts.yes,
4698
+ headed: headedFrom(opts)
4699
+ })
4700
+ )
4701
+ );
4702
+ program.command("certify").description("sync + show the QA-certified flow map; with --run, verify every eligible flow in a browser").option("--cwd <path>", "run as if Seatbelt was started in <path>").option("--run", "run all eligible flows and certify them").option("--no-run", "only sync + show the flow map (default)").option("--headed", "force a visible browser (this is the default)").option("--headless", "run the browser headless (for CI / automated checks)").option("--no-open", "do not auto-open the report when finished").option("--yes", "accept prompts / defaults without asking").action(
4703
+ action(
4704
+ (opts) => certifyCommand({
4705
+ cwd: opts.cwd,
4706
+ run: opts.run,
4707
+ open: opts.open,
4708
+ yes: opts.yes,
4709
+ headed: headedFrom(opts)
4710
+ })
4711
+ )
4712
+ );
4713
+ program.command("setup-reset").alias("reset-prompt").description("generate a paste-ready prompt to add a safe clean-test-user reset to your app").option("--cwd <path>", "run as if Seatbelt was started in <path>").action(action((opts) => setupResetCommand(opts)));
4714
+ program.command("lint").description("run detected checks (lint / typecheck / build / test) \u2014 fast, local, no browser").option("--cwd <path>", "run as if Seatbelt was started in <path>").action(action((opts) => lintCommand(opts)));
4715
+ program.command("doctor").description("plain-language diagnosis of your setup (detection, server, flows, reset, browser)").option("--cwd <path>", "run as if Seatbelt was started in <path>").action(action((opts) => doctorCommand(opts)));
4716
+ program.command("report").description("open the latest report without re-running QA").option("--cwd <path>", "run as if Seatbelt was started in <path>").option("--path", "print the report path instead of opening it").action(action((opts) => reportCommand(opts)));
4717
+ program.command("refresh-flows").description("refresh stale flow YAML; local fallback by default, AI suggestions with --ai").option("--cwd <path>", "run as if Seatbelt was started in <path>").option("--local-only", "no backend/AI; only regenerate missing local starter templates").option("--ai", "use AI to propose refreshed YAML for stale flows").option("--flow <id>", "refresh one flow id").option("--apply", "overwrite existing flow YAML (creates a backup first)").option("--dry-run", "inspect and validate suggestions without writing files").option("--provider <provider>", "AI provider (seatbelt or openai)").option("--model <model>", "AI model to use").option("--yes", "accept safe actions without asking").action(
4718
+ action(
4719
+ (opts) => refreshFlowsCommand(opts)
4720
+ )
4721
+ );
4722
+ program.command("generate-flows").description("inspect this app and generate deterministic Seatbelt flow YAML suggestions").option("--cwd <path>", "run as if Seatbelt was started in <path>").option("--local-only", "no backend/AI; generate cautious local templates and a copy-paste prompt").option("--dry-run", "inspect and validate suggestions without writing files").option("--provider <provider>", "AI provider (seatbelt or openai)").option("--model <model>", "AI model to use").option("--yes", "consent to send the sanitized summary without prompting").action(
4723
+ action(
4724
+ (opts) => generateFlowsCommand(opts)
4725
+ )
4726
+ );
4727
+ program.command("login").description("connect your Seatbelt account in the browser (stores a token; no manual paste)").option("--token <token>", "advanced: store this token directly instead of the browser flow (CI/scripted)").action(action((opts) => loginCommand({ token: opts.token })));
4728
+ program.command("logout").description("remove the stored Seatbelt login token").action(action(() => logoutCommand()));
4729
+ program.command("whoami").description("show the currently connected Seatbelt account").action(action(() => whoamiCommand()));
4730
+ program.command("status").alias("account").description("show your connection, plan/access, backend reachability, and billing link").action(action(() => statusCommand()));
4731
+ program.command("billing").description("open your Stripe billing portal (cancel, update payment, view invoices)").action(action(() => billingCommand()));
4732
+ program.parseAsync(process.argv).catch((err) => {
4733
+ printError(err);
4734
+ process.exitCode = 1;
4735
+ });