@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/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
|
+
}
|
package/vite/index.d.ts
ADDED
|
@@ -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
|
+
}
|