framer-code-link 0.1.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 +196 -0
- package/dist/index.js +2021 -0
- package/dist/project-DhpsFg77.js +53 -0
- package/package.json +36 -0
- package/src/controller.test.ts +966 -0
- package/src/controller.ts +1212 -0
- package/src/helpers/connection.ts +95 -0
- package/src/helpers/files.test.ts +117 -0
- package/src/helpers/files.ts +378 -0
- package/src/helpers/installer.ts +534 -0
- package/src/helpers/sync-validator.ts +87 -0
- package/src/helpers/user-actions.ts +162 -0
- package/src/helpers/watcher.ts +115 -0
- package/src/index.ts +75 -0
- package/src/types.ts +107 -0
- package/src/utils/file-metadata-cache.ts +121 -0
- package/src/utils/hashing.ts +95 -0
- package/src/utils/imports.ts +62 -0
- package/src/utils/logging.ts +47 -0
- package/src/utils/paths.ts +76 -0
- package/src/utils/project.ts +94 -0
- package/src/utils/state-persistence.ts +138 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User Action Coordinator
|
|
3
|
+
*
|
|
4
|
+
* Provides a clean awaitable API for user confirmations via the plugin.
|
|
5
|
+
* Just coordinates the request/response - all business logic stays in the controller.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { WebSocket } from "ws"
|
|
9
|
+
import type { Conflict } from "../types.js"
|
|
10
|
+
import { sendMessage } from "./connection.js"
|
|
11
|
+
import { info, warn } from "../utils/logging.js"
|
|
12
|
+
|
|
13
|
+
class PluginDisconnectedError extends Error {
|
|
14
|
+
constructor() {
|
|
15
|
+
super("Plugin disconnected")
|
|
16
|
+
this.name = "PluginDisconnectedError"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type PendingAction<T> = {
|
|
21
|
+
resolve: (value: T) => void
|
|
22
|
+
reject: (error: Error) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class UserActionCoordinator {
|
|
26
|
+
private pendingActions = new Map<string, PendingAction<any>>()
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Sends the delete request to the plugin and awaits the user's decision
|
|
30
|
+
*/
|
|
31
|
+
async requestDeleteDecision(
|
|
32
|
+
socket: WebSocket | null,
|
|
33
|
+
{
|
|
34
|
+
fileName,
|
|
35
|
+
requireConfirmation,
|
|
36
|
+
}: { fileName: string; requireConfirmation: boolean }
|
|
37
|
+
): Promise<boolean> {
|
|
38
|
+
if (!socket) {
|
|
39
|
+
throw new Error("Cannot request delete decision: plugin not connected")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (requireConfirmation) {
|
|
43
|
+
const confirmationPromise = this.awaitConfirmation<boolean>(
|
|
44
|
+
`delete:${fileName}`,
|
|
45
|
+
"delete confirmation"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
await sendMessage(socket, {
|
|
49
|
+
type: "file-delete",
|
|
50
|
+
fileNames: [fileName],
|
|
51
|
+
requireConfirmation: true,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
return await confirmationPromise
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (err instanceof PluginDisconnectedError) {
|
|
58
|
+
info(
|
|
59
|
+
`[USER-ACTION] Plugin disconnected while waiting for delete confirmation: ${fileName}`
|
|
60
|
+
)
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
throw err
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await sendMessage(socket, {
|
|
68
|
+
type: "file-delete",
|
|
69
|
+
fileNames: [fileName],
|
|
70
|
+
requireConfirmation: false,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Sends conflicts to the plugin and awaits user resolutions
|
|
78
|
+
*/
|
|
79
|
+
async requestConflictDecisions(
|
|
80
|
+
socket: WebSocket | null,
|
|
81
|
+
conflicts: Conflict[]
|
|
82
|
+
): Promise<Map<string, "local" | "remote">> {
|
|
83
|
+
if (!socket) {
|
|
84
|
+
throw new Error("Cannot request conflict decision: plugin not connected")
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (conflicts.length === 0) {
|
|
88
|
+
return new Map()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const pending = conflicts.map((conflict) => ({
|
|
92
|
+
fileName: conflict.fileName,
|
|
93
|
+
promise: this.awaitConfirmation<"local" | "remote">(
|
|
94
|
+
`conflict:${conflict.fileName}`,
|
|
95
|
+
"conflict resolution"
|
|
96
|
+
),
|
|
97
|
+
}))
|
|
98
|
+
|
|
99
|
+
await sendMessage(socket, {
|
|
100
|
+
type: "conflicts-detected",
|
|
101
|
+
conflicts,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const results = await Promise.all(
|
|
106
|
+
pending.map(
|
|
107
|
+
async ({ fileName, promise }) => [fileName, await promise] as const
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return new Map(results)
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (err instanceof PluginDisconnectedError) {
|
|
114
|
+
info(
|
|
115
|
+
"[USER-ACTION] Plugin disconnected while awaiting conflict decisions"
|
|
116
|
+
)
|
|
117
|
+
return new Map()
|
|
118
|
+
}
|
|
119
|
+
throw err
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Generic confirmation awaiter
|
|
125
|
+
*/
|
|
126
|
+
private awaitConfirmation<T>(
|
|
127
|
+
actionId: string,
|
|
128
|
+
description: string
|
|
129
|
+
): Promise<T> {
|
|
130
|
+
return new Promise<T>((resolve, reject) => {
|
|
131
|
+
this.pendingActions.set(actionId, { resolve, reject })
|
|
132
|
+
info(`[USER-ACTION] Awaiting ${description}: ${actionId}`)
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Handle incoming confirmation response
|
|
138
|
+
*/
|
|
139
|
+
handleConfirmation<T>(actionId: string, value: T): boolean {
|
|
140
|
+
const pending = this.pendingActions.get(actionId)
|
|
141
|
+
if (!pending) {
|
|
142
|
+
warn(`[USER-ACTION] Unexpected confirmation for ${actionId}`)
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this.pendingActions.delete(actionId)
|
|
147
|
+
pending.resolve(value)
|
|
148
|
+
info(`[USER-ACTION] Confirmed: ${actionId}`)
|
|
149
|
+
return true
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Cleanup all pending actions (e.g., on disconnect)
|
|
154
|
+
*/
|
|
155
|
+
cleanup(): void {
|
|
156
|
+
for (const [actionId, pending] of this.pendingActions.entries()) {
|
|
157
|
+
pending.reject(new PluginDisconnectedError())
|
|
158
|
+
warn(`[USER-ACTION] Cancelled pending action: ${actionId}`)
|
|
159
|
+
}
|
|
160
|
+
this.pendingActions.clear()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File watcher helper
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper around chokidar that normalizes file paths and emits
|
|
5
|
+
* only supported file types (ts, tsx, js, json). Controller never worries
|
|
6
|
+
* about addDir or platform separators.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import chokidar from "chokidar"
|
|
10
|
+
import fs from "fs/promises"
|
|
11
|
+
import path from "path"
|
|
12
|
+
import type { WatcherEvent } from "../types.js"
|
|
13
|
+
import {
|
|
14
|
+
isSupportedExtension,
|
|
15
|
+
normalizePath,
|
|
16
|
+
sanitizeFilePath,
|
|
17
|
+
} from "@code-link/shared"
|
|
18
|
+
import { getRelativePath } from "../utils/paths.js"
|
|
19
|
+
import { info, debug, warn } from "../utils/logging.js"
|
|
20
|
+
|
|
21
|
+
export interface Watcher {
|
|
22
|
+
on(event: "change", handler: (event: WatcherEvent) => void): void
|
|
23
|
+
close(): Promise<void>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Initializes a file watcher for the given directory
|
|
28
|
+
*/
|
|
29
|
+
export function initWatcher(filesDir: string): Watcher {
|
|
30
|
+
const handlers: Array<(event: WatcherEvent) => void> = []
|
|
31
|
+
|
|
32
|
+
const watcher = chokidar.watch(filesDir, {
|
|
33
|
+
ignored: /(^|[\/\\])\../, // ignore dotfiles
|
|
34
|
+
persistent: true,
|
|
35
|
+
ignoreInitial: false, // Emit add events for existing files so we can sanitize them
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
info(`Watching directory: ${filesDir}`)
|
|
39
|
+
|
|
40
|
+
// Helper to emit normalized events
|
|
41
|
+
const emitEvent = async (
|
|
42
|
+
kind: "add" | "change" | "delete",
|
|
43
|
+
absolutePath: string
|
|
44
|
+
): Promise<void> => {
|
|
45
|
+
if (!isSupportedExtension(absolutePath)) {
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const rawRelativePath = normalizePath(
|
|
50
|
+
getRelativePath(filesDir, absolutePath)
|
|
51
|
+
)
|
|
52
|
+
// Don't capitalize - preserve exact file names as they exist
|
|
53
|
+
// This ensures 1:1 sync with Framer without modifying user's casing choices
|
|
54
|
+
const sanitized = sanitizeFilePath(rawRelativePath, false)
|
|
55
|
+
const relativePath = sanitized.path
|
|
56
|
+
|
|
57
|
+
// If the user created a file that doesn't match our sanitization rules,
|
|
58
|
+
// rename it on disk to match what can be synced.
|
|
59
|
+
let effectiveAbsolutePath = absolutePath
|
|
60
|
+
if (relativePath !== rawRelativePath && kind === "add") {
|
|
61
|
+
const newAbsolutePath = path.join(filesDir, relativePath)
|
|
62
|
+
try {
|
|
63
|
+
await fs.mkdir(path.dirname(newAbsolutePath), { recursive: true })
|
|
64
|
+
await fs.rename(absolutePath, newAbsolutePath)
|
|
65
|
+
info(
|
|
66
|
+
`Renamed ${rawRelativePath} -> ${relativePath} to match Framer rules`
|
|
67
|
+
)
|
|
68
|
+
effectiveAbsolutePath = newAbsolutePath
|
|
69
|
+
} catch (err) {
|
|
70
|
+
warn(
|
|
71
|
+
`Failed to rename ${rawRelativePath} to ${relativePath}, syncing with original name`,
|
|
72
|
+
err
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let content: string | undefined
|
|
78
|
+
if (kind !== "delete") {
|
|
79
|
+
try {
|
|
80
|
+
content = await fs.readFile(effectiveAbsolutePath, "utf-8")
|
|
81
|
+
} catch (err) {
|
|
82
|
+
debug(`Failed to read file ${relativePath}:`, err)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const event: WatcherEvent = {
|
|
88
|
+
kind,
|
|
89
|
+
relativePath,
|
|
90
|
+
content,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
debug(`Watcher event: ${kind} ${relativePath}`)
|
|
94
|
+
|
|
95
|
+
for (const handler of handlers) {
|
|
96
|
+
handler(event)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
watcher.on("add", (filePath) => emitEvent("add", filePath))
|
|
101
|
+
watcher.on("change", (filePath) => emitEvent("change", filePath))
|
|
102
|
+
watcher.on("unlink", (filePath) => emitEvent("delete", filePath))
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
on(event: "change", handler: (event: WatcherEvent) => void): void {
|
|
106
|
+
if (event === "change") {
|
|
107
|
+
handlers.push(handler)
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
async close(): Promise<void> {
|
|
112
|
+
await watcher.close()
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Framer Code Link CLI - Next Generation
|
|
5
|
+
*
|
|
6
|
+
* Entry point for the CLI tool. Parses command-line arguments and starts
|
|
7
|
+
* the controller with the appropriate configuration.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Command } from "commander"
|
|
11
|
+
import { start } from "./controller.js"
|
|
12
|
+
import type { Config } from "./types.js"
|
|
13
|
+
import { setLogLevel, LogLevel, info } from "./utils/logging.js"
|
|
14
|
+
import { getPortFromHash } from "./utils/hashing.js"
|
|
15
|
+
|
|
16
|
+
const program = new Command()
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.name("code-link")
|
|
20
|
+
.description("Sync Framer code components to your local filesystem")
|
|
21
|
+
.version("0.1.0")
|
|
22
|
+
.argument("<projectHash>", "Framer project hash")
|
|
23
|
+
.option("-n, --name <name>", "Project name (optional)")
|
|
24
|
+
.option("-d, --dir <directory>", "Explicit project directory")
|
|
25
|
+
.option("-v, --verbose", "Enable verbose logging")
|
|
26
|
+
.option("--log-level <level>", "Set log level (debug, info, warn, error)")
|
|
27
|
+
.option(
|
|
28
|
+
"--dangerously-auto-delete",
|
|
29
|
+
"Automatically delete remote files without confirmation"
|
|
30
|
+
)
|
|
31
|
+
.action(async (projectHash: string, options) => {
|
|
32
|
+
// Auto-enable debug in development unless overridden
|
|
33
|
+
const isDev = process.env.NODE_ENV === "development"
|
|
34
|
+
|
|
35
|
+
if (options.logLevel) {
|
|
36
|
+
const levelMap: Record<string, LogLevel> = {
|
|
37
|
+
debug: LogLevel.DEBUG,
|
|
38
|
+
info: LogLevel.INFO,
|
|
39
|
+
warn: LogLevel.WARN,
|
|
40
|
+
error: LogLevel.ERROR,
|
|
41
|
+
}
|
|
42
|
+
const level = levelMap[options.logLevel.toLowerCase()]
|
|
43
|
+
if (level !== undefined) {
|
|
44
|
+
setLogLevel(level)
|
|
45
|
+
}
|
|
46
|
+
} else if (options.verbose || isDev) {
|
|
47
|
+
setLogLevel(LogLevel.DEBUG)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const port = getPortFromHash(projectHash)
|
|
51
|
+
|
|
52
|
+
const config: Config = {
|
|
53
|
+
port,
|
|
54
|
+
projectHash,
|
|
55
|
+
projectDir: null, // Will be set during handshake
|
|
56
|
+
filesDir: null, // Will be set during handshake
|
|
57
|
+
dangerouslyAutoDelete: options.dangerouslyAutoDelete ?? false,
|
|
58
|
+
explicitDir: options.dir,
|
|
59
|
+
explicitName: options.name,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (config.dangerouslyAutoDelete) {
|
|
63
|
+
info(
|
|
64
|
+
"⚠️ Auto-delete mode enabled - files will be deleted without confirmation"
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await start(config)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
program.parse()
|
|
72
|
+
|
|
73
|
+
// Export for programmatic usage
|
|
74
|
+
export { start } from "./controller.js"
|
|
75
|
+
export type { Config } from "./types.js"
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types for the controller-centric CLI architecture
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { WebSocket } from "ws"
|
|
6
|
+
import type { PendingDelete } from "@code-link/shared"
|
|
7
|
+
|
|
8
|
+
// Configuration
|
|
9
|
+
export interface Config {
|
|
10
|
+
port: number
|
|
11
|
+
projectHash: string
|
|
12
|
+
projectDir: string | null // Set during handshake if not already determined
|
|
13
|
+
filesDir: string | null // Set during handshake if not already determined
|
|
14
|
+
dangerouslyAutoDelete: boolean
|
|
15
|
+
explicitDir?: string // User-provided directory override
|
|
16
|
+
explicitName?: string // User-provided name override
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// File representations
|
|
20
|
+
export interface FileInfo {
|
|
21
|
+
name: string
|
|
22
|
+
content: string
|
|
23
|
+
modifiedAt?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LocalFile {
|
|
27
|
+
relativePath: string
|
|
28
|
+
content: string
|
|
29
|
+
modifiedAt?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Conflict detection
|
|
33
|
+
export interface Conflict {
|
|
34
|
+
fileName: string
|
|
35
|
+
localContent: string
|
|
36
|
+
remoteContent: string
|
|
37
|
+
localModifiedAt?: number
|
|
38
|
+
remoteModifiedAt?: number
|
|
39
|
+
lastSyncedAt?: number // Timestamp of last successful sync from CLI perspective
|
|
40
|
+
/**
|
|
41
|
+
* True when the local file still matches the last persisted hash.
|
|
42
|
+
* Used for auto-resolution heuristics.
|
|
43
|
+
*/
|
|
44
|
+
localClean?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ConflictResolution {
|
|
48
|
+
conflicts: Conflict[]
|
|
49
|
+
writes: FileInfo[]
|
|
50
|
+
localOnly: FileInfo[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Watcher events
|
|
54
|
+
export type WatcherEventKind = "add" | "change" | "delete"
|
|
55
|
+
|
|
56
|
+
export interface WatcherEvent {
|
|
57
|
+
kind: WatcherEventKind
|
|
58
|
+
relativePath: string
|
|
59
|
+
content?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Conflict version data
|
|
63
|
+
export interface ConflictVersionRequest {
|
|
64
|
+
fileName: string
|
|
65
|
+
lastSyncedAt?: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ConflictVersionData {
|
|
69
|
+
fileName: string
|
|
70
|
+
latestRemoteVersionMs?: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// WebSocket messages (incoming from plugin)
|
|
74
|
+
export type IncomingMessage =
|
|
75
|
+
| { type: "handshake"; projectId: string; projectName: string }
|
|
76
|
+
| { type: "request-files" }
|
|
77
|
+
| { type: "file-list"; files: FileInfo[] }
|
|
78
|
+
| { type: "file-change"; fileName: string; content: string }
|
|
79
|
+
| { type: "file-delete"; fileNames: string[]; requireConfirmation?: boolean }
|
|
80
|
+
| { type: "delete-confirmed"; fileNames: string[] }
|
|
81
|
+
| { type: "delete-cancelled"; files: PendingDelete[] }
|
|
82
|
+
| { type: "file-synced"; fileName: string; remoteModifiedAt: number }
|
|
83
|
+
| {
|
|
84
|
+
type: "conflicts-resolved"
|
|
85
|
+
resolution: "local" | "remote"
|
|
86
|
+
}
|
|
87
|
+
| {
|
|
88
|
+
type: "conflict-version-response"
|
|
89
|
+
versions: ConflictVersionData[]
|
|
90
|
+
}
|
|
91
|
+
| { type: "error"; fileName?: string; message: string }
|
|
92
|
+
|
|
93
|
+
// WebSocket messages (outgoing to plugin)
|
|
94
|
+
export type OutgoingMessage =
|
|
95
|
+
| { type: "request-files" }
|
|
96
|
+
| { type: "file-list"; files: FileInfo[] }
|
|
97
|
+
| { type: "file-change"; fileName: string; content: string }
|
|
98
|
+
| {
|
|
99
|
+
type: "file-delete"
|
|
100
|
+
fileNames: string[]
|
|
101
|
+
requireConfirmation?: boolean
|
|
102
|
+
}
|
|
103
|
+
| { type: "conflicts-detected"; conflicts: Conflict[] }
|
|
104
|
+
| {
|
|
105
|
+
type: "conflict-version-request"
|
|
106
|
+
conflicts: ConflictVersionRequest[]
|
|
107
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hashFileContent,
|
|
3
|
+
loadPersistedState,
|
|
4
|
+
savePersistedState,
|
|
5
|
+
type PersistedFileState,
|
|
6
|
+
} from "./state-persistence.js"
|
|
7
|
+
|
|
8
|
+
export interface FileSyncMetadata {
|
|
9
|
+
localHash: string
|
|
10
|
+
lastSyncedHash: string
|
|
11
|
+
lastRemoteTimestamp?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class FileMetadataCache {
|
|
15
|
+
private metadata = new Map<string, FileSyncMetadata>()
|
|
16
|
+
private persisted = new Map<string, PersistedFileState>()
|
|
17
|
+
private projectDir: string | null = null
|
|
18
|
+
private initialized = false
|
|
19
|
+
private pendingPersist: Promise<void> | null = null
|
|
20
|
+
|
|
21
|
+
async initialize(projectDir: string): Promise<void> {
|
|
22
|
+
if (this.initialized && this.projectDir === projectDir) {
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
this.projectDir = projectDir
|
|
27
|
+
const loaded = await loadPersistedState(projectDir)
|
|
28
|
+
this.persisted = loaded
|
|
29
|
+
this.metadata = new Map()
|
|
30
|
+
|
|
31
|
+
for (const [fileName, state] of loaded.entries()) {
|
|
32
|
+
this.metadata.set(fileName, {
|
|
33
|
+
localHash: state.contentHash,
|
|
34
|
+
lastSyncedHash: state.contentHash,
|
|
35
|
+
lastRemoteTimestamp: state.timestamp,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
this.initialized = true
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get(fileName: string): FileSyncMetadata | undefined {
|
|
43
|
+
return this.metadata.get(fileName)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
has(fileName: string): boolean {
|
|
47
|
+
return this.metadata.has(fileName)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
size(): number {
|
|
51
|
+
return this.metadata.size
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
getPersistedState(): Map<string, PersistedFileState> {
|
|
55
|
+
return this.persisted
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
recordRemoteWrite(
|
|
59
|
+
fileName: string,
|
|
60
|
+
content: string,
|
|
61
|
+
remoteModifiedAt: number
|
|
62
|
+
): void {
|
|
63
|
+
const contentHash = hashFileContent(content)
|
|
64
|
+
this.metadata.set(fileName, {
|
|
65
|
+
localHash: contentHash,
|
|
66
|
+
lastSyncedHash: contentHash,
|
|
67
|
+
lastRemoteTimestamp: remoteModifiedAt,
|
|
68
|
+
})
|
|
69
|
+
this.persisted.set(fileName, {
|
|
70
|
+
contentHash,
|
|
71
|
+
timestamp: remoteModifiedAt,
|
|
72
|
+
})
|
|
73
|
+
this.schedulePersist()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
recordSyncedSnapshot(
|
|
77
|
+
fileName: string,
|
|
78
|
+
contentHash: string,
|
|
79
|
+
remoteModifiedAt: number
|
|
80
|
+
): void {
|
|
81
|
+
this.metadata.set(fileName, {
|
|
82
|
+
localHash: contentHash,
|
|
83
|
+
lastSyncedHash: contentHash,
|
|
84
|
+
lastRemoteTimestamp: remoteModifiedAt,
|
|
85
|
+
})
|
|
86
|
+
this.persisted.set(fileName, {
|
|
87
|
+
contentHash,
|
|
88
|
+
timestamp: remoteModifiedAt,
|
|
89
|
+
})
|
|
90
|
+
this.schedulePersist()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
recordDelete(fileName: string): void {
|
|
94
|
+
this.metadata.delete(fileName)
|
|
95
|
+
this.persisted.delete(fileName)
|
|
96
|
+
this.schedulePersist()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async flush(): Promise<void> {
|
|
100
|
+
if (this.pendingPersist) {
|
|
101
|
+
await this.pendingPersist
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private schedulePersist(): void {
|
|
106
|
+
if (!this.projectDir) {
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!this.pendingPersist) {
|
|
111
|
+
this.pendingPersist = (async () => {
|
|
112
|
+
try {
|
|
113
|
+
await Promise.resolve()
|
|
114
|
+
await savePersistedState(this.projectDir as string, this.persisted)
|
|
115
|
+
} finally {
|
|
116
|
+
this.pendingPersist = null
|
|
117
|
+
}
|
|
118
|
+
})()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
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 hash of file content for comparison
|
|
76
|
+
*/
|
|
77
|
+
function hashContent(content: string): string {
|
|
78
|
+
return createHash("sha256").update(content).digest("hex")
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Generate a deterministic port number from a project hash
|
|
83
|
+
* Port range: 3847-4096 (250 possible ports)
|
|
84
|
+
* Uses simple hash to match client-side implementation
|
|
85
|
+
*/
|
|
86
|
+
export function getPortFromHash(projectHash: string): number {
|
|
87
|
+
let hash = 0
|
|
88
|
+
for (let i = 0; i < projectHash.length; i++) {
|
|
89
|
+
const char = projectHash.charCodeAt(i)
|
|
90
|
+
hash = (hash << 5) - hash + char
|
|
91
|
+
hash = hash & hash // Convert to 32bit integer
|
|
92
|
+
}
|
|
93
|
+
const portOffset = Math.abs(hash) % 250
|
|
94
|
+
return 3847 + portOffset
|
|
95
|
+
}
|