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,158 +0,0 @@
|
|
|
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 { debug, 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
|
-
debug(`Plugin disconnected while waiting for delete confirmation: ${fileName}`)
|
|
59
|
-
return false
|
|
60
|
-
}
|
|
61
|
-
throw err
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
await sendMessage(socket, {
|
|
66
|
-
type: "file-delete",
|
|
67
|
-
fileNames: [fileName],
|
|
68
|
-
requireConfirmation: false,
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
return true
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Sends conflicts to the plugin and awaits user resolutions
|
|
76
|
-
*/
|
|
77
|
-
async requestConflictDecisions(
|
|
78
|
-
socket: WebSocket | null,
|
|
79
|
-
conflicts: Conflict[]
|
|
80
|
-
): Promise<Map<string, "local" | "remote">> {
|
|
81
|
-
if (!socket) {
|
|
82
|
-
throw new Error("Cannot request conflict decision: plugin not connected")
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
if (conflicts.length === 0) {
|
|
86
|
-
return new Map()
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const pending = conflicts.map((conflict) => ({
|
|
90
|
-
fileName: conflict.fileName,
|
|
91
|
-
promise: this.awaitConfirmation<"local" | "remote">(
|
|
92
|
-
`conflict:${conflict.fileName}`,
|
|
93
|
-
"conflict resolution"
|
|
94
|
-
),
|
|
95
|
-
}))
|
|
96
|
-
|
|
97
|
-
await sendMessage(socket, {
|
|
98
|
-
type: "conflicts-detected",
|
|
99
|
-
conflicts,
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
try {
|
|
103
|
-
const results = await Promise.all(
|
|
104
|
-
pending.map(
|
|
105
|
-
async ({ fileName, promise }) => [fileName, await promise] as const
|
|
106
|
-
)
|
|
107
|
-
)
|
|
108
|
-
|
|
109
|
-
return new Map(results)
|
|
110
|
-
} catch (err) {
|
|
111
|
-
if (err instanceof PluginDisconnectedError) {
|
|
112
|
-
debug("Plugin disconnected while awaiting conflict decisions")
|
|
113
|
-
return new Map()
|
|
114
|
-
}
|
|
115
|
-
throw err
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Generic confirmation awaiter
|
|
121
|
-
*/
|
|
122
|
-
private awaitConfirmation<T>(
|
|
123
|
-
actionId: string,
|
|
124
|
-
description: string
|
|
125
|
-
): Promise<T> {
|
|
126
|
-
return new Promise<T>((resolve, reject) => {
|
|
127
|
-
this.pendingActions.set(actionId, { resolve, reject })
|
|
128
|
-
debug(`Awaiting ${description}: ${actionId}`)
|
|
129
|
-
})
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Handle incoming confirmation response
|
|
134
|
-
*/
|
|
135
|
-
handleConfirmation<T>(actionId: string, value: T): boolean {
|
|
136
|
-
const pending = this.pendingActions.get(actionId)
|
|
137
|
-
if (!pending) {
|
|
138
|
-
debug(`Unexpected confirmation for ${actionId}`)
|
|
139
|
-
return false
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
this.pendingActions.delete(actionId)
|
|
143
|
-
pending.resolve(value)
|
|
144
|
-
debug(`Confirmed: ${actionId}`)
|
|
145
|
-
return true
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Cleanup all pending actions (e.g., on disconnect)
|
|
150
|
-
*/
|
|
151
|
-
cleanup(): void {
|
|
152
|
-
for (const [actionId, pending] of this.pendingActions.entries()) {
|
|
153
|
-
pending.reject(new PluginDisconnectedError())
|
|
154
|
-
debug(`Cancelled pending action: ${actionId}`)
|
|
155
|
-
}
|
|
156
|
-
this.pendingActions.clear()
|
|
157
|
-
}
|
|
158
|
-
}
|
|
@@ -1,74 +0,0 @@
|
|
|
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/helpers/watcher.ts
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
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 { 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
|
-
debug(`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
|
-
debug(`Renamed ${rawRelativePath} -> ${relativePath}`)
|
|
66
|
-
effectiveAbsolutePath = newAbsolutePath
|
|
67
|
-
} catch (err) {
|
|
68
|
-
warn(`Failed to rename ${rawRelativePath}`, err)
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
let content: string | undefined
|
|
73
|
-
if (kind !== "delete") {
|
|
74
|
-
try {
|
|
75
|
-
content = await fs.readFile(effectiveAbsolutePath, "utf-8")
|
|
76
|
-
} catch (err) {
|
|
77
|
-
debug(`Failed to read file ${relativePath}:`, err)
|
|
78
|
-
return
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const event: WatcherEvent = {
|
|
83
|
-
kind,
|
|
84
|
-
relativePath,
|
|
85
|
-
content,
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
debug(`Watcher event: ${kind} ${relativePath}`)
|
|
89
|
-
|
|
90
|
-
for (const handler of handlers) {
|
|
91
|
-
handler(event)
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
watcher.on("add", (filePath) => emitEvent("add", filePath))
|
|
96
|
-
watcher.on("change", (filePath) => emitEvent("change", filePath))
|
|
97
|
-
watcher.on("unlink", (filePath) => emitEvent("delete", filePath))
|
|
98
|
-
|
|
99
|
-
return {
|
|
100
|
-
on(event: "change", handler: (event: WatcherEvent) => void): void {
|
|
101
|
-
if (event === "change") {
|
|
102
|
-
handlers.push(handler)
|
|
103
|
-
}
|
|
104
|
-
},
|
|
105
|
-
|
|
106
|
-
async close(): Promise<void> {
|
|
107
|
-
await watcher.close()
|
|
108
|
-
},
|
|
109
|
-
}
|
|
110
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
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, banner, warn } from "./utils/logging.js"
|
|
14
|
-
import { getPortFromHash } from "@code-link/shared"
|
|
15
|
-
import { getProjectHashFromCwd } from "./utils/project.js"
|
|
16
|
-
|
|
17
|
-
const program = new Command()
|
|
18
|
-
|
|
19
|
-
program.exitOverride((err) => {
|
|
20
|
-
if (err.code === "commander.missingArgument") {
|
|
21
|
-
console.error("Missing Project ID. Copy command via Code Link Plugin.")
|
|
22
|
-
process.exit(err.exitCode ?? 1)
|
|
23
|
-
}
|
|
24
|
-
throw err
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
program
|
|
28
|
-
.name("code-link")
|
|
29
|
-
.description("Sync Framer code components to your local filesystem")
|
|
30
|
-
.version("0.1.0")
|
|
31
|
-
.argument(
|
|
32
|
-
"[projectHash]",
|
|
33
|
-
"Framer Project ID Hash (auto-detected from package.json if omitted)"
|
|
34
|
-
)
|
|
35
|
-
.option("-n, --name <name>", "Project name (optional)")
|
|
36
|
-
.option("-d, --dir <directory>", "Explicit project directory")
|
|
37
|
-
.option("-v, --verbose", "Enable verbose logging")
|
|
38
|
-
.option("--log-level <level>", "Set log level (debug, info, warn, error)")
|
|
39
|
-
.option(
|
|
40
|
-
"--dangerously-auto-delete",
|
|
41
|
-
"Automatically delete remote files without confirmation"
|
|
42
|
-
)
|
|
43
|
-
.action(async (projectHash: string | undefined, options) => {
|
|
44
|
-
// If no projectHash provided, try to read from cwd's package.json
|
|
45
|
-
if (!projectHash) {
|
|
46
|
-
const detected = await getProjectHashFromCwd()
|
|
47
|
-
if (detected) {
|
|
48
|
-
projectHash = detected
|
|
49
|
-
} else {
|
|
50
|
-
console.error(
|
|
51
|
-
"No project ID provided and no framerProjectId found in package.json."
|
|
52
|
-
)
|
|
53
|
-
console.error(
|
|
54
|
-
"Either run this command from a project directory or copy the command from the Code Link Plugin."
|
|
55
|
-
)
|
|
56
|
-
process.exit(1)
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Auto-enable debug in development unless overridden
|
|
61
|
-
const isDev = process.env.NODE_ENV === "development"
|
|
62
|
-
|
|
63
|
-
if (options.logLevel) {
|
|
64
|
-
const levelMap: Record<string, LogLevel> = {
|
|
65
|
-
debug: LogLevel.DEBUG,
|
|
66
|
-
info: LogLevel.INFO,
|
|
67
|
-
warn: LogLevel.WARN,
|
|
68
|
-
error: LogLevel.ERROR,
|
|
69
|
-
}
|
|
70
|
-
const level = levelMap[options.logLevel.toLowerCase()]
|
|
71
|
-
if (level !== undefined) {
|
|
72
|
-
setLogLevel(level)
|
|
73
|
-
}
|
|
74
|
-
} else if (options.verbose || isDev) {
|
|
75
|
-
setLogLevel(LogLevel.DEBUG)
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const port = getPortFromHash(projectHash)
|
|
79
|
-
|
|
80
|
-
// Show startup banner
|
|
81
|
-
banner("0.1.3", port)
|
|
82
|
-
|
|
83
|
-
const config: Config = {
|
|
84
|
-
port,
|
|
85
|
-
projectHash,
|
|
86
|
-
projectDir: null, // Will be set during handshake
|
|
87
|
-
filesDir: null, // Will be set during handshake
|
|
88
|
-
dangerouslyAutoDelete: options.dangerouslyAutoDelete ?? false,
|
|
89
|
-
explicitDir: options.dir,
|
|
90
|
-
explicitName: options.name,
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (config.dangerouslyAutoDelete) {
|
|
94
|
-
warn(
|
|
95
|
-
"Auto-delete mode enabled - files will be deleted without confirmation"
|
|
96
|
-
)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
try {
|
|
100
|
-
await start(config)
|
|
101
|
-
} catch (err) {
|
|
102
|
-
// Error already logged, exit cleanly
|
|
103
|
-
process.exit(1)
|
|
104
|
-
}
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
program.parse()
|
|
108
|
-
|
|
109
|
-
// Export for programmatic usage
|
|
110
|
-
export { start } from "./controller.js"
|
|
111
|
-
export type { Config } from "./types.js"
|
package/src/types.ts
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Core types for the controller-centric CLI architecture
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { PendingDelete } from "@code-link/shared"
|
|
6
|
-
|
|
7
|
-
// Configuration
|
|
8
|
-
export interface Config {
|
|
9
|
-
port: number
|
|
10
|
-
projectHash: string
|
|
11
|
-
projectDir: string | null // Set during handshake if not already determined
|
|
12
|
-
filesDir: string | null // Set during handshake if not already determined
|
|
13
|
-
dangerouslyAutoDelete: boolean
|
|
14
|
-
explicitDir?: string // User-provided directory override
|
|
15
|
-
explicitName?: string // User-provided name override
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// File representations
|
|
19
|
-
export interface FileInfo {
|
|
20
|
-
name: string
|
|
21
|
-
content: string
|
|
22
|
-
modifiedAt?: number
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface LocalFile {
|
|
26
|
-
relativePath: string
|
|
27
|
-
content: string
|
|
28
|
-
modifiedAt?: number
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Conflict detection
|
|
32
|
-
// Deletions are represented by null content
|
|
33
|
-
// For AI: Do NOT add remoteDeletes/localDeletes arrays - use localContent/remoteContent === null
|
|
34
|
-
export interface Conflict {
|
|
35
|
-
fileName: string
|
|
36
|
-
/** null means the file was deleted locally */
|
|
37
|
-
localContent: string | null
|
|
38
|
-
/** null means the file was deleted in Framer */
|
|
39
|
-
remoteContent: string | null
|
|
40
|
-
localModifiedAt?: number
|
|
41
|
-
remoteModifiedAt?: number
|
|
42
|
-
lastSyncedAt?: number // Timestamp of last successful sync from CLI perspective
|
|
43
|
-
/**
|
|
44
|
-
* True when the local file still matches the last persisted hash.
|
|
45
|
-
* Used for auto-resolution heuristics.
|
|
46
|
-
*/
|
|
47
|
-
localClean?: boolean
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface ConflictResolution {
|
|
51
|
-
conflicts: Conflict[]
|
|
52
|
-
writes: FileInfo[]
|
|
53
|
-
localOnly: FileInfo[]
|
|
54
|
-
unchanged: FileInfo[]
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Watcher events
|
|
58
|
-
export type WatcherEventKind = "add" | "change" | "delete"
|
|
59
|
-
|
|
60
|
-
export interface WatcherEvent {
|
|
61
|
-
kind: WatcherEventKind
|
|
62
|
-
relativePath: string
|
|
63
|
-
content?: string
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Conflict version data
|
|
67
|
-
export interface ConflictVersionRequest {
|
|
68
|
-
fileName: string
|
|
69
|
-
lastSyncedAt?: number
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export interface ConflictVersionData {
|
|
73
|
-
fileName: string
|
|
74
|
-
latestRemoteVersionMs?: number
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// WebSocket messages (incoming from plugin)
|
|
78
|
-
export type IncomingMessage =
|
|
79
|
-
| { type: "handshake"; projectId: string; projectName: string }
|
|
80
|
-
| { type: "request-files" }
|
|
81
|
-
| { type: "file-list"; files: FileInfo[] }
|
|
82
|
-
| { type: "file-change"; fileName: string; content: string }
|
|
83
|
-
| { type: "file-delete"; fileNames: string[]; requireConfirmation?: boolean }
|
|
84
|
-
| { type: "delete-confirmed"; fileNames: string[] }
|
|
85
|
-
| { type: "delete-cancelled"; files: PendingDelete[] }
|
|
86
|
-
| { type: "file-synced"; fileName: string; remoteModifiedAt: number }
|
|
87
|
-
| {
|
|
88
|
-
type: "conflicts-resolved"
|
|
89
|
-
resolution: "local" | "remote"
|
|
90
|
-
}
|
|
91
|
-
| {
|
|
92
|
-
type: "conflict-version-response"
|
|
93
|
-
versions: ConflictVersionData[]
|
|
94
|
-
}
|
|
95
|
-
| { type: "error"; fileName?: string; message: string }
|
|
96
|
-
|
|
97
|
-
// WebSocket messages (outgoing to plugin)
|
|
98
|
-
export type OutgoingMessage =
|
|
99
|
-
| { type: "request-files" }
|
|
100
|
-
| { type: "file-list"; files: FileInfo[] }
|
|
101
|
-
| { type: "file-change"; fileName: string; content: string }
|
|
102
|
-
| {
|
|
103
|
-
type: "file-delete"
|
|
104
|
-
fileNames: string[]
|
|
105
|
-
requireConfirmation?: boolean
|
|
106
|
-
}
|
|
107
|
-
| { type: "conflicts-detected"; conflicts: Conflict[] }
|
|
108
|
-
| {
|
|
109
|
-
type: "conflict-version-request"
|
|
110
|
-
conflicts: ConflictVersionRequest[]
|
|
111
|
-
}
|
|
112
|
-
| { type: "sync-complete" }
|
|
@@ -1,121 +0,0 @@
|
|
|
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
|
-
}
|