framer-code-link 0.2.0 → 0.2.1
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/dist/index.mjs +0 -0
- package/package.json +7 -4
- package/src/controller.test.ts +0 -891
- package/src/controller.ts +0 -1419
- package/src/helpers/connection.ts +0 -187
- package/src/helpers/files.test.ts +0 -117
- package/src/helpers/files.ts +0 -463
- package/src/helpers/installer.ts +0 -530
- package/src/helpers/sync-validator.ts +0 -87
- package/src/helpers/user-actions.ts +0 -158
- package/src/helpers/watcher.test.ts +0 -74
- package/src/helpers/watcher.ts +0 -110
- package/src/index.ts +0 -111
- package/src/types.ts +0 -112
- package/src/utils/file-metadata-cache.ts +0 -121
- package/src/utils/hash-tracker.ts +0 -79
- package/src/utils/imports.ts +0 -62
- package/src/utils/logging.ts +0 -235
- package/src/utils/paths.ts +0 -76
- package/src/utils/project.ts +0 -120
- package/src/utils/state-persistence.ts +0 -138
- package/tsconfig.json +0 -14
- package/vitest.config.ts +0 -8
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Hash tracking utilities for echo prevention
|
|
3
|
-
*
|
|
4
|
-
* The hash tracker prevents echo loops by remembering content hashes
|
|
5
|
-
* and skipping watcher events for files we just wrote.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { createHash } from "crypto"
|
|
9
|
-
|
|
10
|
-
export interface HashTracker {
|
|
11
|
-
remember(filePath: string, content: string): void
|
|
12
|
-
shouldSkip(filePath: string, content: string): boolean
|
|
13
|
-
forget(filePath: string): void
|
|
14
|
-
clear(): void
|
|
15
|
-
markDelete(filePath: string): void
|
|
16
|
-
shouldSkipDelete(filePath: string): boolean
|
|
17
|
-
clearDelete(filePath: string): void
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Creates a hash tracker instance for echo prevention
|
|
22
|
-
*/
|
|
23
|
-
export function createHashTracker(): HashTracker {
|
|
24
|
-
const hashes = new Map<string, string>()
|
|
25
|
-
const pendingDeletes = new Map<string, ReturnType<typeof setTimeout>>()
|
|
26
|
-
|
|
27
|
-
return {
|
|
28
|
-
remember(filePath: string, content: string): void {
|
|
29
|
-
const hash = hashContent(content)
|
|
30
|
-
hashes.set(filePath, hash)
|
|
31
|
-
},
|
|
32
|
-
|
|
33
|
-
shouldSkip(filePath: string, content: string): boolean {
|
|
34
|
-
const currentHash = hashContent(content)
|
|
35
|
-
const storedHash = hashes.get(filePath)
|
|
36
|
-
return storedHash === currentHash
|
|
37
|
-
},
|
|
38
|
-
|
|
39
|
-
forget(filePath: string): void {
|
|
40
|
-
hashes.delete(filePath)
|
|
41
|
-
},
|
|
42
|
-
|
|
43
|
-
clear(): void {
|
|
44
|
-
hashes.clear()
|
|
45
|
-
},
|
|
46
|
-
|
|
47
|
-
markDelete(filePath: string): void {
|
|
48
|
-
const existingTimer = pendingDeletes.get(filePath)
|
|
49
|
-
if (existingTimer) {
|
|
50
|
-
clearTimeout(existingTimer)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const timeout = setTimeout(() => {
|
|
54
|
-
pendingDeletes.delete(filePath)
|
|
55
|
-
}, 5000)
|
|
56
|
-
|
|
57
|
-
pendingDeletes.set(filePath, timeout)
|
|
58
|
-
},
|
|
59
|
-
|
|
60
|
-
shouldSkipDelete(filePath: string): boolean {
|
|
61
|
-
return pendingDeletes.has(filePath)
|
|
62
|
-
},
|
|
63
|
-
|
|
64
|
-
clearDelete(filePath: string): void {
|
|
65
|
-
const timeout = pendingDeletes.get(filePath)
|
|
66
|
-
if (timeout) {
|
|
67
|
-
clearTimeout(timeout)
|
|
68
|
-
}
|
|
69
|
-
pendingDeletes.delete(filePath)
|
|
70
|
-
},
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Computes a SHA256 hash of file content for comparison
|
|
76
|
-
*/
|
|
77
|
-
function hashContent(content: string): string {
|
|
78
|
-
return createHash("sha256").update(content).digest("hex")
|
|
79
|
-
}
|
package/src/utils/imports.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Utilities for parsing import statements and extracting package information.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
export interface ImportInfo {
|
|
6
|
-
type: "npm" | "url"
|
|
7
|
-
name: string
|
|
8
|
-
raw: string
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Extract npm and URL-based imports from source code.
|
|
13
|
-
*/
|
|
14
|
-
export function extractImports(code: string): ImportInfo[] {
|
|
15
|
-
const imports: ImportInfo[] = []
|
|
16
|
-
const seen = new Set<string>()
|
|
17
|
-
|
|
18
|
-
const npmRegex =
|
|
19
|
-
/import\s+(?:(?:\*\s+as\s+\w+)|(?:\w+)|(?:\{[^}]*\}))\s+from\s+['"]([^.\/][^'"]+)['"]/g
|
|
20
|
-
const urlRegex =
|
|
21
|
-
/import\s+(?:(?:\*\s+as\s+\w+)|(?:\w+)|(?:\{[^}]*\}))\s+from\s+['"]https?:\/\/[^'"]+['"]/g
|
|
22
|
-
|
|
23
|
-
let match: RegExpExecArray | null
|
|
24
|
-
|
|
25
|
-
while ((match = npmRegex.exec(code)) !== null) {
|
|
26
|
-
const pkgName = match[1]
|
|
27
|
-
const normalized = pkgName.startsWith("@")
|
|
28
|
-
? pkgName.split("/").slice(0, 2).join("/")
|
|
29
|
-
: pkgName.split("/")[0]
|
|
30
|
-
|
|
31
|
-
if (!seen.has(normalized)) {
|
|
32
|
-
seen.add(normalized)
|
|
33
|
-
imports.push({
|
|
34
|
-
type: "npm",
|
|
35
|
-
name: normalized,
|
|
36
|
-
raw: match[0],
|
|
37
|
-
})
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
while ((match = urlRegex.exec(code)) !== null) {
|
|
42
|
-
const pkgName = extractPackageFromUrl(match[0])
|
|
43
|
-
if (pkgName && !seen.has(pkgName)) {
|
|
44
|
-
seen.add(pkgName)
|
|
45
|
-
imports.push({
|
|
46
|
-
type: "url",
|
|
47
|
-
name: pkgName,
|
|
48
|
-
raw: match[0],
|
|
49
|
-
})
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return imports
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Attempt to derive an npm-style package specifier from a URL import.
|
|
58
|
-
*/
|
|
59
|
-
export function extractPackageFromUrl(url: string): string | null {
|
|
60
|
-
const match = url.match(/\/(@?[^@\/]+(?:\/[^@\/]+)?)/)
|
|
61
|
-
return match?.[1] ?? null
|
|
62
|
-
}
|
package/src/utils/logging.ts
DELETED
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Logging utilities for consistent, clean CLI output
|
|
3
|
-
*
|
|
4
|
-
* Features:
|
|
5
|
-
* - Log levels (DEBUG, INFO, WARN, ERROR)
|
|
6
|
-
* - Message deduplication with count suffix (x2), (x3)
|
|
7
|
-
* - Reconnect cycle suppression (collapses rapid disconnect/reconnect spam)
|
|
8
|
-
* - Clean prefixes (no [INFO] clutter at default level)
|
|
9
|
-
* - Colored startup banner
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import pc from "picocolors"
|
|
13
|
-
|
|
14
|
-
export enum LogLevel {
|
|
15
|
-
DEBUG = 0,
|
|
16
|
-
INFO = 1,
|
|
17
|
-
WARN = 2,
|
|
18
|
-
ERROR = 3,
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
let currentLevel = LogLevel.INFO
|
|
22
|
-
|
|
23
|
-
// Deduplication state
|
|
24
|
-
let lastMessage = ""
|
|
25
|
-
let lastMessageCount = 0
|
|
26
|
-
const CLEAR_LINE = "\u001b[2K"
|
|
27
|
-
const MOVE_CURSOR_UP = "\u001b[1A"
|
|
28
|
-
|
|
29
|
-
// Redraw the previous line with the updated message/count.
|
|
30
|
-
function rewriteLastLine(text: string): void {
|
|
31
|
-
if (process.stdout.isTTY) {
|
|
32
|
-
process.stdout.write(`${MOVE_CURSOR_UP}\r${CLEAR_LINE}${text}\n`)
|
|
33
|
-
} else {
|
|
34
|
-
// Fallback for non-TTY (e.g., piping output) – just emit the line.
|
|
35
|
-
process.stdout.write(`${text}\n`)
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// Reconnect suppression state
|
|
40
|
-
let disconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
41
|
-
let isShowingDisconnect = false
|
|
42
|
-
let hadRecentDisconnect = false
|
|
43
|
-
// Only show disconnect if down for 4+ seconds
|
|
44
|
-
// Allows for swtiching between Canvas and Code Editor
|
|
45
|
-
const DISCONNECT_DELAY_MS = 4000
|
|
46
|
-
|
|
47
|
-
export function setLogLevel(level: LogLevel): void {
|
|
48
|
-
currentLevel = level
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function getLogLevel(): LogLevel {
|
|
52
|
-
return currentLevel
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function dedupeMessage(message: string, count: number): void {
|
|
56
|
-
rewriteLastLine(`${message} ${pc.dim(`(x${count})`)}`)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Flush any pending deduplicated message
|
|
61
|
-
*/
|
|
62
|
-
function flushDedupe(): void {
|
|
63
|
-
if (lastMessageCount > 1) {
|
|
64
|
-
dedupeMessage(lastMessage, lastMessageCount)
|
|
65
|
-
}
|
|
66
|
-
lastMessage = ""
|
|
67
|
-
lastMessageCount = 0
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Log with deduplication - repeated messages within window get counted
|
|
72
|
-
*/
|
|
73
|
-
function logWithDedupe(message: string, writer: () => void): void {
|
|
74
|
-
if (message === lastMessage) {
|
|
75
|
-
// Same message - increment count regardless of gap
|
|
76
|
-
lastMessageCount++
|
|
77
|
-
// Overwrite previous line (move cursor up, clear, rewrite)
|
|
78
|
-
dedupeMessage(message, lastMessageCount)
|
|
79
|
-
return
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
lastMessage = message
|
|
83
|
-
lastMessageCount = 1
|
|
84
|
-
writer()
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Print the startup banner - one colored line
|
|
89
|
-
*/
|
|
90
|
-
export function banner(version: string, port: number): void {
|
|
91
|
-
console.log()
|
|
92
|
-
let message = ` ${pc.cyan(pc.bold("⚡ Code Link"))} ${pc.dim(`v${version}`)}`
|
|
93
|
-
if (currentLevel <= LogLevel.DEBUG) {
|
|
94
|
-
message += ` ${pc.dim("Port")} ${pc.yellow(port)}`
|
|
95
|
-
}
|
|
96
|
-
console.log(message)
|
|
97
|
-
console.log()
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Debug-level logging - only shown with --verbose flag
|
|
102
|
-
*/
|
|
103
|
-
export function debug(message: string, ...args: unknown[]): void {
|
|
104
|
-
if (currentLevel <= LogLevel.DEBUG) {
|
|
105
|
-
console.debug(pc.dim(`[DEBUG] ${message}`), ...args)
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Info-level logging - shown by default, no prefix
|
|
111
|
-
*/
|
|
112
|
-
export function info(message: string, ...args: unknown[]): void {
|
|
113
|
-
if (currentLevel <= LogLevel.INFO) {
|
|
114
|
-
const formatted = args.length > 0 ? `${message} ${args.join(" ")}` : message
|
|
115
|
-
logWithDedupe(formatted, () => console.log(formatted))
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Warning-level logging
|
|
121
|
-
*/
|
|
122
|
-
export function warn(message: string, ...args: unknown[]): void {
|
|
123
|
-
if (currentLevel <= LogLevel.WARN) {
|
|
124
|
-
if (message === lastMessage) return // Skip exact duplicates silently
|
|
125
|
-
flushDedupe()
|
|
126
|
-
lastMessage = message
|
|
127
|
-
lastMessageCount = 1
|
|
128
|
-
console.warn(pc.yellow(`⚠ ${message}`), ...args)
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Error-level logging
|
|
134
|
-
*/
|
|
135
|
-
export function error(message: string, ...args: unknown[]): void {
|
|
136
|
-
if (currentLevel <= LogLevel.ERROR) {
|
|
137
|
-
flushDedupe()
|
|
138
|
-
console.error(pc.red(`✗ ${message}`), ...args)
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Success message with checkmark
|
|
144
|
-
*/
|
|
145
|
-
export function success(message: string, ...args: unknown[]): void {
|
|
146
|
-
if (currentLevel <= LogLevel.INFO) {
|
|
147
|
-
flushDedupe()
|
|
148
|
-
console.log(pc.green(`✓ ${message}`), ...args)
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* File sync indicators
|
|
154
|
-
*/
|
|
155
|
-
export function fileDown(fileName: string): void {
|
|
156
|
-
if (currentLevel <= LogLevel.INFO) {
|
|
157
|
-
const msg = ` ${pc.blue("↓")} ${fileName}`
|
|
158
|
-
logWithDedupe(msg, () => console.log(msg))
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
export function fileUp(fileName: string): void {
|
|
163
|
-
if (currentLevel <= LogLevel.INFO) {
|
|
164
|
-
const msg = ` ${pc.green("↑")} ${fileName}`
|
|
165
|
-
logWithDedupe(msg, () => console.log(msg))
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export function fileDelete(fileName: string): void {
|
|
170
|
-
if (currentLevel <= LogLevel.INFO) {
|
|
171
|
-
const msg = ` ${pc.red("×")} ${fileName}`
|
|
172
|
-
logWithDedupe(msg, () => console.log(msg))
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Status message (dimmed, for "watching for changes..." etc)
|
|
178
|
-
*/
|
|
179
|
-
export function status(message: string): void {
|
|
180
|
-
if (currentLevel <= LogLevel.INFO) {
|
|
181
|
-
flushDedupe()
|
|
182
|
-
console.log(pc.dim(` ${message}`))
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Schedule a delayed disconnect message.
|
|
188
|
-
* If reconnection happens before the delay, the message is cancelled.
|
|
189
|
-
*/
|
|
190
|
-
export function scheduleDisconnectMessage(callback: () => void): void {
|
|
191
|
-
// Clear any existing timer
|
|
192
|
-
if (disconnectTimer) {
|
|
193
|
-
clearTimeout(disconnectTimer)
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
hadRecentDisconnect = true
|
|
197
|
-
isShowingDisconnect = false
|
|
198
|
-
disconnectTimer = setTimeout(() => {
|
|
199
|
-
isShowingDisconnect = true
|
|
200
|
-
callback()
|
|
201
|
-
disconnectTimer = null
|
|
202
|
-
}, DISCONNECT_DELAY_MS)
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Cancel pending disconnect message (called on reconnect)
|
|
207
|
-
*/
|
|
208
|
-
export function cancelDisconnectMessage(): void {
|
|
209
|
-
if (disconnectTimer) {
|
|
210
|
-
clearTimeout(disconnectTimer)
|
|
211
|
-
disconnectTimer = null
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Check if we showed a disconnect message (need to show reconnect)
|
|
217
|
-
*/
|
|
218
|
-
export function didShowDisconnect(): boolean {
|
|
219
|
-
return isShowingDisconnect
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Check if we recently saw a disconnect (even if the message was suppressed)
|
|
224
|
-
*/
|
|
225
|
-
export function wasRecentlyDisconnected(): boolean {
|
|
226
|
-
return hadRecentDisconnect
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Reset disconnect state after successful reconnect
|
|
231
|
-
*/
|
|
232
|
-
export function resetDisconnectState(): void {
|
|
233
|
-
isShowingDisconnect = false
|
|
234
|
-
hadRecentDisconnect = false
|
|
235
|
-
}
|
package/src/utils/paths.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Path manipulation utilities
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import path from "path"
|
|
6
|
-
import { fileURLToPath } from "url"
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Resolves a path relative to the project directory
|
|
10
|
-
*/
|
|
11
|
-
export function resolveProjectPath(
|
|
12
|
-
projectDir: string,
|
|
13
|
-
relativePath: string
|
|
14
|
-
): string {
|
|
15
|
-
return path.resolve(projectDir, relativePath)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Gets a relative path from the project directory
|
|
20
|
-
*/
|
|
21
|
-
export function getRelativePath(
|
|
22
|
-
projectDir: string,
|
|
23
|
-
absolutePath: string
|
|
24
|
-
): string {
|
|
25
|
-
return path.relative(projectDir, absolutePath)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Ensures a directory path ends with a separator
|
|
30
|
-
*/
|
|
31
|
-
export function ensureTrailingSlash(dirPath: string): string {
|
|
32
|
-
return dirPath.endsWith(path.sep) ? dirPath : dirPath + path.sep
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Gets the directory name from an import.meta.url (ESM)
|
|
37
|
-
*/
|
|
38
|
-
export function getDirname(importMetaUrl: string): string {
|
|
39
|
-
return path.dirname(fileURLToPath(importMetaUrl))
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Normalizes a file path by:
|
|
44
|
-
* - Converting backslashes to forward slashes
|
|
45
|
-
* - Resolving . and .. segments
|
|
46
|
-
* - Removing duplicate slashes
|
|
47
|
-
*/
|
|
48
|
-
export function normalizePath(filePath: string): string {
|
|
49
|
-
if (!filePath) return ""
|
|
50
|
-
|
|
51
|
-
const isAbsolute = filePath.startsWith("/")
|
|
52
|
-
const segments = filePath.replace(/\\/g, "/").split("/")
|
|
53
|
-
const stack: string[] = []
|
|
54
|
-
|
|
55
|
-
for (const segment of segments) {
|
|
56
|
-
if (!segment || segment === ".") {
|
|
57
|
-
continue
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
if (segment === "..") {
|
|
61
|
-
if (stack.length > 0) {
|
|
62
|
-
stack.pop()
|
|
63
|
-
}
|
|
64
|
-
continue
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
stack.push(segment)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const normalized = stack.join("/")
|
|
71
|
-
if (isAbsolute) {
|
|
72
|
-
return `/${normalized}`
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return normalized
|
|
76
|
-
}
|
package/src/utils/project.ts
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import fs from "fs/promises"
|
|
2
|
-
import path from "path"
|
|
3
|
-
import { shortProjectHash } from "@code-link/shared"
|
|
4
|
-
|
|
5
|
-
interface PackageJson {
|
|
6
|
-
shortProjectHash?: string // derived short id (8 chars base58)
|
|
7
|
-
framerProjectHash?: string // full 64-char hex hash from Framer API
|
|
8
|
-
framerProjectName?: string
|
|
9
|
-
name?: string
|
|
10
|
-
version?: string
|
|
11
|
-
[key: string]: unknown
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function toPackageName(name: string): string {
|
|
15
|
-
return name
|
|
16
|
-
.toLowerCase()
|
|
17
|
-
.replace(/[^a-z0-9-]/g, "-")
|
|
18
|
-
.replace(/^-+|-+$/g, "")
|
|
19
|
-
.replace(/-+/g, "-")
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function toDirName(name: string): string {
|
|
23
|
-
return name
|
|
24
|
-
.replace(/[^a-zA-Z0-9- ]/g, "-")
|
|
25
|
-
.replace(/^[-\s]+|[-\s]+$/g, "")
|
|
26
|
-
.replace(/-+/g, "-")
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export async function getProjectHashFromCwd(): Promise<string | null> {
|
|
30
|
-
try {
|
|
31
|
-
const packageJsonPath = path.join(process.cwd(), "package.json")
|
|
32
|
-
const content = await fs.readFile(packageJsonPath, "utf-8")
|
|
33
|
-
const pkg = JSON.parse(content) as PackageJson
|
|
34
|
-
// Return short id for port derivation
|
|
35
|
-
return pkg.shortProjectHash ?? null
|
|
36
|
-
} catch {
|
|
37
|
-
return null
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export async function findOrCreateProjectDir(
|
|
42
|
-
projectHash: string,
|
|
43
|
-
projectName?: string,
|
|
44
|
-
explicitDir?: string
|
|
45
|
-
): Promise<string> {
|
|
46
|
-
if (explicitDir) {
|
|
47
|
-
const resolved = path.resolve(explicitDir)
|
|
48
|
-
await fs.mkdir(path.join(resolved, "files"), { recursive: true })
|
|
49
|
-
return resolved
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const cwd = process.cwd()
|
|
53
|
-
const existing = await findExistingProjectDir(cwd, projectHash)
|
|
54
|
-
if (existing) {
|
|
55
|
-
return existing
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if (!projectName) {
|
|
59
|
-
throw new Error(
|
|
60
|
-
"Project name is required when creating a new workspace. Pass --name <project name>."
|
|
61
|
-
)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const dirName = toDirName(projectName)
|
|
65
|
-
const pkgName = toPackageName(projectName)
|
|
66
|
-
const shortId = shortProjectHash(projectHash)
|
|
67
|
-
const projectDir = path.join(cwd, dirName || shortId)
|
|
68
|
-
|
|
69
|
-
await fs.mkdir(path.join(projectDir, "files"), { recursive: true })
|
|
70
|
-
const pkg: PackageJson = {
|
|
71
|
-
name: pkgName || shortId,
|
|
72
|
-
version: "1.0.0",
|
|
73
|
-
private: true,
|
|
74
|
-
shortProjectHash: shortId,
|
|
75
|
-
framerProjectHash: projectHash,
|
|
76
|
-
framerProjectName: projectName,
|
|
77
|
-
}
|
|
78
|
-
await fs.writeFile(
|
|
79
|
-
path.join(projectDir, "package.json"),
|
|
80
|
-
JSON.stringify(pkg, null, 2)
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
return projectDir
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async function findExistingProjectDir(
|
|
87
|
-
baseDir: string,
|
|
88
|
-
projectHash: string
|
|
89
|
-
): Promise<string | null> {
|
|
90
|
-
const candidate = path.join(baseDir, "package.json")
|
|
91
|
-
if (await matchesProject(candidate, projectHash)) {
|
|
92
|
-
return baseDir
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const entries = await fs.readdir(baseDir, { withFileTypes: true })
|
|
96
|
-
for (const entry of entries) {
|
|
97
|
-
if (!entry.isDirectory()) continue
|
|
98
|
-
const dir = path.join(baseDir, entry.name)
|
|
99
|
-
if (await matchesProject(path.join(dir, "package.json"), projectHash)) {
|
|
100
|
-
return dir
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return null
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async function matchesProject(
|
|
108
|
-
packageJsonPath: string,
|
|
109
|
-
projectHash: string
|
|
110
|
-
): Promise<boolean> {
|
|
111
|
-
try {
|
|
112
|
-
const content = await fs.readFile(packageJsonPath, "utf-8")
|
|
113
|
-
const pkg = JSON.parse(content) as PackageJson
|
|
114
|
-
const inputShort = shortProjectHash(projectHash)
|
|
115
|
-
// Match on short id (handles both full hash input and short id input)
|
|
116
|
-
return pkg.shortProjectHash === inputShort
|
|
117
|
-
} catch {
|
|
118
|
-
return false
|
|
119
|
-
}
|
|
120
|
-
}
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* State persistence helper
|
|
3
|
-
*
|
|
4
|
-
* Persists last sync timestamps along with content hashes.
|
|
5
|
-
* We only trust persisted timestamps if the file content hasn't changed
|
|
6
|
-
* (hash matches), because that means the file wasn't edited while CLI was offline.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import fs from "fs/promises"
|
|
10
|
-
import path from "path"
|
|
11
|
-
import { createHash } from "crypto"
|
|
12
|
-
import { debug, warn } from "./logging.js"
|
|
13
|
-
import { normalizePath } from "./paths.js"
|
|
14
|
-
|
|
15
|
-
export interface PersistedFileState {
|
|
16
|
-
timestamp: number // Remote modified timestamp from last sync
|
|
17
|
-
contentHash: string // Hash of content when we received the sync confirmation
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
interface PersistedState {
|
|
21
|
-
version: number
|
|
22
|
-
files: Record<string, PersistedFileState>
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const STATE_FILE_NAME = ".framer-sync-state.json"
|
|
26
|
-
// Version 2: State machine rewrite - only stores hashes/timestamps for local edit detection
|
|
27
|
-
// Breaking change: users must delete old .framer-sync-state.json files
|
|
28
|
-
const CURRENT_VERSION = 2
|
|
29
|
-
const SUPPORTED_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".json"]
|
|
30
|
-
const DEFAULT_EXTENSION = ".tsx"
|
|
31
|
-
|
|
32
|
-
export function normalizePersistedFileName(fileName: string): string {
|
|
33
|
-
let normalized = normalizePath(fileName.trim())
|
|
34
|
-
if (
|
|
35
|
-
!SUPPORTED_EXTENSIONS.some((ext) => normalized.toLowerCase().endsWith(ext))
|
|
36
|
-
) {
|
|
37
|
-
normalized = `${normalized}${DEFAULT_EXTENSION}`
|
|
38
|
-
}
|
|
39
|
-
return normalized
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Hash file content to detect changes
|
|
44
|
-
*/
|
|
45
|
-
export function hashFileContent(content: string): string {
|
|
46
|
-
return createHash("sha256").update(content, "utf-8").digest("hex")
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Load persisted state from disk
|
|
51
|
-
*/
|
|
52
|
-
export async function loadPersistedState(
|
|
53
|
-
projectDir: string
|
|
54
|
-
): Promise<Map<string, PersistedFileState>> {
|
|
55
|
-
const statePath = path.join(projectDir, STATE_FILE_NAME)
|
|
56
|
-
const result = new Map<string, PersistedFileState>()
|
|
57
|
-
|
|
58
|
-
try {
|
|
59
|
-
const data = await fs.readFile(statePath, "utf-8")
|
|
60
|
-
const parsed: PersistedState = JSON.parse(data)
|
|
61
|
-
|
|
62
|
-
if (parsed.version !== CURRENT_VERSION) {
|
|
63
|
-
warn(
|
|
64
|
-
`State file version mismatch (expected ${CURRENT_VERSION}, got ${parsed.version}). Ignoring persisted state.`
|
|
65
|
-
)
|
|
66
|
-
return result
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
for (const [fileName, state] of Object.entries(parsed.files)) {
|
|
70
|
-
const normalizedName = normalizePersistedFileName(fileName)
|
|
71
|
-
if (normalizedName !== fileName) {
|
|
72
|
-
debug(
|
|
73
|
-
`Normalized persisted key "${fileName}" -> "${normalizedName}" for compatibility`
|
|
74
|
-
)
|
|
75
|
-
}
|
|
76
|
-
result.set(normalizedName, state)
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
debug(`Loaded persisted state for ${result.size} files`)
|
|
80
|
-
return result
|
|
81
|
-
} catch (err) {
|
|
82
|
-
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
83
|
-
debug("No persisted state found (first run)")
|
|
84
|
-
return result
|
|
85
|
-
}
|
|
86
|
-
warn("Failed to load persisted state:", err)
|
|
87
|
-
return result
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Save current state to disk
|
|
93
|
-
*/
|
|
94
|
-
export async function savePersistedState(
|
|
95
|
-
projectDir: string,
|
|
96
|
-
state: Map<string, PersistedFileState>
|
|
97
|
-
): Promise<void> {
|
|
98
|
-
const statePath = path.join(projectDir, STATE_FILE_NAME)
|
|
99
|
-
|
|
100
|
-
const persistedState: PersistedState = {
|
|
101
|
-
version: CURRENT_VERSION,
|
|
102
|
-
files: Object.fromEntries(state.entries()),
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
await fs.writeFile(statePath, JSON.stringify(persistedState, null, 2))
|
|
107
|
-
debug(`Saved persisted state for ${state.size} files`)
|
|
108
|
-
} catch (err) {
|
|
109
|
-
warn("Failed to save persisted state:", err)
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Validate persisted timestamp against current file content
|
|
115
|
-
* Returns the timestamp only if the content hash matches (file unchanged)
|
|
116
|
-
*/
|
|
117
|
-
export function validatePersistedTimestamp(
|
|
118
|
-
persistedState: PersistedFileState | undefined,
|
|
119
|
-
currentContent: string
|
|
120
|
-
): number | null {
|
|
121
|
-
if (!persistedState) {
|
|
122
|
-
return null
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const currentHash = hashFileContent(currentContent)
|
|
126
|
-
|
|
127
|
-
if (currentHash === persistedState.contentHash) {
|
|
128
|
-
debug(
|
|
129
|
-
`Hash matches for persisted state - trusting timestamp ${persistedState.timestamp}`
|
|
130
|
-
)
|
|
131
|
-
return persistedState.timestamp
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
debug(
|
|
135
|
-
"Hash mismatch for persisted state - file was edited while CLI was offline"
|
|
136
|
-
)
|
|
137
|
-
return null
|
|
138
|
-
}
|