framer-code-link 0.1.4 → 0.2.0
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 +1 -1
- package/dist/{index.js → index.mjs} +181 -145
- package/package.json +3 -3
- package/src/controller.test.ts +41 -1
- package/src/controller.ts +56 -5
- package/src/helpers/connection.ts +8 -1
- package/src/helpers/files.ts +29 -6
- package/src/helpers/installer.ts +1 -5
- package/src/helpers/watcher.test.ts +74 -0
- package/src/types.ts +4 -1
- package/src/utils/logging.ts +3 -0
- package/dist/project-DhpsFg77.js +0 -53
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "framer-code-link",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "CLI tool for syncing Framer code components - controller-centric architecture",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@types/node": "^22.19.2",
|
|
31
31
|
"@types/ws": "^8.18.1",
|
|
32
|
-
"tsdown": "^0.17.
|
|
32
|
+
"tsdown": "^0.17.4",
|
|
33
33
|
"tsx": "^4.21.0",
|
|
34
|
-
"vitest": "^
|
|
34
|
+
"vitest": "^4.0.15"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/controller.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest"
|
|
2
|
-
import { transition } from "./controller.js"
|
|
2
|
+
import { filterEchoedFiles, transition } from "./controller.js"
|
|
3
|
+
import { createHashTracker } from "./utils/hash-tracker.js"
|
|
3
4
|
import type { WebSocket } from "ws"
|
|
4
5
|
describe("State Machine", () => {
|
|
5
6
|
describe("HANDSHAKE transition", () => {
|
|
@@ -848,4 +849,43 @@ describe("State Machine", () => {
|
|
|
848
849
|
}
|
|
849
850
|
})
|
|
850
851
|
})
|
|
852
|
+
|
|
853
|
+
describe("echo prevention filter", () => {
|
|
854
|
+
it("skips inbound file-change that matches last local send", () => {
|
|
855
|
+
const hashTracker = createHashTracker()
|
|
856
|
+
hashTracker.remember("Hey.tsx", "content")
|
|
857
|
+
|
|
858
|
+
const filtered = filterEchoedFiles(
|
|
859
|
+
[
|
|
860
|
+
{
|
|
861
|
+
name: "Hey.tsx",
|
|
862
|
+
content: "content",
|
|
863
|
+
modifiedAt: Date.now(),
|
|
864
|
+
},
|
|
865
|
+
],
|
|
866
|
+
hashTracker
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
expect(filtered).toHaveLength(0)
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
it("keeps inbound change when content differs", () => {
|
|
873
|
+
const hashTracker = createHashTracker()
|
|
874
|
+
hashTracker.remember("Hey.tsx", "old content")
|
|
875
|
+
|
|
876
|
+
const filtered = filterEchoedFiles(
|
|
877
|
+
[
|
|
878
|
+
{
|
|
879
|
+
name: "Hey.tsx",
|
|
880
|
+
content: "new content",
|
|
881
|
+
modifiedAt: Date.now(),
|
|
882
|
+
},
|
|
883
|
+
],
|
|
884
|
+
hashTracker
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
expect(filtered).toHaveLength(1)
|
|
888
|
+
expect(filtered[0]?.content).toBe("new content")
|
|
889
|
+
})
|
|
890
|
+
})
|
|
851
891
|
})
|
package/src/controller.ts
CHANGED
|
@@ -161,7 +161,12 @@ type Effect =
|
|
|
161
161
|
| { type: "SEND_MESSAGE"; payload: OutgoingMessage }
|
|
162
162
|
| { type: "LIST_LOCAL_FILES" }
|
|
163
163
|
| { type: "DETECT_CONFLICTS"; remoteFiles: FileInfo[] }
|
|
164
|
-
| {
|
|
164
|
+
| {
|
|
165
|
+
type: "WRITE_FILES"
|
|
166
|
+
files: FileInfo[]
|
|
167
|
+
silent?: boolean
|
|
168
|
+
skipEcho?: boolean // when true, skip writes that match hashTracker (inbound echo)
|
|
169
|
+
}
|
|
165
170
|
| { type: "DELETE_LOCAL_FILES"; names: string[] }
|
|
166
171
|
| { type: "REQUEST_CONFLICT_DECISIONS"; conflicts: Conflict[] }
|
|
167
172
|
| { type: "REQUEST_CONFLICT_VERSIONS"; conflicts: Conflict[] }
|
|
@@ -199,6 +204,20 @@ function log(level: "info" | "debug" | "warn", message: string): Effect {
|
|
|
199
204
|
return { type: "LOG", level, message }
|
|
200
205
|
}
|
|
201
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Filter out files whose content matches the last remembered hash.
|
|
209
|
+
* Used to skip inbound echoes of our own local sends.
|
|
210
|
+
*/
|
|
211
|
+
export function filterEchoedFiles(
|
|
212
|
+
files: FileInfo[],
|
|
213
|
+
hashTracker: ReturnType<typeof createHashTracker>
|
|
214
|
+
): FileInfo[] {
|
|
215
|
+
return files.filter((file) => {
|
|
216
|
+
if (file.content === undefined) return true
|
|
217
|
+
return !hashTracker.shouldSkip(file.name, file.content)
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
202
221
|
/**
|
|
203
222
|
* Pure state transition function
|
|
204
223
|
* Takes current state + event, returns new state + effects to execute
|
|
@@ -336,6 +355,7 @@ function transition(
|
|
|
336
355
|
// - safeWrites = files we can apply (remote-only or local unchanged)
|
|
337
356
|
// - conflicts = files that need manual resolution (content or deletion conflicts)
|
|
338
357
|
// - localOnly = files to upload
|
|
358
|
+
// (unchanged files have metadata recorded in DETECT_CONFLICTS executor)
|
|
339
359
|
|
|
340
360
|
// Apply safe writes
|
|
341
361
|
if (safeWrites.length > 0) {
|
|
@@ -383,7 +403,6 @@ function transition(
|
|
|
383
403
|
}
|
|
384
404
|
|
|
385
405
|
// No conflicts - transition to watching
|
|
386
|
-
const totalSynced = safeWrites.length + localOnly.length
|
|
387
406
|
const remoteTotal = state.queuedDiffs.length
|
|
388
407
|
const totalCount = remoteTotal + localOnly.length
|
|
389
408
|
const updatedCount = safeWrites.length + localOnly.length
|
|
@@ -447,6 +466,7 @@ function transition(
|
|
|
447
466
|
effects.push(log("debug", `Applying remote change: ${event.file.name}`), {
|
|
448
467
|
type: "WRITE_FILES",
|
|
449
468
|
files: [event.file],
|
|
469
|
+
skipEcho: true,
|
|
450
470
|
})
|
|
451
471
|
|
|
452
472
|
return { state, effects }
|
|
@@ -832,12 +852,22 @@ async function executeEffect(
|
|
|
832
852
|
}
|
|
833
853
|
|
|
834
854
|
// Use existing helper to detect conflicts
|
|
835
|
-
const { conflicts, writes, localOnly } = await detectConflicts(
|
|
855
|
+
const { conflicts, writes, localOnly, unchanged } = await detectConflicts(
|
|
836
856
|
effect.remoteFiles,
|
|
837
857
|
config.filesDir,
|
|
838
858
|
{ persistedState: fileMetadataCache.getPersistedState() }
|
|
839
859
|
)
|
|
840
860
|
|
|
861
|
+
// Record metadata for unchanged files so watcher add events get skipped
|
|
862
|
+
// (chokidar ignoreInitial=false fires late adds that would otherwise re-upload)
|
|
863
|
+
for (const file of unchanged) {
|
|
864
|
+
fileMetadataCache.recordRemoteWrite(
|
|
865
|
+
file.name,
|
|
866
|
+
file.content,
|
|
867
|
+
file.modifiedAt ?? Date.now()
|
|
868
|
+
)
|
|
869
|
+
}
|
|
870
|
+
|
|
841
871
|
// Return CONFLICTS_DETECTED event to continue the flow
|
|
842
872
|
return [
|
|
843
873
|
{
|
|
@@ -863,13 +893,29 @@ async function executeEffect(
|
|
|
863
893
|
|
|
864
894
|
case "WRITE_FILES": {
|
|
865
895
|
if (config.filesDir) {
|
|
896
|
+
// skipEcho is opt-in: some callers still need side-effects (metadata/logs)
|
|
897
|
+
// even when content matches the last hash tracked in-memory.
|
|
898
|
+
const filesToWrite =
|
|
899
|
+
effect.skipEcho === true
|
|
900
|
+
? filterEchoedFiles(effect.files, hashTracker)
|
|
901
|
+
: effect.files
|
|
902
|
+
|
|
903
|
+
if (effect.skipEcho && filesToWrite.length !== effect.files.length) {
|
|
904
|
+
const skipped = effect.files.length - filesToWrite.length
|
|
905
|
+
debug(`Skipped ${pluralize(skipped, "echoed change")}`)
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (filesToWrite.length === 0) {
|
|
909
|
+
return []
|
|
910
|
+
}
|
|
911
|
+
|
|
866
912
|
await writeRemoteFiles(
|
|
867
|
-
|
|
913
|
+
filesToWrite,
|
|
868
914
|
config.filesDir,
|
|
869
915
|
hashTracker,
|
|
870
916
|
installer ?? undefined
|
|
871
917
|
)
|
|
872
|
-
for (const file of
|
|
918
|
+
for (const file of filesToWrite) {
|
|
873
919
|
if (!effect.silent) {
|
|
874
920
|
fileDown(file.name)
|
|
875
921
|
}
|
|
@@ -1056,6 +1102,11 @@ async function executeEffect(
|
|
|
1056
1102
|
case "SYNC_COMPLETE": {
|
|
1057
1103
|
const wasDisconnected = wasRecentlyDisconnected()
|
|
1058
1104
|
|
|
1105
|
+
// Notify plugin that sync is complete
|
|
1106
|
+
if (syncState.socket) {
|
|
1107
|
+
await sendMessage(syncState.socket, { type: "sync-complete" })
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1059
1110
|
if (wasDisconnected) {
|
|
1060
1111
|
// Only show reconnect message if we actually showed the disconnect notice
|
|
1061
1112
|
if (didShowDisconnect()) {
|
|
@@ -72,6 +72,7 @@ export function initConnection(port: number): Promise<Connection> {
|
|
|
72
72
|
|
|
73
73
|
wss.on("connection", (ws: WebSocket) => {
|
|
74
74
|
const connId = ++connectionId
|
|
75
|
+
let handshakeReceived = false
|
|
75
76
|
debug(`Client connected (conn ${connId})`)
|
|
76
77
|
|
|
77
78
|
ws.on("message", (data: Buffer) => {
|
|
@@ -81,9 +82,15 @@ export function initConnection(port: number): Promise<Connection> {
|
|
|
81
82
|
// Special handling for handshake
|
|
82
83
|
if (message.type === "handshake") {
|
|
83
84
|
debug(`Received handshake (conn ${connId})`)
|
|
85
|
+
handshakeReceived = true
|
|
84
86
|
handlers.onHandshake?.(ws, message)
|
|
85
|
-
} else {
|
|
87
|
+
} else if (handshakeReceived) {
|
|
86
88
|
handlers.onMessage?.(message)
|
|
89
|
+
} else {
|
|
90
|
+
// Ignore messages before handshake - plugin will send full snapshot after
|
|
91
|
+
debug(
|
|
92
|
+
`Ignoring ${message.type} before handshake (conn ${connId})`
|
|
93
|
+
)
|
|
87
94
|
}
|
|
88
95
|
} catch (err) {
|
|
89
96
|
error(`Failed to parse message:`, err)
|
package/src/helpers/files.ts
CHANGED
|
@@ -103,6 +103,7 @@ export async function detectConflicts(
|
|
|
103
103
|
const conflicts: Conflict[] = []
|
|
104
104
|
const writes: FileInfo[] = []
|
|
105
105
|
const localOnly: FileInfo[] = []
|
|
106
|
+
const unchanged: FileInfo[] = []
|
|
106
107
|
const detect = options.detectConflicts ?? true
|
|
107
108
|
const preferRemote = options.preferRemote ?? false
|
|
108
109
|
const persistedState = options.persistedState
|
|
@@ -169,7 +170,12 @@ export async function detectConflicts(
|
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
if (local.content === remote.content) {
|
|
172
|
-
//
|
|
173
|
+
// Content matches - no disk write needed but track for metadata
|
|
174
|
+
unchanged.push({
|
|
175
|
+
name: normalized.relativePath,
|
|
176
|
+
content: remote.content,
|
|
177
|
+
modifiedAt: remote.modifiedAt,
|
|
178
|
+
})
|
|
173
179
|
continue
|
|
174
180
|
}
|
|
175
181
|
|
|
@@ -203,15 +209,19 @@ export async function detectConflicts(
|
|
|
203
209
|
if (!processedFiles.has(localKey)) {
|
|
204
210
|
const persisted = getPersistedState(local.name)
|
|
205
211
|
if (persisted) {
|
|
206
|
-
// File was previously synced but now missing from remote → deleted in Framer
|
|
207
|
-
|
|
208
|
-
|
|
212
|
+
// File was previously synced but now missing from remote → deleted in Framer
|
|
213
|
+
const localHash = hashFileContent(local.content)
|
|
214
|
+
const localClean = localHash === persisted.contentHash
|
|
215
|
+
debug(
|
|
216
|
+
`Conflict: ${local.name} deleted in Framer (localClean=${localClean})`
|
|
217
|
+
)
|
|
209
218
|
conflicts.push({
|
|
210
219
|
fileName: local.name,
|
|
211
220
|
localContent: local.content,
|
|
212
221
|
remoteContent: null,
|
|
213
222
|
localModifiedAt: local.modifiedAt,
|
|
214
223
|
lastSyncedAt: persisted?.timestamp,
|
|
224
|
+
localClean,
|
|
215
225
|
})
|
|
216
226
|
} else {
|
|
217
227
|
// New local file (never synced before): upload later
|
|
@@ -239,7 +249,7 @@ export async function detectConflicts(
|
|
|
239
249
|
}
|
|
240
250
|
}
|
|
241
251
|
|
|
242
|
-
return { conflicts, writes, localOnly }
|
|
252
|
+
return { conflicts, writes, localOnly, unchanged }
|
|
243
253
|
}
|
|
244
254
|
|
|
245
255
|
export interface AutoResolveResult {
|
|
@@ -265,9 +275,22 @@ export function autoResolveConflicts(
|
|
|
265
275
|
for (const conflict of conflicts) {
|
|
266
276
|
const latestRemoteVersionMs = versionMap.get(conflict.fileName)
|
|
267
277
|
const lastSyncedAt = conflict.lastSyncedAt
|
|
278
|
+
const localClean = conflict.localClean === true
|
|
268
279
|
|
|
269
280
|
debug(`Auto-resolve checking ${conflict.fileName}`)
|
|
270
281
|
|
|
282
|
+
// Remote deletion: file deleted in Framer
|
|
283
|
+
if (conflict.remoteContent === null) {
|
|
284
|
+
if (localClean) {
|
|
285
|
+
debug(` Remote deleted, local clean -> REMOTE (delete locally)`)
|
|
286
|
+
autoResolvedRemote.push(conflict)
|
|
287
|
+
} else {
|
|
288
|
+
debug(` Remote deleted, local modified -> conflict`)
|
|
289
|
+
remainingConflicts.push(conflict)
|
|
290
|
+
}
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
|
|
271
294
|
if (!latestRemoteVersionMs) {
|
|
272
295
|
debug(` No remote version data, keeping conflict`)
|
|
273
296
|
remainingConflicts.push(conflict)
|
|
@@ -285,7 +308,7 @@ export function autoResolveConflicts(
|
|
|
285
308
|
|
|
286
309
|
const remoteUnchanged =
|
|
287
310
|
latestRemoteVersionMs <= lastSyncedAt + remoteDriftMs
|
|
288
|
-
|
|
311
|
+
// localClean already declared above for remote deletion handling
|
|
289
312
|
|
|
290
313
|
if (remoteUnchanged && !localClean) {
|
|
291
314
|
debug(` Remote unchanged, local changed -> LOCAL`)
|
package/src/helpers/installer.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Type installer helper
|
|
3
|
-
*
|
|
4
|
-
* Wraps @typescript/ata with our custom fetcher and initialization routines.
|
|
5
|
-
* This preserves the battle-tested logic from the classic CLI while
|
|
6
|
-
* conforming to the controller-centric architecture.
|
|
2
|
+
* Type installer helper using @typescript/ata
|
|
7
3
|
*/
|
|
8
4
|
|
|
9
5
|
import { setupTypeAcquisition } from "@typescript/ata"
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it, vi, afterEach } from "vitest"
|
|
2
|
+
import fs from "fs/promises"
|
|
3
|
+
import os from "os"
|
|
4
|
+
import path from "path"
|
|
5
|
+
import { initWatcher } from "./watcher.js"
|
|
6
|
+
|
|
7
|
+
const createdWatchers: any[] = []
|
|
8
|
+
|
|
9
|
+
vi.mock("chokidar", () => {
|
|
10
|
+
const createMockWatcher = () => {
|
|
11
|
+
const handlers: Record<string, Array<(filePath: string) => any>> = {
|
|
12
|
+
add: [],
|
|
13
|
+
change: [],
|
|
14
|
+
unlink: [],
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
on(event: "add" | "change" | "unlink", handler: (file: string) => any) {
|
|
19
|
+
handlers[event]?.push(handler)
|
|
20
|
+
return this
|
|
21
|
+
},
|
|
22
|
+
async __emit(event: "add" | "change" | "unlink", filePath: string) {
|
|
23
|
+
for (const handler of handlers[event] ?? []) {
|
|
24
|
+
await handler(filePath)
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const watch = vi.fn(() => {
|
|
32
|
+
const instance = createMockWatcher()
|
|
33
|
+
createdWatchers.push(instance)
|
|
34
|
+
return instance
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
return { default: { watch }, watch }
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe("initWatcher", () => {
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
createdWatchers.length = 0
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("ignores unsupported extensions and sanitizes added files", async () => {
|
|
46
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-watcher-"))
|
|
47
|
+
const events: any[] = []
|
|
48
|
+
const watcher = initWatcher(tmpDir)
|
|
49
|
+
watcher.on("change", (event) => events.push(event))
|
|
50
|
+
const rawWatcher = createdWatchers.at(-1)!
|
|
51
|
+
|
|
52
|
+
const unsupportedPath = path.join(tmpDir, "note.txt")
|
|
53
|
+
await fs.writeFile(unsupportedPath, "hello", "utf-8")
|
|
54
|
+
await rawWatcher.__emit("add", unsupportedPath)
|
|
55
|
+
expect(events).toHaveLength(0)
|
|
56
|
+
|
|
57
|
+
const rawPath = path.join(tmpDir, "bad name!.tsx")
|
|
58
|
+
await fs.writeFile(rawPath, "export const X = 1;", "utf-8")
|
|
59
|
+
await rawWatcher.__emit("add", rawPath)
|
|
60
|
+
|
|
61
|
+
const addEvent = events.find((e) => e.kind === "add")
|
|
62
|
+
expect(addEvent).toBeDefined()
|
|
63
|
+
expect(addEvent?.relativePath).toBe("bad_name_.tsx")
|
|
64
|
+
expect(addEvent?.content).toContain("export const X")
|
|
65
|
+
|
|
66
|
+
const renamedPath = path.join(tmpDir, "bad_name_.tsx")
|
|
67
|
+
await expect(fs.readFile(renamedPath, "utf-8")).resolves.toContain(
|
|
68
|
+
"export const X"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
await watcher.close()
|
|
72
|
+
await fs.rm(tmpDir, { recursive: true, force: true })
|
|
73
|
+
})
|
|
74
|
+
})
|
package/src/types.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* Core types for the controller-centric CLI architecture
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { WebSocket } from "ws"
|
|
6
5
|
import type { PendingDelete } from "@code-link/shared"
|
|
7
6
|
|
|
8
7
|
// Configuration
|
|
@@ -30,6 +29,8 @@ export interface LocalFile {
|
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
// Conflict detection
|
|
32
|
+
// Deletions are represented by null content
|
|
33
|
+
// For AI: Do NOT add remoteDeletes/localDeletes arrays - use localContent/remoteContent === null
|
|
33
34
|
export interface Conflict {
|
|
34
35
|
fileName: string
|
|
35
36
|
/** null means the file was deleted locally */
|
|
@@ -50,6 +51,7 @@ export interface ConflictResolution {
|
|
|
50
51
|
conflicts: Conflict[]
|
|
51
52
|
writes: FileInfo[]
|
|
52
53
|
localOnly: FileInfo[]
|
|
54
|
+
unchanged: FileInfo[]
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
// Watcher events
|
|
@@ -107,3 +109,4 @@ export type OutgoingMessage =
|
|
|
107
109
|
type: "conflict-version-request"
|
|
108
110
|
conflicts: ConflictVersionRequest[]
|
|
109
111
|
}
|
|
112
|
+
| { type: "sync-complete" }
|
package/src/utils/logging.ts
CHANGED
|
@@ -121,7 +121,10 @@ export function info(message: string, ...args: unknown[]): void {
|
|
|
121
121
|
*/
|
|
122
122
|
export function warn(message: string, ...args: unknown[]): void {
|
|
123
123
|
if (currentLevel <= LogLevel.WARN) {
|
|
124
|
+
if (message === lastMessage) return // Skip exact duplicates silently
|
|
124
125
|
flushDedupe()
|
|
126
|
+
lastMessage = message
|
|
127
|
+
lastMessageCount = 1
|
|
125
128
|
console.warn(pc.yellow(`⚠ ${message}`), ...args)
|
|
126
129
|
}
|
|
127
130
|
}
|
package/dist/project-DhpsFg77.js
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import fs from "fs/promises";
|
|
2
|
-
import path from "path";
|
|
3
|
-
|
|
4
|
-
//#region src/utils/project.ts
|
|
5
|
-
function toPackageName(name) {
|
|
6
|
-
return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
|
|
7
|
-
}
|
|
8
|
-
async function findOrCreateProjectDir(projectHash, projectName, explicitDir) {
|
|
9
|
-
if (explicitDir) {
|
|
10
|
-
const resolved = path.resolve(explicitDir);
|
|
11
|
-
await fs.mkdir(path.join(resolved, "files"), { recursive: true });
|
|
12
|
-
return resolved;
|
|
13
|
-
}
|
|
14
|
-
const cwd = process.cwd();
|
|
15
|
-
const existing = await findExistingProjectDir(cwd, projectHash);
|
|
16
|
-
if (existing) return existing;
|
|
17
|
-
if (!projectName) throw new Error("Project name is required when creating a new workspace. Pass --name <project name>.");
|
|
18
|
-
const dirName = toPackageName(projectName);
|
|
19
|
-
const projectDir = path.join(cwd, dirName || projectHash.slice(0, 6));
|
|
20
|
-
await fs.mkdir(path.join(projectDir, "files"), { recursive: true });
|
|
21
|
-
const pkg = {
|
|
22
|
-
name: dirName || projectHash,
|
|
23
|
-
version: "1.0.0",
|
|
24
|
-
private: true,
|
|
25
|
-
framerProjectId: projectHash,
|
|
26
|
-
framerProjectName: projectName
|
|
27
|
-
};
|
|
28
|
-
await fs.writeFile(path.join(projectDir, "package.json"), JSON.stringify(pkg, null, 2));
|
|
29
|
-
return projectDir;
|
|
30
|
-
}
|
|
31
|
-
async function findExistingProjectDir(baseDir, projectHash) {
|
|
32
|
-
const candidate = path.join(baseDir, "package.json");
|
|
33
|
-
if (await matchesProject(candidate, projectHash)) return baseDir;
|
|
34
|
-
const entries = await fs.readdir(baseDir, { withFileTypes: true });
|
|
35
|
-
for (const entry of entries) {
|
|
36
|
-
if (!entry.isDirectory()) continue;
|
|
37
|
-
const dir = path.join(baseDir, entry.name);
|
|
38
|
-
if (await matchesProject(path.join(dir, "package.json"), projectHash)) return dir;
|
|
39
|
-
}
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
async function matchesProject(packageJsonPath, projectHash) {
|
|
43
|
-
try {
|
|
44
|
-
const content = await fs.readFile(packageJsonPath, "utf-8");
|
|
45
|
-
const pkg = JSON.parse(content);
|
|
46
|
-
return pkg.framerProjectId === projectHash;
|
|
47
|
-
} catch {
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
//#endregion
|
|
53
|
-
export { findOrCreateProjectDir };
|