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,62 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logging utilities for consistent output
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export enum LogLevel {
|
|
6
|
+
DEBUG = 0,
|
|
7
|
+
INFO = 1,
|
|
8
|
+
WARN = 2,
|
|
9
|
+
ERROR = 3,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let currentLevel = LogLevel.INFO
|
|
13
|
+
|
|
14
|
+
export function setLogLevel(level: LogLevel): void {
|
|
15
|
+
currentLevel = level
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function debug(message: string, ...args: unknown[]): void {
|
|
19
|
+
if (currentLevel <= LogLevel.DEBUG) {
|
|
20
|
+
console.debug(`[DEBUG] ${message}`, ...args)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function info(message: string, ...args: unknown[]): void {
|
|
25
|
+
if (currentLevel <= LogLevel.INFO) {
|
|
26
|
+
console.info(`[INFO] ${message}`, ...args)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function warn(message: string, ...args: unknown[]): void {
|
|
31
|
+
if (currentLevel <= LogLevel.WARN) {
|
|
32
|
+
console.warn(`[WARN] ${message}`, ...args)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function error(message: string, ...args: unknown[]): void {
|
|
37
|
+
if (currentLevel <= LogLevel.ERROR) {
|
|
38
|
+
console.error(`[ERROR] ${message}`, ...args)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function success(message: string, ...args: unknown[]): void {
|
|
43
|
+
if (currentLevel <= LogLevel.INFO) {
|
|
44
|
+
console.log(`✓ ${message}`, ...args)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import fs from "fs/promises"
|
|
2
|
+
import path from "path"
|
|
3
|
+
|
|
4
|
+
interface PackageJson {
|
|
5
|
+
framerProjectId?: string
|
|
6
|
+
framerProjectName?: string
|
|
7
|
+
name?: string
|
|
8
|
+
version?: string
|
|
9
|
+
[key: string]: unknown
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function toPackageName(name: string): string {
|
|
13
|
+
return name
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
16
|
+
.replace(/^-+|-+$/g, "")
|
|
17
|
+
.replace(/-+/g, "-")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function findOrCreateProjectDir(
|
|
21
|
+
projectHash: string,
|
|
22
|
+
projectName?: string,
|
|
23
|
+
explicitDir?: string
|
|
24
|
+
): Promise<string> {
|
|
25
|
+
if (explicitDir) {
|
|
26
|
+
const resolved = path.resolve(explicitDir)
|
|
27
|
+
await fs.mkdir(path.join(resolved, "files"), { recursive: true })
|
|
28
|
+
return resolved
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const cwd = process.cwd()
|
|
32
|
+
const existing = await findExistingProjectDir(cwd, projectHash)
|
|
33
|
+
if (existing) {
|
|
34
|
+
return existing
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!projectName) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
"Project name is required when creating a new workspace. Pass --name <project name>."
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const dirName = toPackageName(projectName)
|
|
44
|
+
const projectDir = path.join(cwd, dirName || projectHash.slice(0, 6))
|
|
45
|
+
|
|
46
|
+
await fs.mkdir(path.join(projectDir, "files"), { recursive: true })
|
|
47
|
+
const pkg: PackageJson = {
|
|
48
|
+
name: dirName || projectHash,
|
|
49
|
+
version: "1.0.0",
|
|
50
|
+
private: true,
|
|
51
|
+
framerProjectId: projectHash,
|
|
52
|
+
framerProjectName: projectName,
|
|
53
|
+
}
|
|
54
|
+
await fs.writeFile(
|
|
55
|
+
path.join(projectDir, "package.json"),
|
|
56
|
+
JSON.stringify(pkg, null, 2)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return projectDir
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function findExistingProjectDir(
|
|
63
|
+
baseDir: string,
|
|
64
|
+
projectHash: string
|
|
65
|
+
): Promise<string | null> {
|
|
66
|
+
const candidate = path.join(baseDir, "package.json")
|
|
67
|
+
if (await matchesProject(candidate, projectHash)) {
|
|
68
|
+
return baseDir
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const entries = await fs.readdir(baseDir, { withFileTypes: true })
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
if (!entry.isDirectory()) continue
|
|
74
|
+
const dir = path.join(baseDir, entry.name)
|
|
75
|
+
if (await matchesProject(path.join(dir, "package.json"), projectHash)) {
|
|
76
|
+
return dir
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function matchesProject(
|
|
84
|
+
packageJsonPath: string,
|
|
85
|
+
projectHash: string
|
|
86
|
+
): Promise<boolean> {
|
|
87
|
+
try {
|
|
88
|
+
const content = await fs.readFile(packageJsonPath, "utf-8")
|
|
89
|
+
const pkg = JSON.parse(content) as PackageJson
|
|
90
|
+
return pkg.framerProjectId === projectHash
|
|
91
|
+
} catch {
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"resolveJsonModule": true,
|
|
10
|
+
"outDir": "./dist"
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*"]
|
|
13
|
+
}
|
|
14
|
+
|