@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/README.md +287 -0
- package/bin/vynt +17 -0
- package/package.json +46 -0
- package/src/bridge.ts +858 -0
- package/src/cli.ts +1449 -0
- package/src/state.ts +167 -0
- package/src/switch.ts +338 -0
- package/src/types.ts +48 -0
- package/vite/index.d.ts +11 -0
- package/vite/index.js +140 -0
- package/web/react/VyntToolbarProvider.d.ts +9 -0
- package/web/react/VyntToolbarProvider.js +615 -0
- package/web/react/index.d.ts +1 -0
- package/web/react/index.js +1 -0
- package/web/vynt-toolbar.js +1075 -0
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
|
+
})
|