@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/state.ts ADDED
@@ -0,0 +1,167 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises"
2
+ import { dirname, resolve } from "node:path"
3
+ import type { Objective, PreviewProfile, VariantArtifact, VariantStore } from "./types.js"
4
+
5
+ const STATE_DIR = ".vynt"
6
+ const STATE_FILE = "state.json"
7
+
8
+ export function getStatePath(projectRoot: string): string {
9
+ return resolve(projectRoot, STATE_DIR, STATE_FILE)
10
+ }
11
+
12
+ function isObject(value: unknown): value is Record<string, unknown> {
13
+ return typeof value === "object" && value !== null
14
+ }
15
+
16
+ function migrateState(input: unknown, projectRoot: string): VariantStore | undefined {
17
+ if (!isObject(input)) return undefined
18
+ if (typeof input.baseRef !== "string" || input.baseRef.trim().length === 0) return undefined
19
+ const baseRef = input.baseRef
20
+
21
+ if (input.schemaVersion === 2 && Array.isArray(input.objectives) && Array.isArray(input.profiles)) {
22
+ return {
23
+ ...(input as unknown as VariantStore),
24
+ projectRoot: resolve(projectRoot),
25
+ }
26
+ }
27
+
28
+ const now = new Date().toISOString()
29
+ const legacyVariants = Array.isArray(input.variants) ? input.variants : []
30
+ const activeVariantId = typeof input.activeVariantId === "string" ? input.activeVariantId : undefined
31
+
32
+ const variants: VariantArtifact[] = legacyVariants
33
+ .filter(isObject)
34
+ .map((item, index) => {
35
+ const id = typeof item.id === "string" && item.id.trim().length > 0 ? item.id : `legacy-${index + 1}`
36
+ const name = typeof item.name === "string" && item.name.trim().length > 0 ? item.name : id
37
+ const patchFile = typeof item.patchFile === "string" ? item.patchFile : ""
38
+ const changedFiles = Array.isArray(item.changedFiles)
39
+ ? item.changedFiles.filter((entry): entry is string => typeof entry === "string")
40
+ : []
41
+ const screenshots = Array.isArray(item.screenshots)
42
+ ? item.screenshots.filter((entry): entry is string => typeof entry === "string")
43
+ : []
44
+ const status: VariantArtifact["status"] =
45
+ item.status === "draft" ||
46
+ item.status === "running" ||
47
+ item.status === "ready" ||
48
+ item.status === "selected" ||
49
+ item.status === "archived"
50
+ ? item.status
51
+ : "ready"
52
+
53
+ return {
54
+ id,
55
+ objectiveId: "legacy",
56
+ name,
57
+ baseRef,
58
+ patchFile,
59
+ changedFiles,
60
+ sessionId: typeof item.sessionId === "string" ? item.sessionId : undefined,
61
+ screenshots,
62
+ notes: typeof item.notes === "string" ? item.notes : undefined,
63
+ status,
64
+ createdAt: typeof item.createdAt === "string" ? item.createdAt : now,
65
+ updatedAt: typeof item.updatedAt === "string" ? item.updatedAt : now,
66
+ }
67
+ })
68
+ .filter((item) => item.patchFile.length > 0)
69
+
70
+ const objectives: Objective[] =
71
+ variants.length > 0
72
+ ? [
73
+ {
74
+ id: "legacy",
75
+ name: "legacy",
76
+ baseRef,
77
+ status: "open",
78
+ activeVariantId,
79
+ variants,
80
+ createdAt: now,
81
+ updatedAt: now,
82
+ },
83
+ ]
84
+ : []
85
+
86
+ return {
87
+ schemaVersion: 2,
88
+ projectRoot: resolve(projectRoot),
89
+ baseRef,
90
+ objectives,
91
+ profiles: [],
92
+ }
93
+ }
94
+
95
+ export async function loadState(projectRoot: string): Promise<VariantStore | undefined> {
96
+ const statePath = getStatePath(projectRoot)
97
+
98
+ try {
99
+ const content = await readFile(statePath, "utf8")
100
+ return migrateState(JSON.parse(content), projectRoot)
101
+ } catch {
102
+ return undefined
103
+ }
104
+ }
105
+
106
+ export async function saveState(projectRoot: string, state: VariantStore): Promise<void> {
107
+ const statePath = getStatePath(projectRoot)
108
+ await mkdir(dirname(statePath), { recursive: true })
109
+ await writeFile(statePath, JSON.stringify(state, null, 2) + "\n", "utf8")
110
+ }
111
+
112
+ export function createEmptyStore(projectRoot: string, baseRef: string): VariantStore {
113
+ return {
114
+ schemaVersion: 2,
115
+ projectRoot: resolve(projectRoot),
116
+ baseRef,
117
+ objectives: [],
118
+ profiles: [],
119
+ }
120
+ }
121
+
122
+ export function upsertObjective(state: VariantStore, objective: Objective): VariantStore {
123
+ const next = [...state.objectives]
124
+ const index = next.findIndex((item) => item.id === objective.id)
125
+
126
+ if (index >= 0) next[index] = objective
127
+ else next.push(objective)
128
+
129
+ return {
130
+ ...state,
131
+ objectives: next,
132
+ }
133
+ }
134
+
135
+ export function upsertProfile(state: VariantStore, profile: PreviewProfile): VariantStore {
136
+ const next = [...state.profiles]
137
+ const index = next.findIndex((item) => item.id === profile.id)
138
+
139
+ if (index >= 0) next[index] = profile
140
+ else next.push(profile)
141
+
142
+ return {
143
+ ...state,
144
+ profiles: next,
145
+ }
146
+ }
147
+
148
+ export function upsertVariant(state: VariantStore, variant: VariantArtifact): VariantStore {
149
+ return {
150
+ ...state,
151
+ objectives: state.objectives.map((objective) => {
152
+ if (objective.id !== variant.objectiveId) return objective
153
+
154
+ const variants = [...objective.variants]
155
+ const index = variants.findIndex((item) => item.id === variant.id)
156
+
157
+ if (index >= 0) variants[index] = variant
158
+ else variants.push(variant)
159
+
160
+ return {
161
+ ...objective,
162
+ variants,
163
+ updatedAt: new Date().toISOString(),
164
+ }
165
+ }),
166
+ }
167
+ }
package/src/switch.ts ADDED
@@ -0,0 +1,338 @@
1
+ import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises"
2
+ import { join, resolve } from "node:path"
3
+ import { execFile } from "node:child_process"
4
+ import { promisify } from "node:util"
5
+
6
+ const execFileAsync = promisify(execFile)
7
+
8
+ export interface ApplySelection {
9
+ objectiveId: string
10
+ variantId: string
11
+ patchFile: string
12
+ changedFiles?: string[]
13
+ }
14
+
15
+ export interface SwitchSnapshot {
16
+ id: string
17
+ createdAt: string
18
+ head: string
19
+ baseRef: string
20
+ selections: ApplySelection[]
21
+ }
22
+
23
+ export interface StoredSwitchSnapshot extends SwitchSnapshot {
24
+ filePath: string
25
+ }
26
+
27
+ export interface RollbackWorkspaceOptions {
28
+ allowDirtyFiles?: string[]
29
+ }
30
+
31
+ async function runGit(projectRoot: string, args: string[]): Promise<string> {
32
+ try {
33
+ const { stdout } = await execFileAsync("git", args, {
34
+ cwd: projectRoot,
35
+ encoding: "utf8",
36
+ })
37
+ return stdout.trim()
38
+ } catch (error: unknown) {
39
+ const message = error instanceof Error ? error.message : String(error)
40
+ throw new Error(`git ${args.join(" ")} failed: ${message}`)
41
+ }
42
+ }
43
+
44
+ async function ensureGitWorkspace(projectRoot: string): Promise<void> {
45
+ const inside = await runGit(projectRoot, ["rev-parse", "--is-inside-work-tree"])
46
+ if (inside !== "true") {
47
+ throw new Error("Current directory is not inside a git worktree")
48
+ }
49
+ }
50
+
51
+ async function ensureCleanWorkspace(projectRoot: string): Promise<void> {
52
+ const status = await runGit(projectRoot, ["status", "--porcelain", "--untracked-files=no"])
53
+ if (status.length > 0) {
54
+ throw new Error("Workspace has uncommitted changes. Commit or stash changes before apply.")
55
+ }
56
+ }
57
+
58
+ async function ensureWorkspaceSafeForTargetFiles(projectRoot: string, targetFiles: string[]): Promise<void> {
59
+ const normalizedTargets = new Set(targetFiles.map(normalizeRepoPath))
60
+ if (normalizedTargets.size === 0) {
61
+ await ensureCleanWorkspace(projectRoot)
62
+ return
63
+ }
64
+
65
+ const dirtyFiles = await listDirtyTrackedFiles(projectRoot)
66
+ if (dirtyFiles.length === 0) return
67
+
68
+ const overlapping = dirtyFiles.filter((file) => normalizedTargets.has(normalizeRepoPath(file)))
69
+ if (overlapping.length === 0) return
70
+
71
+ const listed = overlapping.slice(0, 5).join(", ")
72
+ const remainder = overlapping.length > 5 ? `, +${overlapping.length - 5} more` : ""
73
+ throw new Error(
74
+ `Workspace has uncommitted changes in files affected by selected variant(s): ${listed}${remainder}. Commit, stash, or move these edits before switching variants.`,
75
+ )
76
+ }
77
+
78
+ function normalizeRepoPath(input: string): string {
79
+ return input.replace(/\\/g, "/").replace(/^\.\//, "")
80
+ }
81
+
82
+ function splitNonEmptyLines(input: string): string[] {
83
+ return input
84
+ .split(/\r?\n/g)
85
+ .map((line) => line.trim())
86
+ .filter((line) => line.length > 0)
87
+ }
88
+
89
+ async function listDirtyTrackedFiles(projectRoot: string): Promise<string[]> {
90
+ const unstaged = await runGit(projectRoot, ["diff", "--name-only"])
91
+ const staged = await runGit(projectRoot, ["diff", "--name-only", "--cached"])
92
+
93
+ const files = [...splitNonEmptyLines(unstaged), ...splitNonEmptyLines(staged)].map(normalizeRepoPath)
94
+ return [...new Set(files)]
95
+ }
96
+
97
+ async function ensureRollbackSafety(projectRoot: string, allowDirtyFiles: string[]): Promise<void> {
98
+ const dirtyFiles = await listDirtyTrackedFiles(projectRoot)
99
+ if (dirtyFiles.length === 0) return
100
+
101
+ const allowed = new Set(allowDirtyFiles.map(normalizeRepoPath))
102
+ const disallowed = dirtyFiles.filter((file) => !allowed.has(normalizeRepoPath(file)))
103
+ if (disallowed.length === 0) return
104
+
105
+ const listed = disallowed.slice(0, 5).join(", ")
106
+ const remainder = disallowed.length > 5 ? `, +${disallowed.length - 5} more` : ""
107
+ throw new Error(
108
+ `Rollback would overwrite uncommitted changes outside active variant files: ${listed}${remainder}. Commit or stash these changes before rollback.`,
109
+ )
110
+ }
111
+
112
+ async function ensurePatchFilesExist(selections: ApplySelection[]): Promise<void> {
113
+ for (const selection of selections) {
114
+ await access(selection.patchFile)
115
+ }
116
+ }
117
+
118
+ async function verifyBaseRef(projectRoot: string, baseRef: string): Promise<void> {
119
+ await runGit(projectRoot, ["rev-parse", "--verify", `${baseRef}^{commit}`])
120
+ }
121
+
122
+ function getSnapshotDir(projectRoot: string): string {
123
+ return resolve(projectRoot, ".vynt", "snapshots")
124
+ }
125
+
126
+ function isObject(value: unknown): value is Record<string, unknown> {
127
+ return typeof value === "object" && value !== null
128
+ }
129
+
130
+ function parseSnapshot(content: unknown, filePath: string): StoredSwitchSnapshot {
131
+ if (!isObject(content)) {
132
+ throw new Error(`Invalid snapshot format: ${filePath}`)
133
+ }
134
+
135
+ if (
136
+ typeof content.id !== "string" ||
137
+ typeof content.createdAt !== "string" ||
138
+ typeof content.head !== "string" ||
139
+ typeof content.baseRef !== "string" ||
140
+ !Array.isArray(content.selections)
141
+ ) {
142
+ throw new Error(`Invalid snapshot payload: ${filePath}`)
143
+ }
144
+
145
+ const selections: ApplySelection[] = content.selections.map((item, index) => {
146
+ if (!isObject(item)) {
147
+ throw new Error(`Invalid snapshot selection at index ${index}: ${filePath}`)
148
+ }
149
+
150
+ if (
151
+ typeof item.objectiveId !== "string" ||
152
+ typeof item.variantId !== "string" ||
153
+ typeof item.patchFile !== "string"
154
+ ) {
155
+ throw new Error(`Invalid snapshot selection payload at index ${index}: ${filePath}`)
156
+ }
157
+
158
+ return {
159
+ objectiveId: item.objectiveId,
160
+ variantId: item.variantId,
161
+ patchFile: item.patchFile,
162
+ }
163
+ })
164
+
165
+ return {
166
+ id: content.id,
167
+ createdAt: content.createdAt,
168
+ head: content.head,
169
+ baseRef: content.baseRef,
170
+ selections,
171
+ filePath,
172
+ }
173
+ }
174
+
175
+ async function readSnapshotFile(filePath: string): Promise<StoredSwitchSnapshot> {
176
+ const raw = await readFile(filePath, "utf8")
177
+ return parseSnapshot(JSON.parse(raw), filePath)
178
+ }
179
+
180
+ function sortSnapshotsDescending(snapshots: StoredSwitchSnapshot[]): StoredSwitchSnapshot[] {
181
+ return [...snapshots].sort((left, right) => {
182
+ const leftTs = Date.parse(left.createdAt)
183
+ const rightTs = Date.parse(right.createdAt)
184
+
185
+ if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) {
186
+ return rightTs - leftTs
187
+ }
188
+
189
+ return right.id.localeCompare(left.id)
190
+ })
191
+ }
192
+
193
+ export async function listSnapshots(projectRoot: string): Promise<StoredSwitchSnapshot[]> {
194
+ const snapshotDir = getSnapshotDir(projectRoot)
195
+ let entries: string[]
196
+
197
+ try {
198
+ entries = await readdir(snapshotDir)
199
+ } catch (error: unknown) {
200
+ const code = (error as NodeJS.ErrnoException)?.code
201
+ if (code === "ENOENT") {
202
+ return []
203
+ }
204
+ throw error
205
+ }
206
+
207
+ const files = entries
208
+ .filter((entry) => entry.endsWith(".json"))
209
+ .map((entry) => join(snapshotDir, entry))
210
+
211
+ const snapshots = await Promise.all(files.map((filePath) => readSnapshotFile(filePath)))
212
+ return sortSnapshotsDescending(snapshots)
213
+ }
214
+
215
+ export async function getLatestSnapshot(projectRoot: string): Promise<StoredSwitchSnapshot | undefined> {
216
+ const snapshots = await listSnapshots(projectRoot)
217
+ return snapshots[0]
218
+ }
219
+
220
+ export async function getSnapshotById(
221
+ projectRoot: string,
222
+ snapshotId: string,
223
+ ): Promise<StoredSwitchSnapshot | undefined> {
224
+ const snapshots = await listSnapshots(projectRoot)
225
+ return snapshots.find((snapshot) => snapshot.id === snapshotId)
226
+ }
227
+
228
+ async function createSnapshot(projectRoot: string, baseRef: string, selections: ApplySelection[]): Promise<string> {
229
+ const createdAt = new Date().toISOString()
230
+ const head = await runGit(projectRoot, ["rev-parse", "HEAD"])
231
+ const snapshotId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
232
+ const snapshotDir = getSnapshotDir(projectRoot)
233
+ const snapshotPath = join(snapshotDir, `${snapshotId}.json`)
234
+
235
+ const payload: SwitchSnapshot = {
236
+ id: snapshotId,
237
+ createdAt,
238
+ head,
239
+ baseRef,
240
+ selections,
241
+ }
242
+
243
+ await mkdir(snapshotDir, { recursive: true })
244
+ await writeFile(snapshotPath, JSON.stringify(payload, null, 2) + "\n", "utf8")
245
+ return snapshotPath
246
+ }
247
+
248
+ async function restoreWorkspaceToBase(projectRoot: string, baseRef: string, files?: string[]): Promise<void> {
249
+ const normalized = (files ?? []).map(normalizeRepoPath).filter((item) => item.length > 0)
250
+ if (normalized.length === 0) {
251
+ await runGit(projectRoot, ["restore", `--source=${baseRef}`, "--staged", "--worktree", "."])
252
+ return
253
+ }
254
+
255
+ await runGit(projectRoot, ["restore", `--source=${baseRef}`, "--staged", "--worktree", "--", ...normalized])
256
+ }
257
+
258
+ type PatchApplyMode = "plain" | "three-way"
259
+
260
+ async function resolvePatchApplyMode(projectRoot: string, patchFile: string): Promise<PatchApplyMode> {
261
+ try {
262
+ await runGit(projectRoot, ["apply", "--check", patchFile])
263
+ return "plain"
264
+ } catch (plainError: unknown) {
265
+ try {
266
+ await runGit(projectRoot, ["apply", "--3way", "--check", patchFile])
267
+ return "three-way"
268
+ } catch (threeWayError: unknown) {
269
+ const plainMessage = plainError instanceof Error ? plainError.message : String(plainError)
270
+ const threeWayMessage = threeWayError instanceof Error ? threeWayError.message : String(threeWayError)
271
+ throw new Error(`${plainMessage}\n3-way fallback also failed: ${threeWayMessage}`)
272
+ }
273
+ }
274
+ }
275
+
276
+ async function applyPatch(projectRoot: string, patchFile: string, mode: PatchApplyMode): Promise<void> {
277
+ const args = mode === "three-way" ? ["apply", "--3way", patchFile] : ["apply", patchFile]
278
+ await runGit(projectRoot, args)
279
+ }
280
+
281
+ export async function applySelectionsToWorkspace(
282
+ projectRoot: string,
283
+ baseRef: string,
284
+ inputSelections: ApplySelection[],
285
+ ): Promise<{ snapshotPath: string }> {
286
+ if (inputSelections.length === 0) {
287
+ throw new Error("No variants selected for apply")
288
+ }
289
+
290
+ const selections = [...inputSelections].sort((left, right) => left.objectiveId.localeCompare(right.objectiveId))
291
+ const targetFiles = [...new Set(selections.flatMap((selection) => selection.changedFiles ?? []).map(normalizeRepoPath))]
292
+
293
+ await ensurePatchFilesExist(selections)
294
+ await ensureGitWorkspace(projectRoot)
295
+ await verifyBaseRef(projectRoot, baseRef)
296
+ await ensureWorkspaceSafeForTargetFiles(projectRoot, targetFiles)
297
+
298
+ const snapshotPath = await createSnapshot(projectRoot, baseRef, selections)
299
+ await restoreWorkspaceToBase(projectRoot, baseRef, targetFiles)
300
+
301
+ const executionPlan: Array<{ selection: ApplySelection; mode: PatchApplyMode }> = []
302
+ for (const selection of selections) {
303
+ const mode = await resolvePatchApplyMode(projectRoot, selection.patchFile)
304
+ executionPlan.push({ selection, mode })
305
+ }
306
+
307
+ for (const step of executionPlan) {
308
+ await applyPatch(projectRoot, step.selection.patchFile, step.mode)
309
+ }
310
+
311
+ return { snapshotPath }
312
+ }
313
+
314
+ export async function rollbackWorkspaceToSnapshot(
315
+ projectRoot: string,
316
+ snapshotId?: string,
317
+ options: RollbackWorkspaceOptions = {},
318
+ ): Promise<{ snapshot: StoredSwitchSnapshot }> {
319
+ await ensureGitWorkspace(projectRoot)
320
+
321
+ const snapshot = snapshotId
322
+ ? await getSnapshotById(projectRoot, snapshotId)
323
+ : await getLatestSnapshot(projectRoot)
324
+
325
+ if (!snapshot) {
326
+ if (snapshotId) {
327
+ throw new Error(`Snapshot not found: ${snapshotId}`)
328
+ }
329
+ throw new Error("No snapshots found. Run apply/profile apply first.")
330
+ }
331
+
332
+ await ensureRollbackSafety(projectRoot, options.allowDirtyFiles ?? [])
333
+
334
+ await verifyBaseRef(projectRoot, snapshot.head)
335
+ await restoreWorkspaceToBase(projectRoot, snapshot.head)
336
+
337
+ return { snapshot }
338
+ }
package/src/types.ts ADDED
@@ -0,0 +1,48 @@
1
+ export type VariantStatus = "draft" | "running" | "ready" | "selected" | "archived"
2
+
3
+ export type ObjectiveStatus = "open" | "review" | "finalized"
4
+
5
+ export interface VariantArtifact {
6
+ id: string
7
+ objectiveId: string
8
+ name: string
9
+ baseRef: string
10
+ patchFile: string
11
+ changedFiles: string[]
12
+ sessionId?: string
13
+ screenshots: string[]
14
+ notes?: string
15
+ status: VariantStatus
16
+ createdAt: string
17
+ updatedAt: string
18
+ }
19
+
20
+ export interface Objective {
21
+ id: string
22
+ name: string
23
+ baseRef: string
24
+ status: ObjectiveStatus
25
+ winnerVariantId?: string
26
+ activeVariantId?: string
27
+ variants: VariantArtifact[]
28
+ createdAt: string
29
+ updatedAt: string
30
+ }
31
+
32
+ export interface PreviewProfile {
33
+ id: string
34
+ name: string
35
+ selections: Record<string, string>
36
+ lastAppliedAt?: string
37
+ createdAt: string
38
+ updatedAt: string
39
+ }
40
+
41
+ export interface VariantStore {
42
+ schemaVersion: 2
43
+ projectRoot: string
44
+ baseRef: string
45
+ objectives: Objective[]
46
+ profiles: PreviewProfile[]
47
+ activeProfileId?: string
48
+ }
@@ -0,0 +1,11 @@
1
+ export interface VyntVitePluginOptions {
2
+ enabled?: boolean;
3
+ bridgeHost?: string;
4
+ bridgePort?: number;
5
+ prefix?: string;
6
+ projectRoot?: string;
7
+ }
8
+
9
+ export declare function vyntVitePlugin(
10
+ options?: VyntVitePluginOptions,
11
+ ): import("vite").Plugin;
package/vite/index.js ADDED
@@ -0,0 +1,140 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = dirname(__filename);
8
+ const CLI_PATH = resolve(__dirname, "..", "src", "cli.ts");
9
+
10
+ function parsePortFromOutput(line) {
11
+ const match = line.match(/Bridge server listening on .*:(\d+)/);
12
+ if (!match) return null;
13
+ const port = Number(match[1]);
14
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
15
+ return port;
16
+ }
17
+
18
+ function discoverProjectRoot(fromDirectory) {
19
+ let current = resolve(fromDirectory);
20
+
21
+ while (true) {
22
+ if (existsSync(join(current, ".vynt", "state.json"))) {
23
+ return current;
24
+ }
25
+
26
+ const parent = dirname(current);
27
+ if (parent === current) {
28
+ return resolve(fromDirectory);
29
+ }
30
+ current = parent;
31
+ }
32
+ }
33
+
34
+ export function vyntVitePlugin(options = {}) {
35
+ const enabled = options.enabled ?? true;
36
+ const bridgeHost = options.bridgeHost ?? "127.0.0.1";
37
+ const bridgePort = options.bridgePort ?? 4173;
38
+ const prefix = options.prefix ?? "/__vynt";
39
+ const configuredProjectRoot = options.projectRoot;
40
+
41
+ let processRef = null;
42
+ let shuttingDown = false;
43
+
44
+ return {
45
+ name: "vynt-vite",
46
+ apply: "serve",
47
+
48
+ config(userConfig) {
49
+ if (!enabled) return undefined;
50
+
51
+ const target = `http://${bridgeHost}:${bridgePort}`;
52
+ const existingProxy = (userConfig.server && userConfig.server.proxy) || {};
53
+
54
+ return {
55
+ server: {
56
+ proxy: {
57
+ ...existingProxy,
58
+ [prefix]: {
59
+ target,
60
+ changeOrigin: false,
61
+ rewrite(path) {
62
+ const rewritten = path.slice(prefix.length);
63
+ return rewritten.length > 0 ? rewritten : "/";
64
+ },
65
+ },
66
+ },
67
+ },
68
+ };
69
+ },
70
+
71
+ configureServer(server) {
72
+ if (!enabled) return;
73
+
74
+ const start = () => {
75
+ if (processRef) return;
76
+
77
+ const bridgeCwd = configuredProjectRoot
78
+ ? resolve(server.config.root, configuredProjectRoot)
79
+ : discoverProjectRoot(server.config.root);
80
+
81
+ const child = spawn("bun", [CLI_PATH, "bridge", "serve", "--host", bridgeHost, "--port", String(bridgePort)], {
82
+ cwd: bridgeCwd,
83
+ env: process.env,
84
+ stdio: ["ignore", "pipe", "pipe"],
85
+ });
86
+
87
+ processRef = child;
88
+
89
+ child.stdout.setEncoding("utf8");
90
+ child.stderr.setEncoding("utf8");
91
+
92
+ child.stdout.on("data", (chunk) => {
93
+ const text = String(chunk);
94
+ for (const line of text.split("\n")) {
95
+ if (!line.trim()) continue;
96
+ const parsedPort = parsePortFromOutput(line);
97
+ if (parsedPort) {
98
+ server.config.logger.info(`[vynt] bridge running on ${bridgeHost}:${parsedPort} proxied at ${prefix}`);
99
+ } else {
100
+ server.config.logger.info(`[vynt] ${line}`);
101
+ }
102
+ }
103
+ });
104
+
105
+ child.stderr.on("data", (chunk) => {
106
+ const text = String(chunk).trim();
107
+ if (text.length > 0) {
108
+ server.config.logger.error(`[vynt] ${text}`);
109
+ }
110
+ });
111
+
112
+ child.on("error", (error) => {
113
+ server.config.logger.error(`[vynt] failed to start bridge: ${error instanceof Error ? error.message : String(error)}`);
114
+ });
115
+
116
+ child.on("exit", (code, signal) => {
117
+ const exitText = `bridge exited (code=${code ?? "null"} signal=${signal ?? "null"})`;
118
+ if (!shuttingDown) {
119
+ server.config.logger.warn(`[vynt] ${exitText}`);
120
+ }
121
+ processRef = null;
122
+ });
123
+ };
124
+
125
+ const stop = () => {
126
+ if (!processRef) return;
127
+ shuttingDown = true;
128
+ processRef.kill("SIGTERM");
129
+ };
130
+
131
+ start();
132
+ server.httpServer?.once("close", stop);
133
+
134
+ const onSigint = () => stop();
135
+ const onSigterm = () => stop();
136
+ process.once("SIGINT", onSigint);
137
+ process.once("SIGTERM", onSigterm);
138
+ },
139
+ };
140
+ }