framer-code-link 0.1.3 → 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} +703 -260
- package/package.json +12 -12
- package/src/controller.test.ts +63 -138
- package/src/controller.ts +296 -109
- package/src/helpers/connection.ts +18 -11
- package/src/helpers/files.ts +125 -40
- package/src/helpers/installer.ts +12 -16
- package/src/helpers/user-actions.ts +7 -11
- package/src/helpers/watcher.test.ts +74 -0
- package/src/helpers/watcher.ts +4 -9
- package/src/index.ts +7 -4
- package/src/types.ts +8 -3
- package/src/utils/{hashing.ts → hash-tracker.ts} +1 -17
- package/src/utils/logging.ts +194 -6
- package/src/utils/project.ts +15 -8
- package/dist/project-DhpsFg77.js +0 -53
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,10 +29,14 @@ 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
|
-
|
|
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
|
|
37
40
|
localModifiedAt?: number
|
|
38
41
|
remoteModifiedAt?: number
|
|
39
42
|
lastSyncedAt?: number // Timestamp of last successful sync from CLI perspective
|
|
@@ -48,6 +51,7 @@ export interface ConflictResolution {
|
|
|
48
51
|
conflicts: Conflict[]
|
|
49
52
|
writes: FileInfo[]
|
|
50
53
|
localOnly: FileInfo[]
|
|
54
|
+
unchanged: FileInfo[]
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
// Watcher events
|
|
@@ -105,3 +109,4 @@ export type OutgoingMessage =
|
|
|
105
109
|
type: "conflict-version-request"
|
|
106
110
|
conflicts: ConflictVersionRequest[]
|
|
107
111
|
}
|
|
112
|
+
| { type: "sync-complete" }
|
|
@@ -72,24 +72,8 @@ export function createHashTracker(): HashTracker {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
|
-
* Computes a hash of file content for comparison
|
|
75
|
+
* Computes a SHA256 hash of file content for comparison
|
|
76
76
|
*/
|
|
77
77
|
function hashContent(content: string): string {
|
|
78
78
|
return createHash("sha256").update(content).digest("hex")
|
|
79
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
|
-
}
|
package/src/utils/logging.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Logging utilities for consistent output
|
|
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
|
|
3
10
|
*/
|
|
4
11
|
|
|
12
|
+
import pc from "picocolors"
|
|
13
|
+
|
|
5
14
|
export enum LogLevel {
|
|
6
15
|
DEBUG = 0,
|
|
7
16
|
INFO = 1,
|
|
@@ -11,37 +20,216 @@ export enum LogLevel {
|
|
|
11
20
|
|
|
12
21
|
let currentLevel = LogLevel.INFO
|
|
13
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
|
+
|
|
14
47
|
export function setLogLevel(level: LogLevel): void {
|
|
15
48
|
currentLevel = level
|
|
16
49
|
}
|
|
17
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
|
+
*/
|
|
18
103
|
export function debug(message: string, ...args: unknown[]): void {
|
|
19
104
|
if (currentLevel <= LogLevel.DEBUG) {
|
|
20
|
-
console.debug(`[DEBUG] ${message}
|
|
105
|
+
console.debug(pc.dim(`[DEBUG] ${message}`), ...args)
|
|
21
106
|
}
|
|
22
107
|
}
|
|
23
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Info-level logging - shown by default, no prefix
|
|
111
|
+
*/
|
|
24
112
|
export function info(message: string, ...args: unknown[]): void {
|
|
25
113
|
if (currentLevel <= LogLevel.INFO) {
|
|
26
|
-
|
|
114
|
+
const formatted = args.length > 0 ? `${message} ${args.join(" ")}` : message
|
|
115
|
+
logWithDedupe(formatted, () => console.log(formatted))
|
|
27
116
|
}
|
|
28
117
|
}
|
|
29
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Warning-level logging
|
|
121
|
+
*/
|
|
30
122
|
export function warn(message: string, ...args: unknown[]): void {
|
|
31
123
|
if (currentLevel <= LogLevel.WARN) {
|
|
32
|
-
|
|
124
|
+
if (message === lastMessage) return // Skip exact duplicates silently
|
|
125
|
+
flushDedupe()
|
|
126
|
+
lastMessage = message
|
|
127
|
+
lastMessageCount = 1
|
|
128
|
+
console.warn(pc.yellow(`⚠ ${message}`), ...args)
|
|
33
129
|
}
|
|
34
130
|
}
|
|
35
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Error-level logging
|
|
134
|
+
*/
|
|
36
135
|
export function error(message: string, ...args: unknown[]): void {
|
|
37
136
|
if (currentLevel <= LogLevel.ERROR) {
|
|
38
|
-
|
|
137
|
+
flushDedupe()
|
|
138
|
+
console.error(pc.red(`✗ ${message}`), ...args)
|
|
39
139
|
}
|
|
40
140
|
}
|
|
41
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Success message with checkmark
|
|
144
|
+
*/
|
|
42
145
|
export function success(message: string, ...args: unknown[]): void {
|
|
43
146
|
if (currentLevel <= LogLevel.INFO) {
|
|
44
|
-
|
|
147
|
+
flushDedupe()
|
|
148
|
+
console.log(pc.green(`✓ ${message}`), ...args)
|
|
45
149
|
}
|
|
46
150
|
}
|
|
47
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/project.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import fs from "fs/promises"
|
|
2
2
|
import path from "path"
|
|
3
|
+
import { shortProjectHash } from "@code-link/shared"
|
|
3
4
|
|
|
4
5
|
interface PackageJson {
|
|
5
|
-
|
|
6
|
+
shortProjectHash?: string // derived short id (8 chars base58)
|
|
7
|
+
framerProjectHash?: string // full 64-char hex hash from Framer API
|
|
6
8
|
framerProjectName?: string
|
|
7
9
|
name?: string
|
|
8
10
|
version?: string
|
|
@@ -19,8 +21,8 @@ export function toPackageName(name: string): string {
|
|
|
19
21
|
|
|
20
22
|
export function toDirName(name: string): string {
|
|
21
23
|
return name
|
|
22
|
-
.replace(/[^a-zA-Z0-9-]/g, "-")
|
|
23
|
-
.replace(
|
|
24
|
+
.replace(/[^a-zA-Z0-9- ]/g, "-")
|
|
25
|
+
.replace(/^[-\s]+|[-\s]+$/g, "")
|
|
24
26
|
.replace(/-+/g, "-")
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -29,7 +31,8 @@ export async function getProjectHashFromCwd(): Promise<string | null> {
|
|
|
29
31
|
const packageJsonPath = path.join(process.cwd(), "package.json")
|
|
30
32
|
const content = await fs.readFile(packageJsonPath, "utf-8")
|
|
31
33
|
const pkg = JSON.parse(content) as PackageJson
|
|
32
|
-
|
|
34
|
+
// Return short id for port derivation
|
|
35
|
+
return pkg.shortProjectHash ?? null
|
|
33
36
|
} catch {
|
|
34
37
|
return null
|
|
35
38
|
}
|
|
@@ -60,14 +63,16 @@ export async function findOrCreateProjectDir(
|
|
|
60
63
|
|
|
61
64
|
const dirName = toDirName(projectName)
|
|
62
65
|
const pkgName = toPackageName(projectName)
|
|
63
|
-
const
|
|
66
|
+
const shortId = shortProjectHash(projectHash)
|
|
67
|
+
const projectDir = path.join(cwd, dirName || shortId)
|
|
64
68
|
|
|
65
69
|
await fs.mkdir(path.join(projectDir, "files"), { recursive: true })
|
|
66
70
|
const pkg: PackageJson = {
|
|
67
|
-
name: pkgName ||
|
|
71
|
+
name: pkgName || shortId,
|
|
68
72
|
version: "1.0.0",
|
|
69
73
|
private: true,
|
|
70
|
-
|
|
74
|
+
shortProjectHash: shortId,
|
|
75
|
+
framerProjectHash: projectHash,
|
|
71
76
|
framerProjectName: projectName,
|
|
72
77
|
}
|
|
73
78
|
await fs.writeFile(
|
|
@@ -106,7 +111,9 @@ async function matchesProject(
|
|
|
106
111
|
try {
|
|
107
112
|
const content = await fs.readFile(packageJsonPath, "utf-8")
|
|
108
113
|
const pkg = JSON.parse(content) as PackageJson
|
|
109
|
-
|
|
114
|
+
const inputShort = shortProjectHash(projectHash)
|
|
115
|
+
// Match on short id (handles both full hash input and short id input)
|
|
116
|
+
return pkg.shortProjectHash === inputShort
|
|
110
117
|
} catch {
|
|
111
118
|
return false
|
|
112
119
|
}
|
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 };
|