@devosurf/vynt 0.1.2

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/src/cli.ts ADDED
@@ -0,0 +1,1449 @@
1
+ import { execFile } from "node:child_process"
2
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises"
3
+ import { basename, join, resolve } from "node:path"
4
+ import process from "node:process"
5
+ import { createInterface } from "node:readline/promises"
6
+ import { promisify } from "node:util"
7
+ import { Command, CommanderError } from "commander"
8
+ import { startBridgeServer } from "./bridge.js"
9
+ import { applySelectionsToWorkspace, getLatestSnapshot, rollbackWorkspaceToSnapshot } from "./switch.js"
10
+ import { createEmptyStore, loadState, saveState, upsertObjective, upsertProfile, upsertVariant } from "./state.js"
11
+ import type { Objective, PreviewProfile, VariantArtifact, VariantStore } from "./types.js"
12
+
13
+ const execFileAsync = promisify(execFile)
14
+
15
+ interface AddCommandOptions {
16
+ files?: string
17
+ notes?: string
18
+ session?: string
19
+ }
20
+
21
+ interface OpenCodeRegisterOptions {
22
+ files?: string
23
+ notes?: string
24
+ quiet?: boolean
25
+ }
26
+
27
+ interface OpenCodeRegisterAutoOptions extends OpenCodeRegisterOptions {
28
+ objective?: string
29
+ }
30
+
31
+ interface OpenCodeCaptureOptions {
32
+ objective?: string
33
+ notes?: string
34
+ quiet?: boolean
35
+ reset?: boolean
36
+ }
37
+
38
+ interface AutoResolvedObjective {
39
+ nextState: VariantStore
40
+ objective: Objective
41
+ autoCreated: boolean
42
+ }
43
+
44
+ interface JsonOutputOption {
45
+ json?: boolean
46
+ }
47
+
48
+ interface IdOption {
49
+ id?: string
50
+ }
51
+
52
+ interface ApplyCommandOptions {
53
+ objective?: string
54
+ review?: boolean
55
+ variant?: string
56
+ }
57
+
58
+ interface BridgeServeOptions {
59
+ host?: string
60
+ port?: string
61
+ }
62
+
63
+ function slugify(input: string): string {
64
+ return input
65
+ .trim()
66
+ .toLowerCase()
67
+ .replace(/[^a-z0-9]+/g, "-")
68
+ .replace(/^-+/, "")
69
+ .replace(/-+$/, "")
70
+ }
71
+
72
+ function makeVariantId(name: string): string {
73
+ return `${slugify(name)}-${Date.now().toString(36)}`
74
+ }
75
+
76
+ function makeObjectiveId(name: string): string {
77
+ return slugify(name)
78
+ }
79
+
80
+ function makeProfileId(name: string): string {
81
+ return slugify(name)
82
+ }
83
+
84
+ async function runGitRaw(cwd: string, args: string[]): Promise<string> {
85
+ try {
86
+ const { stdout } = await execFileAsync("git", args, {
87
+ cwd,
88
+ encoding: "utf8",
89
+ })
90
+ return stdout
91
+ } catch (error: unknown) {
92
+ const message = error instanceof Error ? error.message : String(error)
93
+ throw new Error(`git ${args.join(" ")} failed: ${message}`)
94
+ }
95
+ }
96
+
97
+ async function runGit(cwd: string, args: string[]): Promise<string> {
98
+ return (await runGitRaw(cwd, args)).trim()
99
+ }
100
+
101
+ async function resolveBaseRef(cwd: string, baseRef: string): Promise<string> {
102
+ const candidate = baseRef.trim()
103
+ if (candidate.length === 0) {
104
+ throw new Error("base ref cannot be empty")
105
+ }
106
+
107
+ try {
108
+ return await runGit(cwd, ["rev-parse", "--verify", `${candidate}^{commit}`])
109
+ } catch {
110
+ return candidate
111
+ }
112
+ }
113
+
114
+ function parseChangedFiles(files: string | undefined): string[] {
115
+ if (!files || files.trim().length === 0) {
116
+ return []
117
+ }
118
+
119
+ return files
120
+ .split(",")
121
+ .map((item) => item.trim())
122
+ .filter(Boolean)
123
+ }
124
+
125
+ async function inferChangedFilesFromPatch(patchFile: string): Promise<string[]> {
126
+ const raw = await readFile(patchFile, "utf8")
127
+ const files = new Set<string>()
128
+
129
+ for (const line of raw.split(/\r?\n/g)) {
130
+ if (!line.startsWith("+++ ")) continue
131
+
132
+ const source = line.slice(4).trim()
133
+ if (source === "/dev/null" || source.length === 0) continue
134
+
135
+ const normalized = source.startsWith("b/") ? source.slice(2) : source
136
+ if (normalized.length > 0) {
137
+ files.add(normalized)
138
+ }
139
+ }
140
+
141
+ return [...files]
142
+ }
143
+
144
+ const OBJECTIVE_MARKER_EXTENSIONS = new Set([
145
+ ".html",
146
+ ".htm",
147
+ ".xhtml",
148
+ ".tsx",
149
+ ".jsx",
150
+ ".vue",
151
+ ".svelte",
152
+ ".astro",
153
+ ])
154
+
155
+ function hasObjectiveMarkupFile(changedFiles: string[]): boolean {
156
+ return changedFiles.some((filePath) => {
157
+ const normalized = normalizeRepoPath(filePath).toLowerCase()
158
+ const dot = normalized.lastIndexOf(".")
159
+ if (dot < 0) return false
160
+ const ext = normalized.slice(dot)
161
+ return OBJECTIVE_MARKER_EXTENSIONS.has(ext)
162
+ })
163
+ }
164
+
165
+ function escapeRegex(input: string): string {
166
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
167
+ }
168
+
169
+ function patchContainsObjectiveMarker(patchContent: string, objectiveId: string): boolean {
170
+ const escapedObjective = escapeRegex(objectiveId)
171
+ const directAttribute = new RegExp(
172
+ `data-vynt-objective\\s*=\\s*["']${escapedObjective}["']`,
173
+ "i",
174
+ )
175
+ if (directAttribute.test(patchContent)) return true
176
+
177
+ const jsxLiteralAttribute = new RegExp(
178
+ `data-vynt-objective\\s*=\\s*\\{\\s*["']${escapedObjective}["']\\s*\\}`,
179
+ "i",
180
+ )
181
+ return jsxLiteralAttribute.test(patchContent)
182
+ }
183
+
184
+ async function validateObjectiveMarkerForAutoRegister(
185
+ patchFile: string,
186
+ objectiveId: string,
187
+ changedFiles: string[],
188
+ ): Promise<void> {
189
+ if (!hasObjectiveMarkupFile(changedFiles)) return
190
+
191
+ const patchContent = await readFile(patchFile, "utf8")
192
+ if (patchContainsObjectiveMarker(patchContent, objectiveId)) return
193
+
194
+ throw new Error(
195
+ [
196
+ `Objective marker contract failed for objective ${objectiveId}.`,
197
+ "Changed files include UI markup but patch is missing data-vynt-objective.",
198
+ `Add data-vynt-objective=\"${objectiveId}\" on the objective wrapper container in this variant.`,
199
+ ].join(" "),
200
+ )
201
+ }
202
+
203
+ function makePatchFileName(sessionId: string): string {
204
+ const safeSession = slugify(sessionId).slice(0, 12) || "session"
205
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
206
+ return `capture-${timestamp}-${safeSession}.patch`
207
+ }
208
+
209
+ async function captureCurrentDiffAsPatch(
210
+ cwd: string,
211
+ sessionId: string,
212
+ ): Promise<{ patchPath: string; changedFiles: string[] }> {
213
+ const changedFilesRaw = await runGit(cwd, ["diff", "--name-only"])
214
+ const changedFiles = changedFilesRaw
215
+ .split("\n")
216
+ .map((item) => item.trim())
217
+ .filter((item) => item.length > 0)
218
+
219
+ if (changedFiles.length === 0) {
220
+ throw new Error("No tracked changes to capture. Make edits before running opencode capture.")
221
+ }
222
+
223
+ const patchContent = await runGitRaw(cwd, ["diff", "--binary"])
224
+ if (patchContent.trim().length === 0) {
225
+ throw new Error("Could not create patch from current diff")
226
+ }
227
+
228
+ const patchDir = join(cwd, ".vynt", "patches")
229
+ await mkdir(patchDir, { recursive: true })
230
+
231
+ const patchPath = join(patchDir, makePatchFileName(sessionId))
232
+ const patchPayload = patchContent.endsWith("\n") ? patchContent : `${patchContent}\n`
233
+ await writeFile(patchPath, patchPayload, "utf8")
234
+
235
+ return { patchPath, changedFiles }
236
+ }
237
+
238
+ async function restoreWorkspaceToBaseRef(cwd: string, baseRef: string): Promise<void> {
239
+ await runGit(cwd, ["restore", `--source=${baseRef}`, "--staged", "--worktree", "."])
240
+ await runGit(cwd, ["clean", "-fd"])
241
+ }
242
+
243
+ const COMMON_OBJECTIVE_TOKENS = new Set([
244
+ "src",
245
+ "app",
246
+ "apps",
247
+ "lib",
248
+ "libs",
249
+ "utils",
250
+ "shared",
251
+ "components",
252
+ "component",
253
+ "page",
254
+ "layout",
255
+ "index",
256
+ "main",
257
+ "style",
258
+ "styles",
259
+ "css",
260
+ "ts",
261
+ "tsx",
262
+ "js",
263
+ "jsx",
264
+ ])
265
+
266
+ function normalizeRepoPath(filePath: string): string {
267
+ return filePath.replace(/\\/g, "/").replace(/^\.\//, "")
268
+ }
269
+
270
+ function splitTokens(input: string): string[] {
271
+ return input
272
+ .toLowerCase()
273
+ .split(/[^a-z0-9]+/)
274
+ .map((token) => token.trim())
275
+ .filter((token) => token.length >= 2 && !COMMON_OBJECTIVE_TOKENS.has(token))
276
+ }
277
+
278
+ function objectiveTokenSet(objective: Objective): Set<string> {
279
+ const tokens = new Set<string>()
280
+ for (const token of splitTokens(`${objective.id} ${objective.name}`)) {
281
+ tokens.add(token)
282
+ }
283
+ return tokens
284
+ }
285
+
286
+ function fileDir(filePath: string): string {
287
+ const normalized = normalizeRepoPath(filePath)
288
+ const index = normalized.lastIndexOf("/")
289
+ if (index <= 0) return ""
290
+ return normalized.slice(0, index)
291
+ }
292
+
293
+ function scoreObjectiveAgainstFiles(objective: Objective, changedFiles: string[]): number {
294
+ if (changedFiles.length === 0) return 0
295
+
296
+ const objectiveFiles = new Set(
297
+ objective.variants.flatMap((variant) => variant.changedFiles).map((file) => normalizeRepoPath(file).toLowerCase()),
298
+ )
299
+ const objectiveDirs = new Set(
300
+ objective.variants.map((variant) => variant.changedFiles).flat().map((file) => fileDir(file).toLowerCase()),
301
+ )
302
+ const objectiveTokens = objectiveTokenSet(objective)
303
+
304
+ let score = 0
305
+ const matchedTokens = new Set<string>()
306
+
307
+ for (const file of changedFiles.map((item) => normalizeRepoPath(item).toLowerCase())) {
308
+ if (objectiveFiles.has(file)) {
309
+ score += 12
310
+ }
311
+
312
+ const directory = fileDir(file)
313
+ if (directory.length > 0 && objectiveDirs.has(directory)) {
314
+ score += 6
315
+ }
316
+
317
+ for (const token of splitTokens(file)) {
318
+ if (objectiveTokens.has(token) && !matchedTokens.has(token)) {
319
+ matchedTokens.add(token)
320
+ score += 2
321
+ }
322
+ }
323
+ }
324
+
325
+ return score
326
+ }
327
+
328
+ function inferObjectiveId(state: VariantStore, changedFiles: string[]): string | undefined {
329
+ if (state.objectives.length === 0) return undefined
330
+ if (state.objectives.length === 1) return state.objectives[0]?.id
331
+
332
+ const ranked = state.objectives
333
+ .map((objective) => ({
334
+ objective,
335
+ score: scoreObjectiveAgainstFiles(objective, changedFiles),
336
+ }))
337
+ .sort((left, right) => right.score - left.score)
338
+
339
+ const top = ranked[0]
340
+ const next = ranked[1]
341
+
342
+ if (!top || top.score <= 0) return undefined
343
+ if (next && next.score === top.score) return undefined
344
+
345
+ return top.objective.id
346
+ }
347
+
348
+ function extractObjectiveSeed(changedFiles: string[], variantName: string): string {
349
+ for (const file of changedFiles) {
350
+ const normalized = normalizeRepoPath(file)
351
+ const segments = normalized.split("/").filter(Boolean)
352
+ const fileName = segments[segments.length - 1]
353
+ const baseName = fileName ? fileName.replace(/\.[^.]+$/, "") : ""
354
+
355
+ for (const candidate of [baseName, segments[segments.length - 2], segments[segments.length - 3]]) {
356
+ if (!candidate) continue
357
+ const tokenized = splitTokens(candidate)
358
+ if (tokenized.length > 0) {
359
+ return tokenized.join("-")
360
+ }
361
+ }
362
+ }
363
+
364
+ const fromName = splitTokens(variantName)
365
+ if (fromName.length > 0) return fromName.join("-")
366
+ return "auto-objective"
367
+ }
368
+
369
+ function toTitleCase(input: string): string {
370
+ return input
371
+ .split(/[-_\s]+/)
372
+ .filter(Boolean)
373
+ .map((part) => part[0]?.toUpperCase() + part.slice(1))
374
+ .join(" ")
375
+ }
376
+
377
+ function ensureUniqueObjectiveId(state: VariantStore, baseId: string): string {
378
+ const existing = new Set(state.objectives.map((objective) => objective.id))
379
+ if (!existing.has(baseId)) return baseId
380
+
381
+ let index = 2
382
+ while (existing.has(`${baseId}-${index}`)) {
383
+ index += 1
384
+ }
385
+ return `${baseId}-${index}`
386
+ }
387
+
388
+ function createAutoObjective(state: VariantStore, seed: string): { nextState: VariantStore; objective: Objective } {
389
+ const rawId = slugify(seed)
390
+ const baseId = rawId.length > 0 ? rawId : "auto-objective"
391
+ const objectiveId = ensureUniqueObjectiveId(state, baseId)
392
+ const now = new Date().toISOString()
393
+
394
+ const objective: Objective = {
395
+ id: objectiveId,
396
+ name: `Auto ${toTitleCase(objectiveId)}`,
397
+ baseRef: state.baseRef,
398
+ status: "open",
399
+ variants: [],
400
+ createdAt: now,
401
+ updatedAt: now,
402
+ }
403
+
404
+ return {
405
+ nextState: upsertObjective(state, objective),
406
+ objective,
407
+ }
408
+ }
409
+
410
+ function resolveObjectiveForAutoRegister(
411
+ state: VariantStore,
412
+ changedFiles: string[],
413
+ variantName: string,
414
+ explicitObjectiveId?: string,
415
+ ): AutoResolvedObjective {
416
+ if (explicitObjectiveId && explicitObjectiveId.trim().length > 0) {
417
+ const normalizedId = ensureNonEmptyId(explicitObjectiveId.trim(), "objective id")
418
+ const existing = state.objectives.find((objective) => objective.id === normalizedId)
419
+ if (existing) {
420
+ return { nextState: state, objective: existing, autoCreated: false }
421
+ }
422
+
423
+ const { nextState, objective } = createAutoObjective(state, normalizedId)
424
+ return { nextState, objective, autoCreated: true }
425
+ }
426
+
427
+ const inferredId = inferObjectiveId(state, changedFiles)
428
+ if (inferredId) {
429
+ return { nextState: state, objective: findObjective(state, inferredId), autoCreated: false }
430
+ }
431
+
432
+ const seed = extractObjectiveSeed(changedFiles, variantName)
433
+ const { nextState, objective } = createAutoObjective(state, seed)
434
+ return { nextState, objective, autoCreated: true }
435
+ }
436
+
437
+ async function registerOpenCodeVariant(
438
+ cwd: string,
439
+ state: VariantStore,
440
+ sessionId: string,
441
+ name: string,
442
+ patchInput: string,
443
+ options: OpenCodeRegisterAutoOptions,
444
+ ): Promise<{ nextState: VariantStore; variant: VariantArtifact; resolvedObjective: Objective; autoCreated: boolean }> {
445
+ const patchFile = resolve(cwd, patchInput)
446
+ await assertFileExists(patchFile)
447
+
448
+ const explicitChangedFiles = parseChangedFiles(options.files)
449
+ const changedFiles = explicitChangedFiles.length > 0 ? explicitChangedFiles : await inferChangedFilesFromPatch(patchFile)
450
+ const resolved = resolveObjectiveForAutoRegister(state, changedFiles, name, options.objective)
451
+ await validateObjectiveMarkerForAutoRegister(patchFile, resolved.objective.id, changedFiles)
452
+ const registration = await registerVariantArtifact(cwd, resolved.nextState, resolved.objective.id, name, patchInput, {
453
+ files: changedFiles.join(","),
454
+ notes: options.notes,
455
+ session: sessionId,
456
+ })
457
+
458
+ return {
459
+ nextState: registration.nextState,
460
+ variant: registration.variant,
461
+ resolvedObjective: resolved.objective,
462
+ autoCreated: resolved.autoCreated,
463
+ }
464
+ }
465
+
466
+ async function assertFileExists(filePath: string): Promise<void> {
467
+ await access(filePath)
468
+ }
469
+
470
+ async function registerVariantArtifact(
471
+ cwd: string,
472
+ state: VariantStore,
473
+ objectiveId: string,
474
+ name: string,
475
+ patchInput: string,
476
+ options: AddCommandOptions,
477
+ ): Promise<{ nextState: VariantStore; variant: VariantArtifact }> {
478
+ const targetObjective = findObjective(state, objectiveId)
479
+ const patchFile = resolve(cwd, patchInput)
480
+ await assertFileExists(patchFile)
481
+
482
+ const explicitChangedFiles = parseChangedFiles(options.files)
483
+ const changedFiles = explicitChangedFiles.length > 0 ? explicitChangedFiles : await inferChangedFilesFromPatch(patchFile)
484
+
485
+ const now = new Date().toISOString()
486
+ const variant: VariantArtifact = {
487
+ id: makeVariantId(name),
488
+ objectiveId,
489
+ name,
490
+ baseRef: state.baseRef,
491
+ patchFile,
492
+ changedFiles,
493
+ sessionId: options.session,
494
+ screenshots: [],
495
+ notes: options.notes,
496
+ status: "ready",
497
+ createdAt: now,
498
+ updatedAt: now,
499
+ }
500
+
501
+ const nextState = upsertVariant(state, variant)
502
+ const nextObjective = findObjective(nextState, targetObjective.id)
503
+ const objectiveStatus = nextObjective.status === "open" ? "review" : nextObjective.status
504
+ const finalState = upsertObjective(nextState, {
505
+ ...nextObjective,
506
+ status: objectiveStatus,
507
+ updatedAt: now,
508
+ })
509
+
510
+ return {
511
+ nextState: finalState,
512
+ variant,
513
+ }
514
+ }
515
+
516
+ function requireState(state: VariantStore | undefined): VariantStore {
517
+ if (!state) {
518
+ throw new Error("Variant store not initialized. Run: init <base-ref>")
519
+ }
520
+ return state
521
+ }
522
+
523
+ function findObjective(state: VariantStore, objectiveId: string): Objective {
524
+ const found = state.objectives.find((objective) => objective.id === objectiveId)
525
+ if (!found) {
526
+ throw new Error(`Objective not found: ${objectiveId}`)
527
+ }
528
+ return found
529
+ }
530
+
531
+ function findVariant(objective: Objective, variantId: string): VariantArtifact {
532
+ const found = objective.variants.find((variant) => variant.id === variantId)
533
+ if (!found) {
534
+ throw new Error(`Variant not found in objective ${objective.id}: ${variantId}`)
535
+ }
536
+ return found
537
+ }
538
+
539
+ function findProfile(state: VariantStore, profileId: string): PreviewProfile {
540
+ const found = state.profiles.find((profile) => profile.id === profileId)
541
+ if (!found) {
542
+ throw new Error(`Profile not found: ${profileId}`)
543
+ }
544
+ return found
545
+ }
546
+
547
+ function findVariantAcrossObjectives(
548
+ state: VariantStore,
549
+ variantId: string,
550
+ ): { objective: Objective; variant: VariantArtifact } {
551
+ const matches: Array<{ objective: Objective; variant: VariantArtifact }> = []
552
+
553
+ for (const objective of state.objectives) {
554
+ const variant = objective.variants.find((item) => item.id === variantId)
555
+ if (variant) {
556
+ matches.push({ objective, variant })
557
+ }
558
+ }
559
+
560
+ if (matches.length === 0) {
561
+ throw new Error(`Variant not found: ${variantId}`)
562
+ }
563
+
564
+ if (matches.length > 1) {
565
+ const objectiveIds = matches.map((item) => item.objective.id).join(", ")
566
+ throw new Error(
567
+ `Variant id ${variantId} is ambiguous across objectives (${objectiveIds}). Use: apply --objective <objective-id> --variant <variant-id>`,
568
+ )
569
+ }
570
+
571
+ return matches[0] as { objective: Objective; variant: VariantArtifact }
572
+ }
573
+
574
+ function resolveApplyTarget(
575
+ state: VariantStore,
576
+ variantIdArg: string | undefined,
577
+ options: ApplyCommandOptions,
578
+ ): { objective: Objective; variant: VariantArtifact } {
579
+ const hasObjective = typeof options.objective === "string" && options.objective.trim().length > 0
580
+ const hasVariantOption = typeof options.variant === "string" && options.variant.trim().length > 0
581
+
582
+ if (variantIdArg && (hasObjective || hasVariantOption)) {
583
+ throw new Error(
584
+ "Use either positional apply <variant-id> OR apply --objective <objective-id> --variant <variant-id>",
585
+ )
586
+ }
587
+
588
+ if (variantIdArg) {
589
+ return findVariantAcrossObjectives(state, variantIdArg)
590
+ }
591
+
592
+ if (!hasObjective || !hasVariantOption) {
593
+ throw new Error(
594
+ "apply requires either <variant-id> or both --objective <objective-id> and --variant <variant-id>",
595
+ )
596
+ }
597
+
598
+ const objectiveItem = findObjective(state, options.objective as string)
599
+ const variantItem = findVariant(objectiveItem, options.variant as string)
600
+ return { objective: objectiveItem, variant: variantItem }
601
+ }
602
+
603
+ function markVariantSelectedInState(state: VariantStore, objectiveId: string, variantId: string): VariantStore {
604
+ const objectiveItem = findObjective(state, objectiveId)
605
+ const now = new Date().toISOString()
606
+
607
+ const nextObjective = upsertObjective(state, {
608
+ ...objectiveItem,
609
+ activeVariantId: variantId,
610
+ status: objectiveItem.status === "open" ? "review" : objectiveItem.status,
611
+ updatedAt: now,
612
+ variants: objectiveItem.variants.map((item) => {
613
+ if (item.id === variantId) {
614
+ return {
615
+ ...item,
616
+ status: "selected" as const,
617
+ updatedAt: now,
618
+ }
619
+ }
620
+
621
+ if (item.status === "selected") {
622
+ return {
623
+ ...item,
624
+ status: "ready" as const,
625
+ updatedAt: now,
626
+ }
627
+ }
628
+
629
+ return item
630
+ }),
631
+ })
632
+
633
+ return {
634
+ ...nextObjective,
635
+ activeProfileId: undefined,
636
+ }
637
+ }
638
+
639
+ function clearActiveSelections(state: VariantStore): VariantStore {
640
+ const now = new Date().toISOString()
641
+
642
+ return {
643
+ ...state,
644
+ activeProfileId: undefined,
645
+ objectives: state.objectives.map((objectiveItem) => ({
646
+ ...objectiveItem,
647
+ activeVariantId: undefined,
648
+ updatedAt: now,
649
+ variants: objectiveItem.variants.map((variant) => {
650
+ if (variant.status !== "selected") return variant
651
+ return {
652
+ ...variant,
653
+ status: "ready" as const,
654
+ updatedAt: now,
655
+ }
656
+ }),
657
+ })),
658
+ }
659
+ }
660
+
661
+ function collectActiveSelectionChangedFiles(state: VariantStore): string[] {
662
+ const files = new Set<string>()
663
+
664
+ for (const objective of state.objectives) {
665
+ if (!objective.activeVariantId) continue
666
+ const activeVariant = objective.variants.find((variant) => variant.id === objective.activeVariantId)
667
+ if (!activeVariant) continue
668
+
669
+ for (const file of activeVariant.changedFiles) {
670
+ files.add(file)
671
+ }
672
+ }
673
+
674
+ return [...files]
675
+ }
676
+
677
+ function snapshotIdFromPath(snapshotPath: string): string | undefined {
678
+ const fileName = basename(snapshotPath)
679
+ if (!fileName.endsWith(".json")) return undefined
680
+ return fileName.slice(0, -".json".length)
681
+ }
682
+
683
+ async function waitForReviewConfirmation(): Promise<void> {
684
+ const input = process.stdin
685
+ const output = process.stdout
686
+ const reader = createInterface({
687
+ input,
688
+ output,
689
+ })
690
+
691
+ try {
692
+ await reader.question("Review in browser, then press Enter to rollback to baseline... ")
693
+ } finally {
694
+ reader.close()
695
+ }
696
+ }
697
+
698
+ async function rollbackAndResetState(
699
+ cwd: string,
700
+ state: VariantStore,
701
+ snapshotId?: string,
702
+ ): Promise<{ nextState: VariantStore; snapshotHead: string; snapshotId: string }> {
703
+ const { snapshot } = await rollbackWorkspaceToSnapshot(cwd, snapshotId, {
704
+ allowDirtyFiles: collectActiveSelectionChangedFiles(state),
705
+ })
706
+ return {
707
+ nextState: clearActiveSelections(state),
708
+ snapshotHead: snapshot.head,
709
+ snapshotId: snapshot.id,
710
+ }
711
+ }
712
+
713
+ function printJson(value: unknown): void {
714
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`)
715
+ }
716
+
717
+ function ensureNonEmptyId(input: string, field: string): string {
718
+ if (input.trim().length === 0) {
719
+ throw new Error(`${field} cannot be empty`)
720
+ }
721
+ return input
722
+ }
723
+
724
+ function ensureUniqueId(existing: string[], candidate: string, kind: string): void {
725
+ if (existing.includes(candidate)) {
726
+ throw new Error(`${kind} already exists: ${candidate}`)
727
+ }
728
+ }
729
+
730
+ function formatSelections(selections: Record<string, string>): string {
731
+ const entries = Object.entries(selections)
732
+ if (entries.length === 0) return "-"
733
+ return entries
734
+ .sort(([left], [right]) => left.localeCompare(right))
735
+ .map(([objectiveId, variantId]) => `${objectiveId}=${variantId}`)
736
+ .join(", ")
737
+ }
738
+
739
+ function parsePortOption(value: string | undefined, defaultPort: number): number {
740
+ if (!value) return defaultPort
741
+ const parsed = Number(value)
742
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
743
+ throw new Error(`Invalid port: ${value}`)
744
+ }
745
+ return parsed
746
+ }
747
+
748
+ function createProgram(cwd: string): Command {
749
+ const program = new Command()
750
+
751
+ program
752
+ .name("vynt")
753
+ .description("vynt CLI")
754
+ .exitOverride()
755
+ .configureOutput({
756
+ outputError: () => undefined,
757
+ })
758
+ .showSuggestionAfterError()
759
+
760
+ program.command("init <base-ref>").description("Initialize variant store").action(async (baseRef: string) => {
761
+ const existing = await loadState(cwd)
762
+ if (existing) {
763
+ throw new Error("Variant store already exists in this directory")
764
+ }
765
+
766
+ const resolvedBaseRef = await resolveBaseRef(cwd, baseRef)
767
+ await saveState(cwd, createEmptyStore(cwd, resolvedBaseRef))
768
+ process.stdout.write(`Initialized variant store with base ref: ${resolvedBaseRef}\n`)
769
+ })
770
+
771
+ const objective = program.command("objective").description("Manage objectives")
772
+
773
+ objective
774
+ .command("create <name>")
775
+ .description("Create a new objective")
776
+ .option("--id <id>", "Explicit objective id")
777
+ .action(async (name: string, options: IdOption) => {
778
+ const state = requireState(await loadState(cwd))
779
+ const candidate = options.id ?? makeObjectiveId(name)
780
+ const objectiveId = ensureNonEmptyId(candidate, "objective id")
781
+
782
+ ensureUniqueId(
783
+ state.objectives.map((item) => item.id),
784
+ objectiveId,
785
+ "Objective",
786
+ )
787
+
788
+ const now = new Date().toISOString()
789
+ const next = upsertObjective(state, {
790
+ id: objectiveId,
791
+ name,
792
+ baseRef: state.baseRef,
793
+ status: "open",
794
+ variants: [],
795
+ createdAt: now,
796
+ updatedAt: now,
797
+ })
798
+
799
+ await saveState(cwd, next)
800
+ process.stdout.write(`Created objective ${objectiveId}\n`)
801
+ })
802
+
803
+ objective
804
+ .command("list")
805
+ .description("List objectives")
806
+ .option("--json", "Output as JSON")
807
+ .action(async (options: JsonOutputOption) => {
808
+ const state = requireState(await loadState(cwd))
809
+ if (options.json) {
810
+ printJson(state.objectives)
811
+ return
812
+ }
813
+
814
+ if (state.objectives.length === 0) {
815
+ process.stdout.write("No objectives\n")
816
+ return
817
+ }
818
+
819
+ const lines = state.objectives.map(
820
+ (item) =>
821
+ `${item.id} | ${item.status} | active=${item.activeVariantId ?? "-"} | winner=${item.winnerVariantId ?? "-"} | variants=${item.variants.length} | ${item.name}`,
822
+ )
823
+ process.stdout.write(lines.join("\n") + "\n")
824
+ })
825
+
826
+ objective
827
+ .command("finalize <objective-id> <variant-id>")
828
+ .description("Set winner for an objective")
829
+ .action(async (objectiveId: string, variantId: string) => {
830
+ const state = requireState(await loadState(cwd))
831
+ const current = findObjective(state, objectiveId)
832
+ findVariant(current, variantId)
833
+
834
+ const now = new Date().toISOString()
835
+ const next = upsertObjective(state, {
836
+ ...current,
837
+ status: "finalized",
838
+ winnerVariantId: variantId,
839
+ activeVariantId: current.activeVariantId ?? variantId,
840
+ updatedAt: now,
841
+ })
842
+
843
+ await saveState(cwd, next)
844
+ process.stdout.write(`Objective ${objectiveId} finalized with winner ${variantId}\n`)
845
+ })
846
+
847
+ program
848
+ .command("add <objective-id> <name> <patch-file>")
849
+ .description("Register a variant artifact under an objective")
850
+ .option("--files <files>", "Comma-separated changed files")
851
+ .option("--session <id>", "Session ID")
852
+ .option("--notes <text>", "Additional notes")
853
+ .action(async (objectiveId: string, name: string, patchInput: string, options: AddCommandOptions) => {
854
+ const state = requireState(await loadState(cwd))
855
+ const { nextState, variant } = await registerVariantArtifact(cwd, state, objectiveId, name, patchInput, options)
856
+
857
+ await saveState(cwd, nextState)
858
+ process.stdout.write(`Added variant ${variant.id} to objective ${objectiveId}\n`)
859
+ })
860
+
861
+ const opencode = program.command("opencode").description("OpenCode registration helpers")
862
+
863
+ opencode
864
+ .command("register <objective-id> <session-id> <name> <patch-file>")
865
+ .description("Register an OpenCode session result as a variant")
866
+ .option("--files <files>", "Comma-separated changed files")
867
+ .option("--notes <text>", "Additional notes")
868
+ .option("--quiet", "Suppress success output")
869
+ .action(
870
+ async (
871
+ objectiveId: string,
872
+ sessionId: string,
873
+ name: string,
874
+ patchInput: string,
875
+ options: OpenCodeRegisterOptions,
876
+ ) => {
877
+ const state = requireState(await loadState(cwd))
878
+ const { nextState, variant } = await registerVariantArtifact(cwd, state, objectiveId, name, patchInput, {
879
+ ...options,
880
+ session: sessionId,
881
+ })
882
+
883
+ await saveState(cwd, nextState)
884
+ if (!options.quiet) {
885
+ process.stdout.write(
886
+ `Registered OpenCode session ${sessionId} as variant ${variant.id} in objective ${objectiveId}\n`,
887
+ )
888
+ }
889
+ },
890
+ )
891
+
892
+ opencode
893
+ .command("register-auto <session-id> <name> <patch-file>")
894
+ .description("Register an OpenCode session result with automatic objective routing")
895
+ .option("--objective <objective-id>", "Optional objective override; auto-created if missing")
896
+ .option("--files <files>", "Comma-separated changed files")
897
+ .option("--notes <text>", "Additional notes")
898
+ .option("--quiet", "Suppress success output")
899
+ .action(
900
+ async (
901
+ sessionId: string,
902
+ name: string,
903
+ patchInput: string,
904
+ options: OpenCodeRegisterAutoOptions,
905
+ ) => {
906
+ const state = requireState(await loadState(cwd))
907
+ const { nextState, variant, resolvedObjective, autoCreated } = await registerOpenCodeVariant(
908
+ cwd,
909
+ state,
910
+ sessionId,
911
+ name,
912
+ patchInput,
913
+ options,
914
+ )
915
+
916
+ await saveState(cwd, nextState)
917
+
918
+ if (!options.quiet) {
919
+ if (autoCreated) {
920
+ process.stdout.write(`Auto-created objective ${resolvedObjective.id} (${resolvedObjective.name})\n`)
921
+ }
922
+ process.stdout.write(
923
+ `Registered OpenCode session ${sessionId} as variant ${variant.id} in objective ${resolvedObjective.id}\n`,
924
+ )
925
+ }
926
+ },
927
+ )
928
+
929
+ opencode
930
+ .command("capture <session-id> <name>")
931
+ .description("Capture current git diff into patch and register variant automatically")
932
+ .option("--objective <objective-id>", "Optional objective override; auto-created if missing")
933
+ .option("--notes <text>", "Additional notes")
934
+ .option("--reset", "Restore workspace to base ref after capture")
935
+ .option("--quiet", "Suppress success output")
936
+ .action(async (sessionId: string, name: string, options: OpenCodeCaptureOptions) => {
937
+ const state = requireState(await loadState(cwd))
938
+ const { patchPath, changedFiles } = await captureCurrentDiffAsPatch(cwd, sessionId)
939
+ const { nextState, variant, resolvedObjective, autoCreated } = await registerOpenCodeVariant(
940
+ cwd,
941
+ state,
942
+ sessionId,
943
+ name,
944
+ patchPath,
945
+ {
946
+ objective: options.objective,
947
+ files: changedFiles.join(","),
948
+ notes: options.notes,
949
+ },
950
+ )
951
+
952
+ await saveState(cwd, nextState)
953
+
954
+ if (options.reset) {
955
+ await restoreWorkspaceToBaseRef(cwd, state.baseRef)
956
+ }
957
+
958
+ if (!options.quiet) {
959
+ if (autoCreated) {
960
+ process.stdout.write(`Auto-created objective ${resolvedObjective.id} (${resolvedObjective.name})\n`)
961
+ }
962
+ process.stdout.write(`Captured patch ${patchPath}\n`)
963
+ process.stdout.write(
964
+ `Registered OpenCode session ${sessionId} as variant ${variant.id} in objective ${resolvedObjective.id}\n`,
965
+ )
966
+ if (options.reset) {
967
+ process.stdout.write(`Workspace reset to base ${state.baseRef}\n`)
968
+ }
969
+ }
970
+ })
971
+
972
+ program.command("list").description("List objectives, variants, and profiles").option("--json", "Output as JSON").action(async (options: JsonOutputOption) => {
973
+ const state = requireState(await loadState(cwd))
974
+
975
+ if (options.json) {
976
+ printJson(state)
977
+ return
978
+ }
979
+
980
+ process.stdout.write(`Base ref: ${state.baseRef}\n`)
981
+
982
+ if (state.objectives.length === 0) {
983
+ process.stdout.write("No objectives registered yet\n")
984
+ } else {
985
+ for (const objectiveItem of state.objectives) {
986
+ process.stdout.write(
987
+ `${objectiveItem.id}: ${objectiveItem.name} [${objectiveItem.status}] base=${objectiveItem.baseRef} active=${objectiveItem.activeVariantId ?? "-"} winner=${objectiveItem.winnerVariantId ?? "-"}\n`,
988
+ )
989
+
990
+ if (objectiveItem.variants.length === 0) {
991
+ process.stdout.write(" - no variants\n")
992
+ continue
993
+ }
994
+
995
+ for (const variant of objectiveItem.variants) {
996
+ process.stdout.write(
997
+ ` - ${variant.id} [${variant.status}] files=${variant.changedFiles.length} session=${variant.sessionId ?? "-"} ${variant.name}\n`,
998
+ )
999
+ }
1000
+ }
1001
+ }
1002
+
1003
+ process.stdout.write("\nProfiles:\n")
1004
+ if (state.profiles.length === 0) {
1005
+ process.stdout.write(" - none\n")
1006
+ return
1007
+ }
1008
+
1009
+ for (const profile of state.profiles) {
1010
+ const activeMarker = state.activeProfileId === profile.id ? "*" : " "
1011
+ process.stdout.write(
1012
+ `${activeMarker} ${profile.id} | ${profile.name} | selections=${formatSelections(profile.selections)} | lastApplied=${profile.lastAppliedAt ?? "-"}\n`,
1013
+ )
1014
+ }
1015
+ })
1016
+
1017
+ program
1018
+ .command("status")
1019
+ .description("Show active profile/objective state and latest snapshot")
1020
+ .option("--json", "Output as JSON")
1021
+ .action(async (options: JsonOutputOption) => {
1022
+ const state = requireState(await loadState(cwd))
1023
+ const latestSnapshot = await getLatestSnapshot(cwd)
1024
+
1025
+ if (options.json) {
1026
+ printJson({
1027
+ baseRef: state.baseRef,
1028
+ activeProfileId: state.activeProfileId ?? null,
1029
+ objectives: state.objectives.map((objectiveItem) => ({
1030
+ id: objectiveItem.id,
1031
+ status: objectiveItem.status,
1032
+ activeVariantId: objectiveItem.activeVariantId ?? null,
1033
+ winnerVariantId: objectiveItem.winnerVariantId ?? null,
1034
+ variants: objectiveItem.variants.length,
1035
+ })),
1036
+ latestSnapshot: latestSnapshot
1037
+ ? {
1038
+ id: latestSnapshot.id,
1039
+ createdAt: latestSnapshot.createdAt,
1040
+ head: latestSnapshot.head,
1041
+ baseRef: latestSnapshot.baseRef,
1042
+ selections: latestSnapshot.selections,
1043
+ filePath: latestSnapshot.filePath,
1044
+ }
1045
+ : null,
1046
+ })
1047
+ return
1048
+ }
1049
+
1050
+ process.stdout.write(`Base ref: ${state.baseRef}\n`)
1051
+ process.stdout.write(`Active profile: ${state.activeProfileId ?? "-"}\n`)
1052
+
1053
+ process.stdout.write("Objectives:\n")
1054
+ if (state.objectives.length === 0) {
1055
+ process.stdout.write(" - none\n")
1056
+ } else {
1057
+ for (const objectiveItem of state.objectives) {
1058
+ process.stdout.write(
1059
+ ` - ${objectiveItem.id} [${objectiveItem.status}] active=${objectiveItem.activeVariantId ?? "-"} winner=${objectiveItem.winnerVariantId ?? "-"}\n`,
1060
+ )
1061
+ }
1062
+ }
1063
+
1064
+ process.stdout.write("Latest snapshot:\n")
1065
+ if (!latestSnapshot) {
1066
+ process.stdout.write(" - none\n")
1067
+ return
1068
+ }
1069
+
1070
+ process.stdout.write(` - id=${latestSnapshot.id} createdAt=${latestSnapshot.createdAt}\n`)
1071
+ process.stdout.write(` - head=${latestSnapshot.head} baseRef=${latestSnapshot.baseRef}\n`)
1072
+ process.stdout.write(` - file=${latestSnapshot.filePath}\n`)
1073
+
1074
+ if (latestSnapshot.selections.length === 0) {
1075
+ process.stdout.write(" - selections=none\n")
1076
+ return
1077
+ }
1078
+
1079
+ for (const selection of latestSnapshot.selections) {
1080
+ process.stdout.write(` - selection ${selection.objectiveId}=${selection.variantId}\n`)
1081
+ }
1082
+ })
1083
+
1084
+ program
1085
+ .command("activate <objective-id> <variant-id>")
1086
+ .description("Set active variant for a specific objective")
1087
+ .action(async (objectiveId: string, variantId: string) => {
1088
+ const state = requireState(await loadState(cwd))
1089
+ const current = findObjective(state, objectiveId)
1090
+ findVariant(current, variantId)
1091
+
1092
+ const now = new Date().toISOString()
1093
+ const next = upsertObjective(state, {
1094
+ ...current,
1095
+ activeVariantId: variantId,
1096
+ status: current.status === "open" ? "review" : current.status,
1097
+ updatedAt: now,
1098
+ variants: current.variants.map((item) => {
1099
+ if (item.id === variantId) {
1100
+ return {
1101
+ ...item,
1102
+ status: "selected" as const,
1103
+ updatedAt: now,
1104
+ }
1105
+ }
1106
+
1107
+ if (item.status === "selected") {
1108
+ return {
1109
+ ...item,
1110
+ status: "ready" as const,
1111
+ updatedAt: now,
1112
+ }
1113
+ }
1114
+
1115
+ return item
1116
+ }),
1117
+ })
1118
+
1119
+ await saveState(cwd, next)
1120
+ process.stdout.write(`Objective ${objectiveId} active variant set to ${variantId}\n`)
1121
+ })
1122
+
1123
+ program
1124
+ .command("apply [variant-id]")
1125
+ .description("Restore base state and apply selected variant patch(es) to workspace")
1126
+ .option("--objective <objective-id>", "Explicit objective id")
1127
+ .option("--variant <variant-id>", "Variant id used with --objective")
1128
+ .option("--review", "Wait for confirmation and rollback automatically after preview")
1129
+ .action(async (variantIdArg: string | undefined, options: ApplyCommandOptions) => {
1130
+ const state = requireState(await loadState(cwd))
1131
+
1132
+ const target = resolveApplyTarget(state, variantIdArg, options)
1133
+
1134
+ const { snapshotPath } = await applySelectionsToWorkspace(cwd, state.baseRef, [
1135
+ {
1136
+ objectiveId: target.objective.id,
1137
+ variantId: target.variant.id,
1138
+ patchFile: target.variant.patchFile,
1139
+ changedFiles: target.variant.changedFiles,
1140
+ },
1141
+ ])
1142
+
1143
+ const nextState = markVariantSelectedInState(state, target.objective.id, target.variant.id)
1144
+ await saveState(cwd, nextState)
1145
+ process.stdout.write(
1146
+ `Applied ${target.objective.id}:${target.variant.id} from base ${state.baseRef}. Snapshot: ${snapshotPath}\n`,
1147
+ )
1148
+
1149
+ if (options.review) {
1150
+ await waitForReviewConfirmation()
1151
+ const snapshotId = snapshotIdFromPath(snapshotPath)
1152
+ const rolled = await rollbackAndResetState(cwd, nextState, snapshotId)
1153
+ await saveState(cwd, rolled.nextState)
1154
+ process.stdout.write(`Rolled back to snapshot ${rolled.snapshotId} (head ${rolled.snapshotHead})\n`)
1155
+ }
1156
+ })
1157
+
1158
+ program
1159
+ .command("rollback [snapshot-id]")
1160
+ .description("Restore workspace to the latest or specified pre-apply snapshot")
1161
+ .action(async (snapshotId: string | undefined) => {
1162
+ const state = requireState(await loadState(cwd))
1163
+ const rolled = await rollbackAndResetState(cwd, state, snapshotId)
1164
+
1165
+ await saveState(cwd, rolled.nextState)
1166
+ process.stdout.write(`Rolled back to snapshot ${rolled.snapshotId} (head ${rolled.snapshotHead})\n`)
1167
+ })
1168
+
1169
+ const profile = program.command("profile").description("Manage preview profiles")
1170
+
1171
+ profile
1172
+ .command("create <name>")
1173
+ .description("Create a profile")
1174
+ .option("--id <id>", "Explicit profile id")
1175
+ .action(async (name: string, options: IdOption) => {
1176
+ const state = requireState(await loadState(cwd))
1177
+ const candidate = options.id ?? makeProfileId(name)
1178
+ const profileId = ensureNonEmptyId(candidate, "profile id")
1179
+
1180
+ ensureUniqueId(
1181
+ state.profiles.map((item) => item.id),
1182
+ profileId,
1183
+ "Profile",
1184
+ )
1185
+
1186
+ const now = new Date().toISOString()
1187
+ const next = upsertProfile(state, {
1188
+ id: profileId,
1189
+ name,
1190
+ selections: {},
1191
+ createdAt: now,
1192
+ updatedAt: now,
1193
+ })
1194
+
1195
+ await saveState(cwd, next)
1196
+ process.stdout.write(`Created profile ${profileId}\n`)
1197
+ })
1198
+
1199
+ profile
1200
+ .command("list")
1201
+ .description("List profiles")
1202
+ .option("--json", "Output as JSON")
1203
+ .action(async (options: JsonOutputOption) => {
1204
+ const state = requireState(await loadState(cwd))
1205
+
1206
+ if (options.json) {
1207
+ printJson(state.profiles)
1208
+ return
1209
+ }
1210
+
1211
+ if (state.profiles.length === 0) {
1212
+ process.stdout.write("No profiles\n")
1213
+ return
1214
+ }
1215
+
1216
+ const lines = state.profiles.map((item) => {
1217
+ const activeMarker = state.activeProfileId === item.id ? "*" : " "
1218
+ return `${activeMarker} ${item.id} | ${item.name} | selections=${formatSelections(item.selections)} | lastApplied=${item.lastAppliedAt ?? "-"}`
1219
+ })
1220
+
1221
+ process.stdout.write(lines.join("\n") + "\n")
1222
+ })
1223
+
1224
+ profile
1225
+ .command("set <profile-id> <objective-id> <variant-id>")
1226
+ .description("Set one objective selection in a profile")
1227
+ .action(async (profileId: string, objectiveId: string, variantId: string) => {
1228
+ const state = requireState(await loadState(cwd))
1229
+ const currentProfile = findProfile(state, profileId)
1230
+ const objectiveItem = findObjective(state, objectiveId)
1231
+ findVariant(objectiveItem, variantId)
1232
+
1233
+ const now = new Date().toISOString()
1234
+ const next = upsertProfile(state, {
1235
+ ...currentProfile,
1236
+ selections: {
1237
+ ...currentProfile.selections,
1238
+ [objectiveId]: variantId,
1239
+ },
1240
+ updatedAt: now,
1241
+ })
1242
+
1243
+ await saveState(cwd, next)
1244
+ process.stdout.write(`Profile ${profileId} set ${objectiveId}=${variantId}\n`)
1245
+ })
1246
+
1247
+ profile
1248
+ .command("clear <profile-id> <objective-id>")
1249
+ .description("Remove one objective selection from a profile")
1250
+ .action(async (profileId: string, objectiveId: string) => {
1251
+ const state = requireState(await loadState(cwd))
1252
+ const currentProfile = findProfile(state, profileId)
1253
+
1254
+ const nextSelections = { ...currentProfile.selections }
1255
+ delete nextSelections[objectiveId]
1256
+
1257
+ const now = new Date().toISOString()
1258
+ const next = upsertProfile(state, {
1259
+ ...currentProfile,
1260
+ selections: nextSelections,
1261
+ updatedAt: now,
1262
+ })
1263
+
1264
+ await saveState(cwd, next)
1265
+ process.stdout.write(`Profile ${profileId} cleared objective ${objectiveId}\n`)
1266
+ })
1267
+
1268
+ profile
1269
+ .command("apply <profile-id>")
1270
+ .description("Validate and activate a profile selection plan")
1271
+ .action(async (profileId: string) => {
1272
+ const state = requireState(await loadState(cwd))
1273
+ const profileItem = findProfile(state, profileId)
1274
+ const selectionEntries = Object.entries(profileItem.selections)
1275
+
1276
+ if (selectionEntries.length === 0) {
1277
+ throw new Error(`Profile ${profileId} has no selections`)
1278
+ }
1279
+
1280
+ const resolved = selectionEntries.map(([objectiveId, variantId]) => {
1281
+ const objectiveItem = findObjective(state, objectiveId)
1282
+ const variant = findVariant(objectiveItem, variantId)
1283
+ return {
1284
+ objective: objectiveItem,
1285
+ variant,
1286
+ }
1287
+ })
1288
+
1289
+ const fileOwners = new Map<string, string[]>()
1290
+ for (const item of resolved) {
1291
+ for (const file of item.variant.changedFiles) {
1292
+ const owners = fileOwners.get(file) ?? []
1293
+ owners.push(`${item.objective.id}:${item.variant.id}`)
1294
+ fileOwners.set(file, owners)
1295
+ }
1296
+ }
1297
+
1298
+ const conflicts = Array.from(fileOwners.entries()).filter(([, owners]) => owners.length > 1)
1299
+ if (conflicts.length > 0) {
1300
+ const conflictText = conflicts
1301
+ .map(([file, owners]) => `${file} -> ${owners.join(", ")}`)
1302
+ .join("\n")
1303
+ throw new Error(`Profile ${profileId} has overlapping file selections:\n${conflictText}`)
1304
+ }
1305
+
1306
+ const { snapshotPath } = await applySelectionsToWorkspace(
1307
+ cwd,
1308
+ state.baseRef,
1309
+ resolved.map((item) => ({
1310
+ objectiveId: item.objective.id,
1311
+ variantId: item.variant.id,
1312
+ patchFile: item.variant.patchFile,
1313
+ changedFiles: item.variant.changedFiles,
1314
+ })),
1315
+ )
1316
+
1317
+ const now = new Date().toISOString()
1318
+ const selectionByObjective = new Map(selectionEntries)
1319
+ const nextObjectives = state.objectives.map((objectiveItem) => {
1320
+ const selectedVariantId = selectionByObjective.get(objectiveItem.id)
1321
+ if (!selectedVariantId) return objectiveItem
1322
+
1323
+ return {
1324
+ ...objectiveItem,
1325
+ activeVariantId: selectedVariantId,
1326
+ status: objectiveItem.status === "open" ? "review" : objectiveItem.status,
1327
+ updatedAt: now,
1328
+ variants: objectiveItem.variants.map((variant) => {
1329
+ if (variant.id === selectedVariantId) {
1330
+ return {
1331
+ ...variant,
1332
+ status: "selected" as const,
1333
+ updatedAt: now,
1334
+ }
1335
+ }
1336
+
1337
+ if (variant.status === "selected") {
1338
+ return {
1339
+ ...variant,
1340
+ status: "ready" as const,
1341
+ updatedAt: now,
1342
+ }
1343
+ }
1344
+
1345
+ return variant
1346
+ }),
1347
+ }
1348
+ })
1349
+
1350
+ const nextProfiles = state.profiles.map((current) => {
1351
+ if (current.id !== profileId) return current
1352
+ return {
1353
+ ...current,
1354
+ lastAppliedAt: now,
1355
+ updatedAt: now,
1356
+ }
1357
+ })
1358
+
1359
+ const nextState: VariantStore = {
1360
+ ...state,
1361
+ activeProfileId: profileId,
1362
+ objectives: nextObjectives,
1363
+ profiles: nextProfiles,
1364
+ }
1365
+
1366
+ await saveState(cwd, nextState)
1367
+
1368
+ const plan = resolved
1369
+ .map((item) => `- ${item.objective.id} -> ${item.variant.id}`)
1370
+ .join("\n")
1371
+
1372
+ process.stdout.write(`Profile ${profileId} applied:\n${plan}\n`)
1373
+ process.stdout.write(`Workspace updated from base ${state.baseRef}. Snapshot: ${snapshotPath}\n`)
1374
+ })
1375
+
1376
+ const bridge = program.command("bridge").description("Browser devtools bridge")
1377
+
1378
+ bridge
1379
+ .command("serve")
1380
+ .description("Start local HTTP bridge and serve toolbar script")
1381
+ .option("--host <host>", "Host interface", "127.0.0.1")
1382
+ .option("--port <port>", "Port", "4173")
1383
+ .action(async (options: BridgeServeOptions) => {
1384
+ const host = options.host ?? "127.0.0.1"
1385
+ const port = parsePortOption(options.port, 4173)
1386
+ const server = await startBridgeServer({
1387
+ host,
1388
+ port,
1389
+ projectRoot: cwd,
1390
+ })
1391
+
1392
+ process.stdout.write(`Bridge server listening on ${server.url}\n`)
1393
+ process.stdout.write(`Toolbar script: ${server.url}/toolbar.js\n`)
1394
+ process.stdout.write(`Status endpoint: ${server.url}/status\n`)
1395
+ process.stdout.write("Press Ctrl+C to stop.\n")
1396
+
1397
+ await new Promise<void>((resolvePromise) => {
1398
+ let closed = false
1399
+
1400
+ const shutdown = async () => {
1401
+ if (closed) return
1402
+ closed = true
1403
+ process.stdout.write("Stopping bridge server...\n")
1404
+ await server.close()
1405
+ process.off("SIGINT", onSigint)
1406
+ process.off("SIGTERM", onSigterm)
1407
+ resolvePromise()
1408
+ }
1409
+
1410
+ const onSigint = () => {
1411
+ void shutdown()
1412
+ }
1413
+
1414
+ const onSigterm = () => {
1415
+ void shutdown()
1416
+ }
1417
+
1418
+ process.on("SIGINT", onSigint)
1419
+ process.on("SIGTERM", onSigterm)
1420
+ })
1421
+ })
1422
+
1423
+ return program
1424
+ }
1425
+
1426
+ async function run(): Promise<void> {
1427
+ const program = createProgram(process.cwd())
1428
+ if (process.argv.length <= 2) {
1429
+ program.outputHelp()
1430
+ return
1431
+ }
1432
+
1433
+ try {
1434
+ await program.parseAsync(process.argv)
1435
+ } catch (error: unknown) {
1436
+ if (error instanceof CommanderError && error.code === "commander.helpDisplayed") {
1437
+ return
1438
+ }
1439
+
1440
+ const message = error instanceof Error ? error.message : String(error)
1441
+ throw new Error(message.replace(/^error:\s*/i, ""))
1442
+ }
1443
+ }
1444
+
1445
+ run().catch((error: unknown) => {
1446
+ const message = error instanceof Error ? error.message : String(error)
1447
+ process.stderr.write(`Error: ${message}\n`)
1448
+ process.exit(1)
1449
+ })