diff --git a/.yarn/cache/@babel-generator-npm-7.28.5-fd8f3ae6b1-ae618f0a17.zip b/.yarn/cache/@babel-generator-npm-7.28.5-fd8f3ae6b1-ae618f0a17.zip new file mode 100644 index 000000000..3b704f2c2 Binary files /dev/null and b/.yarn/cache/@babel-generator-npm-7.28.5-fd8f3ae6b1-ae618f0a17.zip differ diff --git a/.yarn/cache/@babel-helper-string-parser-npm-7.27.1-d1471e0598-0ae29cc200.zip b/.yarn/cache/@babel-helper-string-parser-npm-7.27.1-d1471e0598-0ae29cc200.zip new file mode 100644 index 000000000..db113bb54 Binary files /dev/null and b/.yarn/cache/@babel-helper-string-parser-npm-7.27.1-d1471e0598-0ae29cc200.zip differ diff --git a/.yarn/cache/@babel-helper-validator-identifier-npm-7.28.5-1953d49d2b-8e5d9b0133.zip b/.yarn/cache/@babel-helper-validator-identifier-npm-7.28.5-1953d49d2b-8e5d9b0133.zip new file mode 100644 index 000000000..c67a0ac56 Binary files /dev/null and b/.yarn/cache/@babel-helper-validator-identifier-npm-7.28.5-1953d49d2b-8e5d9b0133.zip differ diff --git a/.yarn/cache/@babel-parser-npm-7.28.5-f2345a6b62-8d9bfb437a.zip b/.yarn/cache/@babel-parser-npm-7.28.5-f2345a6b62-8d9bfb437a.zip new file mode 100644 index 000000000..31eab620f Binary files /dev/null and b/.yarn/cache/@babel-parser-npm-7.28.5-f2345a6b62-8d9bfb437a.zip differ diff --git a/.yarn/cache/@babel-types-npm-7.28.5-582d7cca8a-4256bb9fb2.zip b/.yarn/cache/@babel-types-npm-7.28.5-582d7cca8a-4256bb9fb2.zip new file mode 100644 index 000000000..6938c8cff Binary files /dev/null and b/.yarn/cache/@babel-types-npm-7.28.5-582d7cca8a-4256bb9fb2.zip differ diff --git a/.yarn/cache/@emnapi-core-npm-1.7.1-3848c2e48c-260841f6dd.zip b/.yarn/cache/@emnapi-core-npm-1.7.1-3848c2e48c-260841f6dd.zip new file mode 100644 index 000000000..a6afb0263 Binary files /dev/null and b/.yarn/cache/@emnapi-core-npm-1.7.1-3848c2e48c-260841f6dd.zip differ diff --git a/.yarn/cache/@emnapi-runtime-npm-1.7.1-42976fbe7a-6fc83f938e.zip b/.yarn/cache/@emnapi-runtime-npm-1.7.1-42976fbe7a-6fc83f938e.zip new file mode 100644 index 000000000..2b23ed50f Binary files /dev/null and b/.yarn/cache/@emnapi-runtime-npm-1.7.1-42976fbe7a-6fc83f938e.zip differ diff --git a/.yarn/cache/@esbuild-darwin-arm64-npm-0.27.2-d675c4a521-10.zip b/.yarn/cache/@esbuild-darwin-arm64-npm-0.27.2-d675c4a521-10.zip new file mode 100644 index 000000000..df4de2225 Binary files /dev/null and b/.yarn/cache/@esbuild-darwin-arm64-npm-0.27.2-d675c4a521-10.zip differ diff --git a/.yarn/cache/@esbuild-darwin-x64-npm-0.27.2-ae63bf405f-10.zip b/.yarn/cache/@esbuild-darwin-x64-npm-0.27.2-ae63bf405f-10.zip new file mode 100644 index 000000000..987fc4966 Binary files /dev/null and b/.yarn/cache/@esbuild-darwin-x64-npm-0.27.2-ae63bf405f-10.zip differ diff --git a/.yarn/cache/@esbuild-linux-arm64-npm-0.27.2-bf1b0979ac-10.zip b/.yarn/cache/@esbuild-linux-arm64-npm-0.27.2-bf1b0979ac-10.zip new file mode 100644 index 000000000..32c8cc871 Binary files /dev/null and b/.yarn/cache/@esbuild-linux-arm64-npm-0.27.2-bf1b0979ac-10.zip differ diff --git a/.yarn/cache/@esbuild-linux-x64-npm-0.27.2-11f1a3d9db-10.zip b/.yarn/cache/@esbuild-linux-x64-npm-0.27.2-11f1a3d9db-10.zip new file mode 100644 index 000000000..eea4066e3 Binary files /dev/null and b/.yarn/cache/@esbuild-linux-x64-npm-0.27.2-11f1a3d9db-10.zip differ diff --git a/.yarn/cache/@esbuild-win32-arm64-npm-0.27.2-78a0e828ec-10.zip b/.yarn/cache/@esbuild-win32-arm64-npm-0.27.2-78a0e828ec-10.zip new file mode 100644 index 000000000..e3bec141d Binary files /dev/null and b/.yarn/cache/@esbuild-win32-arm64-npm-0.27.2-78a0e828ec-10.zip differ diff --git a/.yarn/cache/@esbuild-win32-x64-npm-0.27.2-fb03408001-10.zip b/.yarn/cache/@esbuild-win32-x64-npm-0.27.2-fb03408001-10.zip new file mode 100644 index 000000000..549aacedc Binary files /dev/null and b/.yarn/cache/@esbuild-win32-x64-npm-0.27.2-fb03408001-10.zip differ diff --git a/.yarn/cache/@jridgewell-gen-mapping-npm-0.3.13-9bd96ac800-902f8261dc.zip b/.yarn/cache/@jridgewell-gen-mapping-npm-0.3.13-9bd96ac800-902f8261dc.zip new file mode 100644 index 000000000..e130971fd Binary files /dev/null and b/.yarn/cache/@jridgewell-gen-mapping-npm-0.3.13-9bd96ac800-902f8261dc.zip differ diff --git a/.yarn/cache/@jridgewell-trace-mapping-npm-0.3.31-1ae81d75ac-da0283270e.zip b/.yarn/cache/@jridgewell-trace-mapping-npm-0.3.31-1ae81d75ac-da0283270e.zip new file mode 100644 index 000000000..d61ababcd Binary files /dev/null and b/.yarn/cache/@jridgewell-trace-mapping-npm-0.3.31-1ae81d75ac-da0283270e.zip differ diff --git a/.yarn/cache/@napi-rs-wasm-runtime-npm-1.1.0-0e9acce7b0-87c7ab4685.zip b/.yarn/cache/@napi-rs-wasm-runtime-npm-1.1.0-0e9acce7b0-87c7ab4685.zip new file mode 100644 index 000000000..f18898529 Binary files /dev/null and b/.yarn/cache/@napi-rs-wasm-runtime-npm-1.1.0-0e9acce7b0-87c7ab4685.zip differ diff --git a/.yarn/cache/@oxc-project-types-npm-0.101.0-8e969d93d4-43a29933af.zip b/.yarn/cache/@oxc-project-types-npm-0.101.0-8e969d93d4-43a29933af.zip new file mode 100644 index 000000000..aeb9b821b Binary files /dev/null and b/.yarn/cache/@oxc-project-types-npm-0.101.0-8e969d93d4-43a29933af.zip differ diff --git a/.yarn/cache/@oxc-project-types-npm-0.103.0-fbdf59d6b5-3c9a1368fb.zip b/.yarn/cache/@oxc-project-types-npm-0.103.0-fbdf59d6b5-3c9a1368fb.zip new file mode 100644 index 000000000..ef5e540af Binary files /dev/null and b/.yarn/cache/@oxc-project-types-npm-0.103.0-fbdf59d6b5-3c9a1368fb.zip differ diff --git a/.yarn/cache/@quansync-fs-npm-1.0.0-869f097647-8a27892b13.zip b/.yarn/cache/@quansync-fs-npm-1.0.0-869f097647-8a27892b13.zip new file mode 100644 index 000000000..7281201bb Binary files /dev/null and b/.yarn/cache/@quansync-fs-npm-1.0.0-869f097647-8a27892b13.zip differ diff --git a/.yarn/cache/@rolldown-binding-darwin-arm64-npm-1.0.0-beta.53-81c8545753-10.zip b/.yarn/cache/@rolldown-binding-darwin-arm64-npm-1.0.0-beta.53-81c8545753-10.zip new file mode 100644 index 000000000..840837c02 Binary files /dev/null and b/.yarn/cache/@rolldown-binding-darwin-arm64-npm-1.0.0-beta.53-81c8545753-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-darwin-arm64-npm-1.0.0-beta.55-0d736b0ec0-10.zip b/.yarn/cache/@rolldown-binding-darwin-arm64-npm-1.0.0-beta.55-0d736b0ec0-10.zip new file mode 100644 index 000000000..5fc750814 Binary files /dev/null and b/.yarn/cache/@rolldown-binding-darwin-arm64-npm-1.0.0-beta.55-0d736b0ec0-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-darwin-x64-npm-1.0.0-beta.53-afe78a815c-10.zip b/.yarn/cache/@rolldown-binding-darwin-x64-npm-1.0.0-beta.53-afe78a815c-10.zip new file mode 100644 index 000000000..cfd2f6b0d Binary files /dev/null and b/.yarn/cache/@rolldown-binding-darwin-x64-npm-1.0.0-beta.53-afe78a815c-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-darwin-x64-npm-1.0.0-beta.55-d380c34427-10.zip b/.yarn/cache/@rolldown-binding-darwin-x64-npm-1.0.0-beta.55-d380c34427-10.zip new file mode 100644 index 000000000..5a1672bf8 Binary files /dev/null and b/.yarn/cache/@rolldown-binding-darwin-x64-npm-1.0.0-beta.55-d380c34427-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-linux-arm64-gnu-npm-1.0.0-beta.53-5250bb6f3b-10.zip b/.yarn/cache/@rolldown-binding-linux-arm64-gnu-npm-1.0.0-beta.53-5250bb6f3b-10.zip new file mode 100644 index 000000000..42ef9f681 Binary files /dev/null and b/.yarn/cache/@rolldown-binding-linux-arm64-gnu-npm-1.0.0-beta.53-5250bb6f3b-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-linux-arm64-gnu-npm-1.0.0-beta.55-9d8b2a6c10-10.zip b/.yarn/cache/@rolldown-binding-linux-arm64-gnu-npm-1.0.0-beta.55-9d8b2a6c10-10.zip new file mode 100644 index 000000000..8740050bc Binary files /dev/null and b/.yarn/cache/@rolldown-binding-linux-arm64-gnu-npm-1.0.0-beta.55-9d8b2a6c10-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-linux-arm64-musl-npm-1.0.0-beta.53-1bf300c71a-10.zip b/.yarn/cache/@rolldown-binding-linux-arm64-musl-npm-1.0.0-beta.53-1bf300c71a-10.zip new file mode 100644 index 000000000..b6ffae765 Binary files /dev/null and b/.yarn/cache/@rolldown-binding-linux-arm64-musl-npm-1.0.0-beta.53-1bf300c71a-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-linux-arm64-musl-npm-1.0.0-beta.55-32e5dc4380-10.zip b/.yarn/cache/@rolldown-binding-linux-arm64-musl-npm-1.0.0-beta.55-32e5dc4380-10.zip new file mode 100644 index 000000000..4bf443059 Binary files /dev/null and b/.yarn/cache/@rolldown-binding-linux-arm64-musl-npm-1.0.0-beta.55-32e5dc4380-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-linux-x64-gnu-npm-1.0.0-beta.53-b563c726f2-10.zip b/.yarn/cache/@rolldown-binding-linux-x64-gnu-npm-1.0.0-beta.53-b563c726f2-10.zip new file mode 100644 index 000000000..79c0447db Binary files /dev/null and b/.yarn/cache/@rolldown-binding-linux-x64-gnu-npm-1.0.0-beta.53-b563c726f2-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-linux-x64-gnu-npm-1.0.0-beta.55-894a162cbe-10.zip b/.yarn/cache/@rolldown-binding-linux-x64-gnu-npm-1.0.0-beta.55-894a162cbe-10.zip new file mode 100644 index 000000000..a78908a9b Binary files /dev/null and b/.yarn/cache/@rolldown-binding-linux-x64-gnu-npm-1.0.0-beta.55-894a162cbe-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-linux-x64-musl-npm-1.0.0-beta.53-768a82203f-10.zip b/.yarn/cache/@rolldown-binding-linux-x64-musl-npm-1.0.0-beta.53-768a82203f-10.zip new file mode 100644 index 000000000..cefd35fdc Binary files /dev/null and b/.yarn/cache/@rolldown-binding-linux-x64-musl-npm-1.0.0-beta.53-768a82203f-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-linux-x64-musl-npm-1.0.0-beta.55-307f2bf6b9-10.zip b/.yarn/cache/@rolldown-binding-linux-x64-musl-npm-1.0.0-beta.55-307f2bf6b9-10.zip new file mode 100644 index 000000000..8271549cd Binary files /dev/null and b/.yarn/cache/@rolldown-binding-linux-x64-musl-npm-1.0.0-beta.55-307f2bf6b9-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-win32-arm64-msvc-npm-1.0.0-beta.53-3931203b8e-10.zip b/.yarn/cache/@rolldown-binding-win32-arm64-msvc-npm-1.0.0-beta.53-3931203b8e-10.zip new file mode 100644 index 000000000..f21e9dc0c Binary files /dev/null and b/.yarn/cache/@rolldown-binding-win32-arm64-msvc-npm-1.0.0-beta.53-3931203b8e-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-win32-arm64-msvc-npm-1.0.0-beta.55-ff4f2c49fa-10.zip b/.yarn/cache/@rolldown-binding-win32-arm64-msvc-npm-1.0.0-beta.55-ff4f2c49fa-10.zip new file mode 100644 index 000000000..dd25d6fa3 Binary files /dev/null and b/.yarn/cache/@rolldown-binding-win32-arm64-msvc-npm-1.0.0-beta.55-ff4f2c49fa-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-win32-x64-msvc-npm-1.0.0-beta.53-217f392378-10.zip b/.yarn/cache/@rolldown-binding-win32-x64-msvc-npm-1.0.0-beta.53-217f392378-10.zip new file mode 100644 index 000000000..fa7ecd435 Binary files /dev/null and b/.yarn/cache/@rolldown-binding-win32-x64-msvc-npm-1.0.0-beta.53-217f392378-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-win32-x64-msvc-npm-1.0.0-beta.55-060da45ebd-10.zip b/.yarn/cache/@rolldown-binding-win32-x64-msvc-npm-1.0.0-beta.55-060da45ebd-10.zip new file mode 100644 index 000000000..0dcb053a0 Binary files /dev/null and b/.yarn/cache/@rolldown-binding-win32-x64-msvc-npm-1.0.0-beta.55-060da45ebd-10.zip differ diff --git a/.yarn/cache/@rolldown-pluginutils-npm-1.0.0-beta.53-0e2b6fa8ac-09dab7cbff.zip b/.yarn/cache/@rolldown-pluginutils-npm-1.0.0-beta.53-0e2b6fa8ac-09dab7cbff.zip new file mode 100644 index 000000000..be8dcec4e Binary files /dev/null and b/.yarn/cache/@rolldown-pluginutils-npm-1.0.0-beta.53-0e2b6fa8ac-09dab7cbff.zip differ diff --git a/.yarn/cache/@rolldown-pluginutils-npm-1.0.0-beta.55-71cd79c55b-46ad40e754.zip b/.yarn/cache/@rolldown-pluginutils-npm-1.0.0-beta.55-71cd79c55b-46ad40e754.zip new file mode 100644 index 000000000..08828924a Binary files /dev/null and b/.yarn/cache/@rolldown-pluginutils-npm-1.0.0-beta.55-71cd79c55b-46ad40e754.zip differ diff --git a/.yarn/cache/@types-node-npm-22.19.3-0fc033f9b1-ffee06ce6d.zip b/.yarn/cache/@types-node-npm-22.19.3-0fc033f9b1-ffee06ce6d.zip new file mode 100644 index 000000000..737822d85 Binary files /dev/null and b/.yarn/cache/@types-node-npm-22.19.3-0fc033f9b1-ffee06ce6d.zip differ diff --git a/.yarn/cache/@types-ws-npm-8.18.1-61dc106ff0-1ce05e3174.zip b/.yarn/cache/@types-ws-npm-8.18.1-61dc106ff0-1ce05e3174.zip new file mode 100644 index 000000000..5c915f403 Binary files /dev/null and b/.yarn/cache/@types-ws-npm-8.18.1-61dc106ff0-1ce05e3174.zip differ diff --git a/.yarn/cache/@typescript-ata-npm-0.9.8-0663e9063b-c0f9daf781.zip b/.yarn/cache/@typescript-ata-npm-0.9.8-0663e9063b-c0f9daf781.zip new file mode 100644 index 000000000..500bfe18e Binary files /dev/null and b/.yarn/cache/@typescript-ata-npm-0.9.8-0663e9063b-c0f9daf781.zip differ diff --git a/.yarn/cache/@vitest-expect-npm-4.0.16-5603b6a8cc-1da98c86d3.zip b/.yarn/cache/@vitest-expect-npm-4.0.16-5603b6a8cc-1da98c86d3.zip new file mode 100644 index 000000000..f08d79ef7 Binary files /dev/null and b/.yarn/cache/@vitest-expect-npm-4.0.16-5603b6a8cc-1da98c86d3.zip differ diff --git a/.yarn/cache/@vitest-mocker-npm-4.0.16-3484557c09-3a34c6571e.zip b/.yarn/cache/@vitest-mocker-npm-4.0.16-3484557c09-3a34c6571e.zip new file mode 100644 index 000000000..50adfeb70 Binary files /dev/null and b/.yarn/cache/@vitest-mocker-npm-4.0.16-3484557c09-3a34c6571e.zip differ diff --git a/.yarn/cache/@vitest-pretty-format-npm-4.0.16-c951e2304a-914d5d35fb.zip b/.yarn/cache/@vitest-pretty-format-npm-4.0.16-c951e2304a-914d5d35fb.zip new file mode 100644 index 000000000..29ef40b28 Binary files /dev/null and b/.yarn/cache/@vitest-pretty-format-npm-4.0.16-c951e2304a-914d5d35fb.zip differ diff --git a/.yarn/cache/@vitest-runner-npm-4.0.16-6e56de81f5-2aed39bb46.zip b/.yarn/cache/@vitest-runner-npm-4.0.16-6e56de81f5-2aed39bb46.zip new file mode 100644 index 000000000..e77415f55 Binary files /dev/null and b/.yarn/cache/@vitest-runner-npm-4.0.16-6e56de81f5-2aed39bb46.zip differ diff --git a/.yarn/cache/@vitest-snapshot-npm-4.0.16-3c523b95f7-30f2977c96.zip b/.yarn/cache/@vitest-snapshot-npm-4.0.16-3c523b95f7-30f2977c96.zip new file mode 100644 index 000000000..322442d20 Binary files /dev/null and b/.yarn/cache/@vitest-snapshot-npm-4.0.16-3c523b95f7-30f2977c96.zip differ diff --git a/.yarn/cache/@vitest-spy-npm-4.0.16-50c20d921d-76cbabfdd7.zip b/.yarn/cache/@vitest-spy-npm-4.0.16-50c20d921d-76cbabfdd7.zip new file mode 100644 index 000000000..9e52a423e Binary files /dev/null and b/.yarn/cache/@vitest-spy-npm-4.0.16-50c20d921d-76cbabfdd7.zip differ diff --git a/.yarn/cache/@vitest-utils-npm-4.0.16-d08786c148-07fb3c9686.zip b/.yarn/cache/@vitest-utils-npm-4.0.16-d08786c148-07fb3c9686.zip new file mode 100644 index 000000000..707fbde3c Binary files /dev/null and b/.yarn/cache/@vitest-utils-npm-4.0.16-d08786c148-07fb3c9686.zip differ diff --git a/.yarn/cache/ansis-npm-4.2.0-35ae97bdc2-493e15fad2.zip b/.yarn/cache/ansis-npm-4.2.0-35ae97bdc2-493e15fad2.zip new file mode 100644 index 000000000..0719e6447 Binary files /dev/null and b/.yarn/cache/ansis-npm-4.2.0-35ae97bdc2-493e15fad2.zip differ diff --git a/.yarn/cache/ast-kit-npm-2.2.0-8d8a4e9bb7-82cf2a8c2d.zip b/.yarn/cache/ast-kit-npm-2.2.0-8d8a4e9bb7-82cf2a8c2d.zip new file mode 100644 index 000000000..cdccb81cc Binary files /dev/null and b/.yarn/cache/ast-kit-npm-2.2.0-8d8a4e9bb7-82cf2a8c2d.zip differ diff --git a/.yarn/cache/birpc-npm-4.0.0-2cc419e494-f4418e2a04.zip b/.yarn/cache/birpc-npm-4.0.0-2cc419e494-f4418e2a04.zip new file mode 100644 index 000000000..982376952 Binary files /dev/null and b/.yarn/cache/birpc-npm-4.0.0-2cc419e494-f4418e2a04.zip differ diff --git a/.yarn/cache/chai-npm-6.2.1-df1838f7a6-f7917749e2.zip b/.yarn/cache/chai-npm-6.2.1-df1838f7a6-f7917749e2.zip new file mode 100644 index 000000000..46760a100 Binary files /dev/null and b/.yarn/cache/chai-npm-6.2.1-df1838f7a6-f7917749e2.zip differ diff --git a/.yarn/cache/chokidar-npm-5.0.0-2f70d31c86-a1c2a4ee6e.zip b/.yarn/cache/chokidar-npm-5.0.0-2f70d31c86-a1c2a4ee6e.zip new file mode 100644 index 000000000..de48a7b82 Binary files /dev/null and b/.yarn/cache/chokidar-npm-5.0.0-2f70d31c86-a1c2a4ee6e.zip differ diff --git a/.yarn/cache/commander-npm-14.0.2-538b84c387-2d202db5e5.zip b/.yarn/cache/commander-npm-14.0.2-538b84c387-2d202db5e5.zip new file mode 100644 index 000000000..df1ef15be Binary files /dev/null and b/.yarn/cache/commander-npm-14.0.2-538b84c387-2d202db5e5.zip differ diff --git a/.yarn/cache/defu-npm-6.1.4-c791c7f2cc-aeffdb4730.zip b/.yarn/cache/defu-npm-6.1.4-c791c7f2cc-aeffdb4730.zip new file mode 100644 index 000000000..df708b6ab Binary files /dev/null and b/.yarn/cache/defu-npm-6.1.4-c791c7f2cc-aeffdb4730.zip differ diff --git a/.yarn/cache/dts-resolver-npm-2.1.3-5deb33a062-9dfa79be6f.zip b/.yarn/cache/dts-resolver-npm-2.1.3-5deb33a062-9dfa79be6f.zip new file mode 100644 index 000000000..a095b39b8 Binary files /dev/null and b/.yarn/cache/dts-resolver-npm-2.1.3-5deb33a062-9dfa79be6f.zip differ diff --git a/.yarn/cache/empathic-npm-2.0.0-440d97be6e-90f47d93f8.zip b/.yarn/cache/empathic-npm-2.0.0-440d97be6e-90f47d93f8.zip new file mode 100644 index 000000000..daca95502 Binary files /dev/null and b/.yarn/cache/empathic-npm-2.0.0-440d97be6e-90f47d93f8.zip differ diff --git a/.yarn/cache/esbuild-npm-0.27.2-7789e62c6d-7f1229328b.zip b/.yarn/cache/esbuild-npm-0.27.2-7789e62c6d-7f1229328b.zip new file mode 100644 index 000000000..18b308c08 Binary files /dev/null and b/.yarn/cache/esbuild-npm-0.27.2-7789e62c6d-7f1229328b.zip differ diff --git a/.yarn/cache/expect-type-npm-1.3.0-95a4384745-a5fada3d0c.zip b/.yarn/cache/expect-type-npm-1.3.0-95a4384745-a5fada3d0c.zip new file mode 100644 index 000000000..20a597e23 Binary files /dev/null and b/.yarn/cache/expect-type-npm-1.3.0-95a4384745-a5fada3d0c.zip differ diff --git a/.yarn/cache/framer-plugin-npm-3.9.0-beta.0-571984760c-bb41b0770a.zip b/.yarn/cache/framer-plugin-npm-3.9.0-beta.0-571984760c-bb41b0770a.zip new file mode 100644 index 000000000..53ad56ccf Binary files /dev/null and b/.yarn/cache/framer-plugin-npm-3.9.0-beta.0-571984760c-bb41b0770a.zip differ diff --git a/.yarn/cache/get-tsconfig-npm-4.13.0-009b232bdd-3603c6da30.zip b/.yarn/cache/get-tsconfig-npm-4.13.0-009b232bdd-3603c6da30.zip new file mode 100644 index 000000000..521c2617f Binary files /dev/null and b/.yarn/cache/get-tsconfig-npm-4.13.0-009b232bdd-3603c6da30.zip differ diff --git a/.yarn/cache/hookable-npm-5.5.3-82b0342097-c6cec06f69.zip b/.yarn/cache/hookable-npm-5.5.3-82b0342097-c6cec06f69.zip new file mode 100644 index 000000000..161d861d3 Binary files /dev/null and b/.yarn/cache/hookable-npm-5.5.3-82b0342097-c6cec06f69.zip differ diff --git a/.yarn/cache/import-without-cache-npm-0.2.4-2e319e6024-ac263dab13.zip b/.yarn/cache/import-without-cache-npm-0.2.4-2e319e6024-ac263dab13.zip new file mode 100644 index 000000000..ba70023e7 Binary files /dev/null and b/.yarn/cache/import-without-cache-npm-0.2.4-2e319e6024-ac263dab13.zip differ diff --git a/.yarn/cache/jsesc-npm-3.1.0-2f4f998cd7-20bd37a142.zip b/.yarn/cache/jsesc-npm-3.1.0-2f4f998cd7-20bd37a142.zip new file mode 100644 index 000000000..0701df326 Binary files /dev/null and b/.yarn/cache/jsesc-npm-3.1.0-2f4f998cd7-20bd37a142.zip differ diff --git a/.yarn/cache/magic-string-npm-0.30.21-9a226cb21e-57d5691f41.zip b/.yarn/cache/magic-string-npm-0.30.21-9a226cb21e-57d5691f41.zip new file mode 100644 index 000000000..53485dc72 Binary files /dev/null and b/.yarn/cache/magic-string-npm-0.30.21-9a226cb21e-57d5691f41.zip differ diff --git a/.yarn/cache/obug-npm-2.1.1-029730d296-bdcf921336.zip b/.yarn/cache/obug-npm-2.1.1-029730d296-bdcf921336.zip new file mode 100644 index 000000000..ca87e6388 Binary files /dev/null and b/.yarn/cache/obug-npm-2.1.1-029730d296-bdcf921336.zip differ diff --git a/.yarn/cache/prettier-npm-3.7.4-78f94d4194-b4d00ea13b.zip b/.yarn/cache/prettier-npm-3.7.4-78f94d4194-b4d00ea13b.zip new file mode 100644 index 000000000..fde1fe689 Binary files /dev/null and b/.yarn/cache/prettier-npm-3.7.4-78f94d4194-b4d00ea13b.zip differ diff --git a/.yarn/cache/quansync-npm-1.0.0-0707dd9045-fba7a8e87a.zip b/.yarn/cache/quansync-npm-1.0.0-0707dd9045-fba7a8e87a.zip new file mode 100644 index 000000000..c024033bb Binary files /dev/null and b/.yarn/cache/quansync-npm-1.0.0-0707dd9045-fba7a8e87a.zip differ diff --git a/.yarn/cache/readdirp-npm-5.0.0-82b01a282e-a17a591b51.zip b/.yarn/cache/readdirp-npm-5.0.0-82b01a282e-a17a591b51.zip new file mode 100644 index 000000000..f62693664 Binary files /dev/null and b/.yarn/cache/readdirp-npm-5.0.0-82b01a282e-a17a591b51.zip differ diff --git a/.yarn/cache/resolve-pkg-maps-npm-1.0.0-135b70c854-0763150adf.zip b/.yarn/cache/resolve-pkg-maps-npm-1.0.0-135b70c854-0763150adf.zip new file mode 100644 index 000000000..8e3561c41 Binary files /dev/null and b/.yarn/cache/resolve-pkg-maps-npm-1.0.0-135b70c854-0763150adf.zip differ diff --git a/.yarn/cache/rolldown-npm-1.0.0-beta.53-db59d0aaea-40713f7a30.zip b/.yarn/cache/rolldown-npm-1.0.0-beta.53-db59d0aaea-40713f7a30.zip new file mode 100644 index 000000000..04429b8c8 Binary files /dev/null and b/.yarn/cache/rolldown-npm-1.0.0-beta.53-db59d0aaea-40713f7a30.zip differ diff --git a/.yarn/cache/rolldown-npm-1.0.0-beta.55-f6b6b87530-74e194192b.zip b/.yarn/cache/rolldown-npm-1.0.0-beta.55-f6b6b87530-74e194192b.zip new file mode 100644 index 000000000..24a5985eb Binary files /dev/null and b/.yarn/cache/rolldown-npm-1.0.0-beta.55-f6b6b87530-74e194192b.zip differ diff --git a/.yarn/cache/rolldown-plugin-dts-npm-0.18.4-b1bc8cfadd-d6157bdfa7.zip b/.yarn/cache/rolldown-plugin-dts-npm-0.18.4-b1bc8cfadd-d6157bdfa7.zip new file mode 100644 index 000000000..247e1a297 Binary files /dev/null and b/.yarn/cache/rolldown-plugin-dts-npm-0.18.4-b1bc8cfadd-d6157bdfa7.zip differ diff --git a/.yarn/cache/semver-npm-7.7.3-9cf7b3b46c-8dbc3168e0.zip b/.yarn/cache/semver-npm-7.7.3-9cf7b3b46c-8dbc3168e0.zip new file mode 100644 index 000000000..c94393ee4 Binary files /dev/null and b/.yarn/cache/semver-npm-7.7.3-9cf7b3b46c-8dbc3168e0.zip differ diff --git a/.yarn/cache/std-env-npm-3.10.0-30d3e2646f-19c9cda4f3.zip b/.yarn/cache/std-env-npm-3.10.0-30d3e2646f-19c9cda4f3.zip new file mode 100644 index 000000000..8803cc08f Binary files /dev/null and b/.yarn/cache/std-env-npm-3.10.0-30d3e2646f-19c9cda4f3.zip differ diff --git a/.yarn/cache/tinyexec-npm-1.0.2-321b713e56-cb709ed424.zip b/.yarn/cache/tinyexec-npm-1.0.2-321b713e56-cb709ed424.zip new file mode 100644 index 000000000..4e06ed7d2 Binary files /dev/null and b/.yarn/cache/tinyexec-npm-1.0.2-321b713e56-cb709ed424.zip differ diff --git a/.yarn/cache/tinyrainbow-npm-3.0.3-06ed35d14d-169cc63c15.zip b/.yarn/cache/tinyrainbow-npm-3.0.3-06ed35d14d-169cc63c15.zip new file mode 100644 index 000000000..55660b3fd Binary files /dev/null and b/.yarn/cache/tinyrainbow-npm-3.0.3-06ed35d14d-169cc63c15.zip differ diff --git a/.yarn/cache/tree-kill-npm-1.2.2-3da0e5a759-49117f5f41.zip b/.yarn/cache/tree-kill-npm-1.2.2-3da0e5a759-49117f5f41.zip new file mode 100644 index 000000000..c9ef40137 Binary files /dev/null and b/.yarn/cache/tree-kill-npm-1.2.2-3da0e5a759-49117f5f41.zip differ diff --git a/.yarn/cache/tsdown-npm-0.17.4-ea0f38adf5-1fe104c1e0.zip b/.yarn/cache/tsdown-npm-0.17.4-ea0f38adf5-1fe104c1e0.zip new file mode 100644 index 000000000..a8d97eea9 Binary files /dev/null and b/.yarn/cache/tsdown-npm-0.17.4-ea0f38adf5-1fe104c1e0.zip differ diff --git a/.yarn/cache/tsx-npm-4.21.0-3bc9626d81-7afedeff85.zip b/.yarn/cache/tsx-npm-4.21.0-3bc9626d81-7afedeff85.zip new file mode 100644 index 000000000..27615df9a Binary files /dev/null and b/.yarn/cache/tsx-npm-4.21.0-3bc9626d81-7afedeff85.zip differ diff --git a/.yarn/cache/typescript-npm-5.9.3-48715be868-c089d9d3da.zip b/.yarn/cache/typescript-npm-5.9.3-48715be868-c089d9d3da.zip new file mode 100644 index 000000000..0eabff58d Binary files /dev/null and b/.yarn/cache/typescript-npm-5.9.3-48715be868-c089d9d3da.zip differ diff --git a/.yarn/cache/typescript-patch-6fda4d02cf-696e1b017b.zip b/.yarn/cache/typescript-patch-6fda4d02cf-696e1b017b.zip new file mode 100644 index 000000000..6cd392703 Binary files /dev/null and b/.yarn/cache/typescript-patch-6fda4d02cf-696e1b017b.zip differ diff --git a/.yarn/cache/unconfig-core-npm-7.4.2-b40a0ca292-837d196508.zip b/.yarn/cache/unconfig-core-npm-7.4.2-b40a0ca292-837d196508.zip new file mode 100644 index 000000000..3f7ff8f9e Binary files /dev/null and b/.yarn/cache/unconfig-core-npm-7.4.2-b40a0ca292-837d196508.zip differ diff --git a/.yarn/cache/unrun-npm-0.2.20-33a1198309-a0d33e12f8.zip b/.yarn/cache/unrun-npm-0.2.20-33a1198309-a0d33e12f8.zip new file mode 100644 index 000000000..1a660837c Binary files /dev/null and b/.yarn/cache/unrun-npm-0.2.20-33a1198309-a0d33e12f8.zip differ diff --git a/.yarn/cache/vite-npm-7.3.0-70284f6792-044490133a.zip b/.yarn/cache/vite-npm-7.3.0-70284f6792-044490133a.zip new file mode 100644 index 000000000..34732b2f6 Binary files /dev/null and b/.yarn/cache/vite-npm-7.3.0-70284f6792-044490133a.zip differ diff --git a/.yarn/cache/vitest-npm-4.0.16-09dd6df1e3-22b3806988.zip b/.yarn/cache/vitest-npm-4.0.16-09dd6df1e3-22b3806988.zip new file mode 100644 index 000000000..f5fb56a29 Binary files /dev/null and b/.yarn/cache/vitest-npm-4.0.16-09dd6df1e3-22b3806988.zip differ diff --git a/.yarn/cache/ws-npm-8.18.3-665d39209d-725964438d.zip b/.yarn/cache/ws-npm-8.18.3-665d39209d-725964438d.zip new file mode 100644 index 000000000..f50dd5806 Binary files /dev/null and b/.yarn/cache/ws-npm-8.18.3-665d39209d-725964438d.zip differ diff --git a/packages/code-link-cli/README.md b/packages/code-link-cli/README.md new file mode 100644 index 000000000..66b29ab73 --- /dev/null +++ b/packages/code-link-cli/README.md @@ -0,0 +1,3 @@ +# Framer Code Link CLI + +Two-way syncing Framer of code components between Framer and your computer. diff --git a/packages/code-link-cli/package.json b/packages/code-link-cli/package.json new file mode 100644 index 000000000..2cab23509 --- /dev/null +++ b/packages/code-link-cli/package.json @@ -0,0 +1,40 @@ +{ + "name": "framer-code-link", + "version": "0.4.3", + "description": "CLI tool for syncing Framer code components - controller-centric architecture", + "main": "dist/index.mjs", + "type": "module", + "bin": "./dist/index.mjs", + "files": [ + "dist" + ], + "scripts": { + "dev": "NODE_ENV=development tsx src/index.ts", + "build": "tsdown", + "start": "node dist/index.mjs", + "test": "vitest run" + }, + "keywords": [ + "framer", + "sync", + "code-components" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@typescript/ata": "^0.9.8", + "chokidar": "^5.0.0", + "commander": "^14.0.2", + "prettier": "^3.7.4", + "typescript": "^5.9.3", + "ws": "^8.18.3" + }, + "devDependencies": { + "@code-link/shared": "workspace:*", + "@types/node": "^22.19.2", + "@types/ws": "^8.18.1", + "tsdown": "^0.17.4", + "tsx": "^4.21.0", + "vitest": "^4.0.15" + } +} diff --git a/packages/code-link-cli/src/controller.test.ts b/packages/code-link-cli/src/controller.test.ts new file mode 100644 index 000000000..65782d8a8 --- /dev/null +++ b/packages/code-link-cli/src/controller.test.ts @@ -0,0 +1,904 @@ +import { describe, it, expect } from "vitest" +import { transition } from "./controller.js" +import { createHashTracker } from "./utils/hash-tracker.js" + +import type { WebSocket } from "ws" +import { filterEchoedFiles } from "./helpers/files.js" + +describe("Sync State Machine", () => { + // Connection Lifecycle Tests + describe("Connection Lifecycle", () => { + it("transitions from disconnected to handshaking on HANDSHAKE", () => { + const initialState = { + mode: "disconnected" as const, + socket: null, + files: new Map(), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const mockSocket = {} as WebSocket + const result = transition(initialState, { + type: "HANDSHAKE", + socket: mockSocket, + projectInfo: { projectId: "test-id", projectName: "Test Project" }, + }) + + expect(result.state.mode).toBe("handshaking") + expect(result.state.socket).toBe(mockSocket) + expect(result.effects).toHaveLength(3) + expect(result.effects[0]).toMatchObject({ type: "INIT_WORKSPACE" }) + expect(result.effects[1]).toMatchObject({ type: "LOAD_PERSISTED_STATE" }) + expect(result.effects[2]).toMatchObject({ + type: "SEND_MESSAGE", + payload: { type: "request-files" }, + }) + }) + + it("ignores handshake when not in disconnected mode", () => { + const initialState = { + mode: "watching" as const, + socket: {} as WebSocket, + files: new Map(), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "HANDSHAKE", + socket: {} as WebSocket, + projectInfo: { projectId: "test-id", projectName: "Test Project" }, + }) + + expect(result.state.mode).toBe("watching") + expect(result.effects).toHaveLength(1) + expect(result.effects[0]).toMatchObject({ + type: "LOG", + level: "warn", + }) + }) + + it("transitions to disconnected and persists state on DISCONNECT", () => { + const initialState = { + mode: "watching" as const, + socket: {} as WebSocket, + files: new Map([ + [ + "Test.tsx", + { + localHash: "abc123", + lastSyncedHash: "abc123", + lastRemoteTimestamp: Date.now(), + }, + ], + ]), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { type: "DISCONNECT" }) + + expect(result.state.mode).toBe("disconnected") + expect(result.state.socket).toBe(null) + expect(result.effects).toHaveLength(2) + expect(result.effects[0]).toMatchObject({ type: "PERSIST_STATE" }) + expect(result.effects[1]).toMatchObject({ + type: "LOG", + level: "debug", + }) + }) + }) + + // File Synchronization Tests + describe("File Synchronization", () => { + it("transitions to snapshot_processing on FILE_LIST and emits DETECT_CONFLICTS", () => { + const initialState = { + mode: "handshaking" as const, + socket: {} as WebSocket, + files: new Map(), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const remoteFiles = [ + { name: "Test.tsx", content: "remote content", modifiedAt: Date.now() }, + ] + + const result = transition(initialState, { + type: "FILE_LIST", + files: remoteFiles, + }) + + expect(result.state.mode).toBe("snapshot_processing") + expect(result.state.pendingRemoteChanges).toEqual(remoteFiles) + expect(result.effects).toHaveLength(2) + expect(result.effects[0]).toMatchObject({ + type: "LOG", + level: "debug", + }) + expect(result.effects[1]).toMatchObject({ + type: "DETECT_CONFLICTS", + remoteFiles, + }) + }) + + it("ignores FILE_LIST when not in handshaking mode", () => { + const initialState = { + mode: "watching" as const, + socket: {} as WebSocket, + files: new Map(), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "FILE_LIST", + files: [], + }) + + expect(result.state.mode).toBe("watching") + expect(result.effects).toHaveLength(1) + expect(result.effects[0]).toMatchObject({ + type: "LOG", + level: "warn", + }) + }) + + it("applies remote FILE_CHANGE immediately in watching mode", () => { + const initialState = { + mode: "watching" as const, + socket: {} as WebSocket, + files: new Map(), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const file = { + name: "Test.tsx", + content: "new content", + modifiedAt: Date.now(), + } + + const result = transition(initialState, { + type: "FILE_CHANGE", + file, + }) + + expect(result.state.mode).toBe("watching") + expect(result.effects.some((e) => e.type === "WRITE_FILES")).toBe(true) + }) + + it("queues remote FILE_CHANGE during snapshot processing", () => { + const initialState = { + mode: "snapshot_processing" as const, + socket: {} as WebSocket, + files: new Map(), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const file = { + name: "Test.tsx", + content: "new content", + modifiedAt: Date.now(), + } + + const result = transition(initialState, { + type: "FILE_CHANGE", + file, + }) + + expect(result.state.mode).toBe("snapshot_processing") + expect(result.state.pendingRemoteChanges).toHaveLength(1) + expect(result.state.pendingRemoteChanges).toContainEqual(file) + expect(result.effects.some((e) => e.type === "WRITE_FILES")).toBe(false) + }) + + it("emits SEND_LOCAL_CHANGE for local file add/change in watching mode", () => { + const initialState = { + mode: "watching" as const, + socket: {} as WebSocket, + files: new Map(), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "WATCHER_EVENT", + event: { + kind: "change", + relativePath: "Test.tsx", + content: "export const Test = () =>
Test
", + }, + }) + + expect(result.state.mode).toBe("watching") + expect(result.effects.some((e) => e.type === "SEND_LOCAL_CHANGE")).toBe( + true + ) + const sendEffect = result.effects.find( + (e) => e.type === "SEND_LOCAL_CHANGE" + ) + expect(sendEffect).toMatchObject({ + type: "SEND_LOCAL_CHANGE", + fileName: "Test.tsx", + content: "export const Test = () =>
Test
", + }) + }) + + it("ignores local WATCHER_EVENT when not in watching mode", () => { + const initialState = { + mode: "handshaking" as const, + socket: {} as WebSocket, + files: new Map(), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "WATCHER_EVENT", + event: { + kind: "change", + relativePath: "Test.tsx", + content: "content", + }, + }) + + expect(result.effects.some((e) => e.type === "SEND_LOCAL_CHANGE")).toBe( + false + ) + }) + + it("ignores local WATCHER_EVENT when disconnected", () => { + const initialState = { + mode: "disconnected" as const, + socket: null, + files: new Map(), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "WATCHER_EVENT", + event: { + kind: "change", + relativePath: "Test.tsx", + content: "content", + }, + }) + + expect(result.effects.some((e) => e.type === "SEND_LOCAL_CHANGE")).toBe( + false + ) + }) + + it("emits LIST_LOCAL_FILES on REQUEST_FILES when in watching mode", () => { + const initialState = { + mode: "watching" as const, + socket: {} as WebSocket, + files: new Map(), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "REQUEST_FILES", + }) + + expect(result.state.mode).toBe("watching") + expect(result.effects.some((e) => e.type === "LIST_LOCAL_FILES")).toBe( + true + ) + }) + + it("rejects REQUEST_FILES when disconnected", () => { + const initialState = { + mode: "disconnected" as const, + socket: null, + files: new Map(), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "REQUEST_FILES", + }) + + expect(result.state.mode).toBe("disconnected") + expect(result.effects.some((e) => e.type === "LIST_LOCAL_FILES")).toBe( + false + ) + expect( + result.effects.some((e) => e.type === "LOG" && e.level === "warn") + ).toBe(true) + }) + + it("updates file metadata on FILE_SYNCED_CONFIRMATION", () => { + const initialState = { + mode: "watching" as const, + socket: {} as WebSocket, + files: new Map([ + [ + "Test.tsx", + { + baseRemoteHash: "abc123", + lastRemoteTimestamp: 1000, + }, + ], + ]), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "FILE_SYNCED_CONFIRMATION", + fileName: "Test.tsx", + remoteModifiedAt: 2000, + }) + + expect( + result.effects.some((e) => e.type === "UPDATE_FILE_METADATA") + ).toBe(true) + }) + + it("creates metadata entry on FILE_SYNCED_CONFIRMATION if file not tracked", () => { + const initialState = { + mode: "watching" as const, + socket: {} as WebSocket, + files: new Map(), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "FILE_SYNCED_CONFIRMATION", + fileName: "NewFile.tsx", + remoteModifiedAt: 3000, + }) + + // Should not throw - creates new entry + expect(result.state.mode).toBe("watching") + }) + }) + + // Deletion Safety Tests + // Remote → Local: Auto-applies (Framer is source of truth) + // Local → Remote: Requires confirmation (protects source of truth) + describe("Deletion Safety", () => { + it("auto-applies remote deletions to local filesystem", () => { + const initialState = { + mode: "watching" as const, + socket: {} as WebSocket, + files: new Map([ + [ + "Test.tsx", + { + localHash: "abc123", + lastSyncedHash: "abc123", + lastRemoteTimestamp: Date.now(), + }, + ], + ]), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "REMOTE_FILE_DELETE", + fileName: "Test.tsx", + }) + + expect(result.state.mode).toBe("watching") + // CRITICAL: Remote deletions immediately emit DELETE_LOCAL_FILES + expect(result.effects.some((e) => e.type === "DELETE_LOCAL_FILES")).toBe( + true + ) + const deleteEffect = result.effects.find( + (e) => e.type === "DELETE_LOCAL_FILES" + ) + expect(deleteEffect).toMatchObject({ + type: "DELETE_LOCAL_FILES", + names: ["Test.tsx"], + }) + expect(result.effects.some((e) => e.type === "PERSIST_STATE")).toBe(true) + }) + + it("auto-applies remote deletions during snapshot processing", () => { + const initialState = { + mode: "snapshot_processing" as const, + socket: {} as WebSocket, + files: new Map(), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "REMOTE_FILE_DELETE", + fileName: "Test.tsx", + }) + + expect(result.state.mode).toBe("snapshot_processing") + expect(result.effects.some((e) => e.type === "DELETE_LOCAL_FILES")).toBe( + true + ) + }) + + it("rejects remote deletions while disconnected", () => { + const initialState = { + mode: "disconnected" as const, + socket: null, + files: new Map(), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "REMOTE_FILE_DELETE", + fileName: "Test.tsx", + }) + + expect(result.state.mode).toBe("disconnected") + expect(result.effects.some((e) => e.type === "DELETE_LOCAL_FILES")).toBe( + false + ) + expect( + result.effects.some((e) => e.type === "LOG" && e.level === "warn") + ).toBe(true) + }) + + it("prompts user before propagating local delete to Framer", () => { + const initialState = { + mode: "watching" as const, + socket: {} as WebSocket, + files: new Map(), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "WATCHER_EVENT", + event: { + kind: "delete", + relativePath: "Test.tsx", + }, + }) + + // CRITICAL: Local deletes do NOT immediately send to Framer + // They emit REQUEST_LOCAL_DELETE_DECISION to ask user first + expect( + result.effects.some((e) => e.type === "REQUEST_LOCAL_DELETE_DECISION") + ).toBe(true) + // Should NOT have SEND_MESSAGE with file-delete + expect( + result.effects.some( + (e) => + e.type === "SEND_MESSAGE" && + "payload" in e && + e.payload?.type === "file-delete" + ) + ).toBe(false) + }) + + it("sends delete to Framer only after user approval", () => { + const initialState = { + mode: "watching" as const, + socket: {} as WebSocket, + files: new Map([ + [ + "Test.tsx", + { + localHash: "abc123", + lastSyncedHash: "abc123", + lastRemoteTimestamp: Date.now(), + }, + ], + ]), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "LOCAL_DELETE_APPROVED", + fileName: "Test.tsx", + }) + + expect(result.state.mode).toBe("watching") + // After approval, the delete is applied locally + expect(result.effects.some((e) => e.type === "DELETE_LOCAL_FILES")).toBe( + true + ) + expect(result.effects.some((e) => e.type === "PERSIST_STATE")).toBe(true) + }) + + it("does NOT send delete to Framer when user rejects - restores file instead", () => { + const initialState = { + mode: "watching" as const, + socket: {} as WebSocket, + files: new Map(), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "LOCAL_DELETE_REJECTED", + fileName: "Test.tsx", + content: "restored content", + }) + + expect(result.state.mode).toBe("watching") + // File is restored locally + expect(result.effects.some((e) => e.type === "WRITE_FILES")).toBe(true) + const writeEffect = result.effects.find((e) => e.type === "WRITE_FILES") + expect(writeEffect).toMatchObject({ + type: "WRITE_FILES", + files: [ + { + name: "Test.tsx", + content: "restored content", + }, + ], + }) + // Should NOT send delete to Framer + expect( + result.effects.some( + (e) => + e.type === "SEND_MESSAGE" && + "payload" in e && + e.payload?.type === "file-delete" + ) + ).toBe(false) + }) + }) + + // Conflict Resolution Tests + describe("Conflict Resolution", () => { + it("applies safe writes and transitions to watching when no conflicts", () => { + const initialState = { + mode: "snapshot_processing" as const, + socket: {} as WebSocket, + files: new Map(), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "CONFLICTS_DETECTED", + conflicts: [], + safeWrites: [ + { + name: "Test.tsx", + content: "new content", + modifiedAt: Date.now(), + }, + ], + localOnly: [], + }) + + expect(result.state.mode).toBe("watching") + expect("pendingConflicts" in result.state).toBe(false) + expect(result.effects.length).toBeGreaterThan(2) + expect(result.effects.some((e) => e.type === "WRITE_FILES")).toBe(true) + expect(result.effects.some((e) => e.type === "PERSIST_STATE")).toBe(true) + }) + + it("transitions to conflict_resolution when manual conflicts exist", () => { + const initialState = { + mode: "snapshot_processing" as const, + socket: {} as WebSocket, + files: new Map(), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const conflict = { + fileName: "Test.tsx", + localContent: "local content", + remoteContent: "remote content", + localModifiedAt: Date.now(), + remoteModifiedAt: Date.now() + 1000, + } + + const result = transition(initialState, { + type: "CONFLICTS_DETECTED", + conflicts: [conflict], + safeWrites: [], + localOnly: [], + }) + + expect(result.state.mode).toBe("conflict_resolution") + if (result.state.mode === "conflict_resolution") { + expect(result.state.pendingConflicts).toHaveLength(1) + } + expect( + result.effects.some((e) => e.type === "REQUEST_CONFLICT_VERSIONS") + ).toBe(true) + }) + + it("applies all remote versions when user picks remote", () => { + const conflict1 = { + fileName: "Test1.tsx", + localContent: "local 1", + remoteContent: "remote 1", + localModifiedAt: Date.now(), + remoteModifiedAt: Date.now() + 1000, + } + const conflict2 = { + fileName: "Test2.tsx", + localContent: "local 2", + remoteContent: "remote 2", + localModifiedAt: Date.now(), + remoteModifiedAt: Date.now() + 1000, + } + + const initialState = { + mode: "conflict_resolution" as const, + socket: {} as WebSocket, + files: new Map(), + pendingConflicts: [conflict1, conflict2], + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "CONFLICTS_RESOLVED", + resolution: "remote", + }) + + expect(result.state.mode).toBe("watching") + expect("pendingConflicts" in result.state).toBe(false) + + const writeEffects = result.effects.filter( + (e) => e.type === "WRITE_FILES" + ) + expect(writeEffects).toHaveLength(2) + expect(writeEffects[0]).toMatchObject({ + type: "WRITE_FILES", + files: [{ name: "Test1.tsx", content: "remote 1" }], + }) + expect(writeEffects[1]).toMatchObject({ + type: "WRITE_FILES", + files: [{ name: "Test2.tsx", content: "remote 2" }], + }) + expect(result.effects.some((e) => e.type === "PERSIST_STATE")).toBe(true) + }) + + it("sends all local versions when user picks local", () => { + const conflict1 = { + fileName: "Test1.tsx", + localContent: "local 1", + remoteContent: "remote 1", + localModifiedAt: Date.now(), + remoteModifiedAt: Date.now() + 1000, + } + const conflict2 = { + fileName: "Test2.tsx", + localContent: "local 2", + remoteContent: "remote 2", + localModifiedAt: Date.now(), + remoteModifiedAt: Date.now() + 1000, + } + + const initialState = { + mode: "conflict_resolution" as const, + socket: {} as WebSocket, + files: new Map(), + pendingConflicts: [conflict1, conflict2], + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "CONFLICTS_RESOLVED", + resolution: "local", + }) + + expect(result.state.mode).toBe("watching") + expect("pendingConflicts" in result.state).toBe(false) + + const sendEffects = result.effects.filter( + (e) => e.type === "SEND_MESSAGE" + ) + expect(sendEffects).toHaveLength(2) + expect(sendEffects[0]).toMatchObject({ + payload: { + type: "file-change", + fileName: "Test1.tsx", + content: "local 1", + }, + }) + expect(sendEffects[1]).toMatchObject({ + payload: { + type: "file-change", + fileName: "Test2.tsx", + content: "local 2", + }, + }) + }) + + it("ignores resolution when not in conflict_resolution mode", () => { + const initialState = { + mode: "watching" as const, + socket: {} as WebSocket, + files: new Map(), + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "CONFLICTS_RESOLVED", + resolution: "remote", + }) + + expect(result.state.mode).toBe("watching") + expect( + result.effects.some((e) => e.type === "LOG" && e.level === "warn") + ).toBe(true) + }) + + it("auto-applies local changes when remote is unchanged", () => { + const conflict = { + fileName: "Test.tsx", + localContent: "local content", + remoteContent: "remote content", + localModifiedAt: 1000, + remoteModifiedAt: 2000, + lastSyncedAt: 5_000, + localClean: false, + } + + const initialState = { + mode: "conflict_resolution" as const, + socket: {} as WebSocket, + pendingConflicts: [conflict], + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "CONFLICT_VERSION_RESPONSE", + versions: [{ fileName: "Test.tsx", latestRemoteVersionMs: 5_000 }], + }) + + expect(result.state.mode).toBe("watching") + expect( + result.effects.some((effect) => effect.type === "SEND_LOCAL_CHANGE") + ).toBe(true) + expect( + result.effects.some((effect) => effect.type === "PERSIST_STATE") + ).toBe(true) + }) + + it("auto-applies remote changes when local is clean", () => { + const conflict = { + fileName: "Test.tsx", + localContent: "local content", + remoteContent: "remote content", + localModifiedAt: 1000, + remoteModifiedAt: 2000, + lastSyncedAt: 5_000, + localClean: true, + } + + const initialState = { + mode: "conflict_resolution" as const, + socket: {} as WebSocket, + pendingConflicts: [conflict], + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "CONFLICT_VERSION_RESPONSE", + versions: [{ fileName: "Test.tsx", latestRemoteVersionMs: 10_000 }], + }) + + expect(result.state.mode).toBe("watching") + expect( + result.effects.some((effect) => effect.type === "WRITE_FILES") + ).toBe(true) + }) + + it("requests manual decisions when both sides changed", () => { + const conflict = { + fileName: "Test.tsx", + localContent: "local content", + remoteContent: "remote content", + localModifiedAt: 1000, + remoteModifiedAt: 2000, + lastSyncedAt: 5_000, + localClean: false, + } + + const initialState = { + mode: "conflict_resolution" as const, + socket: {} as WebSocket, + pendingConflicts: [conflict], + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const result = transition(initialState, { + type: "CONFLICT_VERSION_RESPONSE", + versions: [{ fileName: "Test.tsx", latestRemoteVersionMs: 9_000 }], + }) + + expect(result.state.mode).toBe("conflict_resolution") + expect( + result.effects.some( + (effect) => effect.type === "REQUEST_CONFLICT_DECISIONS" + ) + ).toBe(true) + if (result.state.mode === "conflict_resolution") { + expect(result.state.pendingConflicts).toHaveLength(1) + } + }) + }) + + // Echo Prevention Tests + describe("Echo Prevention", () => { + it("skips inbound file-change that matches last local send", () => { + const hashTracker = createHashTracker() + hashTracker.remember("Hey.tsx", "content") + + const filtered = filterEchoedFiles( + [ + { + name: "Hey.tsx", + content: "content", + modifiedAt: Date.now(), + }, + ], + hashTracker + ) + + expect(filtered).toHaveLength(0) + }) + + it("keeps inbound change when content differs", () => { + const hashTracker = createHashTracker() + hashTracker.remember("Hey.tsx", "old content") + + const filtered = filterEchoedFiles( + [ + { + name: "Hey.tsx", + content: "new content", + modifiedAt: Date.now(), + }, + ], + hashTracker + ) + + expect(filtered).toHaveLength(1) + expect(filtered[0]?.content).toBe("new content") + }) + }) +}) diff --git a/packages/code-link-cli/src/controller.ts b/packages/code-link-cli/src/controller.ts new file mode 100644 index 000000000..d2a954c78 --- /dev/null +++ b/packages/code-link-cli/src/controller.ts @@ -0,0 +1,1404 @@ +/** + * CLI Controller + * + * All runtime state and orchestrates the sync lifecycle. + * Helpers should provide data, nevering hold control or callbacks. + */ + +import fs from "fs/promises" +import type { WebSocket } from "ws" +import type { + Config, + IncomingMessage, + OutgoingMessage, + FileInfo, + Conflict, + WatcherEvent, + ConflictVersionData, +} from "./types.js" +import { initConnection, sendMessage } from "./helpers/connection.js" +import { initWatcher } from "./helpers/watcher.js" +import { + listFiles, + detectConflicts, + writeRemoteFiles, + deleteLocalFile, + readFileSafe, + autoResolveConflicts, + filterEchoedFiles, +} from "./helpers/files.js" +import { Installer } from "./helpers/installer.js" +import { createHashTracker } from "./utils/hash-tracker.js" +import { + info, + warn, + error, + success, + debug, + status, + fileDown, + fileUp, + fileDelete, + scheduleDisconnectMessage, + cancelDisconnectMessage, + didShowDisconnect, + wasRecentlyDisconnected, + resetDisconnectState, +} from "./utils/logging.js" +import { hashFileContent } from "./utils/state-persistence.js" +import { + FileMetadataCache, + type FileSyncMetadata, +} from "./utils/file-metadata-cache.js" +import { UserActionCoordinator } from "./helpers/user-actions.js" +import { validateIncomingChange } from "./helpers/sync-validator.js" +import { findOrCreateProjectDir } from "./utils/project.js" +import { pluralize, shortProjectHash } from "@code-link/shared" + +/** + * Explicit sync lifecycle modes + */ +export type SyncMode = + | "disconnected" + | "handshaking" + | "snapshot_processing" + | "conflict_resolution" + | "watching" + +/** + * Pending operation for echo suppression and replay + */ +type PendingOperation = + | { id: string; type: "write"; file: string; hash: string } + | { id: string; type: "delete"; file: string; previousHash?: string } + +/** + * Shared state that persists across all lifecycle modes + */ +interface SyncStateBase { + pendingRemoteChanges: FileInfo[] + pendingOperations: Map + nextOperationId: number +} + +type DisconnectedState = SyncStateBase & { + mode: "disconnected" + socket: null +} + +type HandshakingState = SyncStateBase & { + mode: "handshaking" + socket: WebSocket +} + +type SnapshotProcessingState = SyncStateBase & { + mode: "snapshot_processing" + socket: WebSocket +} + +type ConflictResolutionState = SyncStateBase & { + mode: "conflict_resolution" + socket: WebSocket + pendingConflicts: Conflict[] +} + +type WatchingState = SyncStateBase & { + mode: "watching" + socket: WebSocket +} + +export type SyncState = + | DisconnectedState + | HandshakingState + | SnapshotProcessingState + | ConflictResolutionState + | WatchingState + +/** + * Events that drive state transitions + */ +type SyncEvent = + | { + type: "HANDSHAKE" + socket: WebSocket + projectInfo: { projectId: string; projectName: string } + } + | { type: "REQUEST_FILES" } + | { type: "FILE_LIST"; files: FileInfo[] } + | { + type: "CONFLICTS_DETECTED" + conflicts: Conflict[] + safeWrites: FileInfo[] + localOnly: FileInfo[] + } + | { type: "FILE_CHANGE"; file: FileInfo; fileMeta?: FileSyncMetadata } + | { type: "REMOTE_FILE_DELETE"; fileName: string } + | { type: "LOCAL_DELETE_APPROVED"; fileName: string } + | { type: "LOCAL_DELETE_REJECTED"; fileName: string; content: string } + | { + type: "CONFLICTS_RESOLVED" + resolution: "local" | "remote" + } + | { + type: "FILE_SYNCED_CONFIRMATION" + fileName: string + remoteModifiedAt: number + } + | { type: "DISCONNECT" } + | { type: "WATCHER_EVENT"; event: WatcherEvent } + | { + type: "CONFLICT_VERSION_RESPONSE" + versions: ConflictVersionData[] + } + +/** + * Side effects emitted by transitions + */ +type Effect = + | { + type: "INIT_WORKSPACE" + projectInfo: { projectId: string; projectName: string } + } + | { type: "LOAD_PERSISTED_STATE" } + | { type: "SEND_MESSAGE"; payload: OutgoingMessage } + | { type: "LIST_LOCAL_FILES" } + | { type: "DETECT_CONFLICTS"; remoteFiles: FileInfo[] } + | { + type: "WRITE_FILES" + files: FileInfo[] + silent?: boolean + skipEcho?: boolean + } + | { type: "DELETE_LOCAL_FILES"; names: string[] } + | { type: "REQUEST_CONFLICT_DECISIONS"; conflicts: Conflict[] } + | { type: "REQUEST_CONFLICT_VERSIONS"; conflicts: Conflict[] } + | { + type: "REQUEST_DELETE_CONFIRMATION" + fileName: string + requireConfirmation: boolean + } + | { + type: "UPDATE_FILE_METADATA" + fileName: string + remoteModifiedAt: number + } + | { + type: "SEND_LOCAL_CHANGE" + fileName: string + content: string + } + | { + type: "REQUEST_LOCAL_DELETE_DECISION" + fileName: string + requireConfirmation: boolean + } + | { type: "PERSIST_STATE" } + | { + type: "SYNC_COMPLETE" + totalCount: number + updatedCount: number + unchangedCount: number + } + | { + type: "LOG" + level: "info" | "debug" | "warn" | "success" + message: string + } + +/** Log helper */ +function log( + level: "info" | "debug" | "warn" | "success", + message: string +): Effect { + return { type: "LOG", level, message } +} + +/** + * Pure state transition function + * Takes current state + event, returns new state + effects to execute + */ +function transition( + state: SyncState, + event: SyncEvent +): { state: SyncState; effects: Effect[] } { + const effects: Effect[] = [] + + switch (event.type) { + case "HANDSHAKE": { + if (state.mode !== "disconnected") { + effects.push( + log("warn", `Received HANDSHAKE in mode ${state.mode}, ignoring`) + ) + return { state, effects } + } + + effects.push( + { type: "INIT_WORKSPACE", projectInfo: event.projectInfo }, + { type: "LOAD_PERSISTED_STATE" }, + { type: "SEND_MESSAGE", payload: { type: "request-files" } } + ) + + return { + state: { + ...state, + mode: "handshaking", + socket: event.socket, + }, + effects, + } + } + + case "FILE_SYNCED_CONFIRMATION": { + // Remote confirms they received our local change + effects.push(log("debug", `Remote confirmed sync: ${event.fileName}`), { + type: "UPDATE_FILE_METADATA", + fileName: event.fileName, + remoteModifiedAt: event.remoteModifiedAt, + }) + + return { state, effects } + } + + case "DISCONNECT": { + effects.push( + { type: "PERSIST_STATE" }, + log("debug", "Disconnected, persisting state") + ) + + if (state.mode === "conflict_resolution") { + const { pendingConflicts: _discarded, ...rest } = state + return { + state: { + ...rest, + mode: "disconnected", + socket: null, + }, + effects, + } + } + + return { + state: { + ...state, + mode: "disconnected", + socket: null, + }, + effects, + } + } + + case "REQUEST_FILES": { + // Plugin is asking for our local file list + // Valid in any mode except disconnected + if (state.mode === "disconnected") { + effects.push( + log("warn", "Received REQUEST_FILES while disconnected, ignoring") + ) + return { state, effects } + } + + effects.push(log("debug", "Plugin requested file list"), { + type: "LIST_LOCAL_FILES", + }) + + return { state, effects } + } + + case "FILE_LIST": { + if (state.mode !== "handshaking") { + effects.push( + log("warn", `Received FILE_LIST in mode ${state.mode}, ignoring`) + ) + return { state, effects } + } + + effects.push( + log("debug", `Received file list: ${event.files.length} files`) + ) + + // During initial file list, detect conflicts between remote snapshot and local files + effects.push({ + type: "DETECT_CONFLICTS", + remoteFiles: event.files, + }) + + // Transition to snapshot_processing - conflict detection effect will determine next mode + return { + state: { + ...state, + mode: "snapshot_processing", + pendingRemoteChanges: event.files, + }, + effects, + } + } + + case "CONFLICTS_DETECTED": { + if (state.mode !== "snapshot_processing") { + effects.push( + log( + "warn", + `Received CONFLICTS_DETECTED in mode ${state.mode}, ignoring` + ) + ) + return { state, effects } + } + + const { conflicts, safeWrites, localOnly } = event + + // detectConflicts returns: + // - safeWrites = files we can apply (remote-only or local unchanged) + // - conflicts = files that need manual resolution (content or deletion conflicts) + // - localOnly = files to upload + // (unchanged files have metadata recorded in DETECT_CONFLICTS executor) + + // Apply safe writes + if (safeWrites.length > 0) { + effects.push( + log("debug", `Applying ${safeWrites.length} safe writes`), + { type: "WRITE_FILES", files: safeWrites, silent: true } + ) + } + + // Upload local-only files + if (localOnly.length > 0) { + effects.push( + log("debug", `Uploading ${localOnly.length} local-only files`) + ) + for (const file of localOnly) { + effects.push({ + type: "SEND_MESSAGE", + payload: { + type: "file-change", + fileName: file.name, + content: file.content, + }, + }) + } + } + + // If conflicts remain, request remote version data before surfacing to user + if (conflicts.length > 0) { + effects.push( + log( + "debug", + `${pluralize(conflicts.length, "conflict")} require version check` + ), + { type: "REQUEST_CONFLICT_VERSIONS", conflicts } + ) + + return { + state: { + ...state, + mode: "conflict_resolution", + pendingConflicts: conflicts, + }, + effects, + } + } + + // No conflicts - transition to watching + const remoteTotal = state.pendingRemoteChanges.length + const totalCount = remoteTotal + localOnly.length + const updatedCount = safeWrites.length + localOnly.length + const unchangedCount = Math.max(0, remoteTotal - safeWrites.length) + effects.push( + { type: "PERSIST_STATE" }, + { + type: "SYNC_COMPLETE", + totalCount, + updatedCount, + unchangedCount, + } + ) + + return { + state: { + ...state, + mode: "watching", + pendingRemoteChanges: [], + }, + effects, + } + } + + case "FILE_CHANGE": { + // Use helper to validate the incoming change + const validation = validateIncomingChange(event.fileMeta, state.mode) + + if (validation.action === "queue") { + effects.push( + log( + "debug", + `Queueing file change: ${event.file.name} (${validation.reason})` + ) + ) + + return { + state: { + ...state, + pendingRemoteChanges: [...state.pendingRemoteChanges, event.file], + }, + effects, + } + } + + if (validation.action === "reject") { + effects.push( + log( + "warn", + `Rejected file change: ${event.file.name} (${validation.reason})` + ) + ) + return { state, effects } + } + + // Apply the change + effects.push(log("debug", `Applying remote change: ${event.file.name}`), { + type: "WRITE_FILES", + files: [event.file], + skipEcho: true, + }) + + return { state, effects } + } + + case "REMOTE_FILE_DELETE": { + // Reject if not connected + if (state.mode === "disconnected") { + effects.push( + log("warn", `Rejected delete while disconnected: ${event.fileName}`) + ) + return { state, effects } + } + + // Remote deletes should always be applied immediately + // (the file is already gone from Framer) + effects.push( + log("debug", `Remote delete applied: ${event.fileName}`), + { type: "DELETE_LOCAL_FILES", names: [event.fileName] }, + { type: "PERSIST_STATE" } + ) + + return { state, effects } + } + + case "LOCAL_DELETE_APPROVED": { + // User confirmed the delete - apply it + effects.push( + log("debug", `Delete confirmed: ${event.fileName}`), + { type: "DELETE_LOCAL_FILES", names: [event.fileName] }, + { type: "PERSIST_STATE" } + ) + + return { state, effects } + } + + case "LOCAL_DELETE_REJECTED": { + // User cancelled - restore the file + effects.push(log("debug", `Delete cancelled: ${event.fileName}`)) + effects.push({ + type: "WRITE_FILES", + files: [ + { + name: event.fileName, + content: event.content, + modifiedAt: Date.now(), + }, + ], + }) + + return { state, effects } + } + + case "CONFLICTS_RESOLVED": { + // Only valid in conflict_resolution mode + if (state.mode !== "conflict_resolution") { + effects.push( + log( + "warn", + `Received CONFLICTS_RESOLVED in mode ${state.mode}, ignoring` + ) + ) + return { state, effects } + } + + // User picked one resolution for ALL conflicts + if (event.resolution === "remote") { + // Apply all remote versions (or delete locally if remote is null) + for (const conflict of state.pendingConflicts) { + if (conflict.remoteContent === null) { + // Remote deleted this file - delete locally + effects.push({ + type: "DELETE_LOCAL_FILES", + names: [conflict.fileName], + }) + } else { + effects.push({ + type: "WRITE_FILES", + files: [ + { + name: conflict.fileName, + content: conflict.remoteContent, + modifiedAt: conflict.remoteModifiedAt, + }, + ], + silent: true, + }) + } + } + effects.push(log("success", "Keeping Framer changes")) + } else { + // Send all local versions (or delete from Framer if local is null) + for (const conflict of state.pendingConflicts) { + if (conflict.localContent === null) { + // Local deleted this file - delete from Framer + effects.push({ + type: "SEND_MESSAGE", + payload: { + type: "file-delete", + fileNames: [conflict.fileName], + }, + }) + } else { + effects.push({ + type: "SEND_MESSAGE", + payload: { + type: "file-change", + fileName: conflict.fileName, + content: conflict.localContent, + }, + }) + } + } + effects.push(log("success", "Keeping local changes")) + } + + // All conflicts resolved - transition to watching + effects.push( + { type: "PERSIST_STATE" }, + { + type: "SYNC_COMPLETE", + totalCount: state.pendingConflicts.length, + updatedCount: state.pendingConflicts.length, + unchangedCount: 0, + } + ) + + const { pendingConflicts: _discarded, ...rest } = state + return { + state: { + ...rest, + mode: "watching", + }, + effects, + } + } + + case "WATCHER_EVENT": { + // Local file system change detected + const { kind, relativePath, content } = event.event + + // Only process changes in watching mode + if (state.mode !== "watching") { + effects.push( + log( + "debug", + `Ignoring watcher event in ${state.mode} mode: ${kind} ${relativePath}` + ) + ) + return { state, effects } + } + + switch (kind) { + case "add": + case "change": { + if (content === undefined) { + effects.push( + log("warn", `Watcher event missing content: ${relativePath}`) + ) + return { state, effects } + } + + effects.push({ + type: "SEND_LOCAL_CHANGE", + fileName: relativePath, + content, + }) + break + } + + case "delete": { + effects.push(log("debug", `Local delete detected: ${relativePath}`), { + type: "REQUEST_LOCAL_DELETE_DECISION", + fileName: relativePath, + requireConfirmation: true, // Will be overridden by config in effect + }) + break + } + } + + return { state, effects } + } + + case "CONFLICT_VERSION_RESPONSE": { + if (state.mode !== "conflict_resolution") { + effects.push( + log( + "warn", + `Received CONFLICT_VERSION_RESPONSE in mode ${state.mode}, ignoring` + ) + ) + return { state, effects } + } + + const { autoResolvedLocal, autoResolvedRemote, remainingConflicts } = + autoResolveConflicts(state.pendingConflicts, event.versions) + + if (autoResolvedLocal.length > 0) { + effects.push( + log( + "debug", + `Auto-resolved ${autoResolvedLocal.length} local changes` + ) + ) + for (const conflict of autoResolvedLocal) { + if (conflict.localContent === null) { + // Local deleted - delete from Framer + effects.push({ + type: "SEND_MESSAGE", + payload: { + type: "file-delete", + fileNames: [conflict.fileName], + }, + }) + } else { + effects.push({ + type: "SEND_LOCAL_CHANGE", + fileName: conflict.fileName, + content: conflict.localContent, + }) + } + } + } + + if (autoResolvedRemote.length > 0) { + effects.push( + log( + "debug", + `Auto-resolved ${autoResolvedRemote.length} remote changes` + ) + ) + for (const conflict of autoResolvedRemote) { + if (conflict.remoteContent === null) { + // Remote deleted - delete locally + effects.push({ + type: "DELETE_LOCAL_FILES", + names: [conflict.fileName], + }) + } else { + effects.push({ + type: "WRITE_FILES", + files: [ + { + name: conflict.fileName, + content: conflict.remoteContent, + modifiedAt: conflict.remoteModifiedAt ?? Date.now(), + }, + ], + silent: true, // Auto-resolved during initial sync - no individual indicators + }) + } + } + } + + if (remainingConflicts.length > 0) { + effects.push( + log( + "warn", + `${pluralize(remainingConflicts.length, "conflict")} require resolution` + ), + { type: "REQUEST_CONFLICT_DECISIONS", conflicts: remainingConflicts } + ) + + return { + state: { + ...state, + pendingConflicts: remainingConflicts, + }, + effects, + } + } + + const resolvedCount = autoResolvedLocal.length + autoResolvedRemote.length + effects.push( + { type: "PERSIST_STATE" }, + { + type: "SYNC_COMPLETE", + totalCount: resolvedCount, + updatedCount: resolvedCount, + unchangedCount: 0, + } + ) + + const { pendingConflicts: _discarded, ...rest } = state + return { + state: { + ...rest, + mode: "watching", + pendingRemoteChanges: [], + }, + effects, + } + } + + default: { + effects.push(log("warn", `Unhandled event type in transition`)) + return { state, effects } + } + } +} + +/** + * Effect executor - interprets effects and calls helpers + * Returns additional events that should be processed (e.g., CONFLICTS_DETECTED after DETECT_CONFLICTS) + */ +async function executeEffect( + effect: Effect, + context: { + config: Config + hashTracker: ReturnType + installer: Installer | null + fileMetadataCache: FileMetadataCache + userActions: UserActionCoordinator + syncState: SyncState + } +): Promise { + const { + config, + hashTracker, + installer, + fileMetadataCache, + userActions, + syncState, + } = context + + switch (effect.type) { + case "INIT_WORKSPACE": { + // Initialize project directory if not already set + if (!config.projectDir) { + const projectName = + config.explicitName ?? effect.projectInfo.projectName + + config.projectDir = await findOrCreateProjectDir( + config.projectHash, + projectName, + config.explicitDir + ) + + // May allow customization of file directory in the future + config.filesDir = `${config.projectDir}/files` + debug(`Files directory: ${config.filesDir}`) + await fs.mkdir(config.filesDir, { recursive: true }) + } + return [] + } + + case "LOAD_PERSISTED_STATE": { + if (config.projectDir) { + await fileMetadataCache.initialize(config.projectDir) + debug(`Loaded persisted metadata for ${fileMetadataCache.size()} files`) + } + return [] + } + + case "LIST_LOCAL_FILES": { + if (!config.filesDir) { + return [] + } + + // List all local files and send to plugin + const files = await listFiles(config.filesDir) + + if (syncState.socket) { + await sendMessage(syncState.socket, { + type: "file-list", + files, + }) + } + + return [] + } + + case "DETECT_CONFLICTS": { + if (!config.filesDir) { + return [] + } + + // Use existing helper to detect conflicts + const { conflicts, writes, localOnly, unchanged } = await detectConflicts( + effect.remoteFiles, + config.filesDir, + { persistedState: fileMetadataCache.getPersistedState() } + ) + + // Record metadata for unchanged files so watcher add events get skipped + // (chokidar ignoreInitial=false fires late adds that would otherwise re-upload) + for (const file of unchanged) { + fileMetadataCache.recordRemoteWrite( + file.name, + file.content, + file.modifiedAt ?? Date.now() + ) + } + + // Return CONFLICTS_DETECTED event to continue the flow + return [ + { + type: "CONFLICTS_DETECTED", + conflicts, + safeWrites: writes, + localOnly, + }, + ] + } + + case "SEND_MESSAGE": { + if (syncState.socket) { + const sent = await sendMessage(syncState.socket, effect.payload) + if (!sent) { + warn(`Failed to send message: ${effect.payload.type}`) + } + } else { + warn(`No socket available to send: ${effect.payload.type}`) + } + return [] + } + + case "WRITE_FILES": { + if (config.filesDir) { + // skipEcho skip writes that match hashTracker (inbound echo) + // it is opt-in: some callers still need side-effects (metadata/logs) + // even when content matches the last hash tracked in-memory. + const filesToWrite = + effect.skipEcho === true + ? filterEchoedFiles(effect.files, hashTracker) + : effect.files + + if (effect.skipEcho && filesToWrite.length !== effect.files.length) { + const skipped = effect.files.length - filesToWrite.length + debug(`Skipped ${pluralize(skipped, "echoed change")}`) + } + + if (filesToWrite.length === 0) { + return [] + } + + await writeRemoteFiles( + filesToWrite, + config.filesDir, + hashTracker, + installer ?? undefined + ) + for (const file of filesToWrite) { + if (!effect.silent) { + fileDown(file.name) + } + const remoteTimestamp = file.modifiedAt ?? Date.now() + fileMetadataCache.recordRemoteWrite( + file.name, + file.content, + remoteTimestamp + ) + } + } + return [] + } + + case "DELETE_LOCAL_FILES": { + if (config.filesDir) { + for (const fileName of effect.names) { + await deleteLocalFile(fileName, config.filesDir, hashTracker) + fileDelete(fileName) + fileMetadataCache.recordDelete(fileName) + } + } + return [] + } + + case "REQUEST_CONFLICT_DECISIONS": { + await userActions.requestConflictDecisions( + syncState.socket, + effect.conflicts + ) + + return [] + } + + case "REQUEST_CONFLICT_VERSIONS": { + if (!syncState.socket) { + warn("Cannot request conflict versions without active socket") + return [] + } + + const persistedState = fileMetadataCache.getPersistedState() + const versionRequests = effect.conflicts.map((conflict) => { + const persisted = persistedState.get(conflict.fileName) + return { + fileName: conflict.fileName, + lastSyncedAt: conflict.lastSyncedAt ?? persisted?.timestamp, + } + }) + + debug( + `Requesting remote version data for ${pluralize(versionRequests.length, "file")}` + ) + + await sendMessage(syncState.socket, { + type: "conflict-version-request", + conflicts: versionRequests, + }) + + return [] + } + + case "REQUEST_DELETE_CONFIRMATION": { + if (syncState.socket) { + // Send delete request to plugin + await sendMessage(syncState.socket, { + type: "file-delete", + fileNames: [effect.fileName], + requireConfirmation: effect.requireConfirmation, + }) + } + // Response will come via delete-confirmed or delete-cancelled message + return [] + } + + case "UPDATE_FILE_METADATA": { + if (!config.filesDir || !config.projectDir) { + return [] + } + + // Read current file content to compute hash + const currentContent = await readFileSafe( + effect.fileName, + config.filesDir + ) + + if (currentContent !== null) { + const contentHash = hashFileContent(currentContent) + fileMetadataCache.recordSyncedSnapshot( + effect.fileName, + contentHash, + effect.remoteModifiedAt + ) + } + + return [] + } + + case "SEND_LOCAL_CHANGE": { + const contentHash = hashFileContent(effect.content) + const metadata = fileMetadataCache.get(effect.fileName) + + // Skip if file matches last confirmed remote content + if (metadata?.lastSyncedHash === contentHash) { + debug( + `Skipping local change for ${effect.fileName}: matches last synced content` + ) + return [] + } + + // Echo prevention: skip if we just wrote this exact content + if (hashTracker.shouldSkip(effect.fileName, effect.content)) { + return [] + } + + debug(`Local change detected: ${effect.fileName}`) + + try { + // Send change to plugin + if (syncState.socket) { + await sendMessage(syncState.socket, { + type: "file-change", + fileName: effect.fileName, + content: effect.content, + }) + fileUp(effect.fileName) + } + + // Only remember hash after successful send (prevents re-sending on failure) + hashTracker.remember(effect.fileName, effect.content) + + // Trigger type installer + if (installer) { + installer.process(effect.fileName, effect.content) + } + } catch (err) { + warn(`Failed to push ${effect.fileName}`) + } + + return [] + } + + case "REQUEST_LOCAL_DELETE_DECISION": { + // Echo prevention: skip if this is a remote-initiated delete + const shouldSkip = hashTracker.shouldSkipDelete(effect.fileName) + + if (shouldSkip) { + // Clear the delete marker now that we've caught the echo + hashTracker.clearDelete(effect.fileName) + return [] + } + + try { + const shouldDelete = await userActions.requestDeleteDecision( + syncState.socket, + { + fileName: effect.fileName, + requireConfirmation: !config.dangerouslyAutoDelete, + } + ) + + if (shouldDelete) { + hashTracker.forget(effect.fileName) + fileMetadataCache.recordDelete(effect.fileName) + + if (syncState.socket) { + await sendMessage(syncState.socket, { + type: "file-delete", + fileNames: [effect.fileName], + }) + } + } + } catch (err) { + console.warn(`Failed to handle deletion for ${effect.fileName}:`, err) + } + + return [] + } + + case "PERSIST_STATE": { + await fileMetadataCache.flush() + return [] + } + + case "SYNC_COMPLETE": { + const wasDisconnected = wasRecentlyDisconnected() + + // Notify plugin that sync is complete + if (syncState.socket) { + await sendMessage(syncState.socket, { type: "sync-complete" }) + } + + if (wasDisconnected) { + // Only show reconnect message if we actually showed the disconnect notice + if (didShowDisconnect()) { + success( + `Reconnected, synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)` + ) + status("Watching for changes...") + } + resetDisconnectState() + return [] + } + + success( + `Synced ${effect.totalCount} files (${effect.updatedCount} updated, ${effect.unchangedCount} unchanged)` + ) + status("Watching for changes...") + return [] + } + + case "LOG": { + const logFns = { info, warn, success, debug } + const logFn = logFns[effect.level] + logFn(effect.message) + return [] + } + } +} + +/** + * Starts the sync controller with the given configuration + */ +export async function start(config: Config): Promise { + status("Waiting for Plugin connection...") + + const hashTracker = createHashTracker() + const fileMetadataCache = new FileMetadataCache() + let installer: Installer | null = null + + // State machine state + let syncState: SyncState = { + mode: "disconnected", + socket: null, + pendingRemoteChanges: [], + pendingOperations: new Map(), + nextOperationId: 1, + } + + const userActions = new UserActionCoordinator() + + // State Machine Helper + // Process events through state machine and execute effects recursively + async function processEvent(event: SyncEvent) { + const socketState = syncState.socket?.readyState + debug( + `[STATE] Processing event: ${event.type} (mode: ${syncState.mode}, socket: ${socketState ?? "none"})` + ) + + const result = transition(syncState, event) + syncState = result.state + + if (result.effects.length > 0) { + debug( + `[STATE] Event produced ${result.effects.length} effects: ${result.effects.map((e) => e.type).join(", ")}` + ) + } + + // Execute all effects and process any follow-up events + for (const effect of result.effects) { + // Check socket state before each effect + const currentSocketState = syncState.socket?.readyState + if (currentSocketState !== undefined && currentSocketState !== 1) { + debug( + `[STATE] Socket not open (state: ${currentSocketState}) before executing ${effect.type}` + ) + } + + const followUpEvents = await executeEffect(effect, { + config, + hashTracker, + installer, + fileMetadataCache, + userActions, + syncState, + }) + + // Recursively process follow-up events + for (const followUpEvent of followUpEvents) { + await processEvent(followUpEvent) + } + } + } + + // WebSocket Connection + const connection = await initConnection(config.port) + + // Handle initial handshake + connection.on("handshake", (client: WebSocket, message) => { + debug(`Received handshake: ${message.projectName} (${message.projectId})`) + + // Validate project hash (normalize both to short hash for comparison) + const expectedShort = shortProjectHash(config.projectHash) + const receivedShort = shortProjectHash(message.projectId) + if (receivedShort !== expectedShort) { + warn( + `Project ID mismatch: expected ${expectedShort}, got ${receivedShort}` + ) + client.close() + return + } + + void (async () => { + // Process handshake through state machine + await processEvent({ + type: "HANDSHAKE", + socket: client, + projectInfo: { + projectId: message.projectId, + projectName: message.projectName, + }, + }) + + // Initialize installer if needed + if (config.projectDir && !installer) { + installer = new Installer({ + projectDir: config.projectDir, + allowUnsupportedNpm: config.allowUnsupportedNpm, + }) + await installer.initialize() + // Start file watcher now that we have a directory + startWatcher() + } + + // Cancel any pending disconnect message (fast reconnect) + cancelDisconnectMessage() + + // Only show "Connected" on initial connection, not reconnects + // Reconnect confirmation happens in SYNC_COMPLETE + const wasDisconnected = wasRecentlyDisconnected() + if (!wasDisconnected && !didShowDisconnect()) { + success(`Connected to ${message.projectName}`) + } + })() + }) + + // Message Handler + async function handleMessage(message: IncomingMessage) { + // Ensure project is initialized before handling messages + if (!config.projectDir || !installer) { + warn("Received message before handshake completed - ignoring") + return + } + + let event: SyncEvent | null = null + + // Map incoming messages to state machine events + switch (message.type) { + case "request-files": + event = { type: "REQUEST_FILES" } + break + + case "file-list": { + debug(`Received file list: ${message.files.length} files`) + event = { type: "FILE_LIST", files: message.files } + break + } + + case "file-change": + event = { + type: "FILE_CHANGE", + file: { + name: message.fileName, + content: message.content, + // Remote modifiedAt is expensive to compute (requires getVerions API call), so we + // use local receipt time. Conflict detection uses content hashes, not timestamps. + modifiedAt: Date.now(), + }, + fileMeta: fileMetadataCache.get(message.fileName), + } + break + + case "file-delete": { + // Remote deletes are always applied immediately (file is already gone from Framer) + for (const fileName of message.fileNames) { + await processEvent({ + type: "REMOTE_FILE_DELETE", + fileName, + }) + } + return + } + + case "delete-confirmed": { + const unmatched: string[] = [] + + for (const fileName of message.fileNames) { + const handled = userActions.handleConfirmation( + `delete:${fileName}`, + true + ) + + if (!handled) { + unmatched.push(fileName) + } + } + + for (const fileName of unmatched) { + await processEvent({ type: "LOCAL_DELETE_APPROVED", fileName }) + } + + return + } + + case "delete-cancelled": { + for (const file of message.files) { + userActions.handleConfirmation(`delete:${file.fileName}`, false) + + await processEvent({ + type: "LOCAL_DELETE_REJECTED", + fileName: file.fileName, + content: file.content ?? "", + }) + } + + return + } + + case "file-synced": + event = { + type: "FILE_SYNCED_CONFIRMATION", + fileName: message.fileName, + remoteModifiedAt: message.remoteModifiedAt, + } + break + + case "conflicts-resolved": + event = { + type: "CONFLICTS_RESOLVED", + resolution: message.resolution, + } + break + + case "conflict-version-response": + event = { + type: "CONFLICT_VERSION_RESPONSE", + versions: message.versions, + } + break + + default: + warn(`Unhandled message type: ${message.type}`) + return + } + + await processEvent(event) + } + + connection.on("message", (message: IncomingMessage) => { + void (async () => { + try { + await handleMessage(message) + } catch (err) { + error("Error handling message:", err) + } + })() + }) + + connection.on("disconnect", () => { + // Schedule disconnect message with delay - if reconnect happens quickly, we skip it + scheduleDisconnectMessage(() => { + status("Disconnected, waiting to reconnect...") + }) + void (async () => { + await processEvent({ type: "DISCONNECT" }) + userActions.cleanup() + })() + }) + + connection.on("error", (err) => { + error("Error on WebSocket connection:", err) + }) + + // File Watcher Setup + // Watcher will be initialized after handshake when filesDir is set + let watcher: ReturnType | null = null + + const startWatcher = () => { + if (!config.filesDir || watcher) return + watcher = initWatcher(config.filesDir) + + watcher.on("change", (event) => { + void processEvent({ type: "WATCHER_EVENT", event }) + }) + } + + // Graceful shutdown + process.on("SIGINT", () => { + console.log() // newline after ^C + status("Shutting down...") + void (async () => { + if (watcher) { + await watcher.close() + } + connection.close() + process.exit(0) + })() + }) +} + +// Export for testing +export { transition } diff --git a/packages/code-link-cli/src/helpers/connection.ts b/packages/code-link-cli/src/helpers/connection.ts new file mode 100644 index 000000000..ee0d2e7ec --- /dev/null +++ b/packages/code-link-cli/src/helpers/connection.ts @@ -0,0 +1,188 @@ +/** + * WebSocket connection helper + * + * Wrapper around ws.Server that normalizes handshake and surfaces callbacks. + */ + +import { WebSocketServer, WebSocket } from "ws" +import type { IncomingMessage, OutgoingMessage } from "../types.js" +import { debug, error } from "../utils/logging.js" + +export interface ConnectionCallbacks { + onHandshake: ( + client: WebSocket, + message: { projectId: string; projectName: string } + ) => void + onMessage: (message: IncomingMessage) => void + onDisconnect: () => void + onError: (error: Error) => void +} + +export interface Connection { + on(event: "handshake", handler: ConnectionCallbacks["onHandshake"]): void + on(event: "message", handler: ConnectionCallbacks["onMessage"]): void + on(event: "disconnect", handler: ConnectionCallbacks["onDisconnect"]): void + on(event: "error", handler: ConnectionCallbacks["onError"]): void + close(): void +} + +/** + * Initializes a WebSocket server and returns a connection interface + * Returns a Promise that resolves when the server is ready, or rejects on startup errors + */ +export function initConnection(port: number): Promise { + return new Promise((resolve, reject) => { + const wss = new WebSocketServer({ port }) + const handlers: Partial = {} + let connectionId = 0 + let isReady = false + + // Handle server-level errors (e.g., EADDRINUSE) + wss.on("error", (err: NodeJS.ErrnoException) => { + if (!isReady) { + // Startup error - reject the promise with a helpful message + if (err.code === "EADDRINUSE") { + error(`Port ${port} is already in use.`) + error( + `This usually means another instance of Code Link is already running.` + ) + error(``) + error(`To fix this:`) + error( + ` 1. Close any other terminal running Code Link for this project` + ) + error(` 2. Or run: lsof -i :${port} | grep LISTEN`) + error(` Then kill the process: kill -9 `) + reject(new Error(`Port ${port} is already in use`)) + } else { + error(`Failed to start WebSocket server: ${err.message}`) + reject(err) + } + return + } + // Runtime error - log but don't crash + error(`WebSocket server error: ${err.message}`) + }) + + // Server is ready when it starts listening + wss.on("listening", () => { + isReady = true + debug(`WebSocket server listening on port ${port}`) + + wss.on("connection", (ws: WebSocket) => { + const connId = ++connectionId + let handshakeReceived = false + debug(`Client connected (conn ${connId})`) + + ws.on("message", (data: Buffer) => { + try { + const message = JSON.parse(data.toString()) as IncomingMessage + + // Special handling for handshake + if (message.type === "handshake") { + debug(`Received handshake (conn ${connId})`) + handshakeReceived = true + handlers.onHandshake?.(ws, message) + } else if (handshakeReceived) { + handlers.onMessage?.(message) + } else { + // Ignore messages before handshake - plugin will send full snapshot after + debug( + `Ignoring ${message.type} before handshake (conn ${connId})` + ) + } + } catch (err) { + error(`Failed to parse message:`, err) + } + }) + + ws.on("close", (code, reason) => { + debug( + `Client disconnected (code: ${code}, reason: ${reason.toString()})` + ) + handlers.onDisconnect?.() + }) + + ws.on("error", (err) => { + error(`WebSocket error:`, err) + }) + }) + + resolve({ + on(event, handler) { + switch (event) { + case "handshake": + handlers.onHandshake = handler as ConnectionCallbacks["onHandshake"] + break + case "message": + handlers.onMessage = handler as ConnectionCallbacks["onMessage"] + break + case "disconnect": + handlers.onDisconnect = handler as ConnectionCallbacks["onDisconnect"] + break + case "error": + handlers.onError = handler as ConnectionCallbacks["onError"] + break + } + }, + + close(): void { + wss.close() + }, + } satisfies Connection) + }) + }) +} + +/** + * WebSocket readyState constants for reference + */ +const READY_STATE = { + CONNECTING: 0, + OPEN: 1, + CLOSING: 2, + CLOSED: 3, +} as const + +function readyStateToString(state: number): string { + switch (state) { + case 0: + return "CONNECTING" + case 1: + return "OPEN" + case 2: + return "CLOSING" + case 3: + return "CLOSED" + default: + return `UNKNOWN(${state})` + } +} + +/** + * Sends a message to a connected socket + * Returns false if the socket is not open (instead of throwing) + */ +export function sendMessage( + socket: WebSocket, + message: OutgoingMessage +): Promise { + return new Promise((resolve) => { + // Check socket state before attempting to send + if (socket.readyState !== READY_STATE.OPEN) { + const stateStr = readyStateToString(socket.readyState) + debug(`Cannot send ${message.type}: socket is ${stateStr}`) + resolve(false) + return + } + + socket.send(JSON.stringify(message), (err) => { + if (err) { + debug(`Send error for ${message.type}: ${err.message}`) + resolve(false) + } else { + resolve(true) + } + }) + }) +} diff --git a/packages/code-link-cli/src/helpers/files.test.ts b/packages/code-link-cli/src/helpers/files.test.ts new file mode 100644 index 000000000..df72f7329 --- /dev/null +++ b/packages/code-link-cli/src/helpers/files.test.ts @@ -0,0 +1,347 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" +import { describe, it, expect } from "vitest" +import { autoResolveConflicts, detectConflicts } from "./files.js" +import type { Conflict } from "../types.js" +import { hashFileContent } from "../utils/state-persistence.js" + +function makeConflict(overrides: Partial = {}): Conflict { + return { + fileName: overrides.fileName ?? "Test.tsx", + localContent: + "localContent" in overrides ? overrides.localContent : "local", + remoteContent: + "remoteContent" in overrides ? overrides.remoteContent : "remote", + localModifiedAt: overrides.localModifiedAt ?? Date.now(), + remoteModifiedAt: overrides.remoteModifiedAt ?? Date.now(), + lastSyncedAt: overrides.lastSyncedAt ?? Date.now(), + localClean: overrides.localClean, + } +} + +// Auto-Resolve Conflicts Tests +describe("autoResolveConflicts", () => { + it("classifies conflicts as local when remote unchanged and local changed", () => { + const conflict = makeConflict({ + lastSyncedAt: 5_000, + localClean: false, + }) + + const result = autoResolveConflicts( + [conflict], + [{ fileName: conflict.fileName, latestRemoteVersionMs: 5_000 }] + ) + + expect(result.autoResolvedLocal).toHaveLength(1) + expect(result.autoResolvedRemote).toHaveLength(0) + expect(result.remainingConflicts).toHaveLength(0) + }) + + it("classifies conflicts as remote when local is clean and remote changed", () => { + const conflict = makeConflict({ + lastSyncedAt: 5_000, + localClean: true, + }) + + const result = autoResolveConflicts( + [conflict], + [{ fileName: conflict.fileName, latestRemoteVersionMs: 10_000 }] + ) + + expect(result.autoResolvedRemote).toHaveLength(1) + expect(result.autoResolvedLocal).toHaveLength(0) + }) + + it("keeps conflicts that have both sides changed", () => { + const conflict = makeConflict({ + lastSyncedAt: 5_000, + localClean: false, + }) + + const result = autoResolveConflicts( + [conflict], + [{ fileName: conflict.fileName, latestRemoteVersionMs: 7_500 }] + ) + + expect(result.remainingConflicts).toHaveLength(1) + expect(result.autoResolvedLocal).toHaveLength(0) + expect(result.autoResolvedRemote).toHaveLength(0) + }) + + it("keeps conflicts when version data is missing", () => { + const conflict = makeConflict({ + lastSyncedAt: 5_000, + localClean: true, + }) + + const result = autoResolveConflicts([conflict], []) + + expect(result.remainingConflicts).toHaveLength(1) + }) + + it("auto-resolves remote deletion when local is clean", () => { + const conflict = makeConflict({ + remoteContent: null, // Deleted in Framer + localClean: true, + }) + + const result = autoResolveConflicts([conflict], []) + + // Remote deletion with clean local -> auto-resolve to remote (delete locally) + expect(result.autoResolvedRemote).toHaveLength(1) + expect(result.remainingConflicts).toHaveLength(0) + }) + + it("keeps conflict when remote deleted but local modified", () => { + const conflict = makeConflict({ + remoteContent: null, // Deleted in Framer + localClean: false, // But local was modified + }) + + const result = autoResolveConflicts([conflict], []) + + // User must decide: keep local changes or accept deletion + expect(result.remainingConflicts).toHaveLength(1) + expect(result.autoResolvedRemote).toHaveLength(0) + }) +}) + +// Detect Conflicts Tests +describe("detectConflicts", () => { + it("marks conflicts as localClean when local matches persisted state", async () => { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cl-test-")) + try { + const filesDir = path.join(tmpRoot, "files") + await fs.mkdir(filesDir, { recursive: true }) + + const localContent = "local content" + await fs.writeFile(path.join(filesDir, "Test.tsx"), localContent, "utf-8") + + const persistedState = new Map([ + [ + "Test.tsx", + { contentHash: hashFileContent(localContent), timestamp: 1_000 }, + ], + ]) + + const result = await detectConflicts( + [ + { + name: "Test.tsx", + content: "remote content", + modifiedAt: 2_000, + }, + ], + filesDir, + { persistedState } + ) + + expect(result.writes).toHaveLength(0) + expect(result.conflicts).toHaveLength(1) + expect(result.conflicts[0]?.localClean).toBe(true) + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }) + } + }) + + it("detects remote-only files as safe writes (new files to download)", async () => { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cl-test-")) + try { + const filesDir = path.join(tmpRoot, "files") + await fs.mkdir(filesDir, { recursive: true }) + + // No local files, one remote file + const result = await detectConflicts( + [ + { + name: "NewFromFramer.tsx", + content: "export const New = () =>
New
", + modifiedAt: Date.now(), + }, + ], + filesDir, + { persistedState: new Map() } + ) + + // Remote-only file should be a safe write + expect(result.writes).toHaveLength(1) + expect(result.writes[0]?.name).toBe("NewFromFramer.tsx") + expect(result.conflicts).toHaveLength(0) + expect(result.localOnly).toHaveLength(0) + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }) + } + }) + + it("detects local-only files (new files to upload)", async () => { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cl-test-")) + try { + const filesDir = path.join(tmpRoot, "files") + await fs.mkdir(filesDir, { recursive: true }) + + // Create a local file that doesn't exist in remote + await fs.writeFile( + path.join(filesDir, "LocalOnly.tsx"), + "export const Local = () =>
Local
", + "utf-8" + ) + + const result = await detectConflicts( + [], // No remote files + filesDir, + { persistedState: new Map() } + ) + + // Local-only file should be detected + expect(result.localOnly).toHaveLength(1) + expect(result.localOnly[0]?.name).toBe("LocalOnly.tsx") + expect(result.writes).toHaveLength(0) + expect(result.conflicts).toHaveLength(0) + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }) + } + }) + + it("handles case-insensitive file matching (macOS/Windows compat)", async () => { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cl-test-")) + try { + const filesDir = path.join(tmpRoot, "files") + await fs.mkdir(filesDir, { recursive: true }) + + // Local file with different casing than remote + await fs.writeFile( + path.join(filesDir, "mycomponent.tsx"), + "local content", + "utf-8" + ) + + const result = await detectConflicts( + [ + { + name: "MyComponent.tsx", // Different casing + content: "remote content", + modifiedAt: Date.now(), + }, + ], + filesDir, + { persistedState: new Map() } + ) + + // Should detect as conflict, not as two separate files + expect(result.conflicts).toHaveLength(1) + expect(result.localOnly).toHaveLength(0) + expect(result.writes).toHaveLength(0) + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }) + } + }) + + it("detects local deletion while offline as conflict", async () => { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cl-test-")) + try { + const filesDir = path.join(tmpRoot, "files") + await fs.mkdir(filesDir, { recursive: true }) + + // File was previously synced but now missing locally + const persistedState = new Map([ + [ + "DeletedLocally.tsx", + { contentHash: hashFileContent("old content"), timestamp: 1_000 }, + ], + ]) + + const result = await detectConflicts( + [ + { + name: "DeletedLocally.tsx", + content: "remote content still exists", + modifiedAt: 2_000, + }, + ], + filesDir, + { persistedState } + ) + + // Should be a conflict: local=null (deleted), remote=content + expect(result.conflicts).toHaveLength(1) + expect(result.conflicts[0]?.localContent).toBe(null) + expect(result.conflicts[0]?.remoteContent).toBe( + "remote content still exists" + ) + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }) + } + }) + + it("detects remote deletion while offline as conflict", async () => { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cl-test-")) + try { + const filesDir = path.join(tmpRoot, "files") + await fs.mkdir(filesDir, { recursive: true }) + + // Local file still exists + await fs.writeFile( + path.join(filesDir, "DeletedRemotely.tsx"), + "local content still exists", + "utf-8" + ) + + // File was previously synced + const persistedState = new Map([ + [ + "DeletedRemotely.tsx", + { + contentHash: hashFileContent("local content still exists"), + timestamp: 1_000, + }, + ], + ]) + + const result = await detectConflicts( + [], // File no longer in remote + filesDir, + { persistedState } + ) + + // Should be a conflict: local=content, remote=null (deleted) + expect(result.conflicts).toHaveLength(1) + expect(result.conflicts[0]?.localContent).toBe( + "local content still exists" + ) + expect(result.conflicts[0]?.remoteContent).toBe(null) + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }) + } + }) + + it("treats identical content as unchanged (no write needed)", async () => { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cl-test-")) + try { + const filesDir = path.join(tmpRoot, "files") + await fs.mkdir(filesDir, { recursive: true }) + + const content = "export const Same = () =>
Same
" + await fs.writeFile(path.join(filesDir, "Same.tsx"), content, "utf-8") + + const result = await detectConflicts( + [ + { + name: "Same.tsx", + content, // Same content + modifiedAt: Date.now(), + }, + ], + filesDir, + { persistedState: new Map() } + ) + + // No write needed, no conflict + expect(result.writes).toHaveLength(0) + expect(result.conflicts).toHaveLength(0) + expect(result.unchanged).toHaveLength(1) + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/code-link-cli/src/helpers/files.ts b/packages/code-link-cli/src/helpers/files.ts new file mode 100644 index 000000000..c6a0e020e --- /dev/null +++ b/packages/code-link-cli/src/helpers/files.ts @@ -0,0 +1,462 @@ +/** + * File operations helper + * + * Single place that understands disk + conflicts. Provides: + * - listFiles: returns current filesystem state + * - detectConflicts: compares remote vs local and returns conflicts + safe writes + * - writeRemoteFiles: applies writes/deletes from remote + * - deleteLocalFile: removes a file from disk + * + * Controller decides WHEN to call these, but never computes conflicts itself. + */ + +import fs from "fs/promises" +import path from "path" +import type { + FileInfo, + ConflictResolution, + Conflict, + ConflictVersionData, +} from "../types.js" +import type { createHashTracker, HashTracker } from "../utils/hash-tracker.js" +import { normalizePath, sanitizeFilePath } from "@code-link/shared" +import { warn, debug } from "../utils/logging.js" +import { + hashFileContent, + type PersistedFileState, +} from "../utils/state-persistence.js" + +const SUPPORTED_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".json"] +const DEFAULT_EXTENSION = ".tsx" +const DEFAULT_REMOTE_DRIFT_MS = 2000 + +/** Normalize file name for case-insensitive comparison (macOS/Windows compat) */ +function normalizeForComparison(fileName: string): string { + return fileName.toLowerCase() +} + +/** + * Lists all supported files in the files directory + */ +export async function listFiles(filesDir: string): Promise { + const files: FileInfo[] = [] + + async function walk(currentDir: string): Promise { + const entries = await fs.readdir(currentDir, { withFileTypes: true }) + + for (const entry of entries) { + const entryPath = path.join(currentDir, entry.name) + + if (entry.isDirectory()) { + await walk(entryPath) + continue + } + + if (!isSupportedExtension(entry.name)) continue + + const relativePath = path.relative(filesDir, entryPath) + const normalizedPath = normalizePath(relativePath) + // Don't capitalize when listing existing files - preserve their actual names + const sanitizedPath = sanitizeFilePath(normalizedPath, false).path + + try { + const [content, stats] = await Promise.all([ + fs.readFile(entryPath, "utf-8"), + fs.stat(entryPath), + ]) + + files.push({ + name: sanitizedPath, + content, + modifiedAt: stats.mtimeMs, + }) + } catch (err) { + warn(`Failed to read ${entryPath}:`, err) + } + } + } + + try { + await walk(filesDir) + } catch (err) { + warn("Failed to list files:", err) + } + + return files +} + +/** + * Detects conflicts between remote files and local filesystem + * Returns conflicts that need user resolution and safe writes that can be applied + */ +export interface ConflictDetectionOptions { + preferRemote?: boolean + detectConflicts?: boolean + persistedState?: Map +} + +export async function detectConflicts( + remoteFiles: FileInfo[], + filesDir: string, + options: ConflictDetectionOptions = {} +): Promise { + const conflicts: Conflict[] = [] + const writes: FileInfo[] = [] + const localOnly: FileInfo[] = [] + const unchanged: FileInfo[] = [] + const detect = options.detectConflicts ?? true + const preferRemote = options.preferRemote ?? false + const persistedState = options.persistedState + + const getPersistedState = (fileName: string) => + persistedState?.get(normalizeForComparison(fileName)) ?? + persistedState?.get(fileName) + + debug(`Detecting conflicts for ${String(remoteFiles.length)} remote files`) + + // Build a snapshot of all local files (keyed by lowercase for case-insensitive matching) + const localFiles = await listFiles(filesDir) + const localFileMap = new Map( + localFiles.map((f) => [normalizeForComparison(f.name), f]) + ) + + // Build a set of remote file names for quick lookup (lowercase keys) + const remoteFileMap = new Map( + remoteFiles.map((f) => { + const normalized = resolveRemoteReference(filesDir, f.name) + return [normalizeForComparison(normalized.relativePath), f] + }) + ) + + // Track which files we've processed (lowercase for case-insensitive matching) + const processedFiles = new Set() + + // Process remote files (remote-only or both sides) + for (const remote of remoteFiles) { + const normalized = resolveRemoteReference(filesDir, remote.name) + const normalizedKey = normalizeForComparison(normalized.relativePath) + const local = localFileMap.get(normalizedKey) + processedFiles.add(normalizedKey) + + const persisted = getPersistedState(normalized.relativePath) + const localHash = local ? hashFileContent(local.content) : null + const localMatchesPersisted = + !!persisted && !!local && localHash === persisted.contentHash + + if (!local) { + // File exists in remote but not locally + if (persisted) { + // File was previously synced but now missing locally → deleted locally while offline + // This is a conflict: local=null (deleted), remote=content + debug( + `Conflict: ${normalized.relativePath} deleted locally while offline` + ) + conflicts.push({ + fileName: normalized.relativePath, + localContent: null, + remoteContent: remote.content, + remoteModifiedAt: remote.modifiedAt, + lastSyncedAt: persisted.timestamp, + }) + } else { + // New file from remote (never synced before): download + writes.push({ + name: normalized.relativePath, + content: remote.content, + modifiedAt: remote.modifiedAt, + }) + } + continue + } + + if (local.content === remote.content) { + // Content matches - no disk write needed but track for metadata + unchanged.push({ + name: normalized.relativePath, + content: remote.content, + modifiedAt: remote.modifiedAt, + }) + continue + } + + if (!detect || preferRemote) { + writes.push({ + name: normalized.relativePath, + content: remote.content, + modifiedAt: remote.modifiedAt, + }) + continue + } + + // Check if local file is "clean" (matches last persisted state) + // If so, we can safely overwrite it with remote changes + // Both sides have the file with different content -> conflict + const localClean = persisted ? localMatchesPersisted : undefined + conflicts.push({ + fileName: normalized.relativePath, + localContent: local.content, + remoteContent: remote.content, + localModifiedAt: local.modifiedAt, + remoteModifiedAt: remote.modifiedAt, + lastSyncedAt: persisted?.timestamp, + localClean, + }) + } + + // Process local-only files (not present in remote) + for (const local of localFiles) { + const localKey = normalizeForComparison(local.name) + if (!processedFiles.has(localKey)) { + const persisted = getPersistedState(local.name) + if (persisted) { + // File was previously synced but now missing from remote → deleted in Framer + const localHash = hashFileContent(local.content) + const localClean = localHash === persisted.contentHash + debug( + `Conflict: ${local.name} deleted in Framer (localClean=${String(localClean)})` + ) + conflicts.push({ + fileName: local.name, + localContent: local.content, + remoteContent: null, + localModifiedAt: local.modifiedAt, + lastSyncedAt: persisted.timestamp, + localClean, + }) + } else { + // New local file (never synced before): upload later + localOnly.push({ + name: local.name, + content: local.content, + modifiedAt: local.modifiedAt, + }) + } + } + } + + // Check for files in persisted state that are missing from BOTH sides + // These were deleted on both sides while offline - auto-clean them (no conflict) + if (persistedState) { + for (const [fileName] of persistedState) { + const normalizedKey = normalizeForComparison(fileName) + const inLocal = localFileMap.has(normalizedKey) + const inRemote = remoteFileMap.has(normalizedKey) + if (!inLocal && !inRemote) { + debug(`[AUTO-RESOLVE] ${fileName}: deleted on both sides, no conflict`) + // No action needed - the file is gone from both sides + // The persisted state will be cleaned up when we persist + } + } + } + + return { conflicts, writes, localOnly, unchanged } +} + +export interface AutoResolveResult { + autoResolvedLocal: Conflict[] + autoResolvedRemote: Conflict[] + remainingConflicts: Conflict[] +} + +export function autoResolveConflicts( + conflicts: Conflict[], + versions: ConflictVersionData[], + options: { remoteDriftMs?: number } = {} +): AutoResolveResult { + const versionMap = new Map( + versions.map((version) => [version.fileName, version.latestRemoteVersionMs]) + ) + const remoteDriftMs = options.remoteDriftMs ?? DEFAULT_REMOTE_DRIFT_MS + + const autoResolvedLocal: Conflict[] = [] + const autoResolvedRemote: Conflict[] = [] + const remainingConflicts: Conflict[] = [] + + for (const conflict of conflicts) { + const latestRemoteVersionMs = versionMap.get(conflict.fileName) + const lastSyncedAt = conflict.lastSyncedAt + const localClean = conflict.localClean === true + + debug(`Auto-resolve checking ${conflict.fileName}`) + + // Remote deletion: file deleted in Framer + if (conflict.remoteContent === null) { + if (localClean) { + debug(` Remote deleted, local clean -> REMOTE (delete locally)`) + autoResolvedRemote.push(conflict) + } else { + debug(` Remote deleted, local modified -> conflict`) + remainingConflicts.push(conflict) + } + continue + } + + if (!latestRemoteVersionMs) { + debug(` No remote version data, keeping conflict`) + remainingConflicts.push(conflict) + continue + } + + if (!lastSyncedAt) { + debug(` No last sync timestamp, keeping conflict`) + remainingConflicts.push(conflict) + continue + } + + debug(` Remote: ${new Date(latestRemoteVersionMs).toISOString()}`) + debug(` Synced: ${new Date(lastSyncedAt).toISOString()}`) + + const remoteUnchanged = + latestRemoteVersionMs <= lastSyncedAt + remoteDriftMs + // localClean already declared above for remote deletion handling + + if (remoteUnchanged && !localClean) { + debug(` Remote unchanged, local changed -> LOCAL`) + autoResolvedLocal.push(conflict) + } else if (localClean && !remoteUnchanged) { + debug(` Local unchanged, remote changed -> REMOTE`) + autoResolvedRemote.push(conflict) + } else if (remoteUnchanged && localClean) { + debug(` Both unchanged, skipping`) + } else { + debug(` Both changed, real conflict`) + remainingConflicts.push(conflict) + } + } + + return { + autoResolvedLocal, + autoResolvedRemote, + remainingConflicts, + } +} + +/** + * Writes remote files to disk and updates hash tracker to prevent echoes + * CRITICAL: Update hashTracker BEFORE writing to disk + */ +export async function writeRemoteFiles( + files: FileInfo[], + filesDir: string, + hashTracker: HashTracker, + installer?: { process: (fileName: string, content: string) => void } +): Promise { + debug(`Writing ${files.length} remote files`) + + for (const file of files) { + try { + const normalized = resolveRemoteReference(filesDir, file.name) + const fullPath = normalized.absolutePath + + // Ensure directory exists + await fs.mkdir(path.dirname(fullPath), { recursive: true }) + + // CRITICAL ORDER: Update hash tracker FIRST (in memory) + hashTracker.remember(normalized.relativePath, file.content) + + // THEN write to disk + await fs.writeFile(fullPath, file.content, "utf-8") + + debug(`Wrote file: ${normalized.relativePath}`) + + // Trigger type installer if available + installer?.process(normalized.relativePath, file.content) + } catch (err) { + warn(`Failed to write file ${file.name}:`, err) + } + } +} + +/** + * Deletes a local file from disk + */ +export async function deleteLocalFile( + fileName: string, + filesDir: string, + hashTracker: HashTracker +): Promise { + const normalized = resolveRemoteReference(filesDir, fileName) + + try { + // CRITICAL ORDER: Mark delete FIRST (in memory) to prevent echo + hashTracker.markDelete(normalized.relativePath) + + // THEN delete from disk + await fs.unlink(normalized.absolutePath) + + // Clear the hash immediately + hashTracker.forget(normalized.relativePath) + + debug(`Deleted file: ${normalized.relativePath}`) + } catch (err) { + const nodeError = err as NodeJS.ErrnoException + + if (nodeError.code === "ENOENT") { + // Treat missing files as already deleted to keep hash tracker in sync + hashTracker.forget(normalized.relativePath) + debug(`File already deleted: ${normalized.relativePath}`) + return + } + + // Clear pending delete marker immediately on failure + hashTracker.clearDelete(normalized.relativePath) + warn(`Failed to delete file ${fileName}:`, err) + } +} + +/** + * Reads a single file from disk (safe, returns null on error) + */ +export async function readFileSafe( + fileName: string, + filesDir: string +): Promise { + const normalized = resolveRemoteReference(filesDir, fileName) + + try { + return await fs.readFile(normalized.absolutePath, "utf-8") + } catch { + return null + } +} + +/** + * Filter out files whose content matches the last remembered hash. + * Used to skip inbound echoes of our own local sends. + */ +export function filterEchoedFiles( + files: FileInfo[], + hashTracker: ReturnType +): FileInfo[] { + return files.filter((file) => { + return !hashTracker.shouldSkip(file.name, file.content) + }) +} + +function resolveRemoteReference(filesDir: string, rawName: string) { + const normalized = sanitizeRelativePath(rawName) + const absolutePath = path.join(filesDir, normalized.relativePath) + return { ...normalized, absolutePath } +} + +function sanitizeRelativePath(relativePath: string) { + const trimmed = normalizePath(relativePath.trim()) + const hasExtension = SUPPORTED_EXTENSIONS.some((ext) => + trimmed.toLowerCase().endsWith(ext) + ) + const candidate = hasExtension ? trimmed : `${trimmed}${DEFAULT_EXTENSION}` + // Don't capitalize when processing remote files - preserve exact casing from Framer + const sanitized = sanitizeFilePath(candidate, false) + const normalized = normalizePath(sanitized.path) + + return { + relativePath: normalized, + extension: + sanitized.extension || path.extname(normalized) || DEFAULT_EXTENSION, + } +} + +function isSupportedExtension(fileName: string) { + const lower = fileName.toLowerCase() + return SUPPORTED_EXTENSIONS.some((ext) => lower.endsWith(ext)) +} diff --git a/packages/code-link-cli/src/helpers/installer.ts b/packages/code-link-cli/src/helpers/installer.ts new file mode 100644 index 000000000..b66936393 --- /dev/null +++ b/packages/code-link-cli/src/helpers/installer.ts @@ -0,0 +1,626 @@ +/** + * Type installer helper using @typescript/ata + */ + +import { setupTypeAcquisition } from "@typescript/ata" +import ts from "typescript" +import path from "path" +import fs from "fs/promises" +import { extractImports } from "../utils/imports.js" +import { debug, warn } from "../utils/logging.js" + +export interface InstallerConfig { + projectDir: string + allowUnsupportedNpm?: boolean +} + +/** npm registry package.json exports field value */ +interface NpmExportValue { + import?: string + require?: string + types?: string +} + +/** npm registry API response for a single package version */ +interface NpmPackageVersion { + exports?: Record +} + +/** npm registry API response */ +interface NpmRegistryResponse { + "dist-tags"?: { latest?: string } + versions?: Record +} + +const FETCH_TIMEOUT_MS = 60_000 +const MAX_FETCH_RETRIES = 3 +const REACT_TYPES_VERSION = "18.3.12" +const REACT_DOM_TYPES_VERSION = "18.3.1" +const CORE_LIBRARIES = ["framer-motion", "framer"] +const JSON_EXTENSION_REGEX = /\.json$/i + +/** + * Packages that are officially supported for type acquisition. + * Use --unsupported-npm flag to allow other packages. + */ +const SUPPORTED_PACKAGES = new Set([ + "framer", + "framer-motion", + "react", + "@types/react", + "eventemitter3", + "csstype", + "motion-dom", + "motion-utils", +]) + +/** + * Installer class for managing automatic type acquisition. + */ +export class Installer { + private projectDir: string + private allowUnsupportedNpm: boolean + private ata: ReturnType + private processedImports = new Set() + private initializationPromise: Promise | null = null + + constructor(config: InstallerConfig) { + this.projectDir = config.projectDir + this.allowUnsupportedNpm = config.allowUnsupportedNpm ?? false + + const seenPackages = new Set() + + this.ata = setupTypeAcquisition({ + projectName: "framer-code-link", + typescript: ts, + logger: console, + fetcher: fetchWithRetry, + delegate: { + started: () => { + seenPackages.clear() + debug("ATA: fetching type definitions...") + }, + progress: () => { + // intentionally noop – progress noise is not helpful in CLI output + }, + finished: (files) => { + if (files.size > 0) { + debug("ATA: type acquisition complete") + } + }, + errorMessage: (message: string, error: Error) => { + warn(`ATA warning: ${message}`, error) + }, + receivedFile: (code: string, receivedPath: string) => { + void (async () => { + const normalized = receivedPath.replace(/^\//, "") + const destination = path.join(this.projectDir, normalized) + + const pkgMatch = /\/node_modules\/(@?[^/]+(?:\/[^/]+)?)\//.exec( + receivedPath + ) + + // Check if file already exists with same content + try { + const existing = await fs.readFile(destination, "utf-8") + if (existing === code) { + if (pkgMatch && !seenPackages.has(pkgMatch[1])) { + seenPackages.add(pkgMatch[1]) + debug(`📦 Types: ${pkgMatch[1]} (from disk cache)`) + } + return // Skip write if identical + } + } catch { + // File doesn't exist or can't be read, proceed with write + } + + if (pkgMatch && !seenPackages.has(pkgMatch[1])) { + seenPackages.add(pkgMatch[1]) + debug(`📦 Types: ${pkgMatch[1]}`) + } + + await this.writeTypeFile(receivedPath, code) + })() + }, + }, + }) + + debug("Type installer initialized") + } + + /** + * Ensure the project scaffolding exists (tsconfig, declarations, etc.) + */ + async initialize(): Promise { + if (this.initializationPromise) { + return this.initializationPromise + } + + this.initializationPromise = this.initializeProject() + .then(() => { + debug("Type installer initialization complete") + }) + .catch((err: unknown) => { + this.initializationPromise = null + throw err + }) + + return this.initializationPromise + } + + /** + * Fire-and-forget processing of a component file to fetch missing types. + * JSON files are ignored. + */ + process(fileName: string, content: string): void { + if (!content || JSON_EXTENSION_REGEX.test(fileName)) { + return + } + + Promise.resolve() + .then(async () => { + await this.processImports(fileName, content) + }) + .catch((err: unknown) => { + debug(`Type installer failed for ${fileName}`, err) + }) + } + + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + + private async initializeProject(): Promise { + await Promise.all([ + this.ensureTsConfig(), + this.ensurePrettierConfig(), + this.ensureFramerDeclarations(), + this.ensurePackageJson(), + ]) + + // Fire-and-forget type installation - don't block initialization + Promise.resolve() + .then(async () => { + await this.ensureReact18Types() + + const coreImports = CORE_LIBRARIES.map( + (lib) => `import "${lib}";` + ).join("\n") + await this.ata(coreImports) + }) + .catch((err: unknown) => { + debug("Type installation failed", err) + }) + } + + private async processImports( + fileName: string, + content: string + ): Promise { + const allImports = extractImports(content).filter((i) => i.type === "npm") + + if (allImports.length === 0) return + + // Filter to supported packages unless --unsupported-npm flag is set + const imports = this.allowUnsupportedNpm + ? allImports + : allImports.filter((i) => this.isSupportedPackage(i.name)) + + const unsupportedCount = allImports.length - imports.length + if (unsupportedCount > 0 && !this.allowUnsupportedNpm) { + const unsupported = allImports + .filter((i) => !this.isSupportedPackage(i.name)) + .map((i) => i.name) + debug( + `Skipping unsupported packages: ${unsupported.join(", ")} (use --unsupported-npm to enable)` + ) + } + + if (imports.length === 0) { + return + } + + const hash = imports + .map((imp) => imp.name) + .sort() + .join(",") + + if (this.processedImports.has(hash)) { + return + } + + this.processedImports.add(hash) + debug(`Processing imports for ${fileName} (${imports.length} packages)`) + + // Build filtered content with only supported imports for ATA + const filteredContent = this.allowUnsupportedNpm + ? content + : this.buildFilteredImports(imports) + + try { + await this.ata(filteredContent) + } catch (err) { + warn(`ATA failed for ${fileName}`, err as Error) + } + } + + /** + * Check if a package is in the supported list. + * Also checks for subpath imports (e.g., "framer/build" -> "framer") + */ + private isSupportedPackage(pkgName: string): boolean { + // Direct match + if (SUPPORTED_PACKAGES.has(pkgName)) { + return true + } + + // Check if base package is supported (e.g., "framer-motion/dist" -> "framer-motion") + const basePkg = pkgName.startsWith("@") + ? pkgName.split("/").slice(0, 2).join("/") + : pkgName.split("/")[0] + + return SUPPORTED_PACKAGES.has(basePkg) + } + + /** + * Build synthetic import statements for ATA from filtered imports + */ + private buildFilteredImports(imports: { name: string }[]): string { + return imports.map((imp) => `import "${imp.name}";`).join("\n") + } + + private async writeTypeFile( + receivedPath: string, + code: string + ): Promise { + const normalized = receivedPath.replace(/^\//, "") + const destination = path.join(this.projectDir, normalized) + + try { + await fs.mkdir(path.dirname(destination), { recursive: true }) + await fs.writeFile(destination, code, "utf-8") + } catch (err) { + warn(`Failed to write type file ${destination}`, err) + return + } + + if (/node_modules\/@types\/[^/]+\/index\.d\.ts$/.exec(normalized)) { + await this.ensureTypesPackageJson(normalized) + } + + if (normalized.includes("node_modules/@types/react/index.d.ts")) { + await this.patchReactTypes(destination) + } + } + + private async ensureTypesPackageJson(normalizedPath: string): Promise { + const pkgMatch = /node_modules\/(@types\/[^/]+)\//.exec(normalizedPath) + if (!pkgMatch) return + + const pkgName = pkgMatch[1] + const pkgDir = path.join(this.projectDir, "node_modules", pkgName) + const pkgJsonPath = path.join(pkgDir, "package.json") + + try { + const response = await fetch(`https://registry.npmjs.org/${pkgName}`) + if (!response.ok) return + + const npmData = (await response.json()) as NpmRegistryResponse + const version = npmData["dist-tags"]?.latest + if (!version || !npmData.versions?.[version]) return + + const pkg = npmData.versions[version] + if (pkg.exports) { + for (const key of Object.keys(pkg.exports)) { + pkg.exports[key] = fixExportTypes(pkg.exports[key]) + } + } + + await fs.mkdir(pkgDir, { recursive: true }) + await fs.writeFile(pkgJsonPath, JSON.stringify(pkg, null, 2)) + } catch { + // best-effort + } + } + + private async patchReactTypes(destination: string): Promise { + try { + let content = await fs.readFile(destination, "utf-8") + if (content.includes("function useRef()")) { + return + } + + const overloadPattern = + /function useRef\(initialValue: T \| undefined\): RefObject;/ + + if (!content.includes("function useRef(initialValue: T | undefined)")) { + return + } + + content = content.replace( + overloadPattern, + `function useRef(initialValue: T | undefined): RefObject; + function useRef(): MutableRefObject;` + ) + + await fs.writeFile(destination, content, "utf-8") + } catch { + // ignore patch failures + } + } + + private async ensureTsConfig(): Promise { + const tsconfigPath = path.join(this.projectDir, "tsconfig.json") + try { + await fs.access(tsconfigPath) + debug("tsconfig.json already exists") + } catch { + const config = { + compilerOptions: { + noEmit: true, + target: "ES2021", + lib: ["ES2021", "DOM", "DOM.Iterable"], + module: "ESNext", + moduleResolution: "bundler", + customConditions: ["source"], + jsx: "react-jsx", + allowJs: true, + allowSyntheticDefaultImports: true, + strict: false, + allowImportingTsExtensions: true, + resolveJsonModule: true, + esModuleInterop: true, + skipLibCheck: true, + typeRoots: ["./node_modules/@types"], + }, + include: ["files/**/*", "framer-modules.d.ts"], + } + await fs.writeFile(tsconfigPath, JSON.stringify(config, null, 2)) + debug("Created tsconfig.json") + } + } + + private async ensurePrettierConfig(): Promise { + const prettierPath = path.join(this.projectDir, ".prettierrc") + try { + await fs.access(prettierPath) + debug(".prettierrc already exists") + } catch { + const config = { + tabWidth: 4, + semi: false, + trailingComma: "es5", + } + await fs.writeFile(prettierPath, JSON.stringify(config, null, 2)) + debug("Created .prettierrc") + } + } + + private async ensureFramerDeclarations(): Promise { + const declarationsPath = path.join(this.projectDir, "framer-modules.d.ts") + try { + await fs.access(declarationsPath) + debug("framer-modules.d.ts already exists") + } catch { + const declarations = `// Type declarations for Framer URL imports +declare module "https://framer.com/m/*" + +declare module "https://framerusercontent.com/*" + +declare module "*.json" +` + await fs.writeFile(declarationsPath, declarations) + debug("Created framer-modules.d.ts") + } + } + + private async ensurePackageJson(): Promise { + const packagePath = path.join(this.projectDir, "package.json") + try { + await fs.access(packagePath) + debug("package.json already exists") + } catch { + const pkg = { + name: path.basename(this.projectDir), + version: "1.0.0", + private: true, + description: "Framer files synced with framer-code-link", + } + await fs.writeFile(packagePath, JSON.stringify(pkg, null, 2)) + debug("Created package.json") + } + } + + private async ensureReact18Types(): Promise { + const reactTypesDir = path.join( + this.projectDir, + "node_modules/@types/react" + ) + + const reactFiles = [ + "package.json", + "index.d.ts", + "global.d.ts", + "jsx-runtime.d.ts", + "jsx-dev-runtime.d.ts", + ] + + if ( + await this.hasTypePackage(reactTypesDir, REACT_TYPES_VERSION, reactFiles) + ) { + debug("📦 React types (from cache)") + } else { + debug("Downloading React 18 types...") + await this.downloadTypePackage( + "@types/react", + REACT_TYPES_VERSION, + reactTypesDir, + reactFiles + ) + } + + const reactDomDir = path.join( + this.projectDir, + "node_modules/@types/react-dom" + ) + + const reactDomFiles = ["package.json", "index.d.ts", "client.d.ts"] + + if ( + await this.hasTypePackage( + reactDomDir, + REACT_DOM_TYPES_VERSION, + reactDomFiles + ) + ) { + debug("📦 React DOM types (from cache)") + } else { + await this.downloadTypePackage( + "@types/react-dom", + REACT_DOM_TYPES_VERSION, + reactDomDir, + reactDomFiles + ) + } + } + + private async hasTypePackage( + destinationDir: string, + version: string, + files: string[] + ): Promise { + try { + const pkgJsonPath = path.join(destinationDir, "package.json") + const pkgJson = await fs.readFile(pkgJsonPath, "utf-8") + const parsed = JSON.parse(pkgJson) as { version?: string } + + if (parsed.version !== version) { + return false + } + + for (const file of files) { + if (file === "package.json") continue + await fs.access(path.join(destinationDir, file)) + } + + return true + } catch { + return false + } + } + + private async downloadTypePackage( + pkgName: string, + version: string, + destinationDir: string, + files: string[] + ): Promise { + const baseUrl = `https://unpkg.com/${pkgName}@${version}` + await fs.mkdir(destinationDir, { recursive: true }) + + await Promise.all( + files.map(async (file) => { + const destination = path.join(destinationDir, file) + + // Check if file already exists + try { + await fs.access(destination) + return // Skip if exists + } catch { + // File doesn't exist, download it + } + + try { + const response = await fetch(`${baseUrl}/${file}`) + if (!response.ok) return + const content = await response.text() + await fs.writeFile(destination, content) + } catch { + // ignore per-file failures + } + }) + ) + } +} + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- + +/** + * Transform package.json exports to include .d.ts type paths + */ +function fixExportTypes( + value: string | NpmExportValue +): string | NpmExportValue { + if (typeof value === "string") { + return { + types: value.replace(/\.js$/, ".d.ts").replace(/\.cjs$/, ".d.cts"), + } + } + + if ((value.import ?? value.require) && !value.types) { + const base = value.import ?? value.require + value.types = base?.replace(/\.js$/, ".d.ts").replace(/\.cjs$/, ".d.cts") + } + + return value +} + +interface FetchError extends Error { + cause?: { code?: string } +} + +async function fetchWithRetry( + url: string | URL | Request, + init?: RequestInit, + retries = MAX_FETCH_RETRIES +): Promise { + let urlString: string + if (typeof url === "string") { + urlString = url + } else if (url instanceof URL) { + urlString = url.href + } else { + urlString = url.url + } + + for (let attempt = 1; attempt <= retries; attempt++) { + const controller = new AbortController() + const timeout = setTimeout(() => { + controller.abort() + }, FETCH_TIMEOUT_MS) + + try { + const response = await fetch(url, { + ...init, + signal: controller.signal, + }) + clearTimeout(timeout) + return response + } catch (err: unknown) { + clearTimeout(timeout) + const error = err as FetchError + + const isRetryable = + error.cause?.code === "ECONNRESET" || + error.cause?.code === "ETIMEDOUT" || + error.cause?.code === "UND_ERR_CONNECT_TIMEOUT" || + error.message.includes("timeout") + + if (attempt < retries && isRetryable) { + const delay = attempt * 1_000 + warn( + `Fetch failed (${error.cause?.code ?? error.message}) for ${urlString}, retrying in ${delay}ms...` + ) + await new Promise((resolve) => setTimeout(resolve, delay)) + continue + } + + warn(`Fetch failed for ${urlString}`, error) + throw error + } + } + + throw new Error(`Max retries exceeded for ${urlString}`) +} diff --git a/packages/code-link-cli/src/helpers/sync-validator.ts b/packages/code-link-cli/src/helpers/sync-validator.ts new file mode 100644 index 000000000..fe74f5211 --- /dev/null +++ b/packages/code-link-cli/src/helpers/sync-validator.ts @@ -0,0 +1,85 @@ +/** + * Sync Validation Helper + * + * Pure functions for validating incoming changes during live sync. + * Determines if a change should be applied, queued, or rejected. + */ + +import { hashFileContent } from "../utils/state-persistence.js" +import type { FileSyncMetadata } from "../utils/file-metadata-cache.js" + +/** + * Result of validating an incoming file change + */ +export type ChangeValidation = + | { action: "apply"; reason: "new-file" | "safe-update" } + | { action: "queue"; reason: "snapshot-in-progress" } + | { action: "reject"; reason: "stale-base" | "unknown-file" } + +/** + * Validates whether an incoming REMOTE file change should be applied + * + * During watching mode, we trust remote changes and apply them immediately. + * During snapshot_processing, we queue them for later (to avoid race conditions). + * + * Note: This is for INCOMING changes from remote. Local changes (from watcher) + * are handled separately and always sent during watching mode. + */ +export function validateIncomingChange( + fileMeta: FileSyncMetadata | undefined, + currentMode: string +): ChangeValidation { + // Queue changes that arrive during snapshot processing + if (currentMode === "snapshot_processing" || currentMode === "handshaking") { + return { action: "queue", reason: "snapshot-in-progress" } + } + + // During watching, apply changes immediately + if (currentMode === "watching") { + if (!fileMeta) { + // New file from remote + return { action: "apply", reason: "new-file" } + } + + // Existing file - trust the remote (we're in steady state) + return { action: "apply", reason: "safe-update" } + } + + // During conflict resolution, queue for now (could be enhanced later) + if (currentMode === "conflict_resolution") { + return { action: "queue", reason: "snapshot-in-progress" } + } + + // Shouldn't receive changes while disconnected + return { action: "reject", reason: "unknown-file" } +} + +/** + * Validates whether an outgoing LOCAL change should be sent to remote + * + * Checks if the local file has actually changed since last sync + * to avoid sending duplicate updates. + * + * Note: This will be used when WATCHER_EVENT is migrated to the state machine. + * Currently, the legacy watcher path always sends changes (with echo prevention). + */ +export function validateOutgoingChange( + fileName: string, + content: string, + fileMeta: FileSyncMetadata | undefined +): { shouldSend: boolean; reason: string } { + const currentHash = hashFileContent(content) + + if (!fileMeta) { + // New local file + return { shouldSend: true, reason: "new-file" } + } + + if (fileMeta.localHash === currentHash) { + // No change since we last saw this file + return { shouldSend: false, reason: "no-change" } + } + + // File has changed + return { shouldSend: true, reason: "changed" } +} diff --git a/packages/code-link-cli/src/helpers/user-actions.ts b/packages/code-link-cli/src/helpers/user-actions.ts new file mode 100644 index 000000000..1f52806f8 --- /dev/null +++ b/packages/code-link-cli/src/helpers/user-actions.ts @@ -0,0 +1,160 @@ +/** + * User Action Coordinator + * + * Provides a clean awaitable API for user confirmations via the Plugin. + * Maybe unneeded abstraction, but lets keep until we see if we need more user actions. + */ + +import type { WebSocket } from "ws" +import type { Conflict } from "../types.js" +import { sendMessage } from "./connection.js" +import { debug, warn } from "../utils/logging.js" + +class PluginDisconnectedError extends Error { + constructor() { + super("Plugin disconnected") + this.name = "PluginDisconnectedError" + } +} + +interface PendingAction { + resolve: (value: unknown) => void + reject: (error: Error) => void +} + +export class UserActionCoordinator { + private pendingActions = new Map() + + /** + * Register a pending action and return a typed promise + */ + private awaitAction(actionId: string, description: string): Promise { + return new Promise((resolve, reject) => { + this.pendingActions.set(actionId, { + resolve: resolve as (value: unknown) => void, + reject, + }) + debug(`Awaiting ${description}: ${actionId}`) + }) + } + + /** + * Sends the delete request to the plugin and awaits the user's decision + */ + async requestDeleteDecision( + socket: WebSocket | null, + { + fileName, + requireConfirmation, + }: { fileName: string; requireConfirmation: boolean } + ): Promise { + if (!socket) { + throw new Error("Cannot request delete decision: plugin not connected") + } + + if (requireConfirmation) { + const confirmationPromise = this.awaitAction( + `delete:${fileName}`, + "delete confirmation" + ) + + await sendMessage(socket, { + type: "file-delete", + fileNames: [fileName], + requireConfirmation: true, + }) + + try { + return await confirmationPromise + } catch (err) { + if (err instanceof PluginDisconnectedError) { + debug( + `Plugin disconnected while waiting for delete confirmation: ${fileName}` + ) + return false + } + throw err + } + } + + await sendMessage(socket, { + type: "file-delete", + fileNames: [fileName], + requireConfirmation: false, + }) + + return true + } + + /** + * Sends conflicts to the plugin and awaits user resolutions + */ + async requestConflictDecisions( + socket: WebSocket | null, + conflicts: Conflict[] + ): Promise> { + if (!socket) { + throw new Error("Cannot request conflict decision: plugin not connected") + } + + if (conflicts.length === 0) { + return new Map() + } + + const pending = conflicts.map((conflict) => ({ + fileName: conflict.fileName, + promise: this.awaitAction<"local" | "remote">( + `conflict:${conflict.fileName}`, + "conflict resolution" + ), + })) + + await sendMessage(socket, { + type: "conflicts-detected", + conflicts, + }) + + try { + const results = await Promise.all( + pending.map( + async ({ fileName, promise }) => [fileName, await promise] as const + ) + ) + + return new Map(results) + } catch (err) { + if (err instanceof PluginDisconnectedError) { + debug("Plugin disconnected while awaiting conflict decisions") + return new Map() + } + throw err + } + } + + /** + * Handle incoming confirmation response + */ + handleConfirmation(actionId: string, value: boolean): boolean { + const pending = this.pendingActions.get(actionId) + if (!pending) { + debug(`Unexpected confirmation for ${actionId}`) + return false + } + + this.pendingActions.delete(actionId) + pending.resolve(value) + debug(`Confirmed: ${actionId}`) + return true + } + + /** + * Cleanup all pending actions (e.g., on disconnect) + */ + cleanup(): void { + for (const [actionId, pending] of this.pendingActions.entries()) { + pending.reject(new PluginDisconnectedError()) + debug(`Cancelled pending action: ${actionId}`) + } + this.pendingActions.clear() + } +} diff --git a/packages/code-link-cli/src/helpers/watcher.test.ts b/packages/code-link-cli/src/helpers/watcher.test.ts new file mode 100644 index 000000000..640a7cc93 --- /dev/null +++ b/packages/code-link-cli/src/helpers/watcher.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi, afterEach } from "vitest" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { initWatcher, type Watcher } from "./watcher.js" +import type { WatcherEvent } from "../types.js" + +interface MockWatcher { + on: (event: "add" | "change" | "unlink", handler: (file: string) => void) => MockWatcher + __emit: (event: "add" | "change" | "unlink", filePath: string) => Promise + close: ReturnType +} + +const createdWatchers: MockWatcher[] = [] + +vi.mock("chokidar", () => { + const createMockWatcher = (): MockWatcher => { + const handlers: Record void)[]> = { + add: [], + change: [], + unlink: [], + } + + return { + on(event: "add" | "change" | "unlink", handler: (file: string) => void) { + handlers[event].push(handler) + return this + }, + async __emit(event: "add" | "change" | "unlink", filePath: string) { + for (const handler of handlers[event] ?? []) { + handler(filePath) + } + // Allow async handlers to complete + await new Promise((resolve) => setTimeout(resolve, 10)) + }, + close: vi.fn().mockResolvedValue(undefined), + } + } + + const watch = vi.fn(() => { + const instance = createMockWatcher() + createdWatchers.push(instance) + return instance + }) + + return { default: { watch }, watch } +}) + +describe("initWatcher", () => { + afterEach(() => { + createdWatchers.length = 0 + }) + + it("ignores unsupported extensions and sanitizes added files", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "framer-watcher-")) + const events: WatcherEvent[] = [] + const watcher: Watcher = initWatcher(tmpDir) + watcher.on("change", (event) => events.push(event)) + const rawWatcher = createdWatchers.at(-1) + if (!rawWatcher) throw new Error("No watcher created") + + const unsupportedPath = path.join(tmpDir, "note.txt") + await fs.writeFile(unsupportedPath, "hello", "utf-8") + await rawWatcher.__emit("add", unsupportedPath) + expect(events).toHaveLength(0) + + const rawPath = path.join(tmpDir, "bad name!.tsx") + await fs.writeFile(rawPath, "export const X = 1;", "utf-8") + await rawWatcher.__emit("add", rawPath) + + const addEvent = events.find((e) => e.kind === "add") + expect(addEvent).toBeDefined() + expect(addEvent?.relativePath).toBe("bad_name_.tsx") + expect(addEvent?.content).toContain("export const X") + + const renamedPath = path.join(tmpDir, "bad_name_.tsx") + await expect(fs.readFile(renamedPath, "utf-8")).resolves.toContain( + "export const X" + ) + + await watcher.close() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) +}) diff --git a/packages/code-link-cli/src/helpers/watcher.ts b/packages/code-link-cli/src/helpers/watcher.ts new file mode 100644 index 000000000..6ecc99200 --- /dev/null +++ b/packages/code-link-cli/src/helpers/watcher.ts @@ -0,0 +1,112 @@ +/** + * File watcher helper + * + * Wrapper around chokidar that normalizes file paths and filters to ts, tsx, js, json. + */ + +import chokidar from "chokidar" +import fs from "fs/promises" +import path from "path" +import type { WatcherEvent } from "../types.js" +import { + isSupportedExtension, + normalizePath, + sanitizeFilePath, +} from "@code-link/shared" +import { getRelativePath } from "../utils/node-paths.js" +import { debug, warn } from "../utils/logging.js" + +export interface Watcher { + on(event: "change", handler: (event: WatcherEvent) => void): void + close(): Promise +} + +/** + * Initializes a file watcher for the given directory + */ +export function initWatcher(filesDir: string): Watcher { + const handlers: ((event: WatcherEvent) => void)[] = [] + + const watcher = chokidar.watch(filesDir, { + ignored: /(^|[/\\])\.\./, // ignore dotfiles + persistent: true, + ignoreInitial: false, // Emit add events for existing files so we can sanitize them + }) + + debug(`Watching directory: ${filesDir}`) + + // Helper to emit normalized events + const emitEvent = async ( + kind: "add" | "change" | "delete", + absolutePath: string + ): Promise => { + if (!isSupportedExtension(absolutePath)) { + return + } + + const rawRelativePath = normalizePath( + getRelativePath(filesDir, absolutePath) + ) + // Don't capitalize - preserve exact file names as they exist + // This ensures 1:1 sync with Framer without modifying user's casing choices + const sanitized = sanitizeFilePath(rawRelativePath, false) + const relativePath = sanitized.path + + // If the user created a file that doesn't match our sanitization rules, + // rename it on disk to match what can be synced. + let effectiveAbsolutePath = absolutePath + if (relativePath !== rawRelativePath && kind === "add") { + const newAbsolutePath = path.join(filesDir, relativePath) + try { + await fs.mkdir(path.dirname(newAbsolutePath), { recursive: true }) + await fs.rename(absolutePath, newAbsolutePath) + debug(`Renamed ${rawRelativePath} -> ${relativePath}`) + effectiveAbsolutePath = newAbsolutePath + } catch (err) { + warn(`Failed to rename ${rawRelativePath}`, err) + } + } + + let content: string | undefined + if (kind !== "delete") { + try { + content = await fs.readFile(effectiveAbsolutePath, "utf-8") + } catch (err) { + debug(`Failed to read file ${relativePath}:`, err) + return + } + } + + const event: WatcherEvent = { + kind, + relativePath, + content, + } + + debug(`Watcher event: ${kind} ${relativePath}`) + + for (const handler of handlers) { + handler(event) + } + } + + watcher.on("add", (filePath) => { + void emitEvent("add", filePath) + }) + watcher.on("change", (filePath) => { + void emitEvent("change", filePath) + }) + watcher.on("unlink", (filePath) => { + void emitEvent("delete", filePath) + }) + + return { + on(_event: "change", handler: (event: WatcherEvent) => void): void { + handlers.push(handler) + }, + + async close(): Promise { + await watcher.close() + }, + } +} diff --git a/packages/code-link-cli/src/index.ts b/packages/code-link-cli/src/index.ts new file mode 100644 index 000000000..82d978835 --- /dev/null +++ b/packages/code-link-cli/src/index.ts @@ -0,0 +1,124 @@ +#!/usr/bin/env node + +/** + * Framer Code Link CLI - Next Generation + * + * Entry point for the CLI tool. Parses command-line arguments and starts + * the controller with the appropriate configuration. + */ + +import { Command } from "commander" +import { createRequire } from "node:module" +import { start } from "./controller.js" +import type { Config } from "./types.js" +import { setLogLevel, LogLevel, banner, warn } from "./utils/logging.js" +import { getPortFromHash } from "@code-link/shared" +import { getProjectHashFromCwd } from "./utils/project.js" + +const require = createRequire(import.meta.url) +const { version } = require("../package.json") as { version: string } + +const program = new Command() + +program.exitOverride((err) => { + if (err.code === "commander.missingArgument") { + console.error("Missing Project ID. Copy command via Code Link Plugin.") + process.exit(err.exitCode) + } + throw err +}) + +program + .name("framer-code-link") + .description("Sync Framer code components to your local filesystem") + .version(version) + .argument( + "[projectHash]", + "Framer Project ID Hash (auto-detected from package.json if omitted)" + ) + .option("-n, --name ", "Project name (optional)") + .option("-d, --dir ", "Explicit project directory") + .option("-v, --verbose", "Enable verbose logging") + .option("--log-level ", "Set log level (debug, info, warn, error)") + .option( + "--dangerously-auto-delete", + "Automatically delete remote files without confirmation" + ) + .option( + "--unsupported-npm", + "Allow type acquisition for unsupported npm packages" + ) + .action(async (projectHash: string | undefined, options: { + name?: string + dir?: string + verbose?: boolean + logLevel?: string + dangerouslyAutoDelete?: boolean + unsupportedNpm?: boolean + }) => { + // If no projectHash provided, try to read from cwd's package.json + if (!projectHash) { + const detected = await getProjectHashFromCwd() + if (detected) { + projectHash = detected + } else { + console.error( + "No project ID provided and no existing code-link directory found." + ) + console.error( + "Copy the command from the Code Link Plugin to get started." + ) + process.exit(1) + } + } + + if (options.logLevel) { + const levelMap: Record = { + debug: LogLevel.DEBUG, + info: LogLevel.INFO, + warn: LogLevel.WARN, + error: LogLevel.ERROR, + } + const level = levelMap[options.logLevel.toLowerCase()] as LogLevel | undefined + if (level !== undefined) { + setLogLevel(level) + } + } else if (options.verbose) { + setLogLevel(LogLevel.DEBUG) + } + + const port = getPortFromHash(projectHash) + + // Show startup banner + banner(version, port) + + const config: Config = { + port, + projectHash, + projectDir: null, // Will be set during handshake + filesDir: null, // Will be set during handshake + dangerouslyAutoDelete: options.dangerouslyAutoDelete ?? false, + allowUnsupportedNpm: options.unsupportedNpm ?? false, + explicitDir: options.dir, + explicitName: options.name, + } + + if (config.dangerouslyAutoDelete) { + warn( + "Auto-delete mode enabled - files will be deleted without confirmation" + ) + } + + try { + await start(config) + } catch (err) { + // Error already logged, exit cleanly + process.exit(1) + } + }) + +program.parse() + +// Export for programmatic usage +export { start } from "./controller.js" +export type { Config } from "./types.js" diff --git a/packages/code-link-cli/src/types.ts b/packages/code-link-cli/src/types.ts new file mode 100644 index 000000000..300a0e6fb --- /dev/null +++ b/packages/code-link-cli/src/types.ts @@ -0,0 +1,113 @@ +/** + * Core types for the controller-centric CLI architecture + */ + +import type { PendingDelete } from "@code-link/shared" + +// Configuration +export interface Config { + port: number + projectHash: string + projectDir: string | null // Set during handshake if not already determined + filesDir: string | null // Set during handshake , always projectDir/files + dangerouslyAutoDelete: boolean + allowUnsupportedNpm: boolean // Allow type acquisition for unsupported npm packages + explicitDir?: string // User-provided directory override + explicitName?: string // User-provided name override +} + +// File representations +export interface FileInfo { + name: string + content: string + modifiedAt?: number +} + +export interface LocalFile { + relativePath: string + content: string + modifiedAt?: number +} + +// Conflict detection +// Deletions are represented by null content +// For AI: Do NOT add remoteDeletes/localDeletes arrays - use localContent/remoteContent === null +export interface Conflict { + fileName: string + /** null means the file was deleted locally */ + localContent: string | null + /** null means the file was deleted in Framer */ + remoteContent: string | null + localModifiedAt?: number + remoteModifiedAt?: number + lastSyncedAt?: number // Timestamp of last successful sync from CLI perspective + /** + * True when the local file still matches the last persisted hash. + * Used for auto-resolution heuristics. + */ + localClean?: boolean +} + +export interface ConflictResolution { + conflicts: Conflict[] + writes: FileInfo[] + localOnly: FileInfo[] + unchanged: FileInfo[] +} + +// Watcher events +export type WatcherEventKind = "add" | "change" | "delete" + +export interface WatcherEvent { + kind: WatcherEventKind + relativePath: string + content?: string +} + +// Conflict version data +export interface ConflictVersionRequest { + fileName: string + lastSyncedAt?: number +} + +export interface ConflictVersionData { + fileName: string + latestRemoteVersionMs?: number +} + +// WebSocket messages (incoming from plugin) +export type IncomingMessage = + | { type: "handshake"; projectId: string; projectName: string } + | { type: "request-files" } + | { type: "file-list"; files: FileInfo[] } + | { type: "file-change"; fileName: string; content: string } + | { type: "file-delete"; fileNames: string[]; requireConfirmation?: boolean } + | { type: "delete-confirmed"; fileNames: string[] } + | { type: "delete-cancelled"; files: PendingDelete[] } + | { type: "file-synced"; fileName: string; remoteModifiedAt: number } + | { + type: "conflicts-resolved" + resolution: "local" | "remote" + } + | { + type: "conflict-version-response" + versions: ConflictVersionData[] + } + | { type: "error"; fileName?: string; message: string } + +// WebSocket messages (outgoing to plugin) +export type OutgoingMessage = + | { type: "request-files" } + | { type: "file-list"; files: FileInfo[] } + | { type: "file-change"; fileName: string; content: string } + | { + type: "file-delete" + fileNames: string[] + requireConfirmation?: boolean + } + | { type: "conflicts-detected"; conflicts: Conflict[] } + | { + type: "conflict-version-request" + conflicts: ConflictVersionRequest[] + } + | { type: "sync-complete" } diff --git a/packages/code-link-cli/src/utils/file-metadata-cache.ts b/packages/code-link-cli/src/utils/file-metadata-cache.ts new file mode 100644 index 000000000..b76732b06 --- /dev/null +++ b/packages/code-link-cli/src/utils/file-metadata-cache.ts @@ -0,0 +1,120 @@ +import { + hashFileContent, + loadPersistedState, + savePersistedState, + type PersistedFileState, +} from "./state-persistence.js" + +export interface FileSyncMetadata { + localHash: string + lastSyncedHash: string + lastRemoteTimestamp?: number +} + +export class FileMetadataCache { + private metadata = new Map() + private persisted = new Map() + private projectDir: string | null = null + private initialized = false + private pendingPersist: Promise | null = null + + async initialize(projectDir: string): Promise { + if (this.initialized && this.projectDir === projectDir) { + return + } + + this.projectDir = projectDir + const loaded = await loadPersistedState(projectDir) + this.persisted = loaded + this.metadata = new Map() + + for (const [fileName, state] of loaded.entries()) { + this.metadata.set(fileName, { + localHash: state.contentHash, + lastSyncedHash: state.contentHash, + lastRemoteTimestamp: state.timestamp, + }) + } + + this.initialized = true + } + + get(fileName: string): FileSyncMetadata | undefined { + return this.metadata.get(fileName) + } + + has(fileName: string): boolean { + return this.metadata.has(fileName) + } + + size(): number { + return this.metadata.size + } + + getPersistedState(): Map { + return this.persisted + } + + recordRemoteWrite( + fileName: string, + content: string, + remoteModifiedAt: number + ): void { + const contentHash = hashFileContent(content) + this.metadata.set(fileName, { + localHash: contentHash, + lastSyncedHash: contentHash, + lastRemoteTimestamp: remoteModifiedAt, + }) + this.persisted.set(fileName, { + contentHash, + timestamp: remoteModifiedAt, + }) + this.schedulePersist() + } + + recordSyncedSnapshot( + fileName: string, + contentHash: string, + remoteModifiedAt: number + ): void { + this.metadata.set(fileName, { + localHash: contentHash, + lastSyncedHash: contentHash, + lastRemoteTimestamp: remoteModifiedAt, + }) + this.persisted.set(fileName, { + contentHash, + timestamp: remoteModifiedAt, + }) + this.schedulePersist() + } + + recordDelete(fileName: string): void { + this.metadata.delete(fileName) + this.persisted.delete(fileName) + this.schedulePersist() + } + + async flush(): Promise { + if (this.pendingPersist) { + await this.pendingPersist + } + } + + private schedulePersist(): void { + const projectDir = this.projectDir + if (!projectDir) { + return + } + + this.pendingPersist ??= (async () => { + try { + await Promise.resolve() + await savePersistedState(projectDir, this.persisted) + } finally { + this.pendingPersist = null + } + })() + } +} diff --git a/packages/code-link-cli/src/utils/hash-tracker.ts b/packages/code-link-cli/src/utils/hash-tracker.ts new file mode 100644 index 000000000..b87e826b9 --- /dev/null +++ b/packages/code-link-cli/src/utils/hash-tracker.ts @@ -0,0 +1,79 @@ +/** + * Hash tracking utilities for echo prevention + * + * The hash tracker prevents echo loops by remembering content hashes + * and skipping watcher events for files we just wrote. + */ + +import { createHash } from "crypto" + +export interface HashTracker { + remember(filePath: string, content: string): void + shouldSkip(filePath: string, content: string): boolean + forget(filePath: string): void + clear(): void + markDelete(filePath: string): void + shouldSkipDelete(filePath: string): boolean + clearDelete(filePath: string): void +} + +/** + * Creates a hash tracker instance for echo prevention + */ +export function createHashTracker(): HashTracker { + const hashes = new Map() + const pendingDeletes = new Map>() + + return { + remember(filePath: string, content: string): void { + const hash = hashContent(content) + hashes.set(filePath, hash) + }, + + shouldSkip(filePath: string, content: string): boolean { + const currentHash = hashContent(content) + const storedHash = hashes.get(filePath) + return storedHash === currentHash + }, + + forget(filePath: string): void { + hashes.delete(filePath) + }, + + clear(): void { + hashes.clear() + }, + + markDelete(filePath: string): void { + const existingTimer = pendingDeletes.get(filePath) + if (existingTimer) { + clearTimeout(existingTimer) + } + + const timeout = setTimeout(() => { + pendingDeletes.delete(filePath) + }, 5000) + + pendingDeletes.set(filePath, timeout) + }, + + shouldSkipDelete(filePath: string): boolean { + return pendingDeletes.has(filePath) + }, + + clearDelete(filePath: string): void { + const timeout = pendingDeletes.get(filePath) + if (timeout) { + clearTimeout(timeout) + } + pendingDeletes.delete(filePath) + }, + } +} + +/** + * Computes a SHA256 hash of file content for comparison + */ +function hashContent(content: string): string { + return createHash("sha256").update(content).digest("hex") +} diff --git a/packages/code-link-cli/src/utils/imports.ts b/packages/code-link-cli/src/utils/imports.ts new file mode 100644 index 000000000..fbbe8105e --- /dev/null +++ b/packages/code-link-cli/src/utils/imports.ts @@ -0,0 +1,62 @@ +/** + * Utilities for parsing import statements and extracting package information. + */ + +export interface ImportInfo { + type: "npm" | "url" + name: string + raw: string +} + +/** + * Extract npm and URL-based imports from source code. + */ +export function extractImports(code: string): ImportInfo[] { + const imports: ImportInfo[] = [] + const seen = new Set() + + const npmRegex = + /import\s+(?:(?:\*\s+as\s+\w+)|(?:\w+)|(?:\{[^}]*\}))\s+from\s+['"]([^./][^'"]+)['"]/g + const urlRegex = + /import\s+(?:(?:\*\s+as\s+\w+)|(?:\w+)|(?:\{[^}]*\}))\s+from\s+['"]https?:\/\/[^'"]+['"]/g + + let match: RegExpExecArray | null + + while ((match = npmRegex.exec(code)) !== null) { + const pkgName = match[1] + const normalized = pkgName.startsWith("@") + ? pkgName.split("/").slice(0, 2).join("/") + : pkgName.split("/")[0] + + if (!seen.has(normalized)) { + seen.add(normalized) + imports.push({ + type: "npm", + name: normalized, + raw: match[0], + }) + } + } + + while ((match = urlRegex.exec(code)) !== null) { + const pkgName = extractPackageFromUrl(match[0]) + if (pkgName && !seen.has(pkgName)) { + seen.add(pkgName) + imports.push({ + type: "url", + name: pkgName, + raw: match[0], + }) + } + } + + return imports +} + +/** + * Attempt to derive an npm-style package specifier from a URL import. + */ +export function extractPackageFromUrl(url: string): string | null { + const match = /\/(@?[^@/]+(?:\/[^@/]+)?)/.exec(url) + return match?.[1] ?? null +} diff --git a/packages/code-link-cli/src/utils/logging.ts b/packages/code-link-cli/src/utils/logging.ts new file mode 100644 index 000000000..89a48280a --- /dev/null +++ b/packages/code-link-cli/src/utils/logging.ts @@ -0,0 +1,243 @@ +/** + * Logging utilities for consistent, clean CLI output + * + * Features: + * - Log levels (DEBUG, INFO, WARN, ERROR) + * - Message deduplication with count suffix (x2), (x3) + * - Reconnect cycle suppression (collapses rapid disconnect/reconnect spam) + * - Clean prefixes (no [INFO] clutter at default level) + * - Colored startup banner + */ + +import pc from "picocolors" + +export enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, +} + +let currentLevel = LogLevel.INFO + +// Deduplication state +let lastMessage = "" +let lastMessageCount = 0 +const CLEAR_LINE = "\u001b[2K" +const MOVE_CURSOR_UP = "\u001b[1A" + +// Redraw the previous line with the updated message/count. +function rewriteLastLine(text: string): void { + if (process.stdout.isTTY) { + process.stdout.write(`${MOVE_CURSOR_UP}\r${CLEAR_LINE}${text}\n`) + } else { + // Fallback for non-TTY (e.g., piping output) – just emit the line. + process.stdout.write(`${text}\n`) + } +} + +// Reconnect suppression state +let disconnectTimer: ReturnType | null = null +let isShowingDisconnect = false +let hadRecentDisconnect = false +// Only show disconnect if down for 4+ seconds +// Allows for swtiching between Canvas and Code Editor +const DISCONNECT_DELAY_MS = 4000 + +export function setLogLevel(level: LogLevel): void { + currentLevel = level +} + +export function getLogLevel(): LogLevel { + return currentLevel +} + +function dedupeMessage(message: string, count: number): void { + rewriteLastLine(`${message} ${pc.dim(`(x${count})`)}`) +} + +/** + * Flush any pending deduplicated message + */ +function flushDedupe(): void { + if (lastMessageCount > 1) { + dedupeMessage(lastMessage, lastMessageCount) + } + lastMessage = "" + lastMessageCount = 0 +} + +/** + * Log with deduplication - repeated messages within window get counted + */ +function logWithDedupe(message: string, writer: () => void): void { + if (message === lastMessage) { + // Same message - increment count regardless of gap + lastMessageCount++ + // Overwrite previous line (move cursor up, clear, rewrite) + dedupeMessage(message, lastMessageCount) + return + } + + lastMessage = message + lastMessageCount = 1 + writer() +} + +/** + * Print the startup banner - one colored line + */ +export function banner(version: string, port: number): void { + console.log() + let message = ` ${pc.cyan(pc.bold("⚡ Code Link"))} ${pc.dim(`v${version}`)}` + if (currentLevel <= LogLevel.DEBUG) { + message += ` ${pc.dim("Port")} ${pc.yellow(port)}` + } + console.log(message) + console.log() +} + +/** + * Debug-level logging - only shown with --verbose flag + */ +export function debug(message: string, ...args: unknown[]): void { + if (currentLevel <= LogLevel.DEBUG) { + console.debug(pc.dim(`[DEBUG] ${message}`), ...args) + } +} + +/** + * Info-level logging - shown by default, no prefix + */ +export function info(message: string, ...args: unknown[]): void { + if (currentLevel <= LogLevel.INFO) { + const formatted = args.length > 0 ? `${message} ${args.join(" ")}` : message + logWithDedupe(formatted, () => { + console.log(formatted) + }) + } +} + +/** + * Warning-level logging + */ +export function warn(message: string, ...args: unknown[]): void { + if (currentLevel <= LogLevel.WARN) { + if (message === lastMessage) return // Skip exact duplicates silently + flushDedupe() + lastMessage = message + lastMessageCount = 1 + console.warn(pc.yellow(`⚠ ${message}`), ...args) + } +} + +/** + * Error-level logging + */ +export function error(message: string, ...args: unknown[]): void { + if (currentLevel <= LogLevel.ERROR) { + flushDedupe() + console.error(pc.red(`✗ ${message}`), ...args) + } +} + +/** + * Success message with checkmark + */ +export function success(message: string, ...args: unknown[]): void { + if (currentLevel <= LogLevel.INFO) { + flushDedupe() + console.log(pc.green(`✓ ${message}`), ...args) + } +} + +/** + * File sync indicators + */ +export function fileDown(fileName: string): void { + if (currentLevel <= LogLevel.INFO) { + const msg = ` ${pc.blue("↓")} ${fileName}` + logWithDedupe(msg, () => { + console.log(msg) + }) + } +} + +export function fileUp(fileName: string): void { + if (currentLevel <= LogLevel.INFO) { + const msg = ` ${pc.green("↑")} ${fileName}` + logWithDedupe(msg, () => { + console.log(msg) + }) + } +} + +export function fileDelete(fileName: string): void { + if (currentLevel <= LogLevel.INFO) { + const msg = ` ${pc.red("×")} ${fileName}` + logWithDedupe(msg, () => { + console.log(msg) + }) + } +} + +/** + * Status message (dimmed, for "watching for changes..." etc) + */ +export function status(message: string): void { + if (currentLevel <= LogLevel.INFO) { + flushDedupe() + console.log(pc.dim(` ${message}`)) + } +} + +/** + * Schedule a delayed disconnect message. + * If reconnection happens before the delay, the message is cancelled. + */ +export function scheduleDisconnectMessage(callback: () => void): void { + // Clear any existing timer + if (disconnectTimer) { + clearTimeout(disconnectTimer) + } + + hadRecentDisconnect = true + isShowingDisconnect = false + disconnectTimer = setTimeout(() => { + isShowingDisconnect = true + callback() + disconnectTimer = null + }, DISCONNECT_DELAY_MS) +} + +/** + * Cancel pending disconnect message (called on reconnect) + */ +export function cancelDisconnectMessage(): void { + if (disconnectTimer) { + clearTimeout(disconnectTimer) + disconnectTimer = null + } +} + +/** + * Check if we showed a disconnect message (need to show reconnect) + */ +export function didShowDisconnect(): boolean { + return isShowingDisconnect +} + +/** + * Check if we recently saw a disconnect (even if the message was suppressed) + */ +export function wasRecentlyDisconnected(): boolean { + return hadRecentDisconnect +} + +/** + * Reset disconnect state after successful reconnect + */ +export function resetDisconnectState(): void { + isShowingDisconnect = false + hadRecentDisconnect = false +} diff --git a/packages/code-link-cli/src/utils/node-paths.ts b/packages/code-link-cli/src/utils/node-paths.ts new file mode 100644 index 000000000..260c79791 --- /dev/null +++ b/packages/code-link-cli/src/utils/node-paths.ts @@ -0,0 +1,76 @@ +/** + * Path manipulation utilities + */ + +import path from "path" +import { fileURLToPath } from "url" + +/** + * Resolves a path relative to the project directory + */ +export function resolveProjectPath( + projectDir: string, + relativePath: string +): string { + return path.resolve(projectDir, relativePath) +} + +/** + * Gets a relative path from the project directory + */ +export function getRelativePath( + projectDir: string, + absolutePath: string +): string { + return path.relative(projectDir, absolutePath) +} + +/** + * Ensures a directory path ends with a separator + */ +export function ensureTrailingSlash(dirPath: string): string { + return dirPath.endsWith(path.sep) ? dirPath : dirPath + path.sep +} + +/** + * Gets the directory name from an import.meta.url (ESM) + */ +export function getDirname(importMetaUrl: string): string { + return path.dirname(fileURLToPath(importMetaUrl)) +} + +/** + * Normalizes a file path by: + * - Converting backslashes to forward slashes + * - Resolving . and .. segments + * - Removing duplicate slashes + */ +export function normalizePath(filePath: string): string { + if (!filePath) return "" + + const isAbsolute = filePath.startsWith("/") + const segments = filePath.replace(/\\/g, "/").split("/") + const stack: string[] = [] + + for (const segment of segments) { + if (!segment || segment === ".") { + continue + } + + if (segment === "..") { + if (stack.length > 0) { + stack.pop() + } + continue + } + + stack.push(segment) + } + + const normalized = stack.join("/") + if (isAbsolute) { + return `/${normalized}` + } + + return normalized +} diff --git a/packages/code-link-cli/src/utils/project.ts b/packages/code-link-cli/src/utils/project.ts new file mode 100644 index 000000000..07591d846 --- /dev/null +++ b/packages/code-link-cli/src/utils/project.ts @@ -0,0 +1,118 @@ +import fs from "fs/promises" +import path from "path" +import { shortProjectHash } from "@code-link/shared" + +interface PackageJson { + shortProjectHash?: string // derived short id (8 chars base58) + framerProjectHash?: string // full 64-char hex hash from Framer API + framerProjectName?: string + name?: string + version?: string + [key: string]: unknown +} + +export function toPackageName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-+/g, "-") +} + +export function toDirName(name: string): string { + return name + .replace(/[^a-zA-Z0-9- ]/g, "-") + .replace(/^[-\s]+|[-\s]+$/g, "") + .replace(/-+/g, "-") +} + +export async function getProjectHashFromCwd(): Promise { + try { + const packageJsonPath = path.join(process.cwd(), "package.json") + const content = await fs.readFile(packageJsonPath, "utf-8") + const pkg = JSON.parse(content) as PackageJson + // Return short id for port derivation + return pkg.shortProjectHash ?? null + } catch { + return null + } +} + +export async function findOrCreateProjectDir( + projectHash: string, + projectName?: string, + explicitDir?: string +): Promise { + if (explicitDir) { + const resolved = path.resolve(explicitDir) + await fs.mkdir(path.join(resolved, "files"), { recursive: true }) + return resolved + } + + const cwd = process.cwd() + const existing = await findExistingProjectDir(cwd, projectHash) + if (existing) { + return existing + } + + if (!projectName) { + throw new Error("Failed to get Project name. Pass --name .") + } + + const dirName = toDirName(projectName) + const pkgName = toPackageName(projectName) + const shortId = shortProjectHash(projectHash) + const projectDir = path.join(cwd, dirName || shortId) + + await fs.mkdir(path.join(projectDir, "files"), { recursive: true }) + const pkg: PackageJson = { + name: pkgName || shortId, + version: "1.0.0", + private: true, + shortProjectHash: shortId, + framerProjectHash: projectHash, + framerProjectName: projectName, + } + await fs.writeFile( + path.join(projectDir, "package.json"), + JSON.stringify(pkg, null, 2) + ) + + return projectDir +} + +async function findExistingProjectDir( + baseDir: string, + projectHash: string +): Promise { + const candidate = path.join(baseDir, "package.json") + if (await matchesProject(candidate, projectHash)) { + return baseDir + } + + const entries = await fs.readdir(baseDir, { withFileTypes: true }) + for (const entry of entries) { + if (!entry.isDirectory()) continue + const dir = path.join(baseDir, entry.name) + if (await matchesProject(path.join(dir, "package.json"), projectHash)) { + return dir + } + } + + return null +} + +async function matchesProject( + packageJsonPath: string, + projectHash: string +): Promise { + try { + const content = await fs.readFile(packageJsonPath, "utf-8") + const pkg = JSON.parse(content) as PackageJson + const inputShort = shortProjectHash(projectHash) + // Match on short id (handles both full hash input and short id input) + return pkg.shortProjectHash === inputShort + } catch { + return false + } +} diff --git a/packages/code-link-cli/src/utils/state-persistence.ts b/packages/code-link-cli/src/utils/state-persistence.ts new file mode 100644 index 000000000..70920dea2 --- /dev/null +++ b/packages/code-link-cli/src/utils/state-persistence.ts @@ -0,0 +1,136 @@ +/** + * State persistence helper + * + * Persists last sync timestamps along with content hashes. + * We only trust persisted timestamps if the file content hasn't changed + * (hash matches), because that means the file wasn't edited while CLI was offline. + */ + +import fs from "fs/promises" +import path from "path" +import { createHash } from "crypto" +import { debug, warn } from "./logging.js" +import { normalizePath } from "./node-paths.js" + +export interface PersistedFileState { + timestamp: number // Remote modified timestamp from last sync + contentHash: string // Hash of content when we received the sync confirmation +} + +interface PersistedState { + version: number + files: Record +} + +const STATE_FILE_NAME = ".framer-sync-state.json" +const CURRENT_VERSION = 1 +const SUPPORTED_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".json"] +const DEFAULT_EXTENSION = ".tsx" + +export function normalizePersistedFileName(fileName: string): string { + let normalized = normalizePath(fileName.trim()) + if ( + !SUPPORTED_EXTENSIONS.some((ext) => normalized.toLowerCase().endsWith(ext)) + ) { + normalized = `${normalized}${DEFAULT_EXTENSION}` + } + return normalized +} + +/** + * Hash file content to detect changes + */ +export function hashFileContent(content: string): string { + return createHash("sha256").update(content, "utf-8").digest("hex") +} + +/** + * Load persisted state from disk + */ +export async function loadPersistedState( + projectDir: string +): Promise> { + const statePath = path.join(projectDir, STATE_FILE_NAME) + const result = new Map() + + try { + const data = await fs.readFile(statePath, "utf-8") + const parsed = JSON.parse(data) as PersistedState + + if (parsed.version !== CURRENT_VERSION) { + warn( + `State file version mismatch (expected ${CURRENT_VERSION}, got ${parsed.version}). Ignoring persisted state.` + ) + return result + } + + for (const [fileName, state] of Object.entries(parsed.files)) { + const normalizedName = normalizePersistedFileName(fileName) + if (normalizedName !== fileName) { + debug( + `Normalized persisted key "${fileName}" -> "${normalizedName}" for compatibility` + ) + } + result.set(normalizedName, state) + } + + debug(`Loaded persisted state for ${result.size} files`) + return result + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + debug("No persisted state found (first run)") + return result + } + warn("Failed to load persisted state:", err) + return result + } +} + +/** + * Save current state to disk + */ +export async function savePersistedState( + projectDir: string, + state: Map +): Promise { + const statePath = path.join(projectDir, STATE_FILE_NAME) + + const persistedState: PersistedState = { + version: CURRENT_VERSION, + files: Object.fromEntries(state.entries()), + } + + try { + await fs.writeFile(statePath, JSON.stringify(persistedState, null, 2)) + debug(`Saved persisted state for ${state.size} files`) + } catch (err) { + warn("Failed to save persisted state:", err) + } +} + +/** + * Validate persisted timestamp against current file content + * Returns the timestamp only if the content hash matches (file unchanged) + */ +export function validatePersistedTimestamp( + persistedState: PersistedFileState | undefined, + currentContent: string +): number | null { + if (!persistedState) { + return null + } + + const currentHash = hashFileContent(currentContent) + + if (currentHash === persistedState.contentHash) { + debug( + `Hash matches for persisted state - trusting timestamp ${persistedState.timestamp}` + ) + return persistedState.timestamp + } + + debug( + "Hash mismatch for persisted state - file was edited while CLI was offline" + ) + return null +} diff --git a/packages/code-link-cli/tsconfig.json b/packages/code-link-cli/tsconfig.json new file mode 100644 index 000000000..7a8ab5b7d --- /dev/null +++ b/packages/code-link-cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "outDir": "./dist" + }, + "include": ["src/**/*"] +} + diff --git a/packages/code-link-cli/tsdown.config.ts b/packages/code-link-cli/tsdown.config.ts new file mode 100644 index 000000000..257ca28f6 --- /dev/null +++ b/packages/code-link-cli/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "tsdown" + +export default defineConfig({ + entry: ["src/index.ts"], + format: "esm", + noExternal: ["@code-link/shared"], +}) + diff --git a/packages/code-link-cli/vitest.config.ts b/packages/code-link-cli/vitest.config.ts new file mode 100644 index 000000000..9f13d5869 --- /dev/null +++ b/packages/code-link-cli/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + pool: "threads", // Fork pool trips tinypool on Node 25; threads stay stable + }, +}) + diff --git a/packages/code-link-shared/package.json b/packages/code-link-shared/package.json new file mode 100644 index 000000000..d51f730da --- /dev/null +++ b/packages/code-link-shared/package.json @@ -0,0 +1,23 @@ +{ + "name": "@code-link/shared", + "version": "1.0.0", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "typescript": "^5.9.3", + "vitest": "^4.0.15" + } +} diff --git a/packages/code-link-shared/src/hash.test.ts b/packages/code-link-shared/src/hash.test.ts new file mode 100644 index 000000000..290806995 --- /dev/null +++ b/packages/code-link-shared/src/hash.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest" +import { shortProjectHash } from "./hash.js" +import { getPortFromHash } from "./ports.js" + +describe("shortProjectHash", () => { + const FULL_HASH = + "14c01541d3af3ff6a7cd40ac77a5586f0d273c9c371ac04dd31e4410b411c8f5" + + it("returns 8 chars by default", () => { + const short = shortProjectHash(FULL_HASH) + expect(short).toHaveLength(8) + }) + + it("returns requested length", () => { + expect(shortProjectHash(FULL_HASH, 6)).toHaveLength(6) + expect(shortProjectHash(FULL_HASH, 10)).toHaveLength(10) + }) + + it("is deterministic", () => { + const a = shortProjectHash(FULL_HASH) + const b = shortProjectHash(FULL_HASH) + expect(a).toBe(b) + }) + + it("produces different ids for different hashes", () => { + const hash2 = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + expect(shortProjectHash(FULL_HASH)).not.toBe(shortProjectHash(hash2)) + }) + + it("uses only base58 characters", () => { + const short = shortProjectHash(FULL_HASH) + const base58Regex = + /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/ + expect(short).toMatch(base58Regex) + }) + + it("is idempotent (short id of short id equals short id)", () => { + const short = shortProjectHash(FULL_HASH) + const shortOfShort = shortProjectHash(short) + expect(shortOfShort).toBe(short) + }) +}) + +describe("getPortFromHash", () => { + const FULL_HASH = + "14c01541d3af3ff6a7cd40ac77a5586f0d273c9c371ac04dd31e4410b411c8f5" + + it("returns port in valid range", () => { + const port = getPortFromHash(FULL_HASH) + expect(port).toBeGreaterThanOrEqual(3847) + expect(port).toBeLessThanOrEqual(4096) + }) + + it("is deterministic", () => { + const a = getPortFromHash(FULL_HASH) + const b = getPortFromHash(FULL_HASH) + expect(a).toBe(b) + }) + + it("returns same port for full hash and its short id", () => { + const short = shortProjectHash(FULL_HASH) + expect(getPortFromHash(FULL_HASH)).toBe(getPortFromHash(short)) + }) + + it("produces different ports for different hashes", () => { + const hash2 = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + // Note: could collide in theory, but extremely unlikely for these inputs + expect(getPortFromHash(FULL_HASH)).not.toBe(getPortFromHash(hash2)) + }) +}) diff --git a/packages/code-link-shared/src/hash.ts b/packages/code-link-shared/src/hash.ts new file mode 100644 index 000000000..2ab2302d7 --- /dev/null +++ b/packages/code-link-shared/src/hash.ts @@ -0,0 +1,55 @@ +/** + * Simple content hash for echo prevention + * Uses length + head + tail to quickly detect content changes + */ +export function hashContent(content: string): string { + return `${content.length}:${content.slice(0, 50)}:${content.slice(-50)}` +} + +/** + * Base58 alphabet (no 0/O/I/l to avoid confusion) + */ +const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +/** + * Derive a short, deterministic hash from the full Framer project hash. + * Uses a simple numeric hash encoded in base58 for compactness. + * Idempotent: if input is already the target length, returns it unchanged. + */ +export function shortProjectHash(fullHash: string, length = 8): string { + // If already short, return as-is (idempotent) + if (fullHash.length === length) { + return fullHash + } + + // Compute a 32-bit hash from the full hash string + let h1 = 0 + let h2 = 0 + for (let i = 0; i < fullHash.length; i++) { + const char = fullHash.charCodeAt(i) + h1 = Math.imul(h1 ^ char, 0x85ebca6b) + h2 = Math.imul(h2 ^ char, 0xc2b2ae35) + } + // Mix the two hashes + h1 ^= h2 >>> 16 + h2 ^= h1 >>> 13 + + // Convert to base58 + let result = "" + // Use both h1 and h2 to get more bits + const combined = [Math.abs(h1), Math.abs(h2)] + for (const num of combined) { + let n = num >>> 0 // ensure unsigned + while (n > 0 && result.length < length) { + result += BASE58[n % 58] + n = Math.floor(n / 58) + } + } + + // Pad if needed + while (result.length < length) { + result += BASE58[0] + } + + return result.slice(0, length) +} diff --git a/packages/code-link-shared/src/index.ts b/packages/code-link-shared/src/index.ts new file mode 100644 index 000000000..c21ae6ba2 --- /dev/null +++ b/packages/code-link-shared/src/index.ts @@ -0,0 +1,29 @@ +// Types +export type { + Mode, + ProjectInfo, + PendingDelete, + ConflictSummary, + FileInfo, + IncomingMessage, + OutgoingMessage, +} from "./types.js" +export { isIncomingMessage } from "./types.js" + +// Utilities +export { getPortFromHash } from "./ports.js" +export { hashContent, shortProjectHash } from "./hash.js" +export { + normalizePath, + normalizeCodeFilePath, + stripExtension, + ensureExtension, + canonicalFileName, + sanitizeFilePath, + isSupportedExtension, + capitalizeFirstLetter, + pluralize, +} from "./paths.js" + +// Sync tracker +export { createSyncTracker, type SyncTracker } from "./sync-tracker.js" diff --git a/packages/code-link-shared/src/paths.test.ts b/packages/code-link-shared/src/paths.test.ts new file mode 100644 index 000000000..03ede0dad --- /dev/null +++ b/packages/code-link-shared/src/paths.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from "vitest" +import { + sanitizeFilePath, + isSupportedExtension, + normalizePath, + ensureExtension, +} from "./paths.js" + +describe("File Name Sanitization", () => { + describe("sanitizeFilePath", () => { + it("replaces invalid characters with underscores", () => { + const result = sanitizeFilePath("bad name!.tsx") + expect(result.path).toBe("Bad_name_.tsx") + expect(result.name).toBe("Bad_name_") + }) + + it("prefixes names starting with numbers", () => { + const result = sanitizeFilePath("123Component.tsx") + expect(result.name).toMatch(/^\$/) + }) + + it("capitalizes React component names (.tsx)", () => { + const result = sanitizeFilePath("myComponent.tsx") + expect(result.name).toBe("MyComponent") + expect(result.path).toBe("MyComponent.tsx") + }) + + it("preserves lowercase for non-component files (.ts)", () => { + const result = sanitizeFilePath("utils.ts", false) + expect(result.name).toBe("utils") + expect(result.path).toBe("utils.ts") + }) + + it("preserves lowercase for .json files", () => { + const result = sanitizeFilePath("config.json", false) + expect(result.name).toBe("config") + expect(result.path).toBe("config.json") + }) + + it("handles nested directory paths", () => { + const result = sanitizeFilePath("components/ui/Button.tsx") + expect(result.dirName).toBe("components/ui") + expect(result.name).toBe("Button") + expect(result.path).toBe("components/ui/Button.tsx") + }) + + it("sanitizes directory names with invalid characters", () => { + const result = sanitizeFilePath("my folder!/Component.tsx") + expect(result.dirName).toBe("my_folder_") + expect(result.path).toBe("my_folder_/Component.tsx") + }) + + it("collapses multiple underscores", () => { + const result = sanitizeFilePath("bad___name.tsx") + expect(result.name).toBe("Bad_name") + }) + + it("handles empty input gracefully", () => { + const result = sanitizeFilePath("") + expect(result.name).toBe("MyComponent") + }) + + it("preserves extension in result", () => { + const result = sanitizeFilePath("Test.tsx") + expect(result.extension).toBe("tsx") + }) + }) + + describe("Extension Handling", () => { + it("recognizes .tsx as supported", () => { + expect(isSupportedExtension("Component.tsx")).toBe(true) + }) + + it("recognizes .ts as supported", () => { + expect(isSupportedExtension("utils.ts")).toBe(true) + }) + + it("recognizes .jsx as supported", () => { + expect(isSupportedExtension("Component.jsx")).toBe(true) + }) + + it("recognizes .js as supported", () => { + expect(isSupportedExtension("script.js")).toBe(true) + }) + + it("recognizes .json as supported", () => { + expect(isSupportedExtension("data.json")).toBe(true) + }) + + it("rejects .txt as unsupported", () => { + expect(isSupportedExtension("readme.txt")).toBe(false) + }) + + it("rejects .md as unsupported", () => { + expect(isSupportedExtension("README.md")).toBe(false) + }) + + it("rejects .css as unsupported", () => { + expect(isSupportedExtension("styles.css")).toBe(false) + }) + + it("is case-insensitive for extensions", () => { + expect(isSupportedExtension("Component.TSX")).toBe(true) + expect(isSupportedExtension("utils.TS")).toBe(true) + }) + }) + + describe("Path Normalization", () => { + it("normalizes backslashes to forward slashes", () => { + expect(normalizePath("foo\\bar\\baz.tsx")).toBe("foo/bar/baz.tsx") + }) + + it("removes redundant slashes", () => { + expect(normalizePath("foo//bar///baz.tsx")).toBe("foo/bar/baz.tsx") + }) + + it("resolves . segments", () => { + expect(normalizePath("foo/./bar/baz.tsx")).toBe("foo/bar/baz.tsx") + }) + + it("resolves .. segments", () => { + expect(normalizePath("foo/bar/../baz.tsx")).toBe("foo/baz.tsx") + }) + + it("preserves absolute paths", () => { + expect(normalizePath("/foo/bar")).toBe("/foo/bar") + }) + }) + + describe("ensureExtension", () => { + it("adds .tsx extension when missing", () => { + expect(ensureExtension("Component")).toBe("Component.tsx") + }) + + it("preserves existing .tsx extension", () => { + expect(ensureExtension("Component.tsx")).toBe("Component.tsx") + }) + + it("preserves existing .ts extension", () => { + expect(ensureExtension("utils.ts")).toBe("utils.ts") + }) + + it("preserves existing .json extension", () => { + expect(ensureExtension("data.json")).toBe("data.json") + }) + + it("allows custom default extension", () => { + expect(ensureExtension("utils", ".ts")).toBe("utils.ts") + }) + }) +}) + diff --git a/packages/code-link-shared/src/paths.ts b/packages/code-link-shared/src/paths.ts new file mode 100644 index 000000000..83b6f4df4 --- /dev/null +++ b/packages/code-link-shared/src/paths.ts @@ -0,0 +1,187 @@ +/** + * File path normalization utilities + * Framer code files include extensions in their paths (.tsx, .ts, etc.) + */ + +const firstCharacterRegex = /^[a-zA-Z$_]/ +const remainingCharactersRegex = /[^a-zA-Z0-9$_]/g +const onlyDotsRegex = /^\.+$/ +const tsxExtension = ".tsx" + +enum NameType { + Variable = "Variable", + Selector = "Selector", + Directory = "Directory", +} + +interface SanitizedNameResult { + path: string + dirName: string + name: string + extension: string +} + +function sanitizedName(type: NameType, name: string | null): string | null { + if (!name) return null + + let validName = name.trim() + if (validName.length === 0) return null + const validFirstChar = type === NameType.Selector ? "_" : "$" + + if (type === NameType.Directory) { + if (onlyDotsRegex.test(validName)) return null + } else if (!firstCharacterRegex.test(validName)) { + validName = validFirstChar + validName + } + + validName = validName.replace(remainingCharactersRegex, "_") + validName = validName.replace(/_+/g, "_") + validName = validName.replace(/^\$_/u, validFirstChar) + return validName +} + +function sanitizedVariableName(name: string | null): string | null { + return sanitizedName(NameType.Variable, name) +} + +function sanitizedDirectoryName(name: string | null): string | null { + return sanitizedName(NameType.Directory, name) +} + +export function capitalizeFirstLetter(str: string): string { + if (str.length === 0) return str + return str.charAt(0).toUpperCase() + str.slice(1) +} + +function hasValidExtension(fileName: string): boolean { + if (fileName.endsWith(".json")) return true + return /\.[tj]sx?$/u.test(fileName) +} + +function splitExtension(fileName: string): [string, string] { + const lastDot = fileName.lastIndexOf(".") + if (lastDot <= 0) return [fileName, ""] + return [fileName.slice(0, lastDot), fileName.slice(lastDot + 1)] +} + +function dirname(filePath: string): string { + const at = filePath.lastIndexOf("/") + if (at < 0) return "" + return filePath.slice(0, at) +} + +function filename(filePath: string): string { + const at = filePath.lastIndexOf("/") + 1 + return filePath.slice(at) +} + +function pathJoin(...parts: string[]): string { + let res = "" + parts.forEach((part) => { + while (part.startsWith("/")) part = part.slice(1) + while (part.endsWith("/")) part = part.slice(0, -1) + if (part === "") return + if (res !== "") res += "/" + res += part + }) + return res +} + +export function normalizePath(filePath: string): string { + if (!filePath) return "" + + const isAbsolute = filePath.startsWith("/") + const segments = filePath.replace(/\\/g, "/").split("/") + const stack: string[] = [] + + for (const segment of segments) { + if (!segment || segment === ".") { + continue + } + + if (segment === "..") { + if (stack.length > 0) { + stack.pop() + } + continue + } + + stack.push(segment) + } + + const normalized = stack.join("/") + if (isAbsolute) { + return `/${normalized}` + } + + return normalized +} + +export function normalizeCodeFilePath(filePath: string): string { + // Use proper path normalization that handles .., ., //, etc. + // Always return relative paths (strip leading slash) + const normalized = normalizePath(filePath) + return normalized.startsWith("/") ? normalized.slice(1) : normalized +} + +export function stripExtension(filePath: string): string { + return normalizeCodeFilePath(filePath).replace(/\.(tsx?|jsx?)$/, "") +} + +export function ensureExtension(filePath: string, extension = ".tsx"): string { + const normalized = normalizeCodeFilePath(filePath) + // Check if file already has a valid extension + const hasValidExtension = /\.(tsx?|jsx?|json)$/i.test(normalized) + return hasValidExtension ? normalized : `${normalized}${extension}` +} + +export function canonicalFileName(filePath: string): string { + // Keep the extension - just normalize the path + // This prevents collisions between files with same name but different extensions + return normalizeCodeFilePath(filePath) +} + +export function sanitizeFilePath( + input: string, + capitalizeReactComponent = true +): SanitizedNameResult { + const trimmed = input.trim() + const [inputName, extension] = splitExtension(filename(trimmed)) + const extensionWithDot = extension ? `.${extension}` : "" + + const dirName = dirname(trimmed) + .split("/") + .map((part) => sanitizedDirectoryName(part)) + .filter((part): part is string => Boolean(part)) + .join("/") + + let name = sanitizedVariableName(inputName) ?? "MyComponent" + if ( + (!hasValidExtension(extension) || extension === tsxExtension) && + capitalizeReactComponent + ) { + name = capitalizeFirstLetter(name) + } + + const sanitizedPath = pathJoin(dirName, name + extensionWithDot) + return { path: sanitizedPath, dirName, name, extension } +} + +export function isSupportedExtension(filePath: string): boolean { + return /\.(tsx?|jsx?|json)$/i.test(filePath) +} + +/** + * Pluralize a word based on count + * @example pluralize(1, "file") => "1 file" + * @example pluralize(3, "file") => "3 files" + * @example pluralize(0, "conflict") => "0 conflicts" + */ +export function pluralize( + count: number, + singular: string, + plural?: string +): string { + const word = count === 1 ? singular : (plural ?? `${singular}s`) + return `${count} ${word}` +} diff --git a/packages/code-link-shared/src/ports.ts b/packages/code-link-shared/src/ports.ts new file mode 100644 index 000000000..4c8f5ce34 --- /dev/null +++ b/packages/code-link-shared/src/ports.ts @@ -0,0 +1,22 @@ +import { shortProjectHash } from "./hash.js" + +/** + * Generate a deterministic port number from a project hash (full or short). + * Port range: 3847-4096 (250 possible ports) + * Must match between CLI and plugin. + * + * Internally normalizes to the short id so both full and short inputs yield the same port. + */ +export function getPortFromHash(projectHash: string): number { + // Normalize to short hash so full hash and short hash yield same port + const shortId = shortProjectHash(projectHash) + + let hash = 0 + for (let i = 0; i < shortId.length; i++) { + const char = shortId.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash // Convert to 32bit integer + } + const portOffset = Math.abs(hash) % 250 + return 3847 + portOffset +} diff --git a/packages/code-link-shared/src/sync-tracker.ts b/packages/code-link-shared/src/sync-tracker.ts new file mode 100644 index 000000000..7c74fb1b5 --- /dev/null +++ b/packages/code-link-shared/src/sync-tracker.ts @@ -0,0 +1,38 @@ +import { hashContent } from "./hash.js" +import { canonicalFileName } from "./paths.js" + +export interface SyncTracker { + remember(fileName: string, content: string): void + shouldSkip(fileName: string, content: string): boolean + forget(fileName: string): void + clear(): void +} + +/** + * Creates a sync tracker for echo prevention + * Remembers content hashes to avoid syncing back what we just received + */ +export function createSyncTracker(): SyncTracker { + const contentHashes = new Map() + + return { + remember(fileName: string, content: string) { + contentHashes.set(canonicalFileName(fileName), hashContent(content)) + }, + + shouldSkip(fileName: string, content: string) { + return ( + contentHashes.get(canonicalFileName(fileName)) === hashContent(content) + ) + }, + + forget(fileName: string) { + contentHashes.delete(canonicalFileName(fileName)) + }, + + clear() { + contentHashes.clear() + }, + } +} + diff --git a/packages/code-link-shared/src/types.ts b/packages/code-link-shared/src/types.ts new file mode 100644 index 000000000..cf17e0eac --- /dev/null +++ b/packages/code-link-shared/src/types.ts @@ -0,0 +1,85 @@ +// Shared types between plugin and CLI + +export type Mode = + | "loading" + | "info" + | "syncing" + | "delete_confirmation" + | "conflict_resolution" + | "idle" + +export interface ProjectInfo { + id: string + name: string +} + +export interface PendingDelete { + fileName: string + content?: string +} + +export interface ConflictSummary { + fileName: string + /** null means the file was deleted on this side */ + localContent: string | null + /** null means the file was deleted on this side */ + remoteContent: string | null +} + +export interface FileInfo { + name: string + content: string + modifiedAt?: number +} + +// CLI → Plugin messages +export type IncomingMessage = + | { type: "request-files" } + | { type: "file-change"; fileName: string; content: string } + | { + type: "file-delete" + fileNames: string[] + requireConfirmation?: boolean + } + | { type: "conflicts-detected"; conflicts: ConflictSummary[] } + | { + type: "conflict-version-request" + conflicts: { fileName: string; lastSyncedAt?: number }[] + } + | { type: "sync-complete" } + +const incomingMessageTypes = [ + "request-files", + "file-change", + "file-delete", + "conflicts-detected", + "conflict-version-request", + "sync-complete", +] as const + +export function isIncomingMessage(data: unknown): data is IncomingMessage { + if (typeof data !== "object" || data === null) return false + if (!("type" in data) || typeof data.type !== "string") return false + return incomingMessageTypes.includes( + data.type as (typeof incomingMessageTypes)[number] + ) +} + +// Plugin → CLI messages +export type OutgoingMessage = + | { type: "handshake"; projectId: string; projectName: string } + | { type: "file-list"; files: FileInfo[] } + | { type: "file-change"; fileName: string; content: string } + | { type: "file-delete"; fileNames: string[] } + | { type: "delete-confirmed"; fileNames: string[] } + | { type: "delete-cancelled"; files: PendingDelete[] } + | { type: "file-synced"; fileName: string; remoteModifiedAt: number } + | { + type: "conflict-resolution" + fileName: string + resolution: "local" | "remote" + } + | { + type: "conflict-version-response" + versions: { fileName: string; latestRemoteVersionMs?: number }[] + } diff --git a/packages/code-link-shared/tsconfig.json b/packages/code-link-shared/tsconfig.json new file mode 100644 index 000000000..bd27ea24c --- /dev/null +++ b/packages/code-link-shared/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "lib": ["ES2022"], + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "moduleResolution": "bundler" + }, + "include": ["src/**/*"] +} + diff --git a/packages/code-link-shared/vitest.config.ts b/packages/code-link-shared/vitest.config.ts new file mode 100644 index 000000000..df9c8f447 --- /dev/null +++ b/packages/code-link-shared/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + pool: "threads", + }, +}) diff --git a/plugins/code-link/README.md b/plugins/code-link/README.md new file mode 100644 index 000000000..70ab8d39f --- /dev/null +++ b/plugins/code-link/README.md @@ -0,0 +1,9 @@ +# Code Link Plugin + +Framer plugin that syncs code files between Framer and your local filesystem via the Framer Code Link CLI. + +## Usage + +1. Open The plugin in Framer +2. Copy the CLI command shown in the plugin +3. Run the command in your terminal to start syncing diff --git a/plugins/code-link/framer.json b/plugins/code-link/framer.json new file mode 100644 index 000000000..ee119b69f --- /dev/null +++ b/plugins/code-link/framer.json @@ -0,0 +1,7 @@ +{ + "id": "40c32f", + "name": "Code Link", + "modes": ["canvas", "code"], + "disableEditViaPlugin": ["code"], + "icon": "/icon.svg" +} diff --git a/plugins/code-link/index.html b/plugins/code-link/index.html new file mode 100644 index 000000000..f4879fb7b --- /dev/null +++ b/plugins/code-link/index.html @@ -0,0 +1,14 @@ + + + + + + + Plugin + + +
+ + + + diff --git a/plugins/code-link/package.json b/plugins/code-link/package.json new file mode 100644 index 000000000..1fb57d044 --- /dev/null +++ b/plugins/code-link/package.json @@ -0,0 +1,25 @@ +{ + "name": "code-link", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "run g:dev", + "build": "run g:build", + "check-biome": "run g:check-biome", + "check-eslint": "run g:check-eslint", + "check-typescript": "run g:check-typescript", + "preview": "run g:preview", + "pack": "npx framer-plugin-tools@latest pack" + }, + "dependencies": { + "@code-link/shared": "workspace:*", + "framer-plugin": "3.9.0-beta.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.24", + "@types/react-dom": "^18.3.7" + } +} diff --git a/plugins/code-link/public/icon.svg b/plugins/code-link/public/icon.svg new file mode 100644 index 000000000..e67a27598 --- /dev/null +++ b/plugins/code-link/public/icon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/plugins/code-link/src/App.css b/plugins/code-link/src/App.css new file mode 100644 index 000000000..6bedf56d6 --- /dev/null +++ b/plugins/code-link/src/App.css @@ -0,0 +1,278 @@ +/* Your Plugin CSS */ + +body main { + --framer-color-tint: #000000; + --framer-color-tint-dimmed: #666; + --framer-color-tint-dark: #222; + --framer-color-tint-extra-dark: #444; +} + +body[data-framer-theme="dark"] main { + --framer-color-tint: #ffffff; + --framer-color-tint-dimmed: #999; + --framer-color-tint-dark: #e3e3e3; + --framer-color-tint-extra-dark: #c3c3c3; + --framer-color-text-reversed: #000; +} + +main { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; + width: 100%; + padding: 15px; +} + +.info { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; +} + +.info h1 { + font-size: 12px; + margin-bottom: 8px; +} + +.info p { + color: var(--framer-color-text-tertiary); + max-width: 210px; +} + +.plugin-icon { + position: relative; + width: 30px; + height: 30px; + background-color: #000; + border-radius: 8px; + margin-bottom: 15px; +} + +.plugin-icon::after { + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05); + content: ""; + pointer-events: none; +} + +.command-container { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.command-block { + position: relative; + display: flex; + overflow: hidden; + padding: 0 10px; + height: 30px; + align-items: center; + background: #f5f5f5; + border-radius: 8px; + word-break: break-all; + width: 100%; + box-sizing: border-box; + font-weight: 600; +} + +[data-framer-theme="dark"] .command-block { + background: var(--framer-color-bg-tertiary); +} + +.command-block .mask { + position: absolute; + top: 0; + right: 0; + width: 20%; + height: 100%; + background: linear-gradient(to right, transparent, #f5f5f5); +} + +body, +html, +#root { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + overflow: hidden; +} + +/* Destructive Button */ + +.destructive-button { + background: #ff3366; + color: #fff; +} + +.destructive-button:hover { + background: #e7315e; +} + +.destructive-button:active { + background: #cf2e55; +} +/* Copy Button */ +.copy-button { + display: flex; + gap: 4px; + align-items: center; + justify-content: center; +} + +.copy-label-animate { + animation: copyFadeIn 0.2s cubic-bezier(0.77, 0, 0.175, 1) forwards; +} + +@keyframes copyFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.checkmark-path { + stroke-dasharray: 14; + stroke-dashoffset: 14; + animation: checkmarkDraw 0.25s cubic-bezier(0.65, 0, 0.35, 1) forwards; +} + +@keyframes checkmarkDraw { + to { + stroke-dashoffset: 0; + } +} + +/* Conflict/Delete Views */ + +.user-action-view { + padding-top: 0; + justify-content: start; +} + +.user-action-view h1 { + font-size: 12px; + margin-bottom: 8px; +} + +.user-action-view p { + color: var(--framer-color-text-tertiary); +} + +.user-action-view header { + padding: 15px 0; + max-width: 285px; +} + +.actions { + display: flex; + gap: 10px; + padding-top: 15px; + background: var(--framer-color-background); +} + +/* Look into why we need class-name specificity */ +.actions button { + flex: 1; +} + +.single-file-view { + display: flex; + + justify-content: space-between; + align-items: center; + padding-bottom: 15px; +} + +.list-header { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 40px; +} + +.list-titles { + display: grid; + grid-template-columns: 3fr 1fr 1fr; + gap: 10px; + justify-content: space-between; + align-items: center; + font-weight: 600; + font-size: 12px; + color: var(--framer-color-text-tertiary); +} + +.list { + list-style: none; + padding: 5px 0; + margin: 0; + display: flex; + flex-direction: column; + overflow-y: auto; + flex: 1; +} + +.list li { + display: grid; + height: 32px; + gap: 10px; + align-items: center; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + grid-template-columns: 3fr 1fr 1fr; +} + +.list .file-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.list .lines-changed { + font-variant-numeric: tabular-nums; +} + +.delete-confirmation .list-titles { + grid-template-columns: 3fr 1fr; +} + +.delete-confirmation .list li { + grid-template-columns: 3fr 1fr; +} + +/* Line change badges */ +.line-badge { + padding: 2px 4px; + border-radius: 5px; + font-weight: 500; + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.line-badge.added { + color: #00bb77; + background: rgba(0, 187, 119, 0.15); +} + +.line-badge.removed { + color: #ff3366; + background: rgba(255, 51, 102, 0.15); +} + +.line-badge.unchanged { + color: #999999; + background: rgba(153, 153, 153, 0.15); +} diff --git a/plugins/code-link/src/App.tsx b/plugins/code-link/src/App.tsx new file mode 100644 index 000000000..df17ebfee --- /dev/null +++ b/plugins/code-link/src/App.tsx @@ -0,0 +1,692 @@ +import { + type ConflictSummary, + createSyncTracker, + getPortFromHash, + type IncomingMessage, + isIncomingMessage, + type Mode, + type PendingDelete, + type ProjectInfo, + type SyncTracker, + shortProjectHash, +} from "@code-link/shared" +import { framer } from "framer-plugin" +import { useCallback, useEffect, useReducer, useRef, useState } from "react" +import { CodeFilesAPI } from "./api" +import { copyToClipboard } from "./utils/clipboard" +import { computeLineDiff } from "./utils/diffing" +import * as log from "./utils/logger" +import { useConstant } from "./utils/useConstant" + +interface State { + mode: Mode + project?: ProjectInfo + permissionsGranted: boolean + pendingDeletes: PendingDelete[] + conflicts: ConflictSummary[] +} + +type Action = + | { type: "project-loaded"; project: ProjectInfo } + | { type: "permissions-updated"; granted: boolean } + | { type: "set-mode"; mode: Mode } + | { type: "socket-disconnected"; message: string } + | { type: "pending-deletes"; files: PendingDelete[] } + | { type: "clear-pending-deletes" } + | { type: "conflicts"; conflicts: ConflictSummary[] } + | { type: "clear-conflicts" } + +const initialState: State = { + mode: "loading", + permissionsGranted: false, + pendingDeletes: [], + conflicts: [], +} + +function reducer(state: State, action: Action): State { + switch (action.type) { + case "project-loaded": + return { + ...state, + project: action.project, + } + case "permissions-updated": + return { + ...state, + permissionsGranted: action.granted, + mode: action.granted ? state.mode : "info", + } + case "set-mode": + return { + ...state, + mode: action.mode, + } + case "socket-disconnected": + return { + ...state, + mode: "info", + } + case "pending-deletes": + return { + ...state, + pendingDeletes: [...state.pendingDeletes, ...action.files], + mode: "delete_confirmation", + } + case "clear-pending-deletes": + return { ...state, pendingDeletes: [], mode: "idle" } + case "conflicts": + return { + ...state, + conflicts: action.conflicts, + mode: "conflict_resolution", + } + case "clear-conflicts": + return { ...state, conflicts: [], mode: "idle" } + } +} + +export function App() { + const [state, dispatch] = useReducer(reducer, initialState) + const socketRef = useRef(null) + const api = useConstant(() => new CodeFilesAPI()) + const syncTracker = useConstant(createSyncTracker) + const retryTimeoutRef = useRef | null>(null) + const connectionAttemptsRef = useRef(0) + const failureCountRef = useRef(0) + + const command = state.project && `npx framer-code-link ${shortProjectHash(state.project.id)}` + + // Permissions check + useEffect(() => { + let unsubscribePermissions: (() => void) | undefined + + async function checkPermissions() { + log.debug("Bootstrapping plugin...") + const project = await framer.getProjectInfo() + log.debug("Project loaded:", project) + dispatch({ type: "project-loaded", project }) + + // Check initial permission state + const initialPermissions = framer.isAllowedTo( + "createCodeFile", + "CodeFile.setFileContent", + "CodeFile.remove" + ) + log.debug("Initial permissions:", initialPermissions) + dispatch({ type: "permissions-updated", granted: initialPermissions }) + + // Subscribe to permission changes + unsubscribePermissions = framer.subscribeToIsAllowedTo( + "createCodeFile", + "CodeFile.setFileContent", + "CodeFile.remove", + granted => { + log.debug("Permissions changed:", granted) + dispatch({ type: "permissions-updated", granted }) + } + ) + } + + void checkPermissions() + return () => unsubscribePermissions?.() + }, []) + + // Code file subscription + useEffect(() => { + if (!state.project || !state.permissionsGranted) return + + const unsubscribeCodeFiles = framer.subscribeToCodeFiles(() => { + log.debug("Framer files changed") + const socket = socketRef.current + if (socket && socket.readyState === WebSocket.OPEN) { + void api.handleFramerFilesChanged(socket, syncTracker) + } + }) + + return () => { + unsubscribeCodeFiles() + } + }, [state.project, state.permissionsGranted, api, syncTracker]) + + // Socket connection + useEffect(() => { + if (!state.project || !state.permissionsGranted) { + log.debug("Waiting for project/permissions:", { + project: !!state.project, + permissions: state.permissionsGranted, + }) + return + } + + // Reset debug counters when project/permissions change + connectionAttemptsRef.current = 0 + failureCountRef.current = 0 + + const handleMessage = createMessageHandler({ dispatch, api, syncTracker }) + + let disposed = false + + const clearRetry = () => { + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current) + retryTimeoutRef.current = null + } + } + + const scheduleReconnect = () => { + if (disposed) return + clearRetry() + retryTimeoutRef.current = setTimeout(() => { + connect() + }, 2000) + } + + const connect = () => { + if (disposed) return + if ( + socketRef.current?.readyState === WebSocket.OPEN || + socketRef.current?.readyState === WebSocket.CONNECTING + ) { + log.debug("WebSocket already active – skipping connect") + return + } + + if (!state.project) { + log.debug("Error loading Project Info") + return + } + + const port = getPortFromHash(state.project.id) + const attempt = ++connectionAttemptsRef.current + const projectName = state.project.name + const projectShortHash = shortProjectHash(state.project.id) + + log.debug("Opening WebSocket", { port, attempt, project: projectName }) + const socket = new WebSocket(`ws://localhost:${port}`) + socketRef.current = socket + + const isStale = () => socketRef.current !== socket + + socket.onopen = async () => { + if (disposed || isStale()) return + failureCountRef.current = 0 + clearRetry() + log.debug("WebSocket connected, sending handshake") + // Don't change mode here - wait for CLI to confirm via request-files + // This prevents UI flashing during failed handshakes + const latestProjectInfo = await framer.getProjectInfo() + log.debug("Project info:", latestProjectInfo) + socket.send( + JSON.stringify({ + type: "handshake", + projectId: latestProjectInfo.id, + projectName: latestProjectInfo.name, + }) + ) + } + + socket.onmessage = event => { + if (isStale()) return + const parsed: unknown = JSON.parse(event.data as string) + if (!isIncomingMessage(parsed)) { + log.warn("Invalid message received:", parsed) + return + } + log.debug("Received message:", parsed.type) + void handleMessage(parsed, socket) + } + + socket.onclose = event => { + if (disposed || isStale()) return + socketRef.current = null + const failureCount = ++failureCountRef.current + log.debug("WebSocket closed – scheduling reconnect", { + code: event.code, + reason: event.reason || "none", + wasClean: event.wasClean, + port, + attempt, + project: projectName, + failureCount, + }) + dispatch({ + type: "socket-disconnected", + message: `Cannot reach CLI for ${projectName} on port ${port}. Run: npx framer-code-link ${projectShortHash}`, + }) + scheduleReconnect() + } + + socket.onerror = event => { + if (isStale()) return + const failureCount = failureCountRef.current + log.debug("WebSocket error event", { + type: event.type, + port, + attempt, + project: projectName, + failureCount, + }) + } + } + + connect() + + return () => { + disposed = true + log.debug("Cleaning up socket connection") + clearRetry() + const socket = socketRef.current + socketRef.current = null + if (socket && socket.readyState === WebSocket.OPEN) { + socket.close() + } + } + }, [state.project, state.permissionsGranted, api, syncTracker]) + + const sendMessage = useCallback((payload: unknown) => { + const socket = socketRef.current + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(payload)) + } + }, []) + + const resolveConflicts = (choice: "local" | "remote") => { + // Send all conflict resolutions at once + sendMessage({ + type: "conflicts-resolved", + resolution: choice, + }) + dispatch({ type: "clear-conflicts" }) + } + + const confirmDeletes = () => { + if (state.pendingDeletes.length === 0) { + return + } + + sendMessage({ + type: "delete-confirmed", + fileNames: state.pendingDeletes.map(file => file.fileName), + }) + dispatch({ type: "clear-pending-deletes" }) + } + + const keepDeletes = () => { + if (state.pendingDeletes.length === 0) { + return + } + + sendMessage({ + type: "delete-cancelled", + files: state.pendingDeletes, + }) + dispatch({ type: "clear-pending-deletes" }) + } + + switch (state.mode) { + case "delete_confirmation": + if (state.pendingDeletes.length === 1) { + void framer.showUI({ + width: 260, + height: 187, + position: "center", + resizable: false, + }) + } else { + void framer.showUI({ + width: 320, + height: 420, + position: "center", + resizable: "width", + }) + } + return + + case "conflict_resolution": + void framer.showUI({ + width: 320, + height: 420, + position: "center", + resizable: "width", + }) + return + + case "info": + void framer.showUI({ + width: 260, + height: 360, + position: "center", + resizable: false, + }) + return + + default: + void framer.setBackgroundMessage(backgroundStatusFromMode(state.mode)) + void framer.hideUI() + return null + } +} + +function backgroundStatusFromMode(mode: Mode | undefined): string | null { + switch (mode) { + case "loading": + return "Loading…" + case "info": + return null + case "syncing": + return "Syncing…" + case "delete_confirmation": + return null + case "conflict_resolution": + return null + case "idle": + return "Watching Files…" + default: + return "Loading…" + } +} + +interface InfoPanelProps { + command: string | undefined +} + +type CopyState = "initial" | "copied" | "returning" + +function InfoPanel({ command }: InfoPanelProps) { + const [copyState, setCopyState] = useState("initial") + const copyTimeout = useRef | null>(null) + + const handleCopy = async () => { + if (copyTimeout.current) clearTimeout(copyTimeout.current) + if (!command) return + + try { + await copyToClipboard(command) + setCopyState("copied") + copyTimeout.current = setTimeout(() => { + setCopyState("returning") + }, 2000) + } catch { + // Don't animate when failing + } + } + + return ( +
+
+
+

Code Link

+

+ Run the command locally in your terminal to get started.{" "} + + Learn More + +

+
+
+
+ {/*
*/} +
{command}
+
+ +
+
+ ) +} + +interface DeletePanelProps { + files: PendingDelete[] + onConfirm: () => void + onKeep: () => void +} + +function DeletePanel({ files, onConfirm, onKeep }: DeletePanelProps) { + const multiple = files.length > 1 + const text = multiple + ? { + title: "Confirm Deletions", + description: + "The following code files were deleted locally and will be permanently removed from this Project.", + } + : { + title: "Confirm Deletion", + description: "Code file was deleted locally and will be permanently removed from this Project.", + } + + const lines = files.map(file => file.content?.split("\n").length ?? 0) + + if (files.length === 0 || files[0] === undefined) return null + + return ( +
+
+
+

{text.title}

+

{text.description}

+
+ {multiple ? ( + <> +
+
+
+
File
+
Lines
+
+
+
+
    + {files.map((file, index) => ( +
  • +
    {file.fileName}
    +
    + -{lines[index]} +
    +
  • + ))} +
+ + ) : ( +
+
{files[0].fileName}
+
+ -{lines[0]} +
+
+ )} + +
+
+ + +
+
+ ) +} + +interface ConflictPanelProps { + conflicts: ConflictSummary[] + onResolve: (choice: "local" | "remote") => void +} + +function ConflictPanel({ conflicts, onResolve }: ConflictPanelProps) { + return ( +
+
+
+

Resolve Conflicts

+

The following code files have changed in Framer and locally. Select which set of changes to keep.

+
+
+
+
+
File
+
Local
+
Framer
+
+
+
+
    + {conflicts.map(conflict => { + // Show unique lines on each side + const diff = + conflict.localContent !== null && conflict.remoteContent !== null + ? computeLineDiff(conflict.remoteContent, conflict.localContent) + : null + + // diff.added = lines only in local + // diff.removed = lines only in remote + + const LocalBadge = () => { + if (conflict.localContent === null) { + return deleted + } + if (!diff || diff.added === 0) { + return ±0 + } + return +{diff.added} + } + + const FramerBadge = () => { + if (conflict.remoteContent === null) { + return deleted + } + if (!diff || diff.removed === 0) { + return ±0 + } + return +{diff.removed} + } + + return ( +
  • +
    {conflict.fileName}
    +
    + +
    +
    + +
    +
  • + ) + })} +
+
+
+ + +
+
+ ) +} + +function createMessageHandler({ + dispatch, + api, + syncTracker, +}: { + dispatch: (action: Action) => void + api: CodeFilesAPI + syncTracker: SyncTracker +}) { + return async function handleMessage(message: IncomingMessage, socket: WebSocket) { + log.debug("Handling message:", message.type) + + switch (message.type) { + case "request-files": + log.debug("Publishing snapshot to CLI") + await api.publishSnapshot(socket) + dispatch({ + type: "set-mode", + mode: "syncing", + }) + break + case "file-change": + log.debug("Applying remote change:", message.fileName) + await api.applyRemoteChange(message.fileName, message.content, socket) + syncTracker.remember(message.fileName, message.content) + dispatch({ type: "set-mode", mode: "idle" }) + break + case "file-delete": + if (message.requireConfirmation) { + log.debug(`Delete requires confirmation for ${message.fileNames.length} file(s)`) + const files: PendingDelete[] = [] + for (const fileName of message.fileNames) { + const content = await api.readCurrentContent(fileName) + files.push({ fileName, content }) + } + dispatch({ + type: "pending-deletes", + files, + }) + } else { + for (const fileName of message.fileNames) { + log.debug("Deleting file:", fileName) + await api.applyRemoteDelete(fileName) + } + } + break + case "conflicts-detected": + log.debug(`Received ${message.conflicts.length} conflicts from CLI`) + dispatch({ type: "conflicts", conflicts: message.conflicts }) + break + case "conflict-version-request": { + log.debug(`Fetching conflict versions for ${message.conflicts.length} files`) + const versions = await api.fetchConflictVersions(message.conflicts) + log.debug(`Sending version response for ${versions.length} files`) + socket.send( + JSON.stringify({ + type: "conflict-version-response", + versions, + }) + ) + break + } + case "sync-complete": + log.debug("Sync complete, transitioning to idle") + dispatch({ type: "set-mode", mode: "idle" }) + break + default: + log.warn("Unknown message type:", (message as unknown as { type: string }).type) + break + } + } +} diff --git a/plugins/code-link/src/api.ts b/plugins/code-link/src/api.ts new file mode 100644 index 000000000..525ce7190 --- /dev/null +++ b/plugins/code-link/src/api.ts @@ -0,0 +1,184 @@ +import { canonicalFileName, ensureExtension, type SyncTracker } from "@code-link/shared" +import { framer } from "framer-plugin" +import * as log from "./utils/logger" + +/** + * Plugin API Handlers + * + * Tries to be as stateless as possible. + */ + +export class CodeFilesAPI { + private lastSnapshot = new Map() + + private async getCodeFilesWithCanonicalNames() { + // Always all files instead of single file calls. + // The API internally does that anyways. + // Also ensures everything is fresh. + const codeFiles = await framer.getCodeFiles() + + return codeFiles.map(file => { + const source = file.path || file.name + return { + name: canonicalFileName(source), + content: file.content, + } + }) + } + + async publishSnapshot(socket: WebSocket) { + const files = await this.getCodeFilesWithCanonicalNames() + socket.send(JSON.stringify({ type: "file-list", files })) + this.lastSnapshot.clear() + files.forEach(file => this.lastSnapshot.set(file.name, file.content)) + } + + async handleFramerFilesChanged(socket: WebSocket, tracker: SyncTracker) { + const files = await this.getCodeFilesWithCanonicalNames() + const seen = new Set() + + for (const file of files) { + seen.add(file.name) + + const previous = this.lastSnapshot.get(file.name) + if (previous !== file.content) { + // Generally only a small number of files change. + // So we just send each change one by one. + socket.send( + JSON.stringify({ + type: "file-change", + fileName: file.name, + content: file.content, + }) + ) + tracker.remember(file.name, file.content) + this.lastSnapshot.set(file.name, file.content) + } + } + + for (const fileName of Array.from(this.lastSnapshot.keys())) { + if (!seen.has(fileName)) { + socket.send( + JSON.stringify({ + type: "file-delete", + fileNames: [fileName], + requireConfirmation: false, + }) + ) + this.lastSnapshot.delete(fileName) + } + } + } + + async applyRemoteChange(fileName: string, content: string, socket: WebSocket) { + const normalizedName = canonicalFileName(fileName) + // Update snapshot BEFORE upsert to prevent race with file subscription + this.lastSnapshot.set(normalizedName, content) + + const updatedAt = await upsertFramerFile(fileName, content) + // Send file-synced message with timestamp + const syncTimestamp = updatedAt ?? Date.now() + log.debug( + `Confirming sync for ${fileName} with timestamp ${new Date(syncTimestamp).toISOString()} (${syncTimestamp})` + ) + socket.send( + JSON.stringify({ + type: "file-synced", + fileName: normalizedName, + remoteModifiedAt: syncTimestamp, + }) + ) + } + + async applyRemoteDelete(fileName: string) { + await deleteFramerFile(fileName) + this.lastSnapshot.delete(canonicalFileName(fileName)) + } + + async readCurrentContent(fileName: string) { + const files = await this.getCodeFilesWithCanonicalNames() + const normalizedName = canonicalFileName(fileName) + return files.find(file => file.name === normalizedName)?.content + } + + async fetchConflictVersions(requests: { fileName: string; lastSyncedAt?: number }[]) { + log.debug(`Fetching versions for ${String(requests.length)} files`) + + // @TODO why only handle errors here... + let codeFiles + try { + codeFiles = await framer.getCodeFiles() + } catch (err) { + log.error("Failed to fetch code files", err) + return requests.map(r => ({ + fileName: r.fileName, + latestRemoteVersionMs: undefined, + })) + } + + const versionPromises = requests.map(async request => { + const file = codeFiles.find( + f => canonicalFileName(f.path || f.name) === canonicalFileName(request.fileName) + ) + + if (!file) { + log.warn(`File ${request.fileName} not found in Framer`) + return { + fileName: request.fileName, + latestRemoteVersionMs: undefined, + } + } + + try { + // We need to find the timestamp for the last save to know if we can auto-resolve safetly + const versions = await file.getVersions() + if (versions.length > 0 && versions[0]?.createdAt) { + const latestRemoteVersionMs = Date.parse(versions[0].createdAt) + log.debug(`${request.fileName}: ${versions[0].createdAt} (${latestRemoteVersionMs})`) + return { + fileName: request.fileName, + latestRemoteVersionMs, + } + } + } catch (err) { + log.error(`Failed to fetch versions for ${request.fileName}`, err) + } + + return { + fileName: request.fileName, + latestRemoteVersionMs: undefined, + } + }) + + const results = await Promise.all(versionPromises) + log.debug(`Returning version data for ${String(results.length)} files`) + return results + } +} + +async function upsertFramerFile(fileName: string, content: string): Promise { + const normalisedName = canonicalFileName(fileName) + const codeFiles = await framer.getCodeFiles() + const existing = codeFiles.find(file => canonicalFileName(file.path || file.name) === normalisedName) + + if (existing) { + await existing.setFileContent(content) + return Date.now() + } + + await framer.createCodeFile(ensureExtension(normalisedName), content, { + editViaPlugin: false, + }) + + return Date.now() +} + +async function deleteFramerFile(fileName: string) { + const normalisedName = canonicalFileName(fileName) + const codeFiles = await framer.getCodeFiles() + const existing = codeFiles.find(file => canonicalFileName(file.path || file.name) === normalisedName) + + if (existing) { + await existing.remove() + } +} diff --git a/plugins/code-link/src/main.tsx b/plugins/code-link/src/main.tsx new file mode 100644 index 000000000..e3db46486 --- /dev/null +++ b/plugins/code-link/src/main.tsx @@ -0,0 +1,21 @@ +import "framer-plugin/framer.css" +import "./App.css" + +import React from "react" +import ReactDOM from "react-dom/client" +import { App } from "./App.tsx" +import { LogLevel, setLogLevel } from "./utils/logger" + +// Enable debug logging in development +if (import.meta.env.DEV) { + setLogLevel(LogLevel.DEBUG) +} + +const root = document.getElementById("root") +if (!root) throw new Error("Root element not found") + +ReactDOM.createRoot(root).render( + + + +) diff --git a/plugins/code-link/src/utils/clipboard.ts b/plugins/code-link/src/utils/clipboard.ts new file mode 100644 index 000000000..983461011 --- /dev/null +++ b/plugins/code-link/src/utils/clipboard.ts @@ -0,0 +1,22 @@ +export async function copyToClipboard(text: string): Promise { + // Try execCommand first (no permissions needed) + if (execCommandCopy(text)) return + // Fall back to modern API if execCommand fails + await navigator.clipboard.writeText(text) +} +function execCommandCopy(text: string): boolean { + try { + const textarea = document.createElement("textarea") + textarea.value = text + textarea.style.position = "fixed" + textarea.style.opacity = "0" + document.body.appendChild(textarea) + textarea.select() + // eslint-disable-next-line @typescript-eslint/no-deprecated + const success = document.execCommand("copy") + document.body.removeChild(textarea) + return success + } catch { + return false + } +} diff --git a/plugins/code-link/src/utils/diffing.ts b/plugins/code-link/src/utils/diffing.ts new file mode 100644 index 000000000..0b8d68b69 --- /dev/null +++ b/plugins/code-link/src/utils/diffing.ts @@ -0,0 +1,19 @@ +/** Compute line-based diff: lines added/removed going from `from` to `to` */ +export function computeLineDiff(from: string, to: string): { added: number; removed: number } { + const fromLines = from.split("\n") + const toLines = to.split("\n") + const fromSet = new Set(fromLines) + const toSet = new Set(toLines) + + let added = 0 + let removed = 0 + + for (const line of toLines) { + if (!fromSet.has(line)) added++ + } + for (const line of fromLines) { + if (!toSet.has(line)) removed++ + } + + return { added, removed } +} diff --git a/plugins/code-link/src/utils/logger.ts b/plugins/code-link/src/utils/logger.ts new file mode 100644 index 000000000..eef07abc8 --- /dev/null +++ b/plugins/code-link/src/utils/logger.ts @@ -0,0 +1,40 @@ +/** + * Logging utilities for consistent output + */ + +export enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, +} + +let currentLevel = LogLevel.INFO + +export function setLogLevel(level: LogLevel): void { + currentLevel = level +} + +export function debug(message: string, ...args: unknown[]): void { + if (currentLevel <= LogLevel.DEBUG) { + console.debug(`[DEBUG] ${message}`, ...args) + } +} + +export function info(message: string, ...args: unknown[]): void { + if (currentLevel <= LogLevel.INFO) { + console.info(`[INFO] ${message}`, ...args) + } +} + +export function warn(message: string, ...args: unknown[]): void { + if (currentLevel <= LogLevel.WARN) { + console.warn(`[WARN] ${message}`, ...args) + } +} + +export function error(message: string, ...args: unknown[]): void { + if (currentLevel <= LogLevel.ERROR) { + console.error(`[ERROR] ${message}`, ...args) + } +} diff --git a/plugins/code-link/src/utils/useConstant.ts b/plugins/code-link/src/utils/useConstant.ts new file mode 100644 index 000000000..4b33338ac --- /dev/null +++ b/plugins/code-link/src/utils/useConstant.ts @@ -0,0 +1,10 @@ +import { useRef } from "react" + +// Only init the constant once +export function useConstant(init: () => T): T { + const ref = useRef(null) + + ref.current ??= init() + + return ref.current +} diff --git a/plugins/code-link/src/vite-env.d.ts b/plugins/code-link/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/plugins/code-link/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/plugins/code-link/tsconfig.json b/plugins/code-link/tsconfig.json new file mode 100644 index 000000000..69ad5d606 --- /dev/null +++ b/plugins/code-link/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "*"] +} diff --git a/yarn.lock b/yarn.lock index f959f91a1..8313b9e66 100644 --- a/yarn.lock +++ b/yarn.lock @@ -51,6 +51,26 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/generator@npm:7.28.5" + dependencies: + "@babel/parser": "npm:^7.28.5" + "@babel/types": "npm:^7.28.5" + "@jridgewell/gen-mapping": "npm:^0.3.12" + "@jridgewell/trace-mapping": "npm:^0.3.28" + jsesc: "npm:^3.0.2" + checksum: 10/ae618f0a17a6d76c3983e1fd5d9c2f5fdc07703a119efdb813a7d9b8ad4be0a07d4c6f0d718440d2de01a68e321f64e2d63c77fc5d43ae47ae143746ef28ac1f + languageName: node + linkType: hard + +"@babel/helper-string-parser@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-string-parser@npm:7.27.1" + checksum: 10/0ae29cc2005084abdae2966afdb86ed14d41c9c37db02c3693d5022fba9f5d59b011d039380b8e537c34daf117c549f52b452398f576e908fb9db3c7abbb3a00 + languageName: node + linkType: hard + "@babel/helper-validator-identifier@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-validator-identifier@npm:7.27.1" @@ -58,6 +78,24 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-identifier@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/helper-validator-identifier@npm:7.28.5" + checksum: 10/8e5d9b0133702cfacc7f368bf792f0f8ac0483794877c6dca5fcb73810ee138e27527701826fb58a40a004f3a5ec0a2f3c3dd5e326d262530b119918f3132ba7 + languageName: node + linkType: hard + +"@babel/parser@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/parser@npm:7.28.5" + dependencies: + "@babel/types": "npm:^7.28.5" + bin: + parser: ./bin/babel-parser.js + checksum: 10/8d9bfb437af6c97a7f6351840b9ac06b4529ba79d6d3def24d6c2996ab38ff7f1f9d301e868ca84a93a3050fadb3d09dbc5105b24634cd281671ac11eebe8df7 + languageName: node + linkType: hard + "@babel/runtime@npm:^7.12.5": version: 7.27.6 resolution: "@babel/runtime@npm:7.27.6" @@ -65,6 +103,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/types@npm:7.28.5" + dependencies: + "@babel/helper-string-parser": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.28.5" + checksum: 10/4256bb9fb2298c4f9b320bde56e625b7091ea8d2433d98dcf524d4086150da0b6555aabd7d0725162670614a9ac5bf036d1134ca13dedc9707f988670f1362d7 + languageName: node + linkType: hard + "@biomejs/biome@npm:^2.2.4": version: 2.2.4 resolution: "@biomejs/biome@npm:2.2.4" @@ -156,6 +204,15 @@ __metadata: languageName: node linkType: hard +"@code-link/shared@workspace:*, @code-link/shared@workspace:packages/code-link-shared": + version: 0.0.0-use.local + resolution: "@code-link/shared@workspace:packages/code-link-shared" + dependencies: + typescript: "npm:^5.9.3" + vitest: "npm:^4.0.15" + languageName: unknown + linkType: soft + "@emnapi/core@npm:^1.4.3, @emnapi/core@npm:^1.4.5": version: 1.5.0 resolution: "@emnapi/core@npm:1.5.0" @@ -166,6 +223,16 @@ __metadata: languageName: node linkType: hard +"@emnapi/core@npm:^1.7.1": + version: 1.7.1 + resolution: "@emnapi/core@npm:1.7.1" + dependencies: + "@emnapi/wasi-threads": "npm:1.1.0" + tslib: "npm:^2.4.0" + checksum: 10/260841f6dd2a7823a964d9de6da3a5e6f565dac8d21a5bd8f6215b87c45c22a4dc371b9ad877961579ee3cca8a76e55e3dd033ae29cba1998999cda6d794bdab + languageName: node + linkType: hard + "@emnapi/runtime@npm:^1.4.3, @emnapi/runtime@npm:^1.4.5": version: 1.5.0 resolution: "@emnapi/runtime@npm:1.5.0" @@ -175,6 +242,15 @@ __metadata: languageName: node linkType: hard +"@emnapi/runtime@npm:^1.7.1": + version: 1.7.1 + resolution: "@emnapi/runtime@npm:1.7.1" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10/6fc83f938e3c70e32e84c1fbe5cab6cb9340b8107cee4048384ad5b8f2998a06502b4bed342acaf6e44f473f2c14c4ab1e3fd5083bd7823fc63abfca9eff0175 + languageName: node + linkType: hard + "@emnapi/wasi-threads@npm:1.1.0, @emnapi/wasi-threads@npm:^1.0.4": version: 1.1.0 resolution: "@emnapi/wasi-threads@npm:1.1.0" @@ -191,6 +267,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/aix-ppc64@npm:0.27.2" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/android-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/android-arm64@npm:0.25.9" @@ -198,6 +281,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-arm64@npm:0.27.2" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/android-arm@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/android-arm@npm:0.25.9" @@ -205,6 +295,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-arm@npm:0.27.2" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@esbuild/android-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/android-x64@npm:0.25.9" @@ -212,6 +309,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-x64@npm:0.27.2" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + "@esbuild/darwin-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/darwin-arm64@npm:0.25.9" @@ -219,6 +323,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/darwin-arm64@npm:0.27.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/darwin-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/darwin-x64@npm:0.25.9" @@ -226,6 +337,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/darwin-x64@npm:0.27.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@esbuild/freebsd-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/freebsd-arm64@npm:0.25.9" @@ -233,6 +351,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/freebsd-arm64@npm:0.27.2" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/freebsd-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/freebsd-x64@npm:0.25.9" @@ -240,6 +365,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/freebsd-x64@npm:0.27.2" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/linux-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-arm64@npm:0.25.9" @@ -247,6 +379,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-arm64@npm:0.27.2" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/linux-arm@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-arm@npm:0.25.9" @@ -254,6 +393,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-arm@npm:0.27.2" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@esbuild/linux-ia32@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-ia32@npm:0.25.9" @@ -261,6 +407,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ia32@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-ia32@npm:0.27.2" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/linux-loong64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-loong64@npm:0.25.9" @@ -268,6 +421,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-loong64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-loong64@npm:0.27.2" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + "@esbuild/linux-mips64el@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-mips64el@npm:0.25.9" @@ -275,6 +435,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-mips64el@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-mips64el@npm:0.27.2" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + "@esbuild/linux-ppc64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-ppc64@npm:0.25.9" @@ -282,6 +449,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ppc64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-ppc64@npm:0.27.2" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/linux-riscv64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-riscv64@npm:0.25.9" @@ -289,6 +463,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-riscv64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-riscv64@npm:0.27.2" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + "@esbuild/linux-s390x@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-s390x@npm:0.25.9" @@ -296,6 +477,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-s390x@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-s390x@npm:0.27.2" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + "@esbuild/linux-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/linux-x64@npm:0.25.9" @@ -303,6 +491,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-x64@npm:0.27.2" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@esbuild/netbsd-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/netbsd-arm64@npm:0.25.9" @@ -310,6 +505,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/netbsd-arm64@npm:0.27.2" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/netbsd-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/netbsd-x64@npm:0.25.9" @@ -317,6 +519,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/netbsd-x64@npm:0.27.2" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openbsd-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/openbsd-arm64@npm:0.25.9" @@ -324,6 +533,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openbsd-arm64@npm:0.27.2" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/openbsd-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/openbsd-x64@npm:0.25.9" @@ -331,6 +547,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openbsd-x64@npm:0.27.2" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openharmony-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/openharmony-arm64@npm:0.25.9" @@ -338,6 +561,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openharmony-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openharmony-arm64@npm:0.27.2" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/sunos-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/sunos-x64@npm:0.25.9" @@ -345,6 +575,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/sunos-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/sunos-x64@npm:0.27.2" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + "@esbuild/win32-arm64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/win32-arm64@npm:0.25.9" @@ -352,6 +589,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-arm64@npm:0.27.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/win32-ia32@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/win32-ia32@npm:0.25.9" @@ -359,6 +603,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-ia32@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-ia32@npm:0.27.2" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/win32-x64@npm:0.25.9": version: 0.25.9 resolution: "@esbuild/win32-x64@npm:0.25.9" @@ -366,6 +617,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-x64@npm:0.27.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.7.0, @eslint-community/eslint-utils@npm:^4.8.0": version: 4.9.0 resolution: "@eslint-community/eslint-utils@npm:4.9.0" @@ -601,6 +859,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/gen-mapping@npm:^0.3.12": + version: 0.3.13 + resolution: "@jridgewell/gen-mapping@npm:0.3.13" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.0" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10/902f8261dcf450b4af7b93f9656918e02eec80a2169e155000cb2059f90113dd98f3ccf6efc6072cee1dd84cac48cade51da236972d942babc40e4c23da4d62a + languageName: node + linkType: hard + "@jridgewell/gen-mapping@npm:^0.3.5": version: 0.3.8 resolution: "@jridgewell/gen-mapping@npm:0.3.8" @@ -636,7 +904,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.5": +"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0, @jridgewell/sourcemap-codec@npm:^1.5.5": version: 1.5.5 resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" checksum: 10/5d9d207b462c11e322d71911e55e21a4e2772f71ffe8d6f1221b8eb5ae6774458c1d242f897fb0814e8714ca9a6b498abfa74dfe4f434493342902b1a48b33a5 @@ -653,6 +921,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/trace-mapping@npm:^0.3.28": + version: 0.3.31 + resolution: "@jridgewell/trace-mapping@npm:0.3.31" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10/da0283270e691bdb5543806077548532791608e52386cfbbf3b9e8fb00457859d1bd01d512851161c886eb3a2f3ce6fd9bcf25db8edf3bddedd275bd4a88d606 + languageName: node + linkType: hard + "@napi-rs/wasm-runtime@npm:^0.2.12": version: 0.2.12 resolution: "@napi-rs/wasm-runtime@npm:0.2.12" @@ -664,6 +942,17 @@ __metadata: languageName: node linkType: hard +"@napi-rs/wasm-runtime@npm:^1.1.0": + version: 1.1.0 + resolution: "@napi-rs/wasm-runtime@npm:1.1.0" + dependencies: + "@emnapi/core": "npm:^1.7.1" + "@emnapi/runtime": "npm:^1.7.1" + "@tybys/wasm-util": "npm:^0.10.1" + checksum: 10/87c7ab4685527aa4820320020e2af5879b99d88e94b42cdc3690646722536f14656667392975a9576bf411a3804a464949fadbc343a646a2c2a8b2f10d921f6c + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -720,6 +1009,20 @@ __metadata: languageName: node linkType: hard +"@oxc-project/types@npm:=0.101.0": + version: 0.101.0 + resolution: "@oxc-project/types@npm:0.101.0" + checksum: 10/43a29933af8d29ed71e5c3f9d5bc23e41c629ed2a00bb53b661c278d6d64771caff8879047cad11ce511868eaeb16da9b132fd1d856bcda258eba91b9296cb32 + languageName: node + linkType: hard + +"@oxc-project/types@npm:=0.103.0": + version: 0.103.0 + resolution: "@oxc-project/types@npm:0.103.0" + checksum: 10/3c9a1368fbf5cd96317b0a4c3f241b8317d01cb294c9819b252fe7168db91b03e3275c6b47188f93a8d84e3e2bb59966d18f6507e1ac9e6fafafc5ae44181206 + languageName: node + linkType: hard + "@phosphor-icons/core@npm:^2.1.1": version: 2.1.1 resolution: "@phosphor-icons/core@npm:2.1.1" @@ -751,6 +1054,15 @@ __metadata: languageName: node linkType: hard +"@quansync/fs@npm:^1.0.0": + version: 1.0.0 + resolution: "@quansync/fs@npm:1.0.0" + dependencies: + quansync: "npm:^1.0.0" + checksum: 10/8a27892b1330c01e1312e09e9fd92f676fa89d13fa5a201cb5b9b2f99347ef6bac67a2f6f69fe3e64612427eabf45f88b4294cc5d5d33e0031bb5263b5cd37c9 + languageName: node + linkType: hard + "@radix-ui/number@npm:1.1.1": version: 1.1.1 resolution: "@radix-ui/number@npm:1.1.1" @@ -2068,6 +2380,192 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-android-arm64@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-android-arm64@npm:1.0.0-beta.53" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-android-arm64@npm:1.0.0-beta.55": + version: 1.0.0-beta.55 + resolution: "@rolldown/binding-android-arm64@npm:1.0.0-beta.55" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-arm64@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-beta.53" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-arm64@npm:1.0.0-beta.55": + version: 1.0.0-beta.55 + resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-beta.55" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-x64@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-beta.53" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-x64@npm:1.0.0-beta.55": + version: 1.0.0-beta.55 + resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-beta.55" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-freebsd-x64@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-beta.53" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-freebsd-x64@npm:1.0.0-beta.55": + version: 1.0.0-beta.55 + resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-beta.55" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-beta.53" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-beta.55": + version: 1.0.0-beta.55 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-beta.55" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-gnu@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-beta.53" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-gnu@npm:1.0.0-beta.55": + version: 1.0.0-beta.55 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-beta.55" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-musl@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-beta.53" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-musl@npm:1.0.0-beta.55": + version: 1.0.0-beta.55 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-beta.55" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-gnu@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-beta.53" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-gnu@npm:1.0.0-beta.55": + version: 1.0.0-beta.55 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-beta.55" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-musl@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-beta.53" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-musl@npm:1.0.0-beta.55": + version: 1.0.0-beta.55 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-beta.55" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-openharmony-arm64@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-beta.53" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-openharmony-arm64@npm:1.0.0-beta.55": + version: 1.0.0-beta.55 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-beta.55" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-wasm32-wasi@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-beta.53" + dependencies: + "@napi-rs/wasm-runtime": "npm:^1.1.0" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@rolldown/binding-wasm32-wasi@npm:1.0.0-beta.55": + version: 1.0.0-beta.55 + resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-beta.55" + dependencies: + "@napi-rs/wasm-runtime": "npm:^1.1.0" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@rolldown/binding-win32-arm64-msvc@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-beta.53" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-win32-arm64-msvc@npm:1.0.0-beta.55": + version: 1.0.0-beta.55 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-beta.55" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-win32-x64-msvc@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-beta.53" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-win32-x64-msvc@npm:1.0.0-beta.55": + version: 1.0.0-beta.55 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-beta.55" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rolldown/pluginutils@npm:1.0.0-beta.32": version: 1.0.0-beta.32 resolution: "@rolldown/pluginutils@npm:1.0.0-beta.32" @@ -2075,6 +2573,20 @@ __metadata: languageName: node linkType: hard +"@rolldown/pluginutils@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/pluginutils@npm:1.0.0-beta.53" + checksum: 10/09dab7cbff3143838310a003ea5e453b219b27d00f34a88efe4c0c4d2540f16d95b770db2be111a424d51947dc3a3598798124e3f3622a99337f7a7c3f6913b2 + languageName: node + linkType: hard + +"@rolldown/pluginutils@npm:1.0.0-beta.55": + version: 1.0.0-beta.55 + resolution: "@rolldown/pluginutils@npm:1.0.0-beta.55" + checksum: 10/46ad40e75430559dfb1b200b9cf4f35c4e0921756df2f16fb05f8b3614cf75992fba08079b8d00bdde663e2d149a2a670807479ea44b06bc425fd1668be312db + languageName: node + linkType: hard + "@rollup/rollup-android-arm-eabi@npm:4.50.2": version: 4.50.2 resolution: "@rollup/rollup-android-arm-eabi@npm:4.50.2" @@ -2630,7 +3142,7 @@ __metadata: languageName: node linkType: hard -"@tybys/wasm-util@npm:^0.10.0": +"@tybys/wasm-util@npm:^0.10.0, @tybys/wasm-util@npm:^0.10.1": version: 0.10.1 resolution: "@tybys/wasm-util@npm:0.10.1" dependencies: @@ -2773,6 +3285,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^22.19.2": + version: 22.19.3 + resolution: "@types/node@npm:22.19.3" + dependencies: + undici-types: "npm:~6.21.0" + checksum: 10/ffee06ce6d741fde98a40bc65a57394ed2283c521f57f9143d2356513181162bd4108809be6902a861d098b35e35569f61f14c64d3032e48a0289b74f917669a + languageName: node + linkType: hard + "@types/papaparse@npm:^5.3.16": version: 5.3.16 resolution: "@types/papaparse@npm:5.3.16" @@ -2838,6 +3359,15 @@ __metadata: languageName: node linkType: hard +"@types/ws@npm:^8.18.1": + version: 8.18.1 + resolution: "@types/ws@npm:8.18.1" + dependencies: + "@types/node": "npm:*" + checksum: 10/1ce05e3174dcacf28dae0e9b854ef1c9a12da44c7ed73617ab6897c5cbe4fccbb155a20be5508ae9a7dde2f83bd80f5cf3baa386b934fc4b40889ec963e94f3a + languageName: node + linkType: hard + "@typescript-eslint/eslint-plugin@npm:8.44.0": version: 8.44.0 resolution: "@typescript-eslint/eslint-plugin@npm:8.44.0" @@ -2975,6 +3505,15 @@ __metadata: languageName: node linkType: hard +"@typescript/ata@npm:^0.9.8": + version: 0.9.8 + resolution: "@typescript/ata@npm:0.9.8" + peerDependencies: + typescript: ">=4.4.4" + checksum: 10/c0f9daf7818fff7f94030387e6bb6e8e270b1d6191ce2937040f039fedb977f5c96363610bb4ff99cb061b87a4b00213a5b79b28d85759ed876984e802b01cd9 + languageName: node + linkType: hard + "@vitejs/plugin-react-swc@npm:^4.0.1": version: 4.0.1 resolution: "@vitejs/plugin-react-swc@npm:4.0.1" @@ -3000,6 +3539,20 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:4.0.16": + version: 4.0.16 + resolution: "@vitest/expect@npm:4.0.16" + dependencies: + "@standard-schema/spec": "npm:^1.0.0" + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:4.0.16" + "@vitest/utils": "npm:4.0.16" + chai: "npm:^6.2.1" + tinyrainbow: "npm:^3.0.3" + checksum: 10/1da98c86d394a4955bef381ac2c63a52d2eec0086f55e18858083da928cfdf51e7a30bfd88b1814e861906dae44d089aeab0fcc67b2597a4a8073c70cd14bdf7 + languageName: node + linkType: hard + "@vitest/mocker@npm:3.2.4": version: 3.2.4 resolution: "@vitest/mocker@npm:3.2.4" @@ -3019,6 +3572,25 @@ __metadata: languageName: node linkType: hard +"@vitest/mocker@npm:4.0.16": + version: 4.0.16 + resolution: "@vitest/mocker@npm:4.0.16" + dependencies: + "@vitest/spy": "npm:4.0.16" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.21" + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10/3a34c6571ef278b80d33feabb8389d6cf7cfd248fe592b8b2a373650ab460b95805fde65e6bd76aebc75729fc0c94b4d8b9bba25fa55e21c2745ae03c10316bf + languageName: node + linkType: hard + "@vitest/pretty-format@npm:3.2.4, @vitest/pretty-format@npm:^3.2.4": version: 3.2.4 resolution: "@vitest/pretty-format@npm:3.2.4" @@ -3028,6 +3600,15 @@ __metadata: languageName: node linkType: hard +"@vitest/pretty-format@npm:4.0.16": + version: 4.0.16 + resolution: "@vitest/pretty-format@npm:4.0.16" + dependencies: + tinyrainbow: "npm:^3.0.3" + checksum: 10/914d5d35fb3b0aa67f8e6065ac3d1f1798b7774e1ad9d1e873e7c6efdc7925c98e0f8188bb13c4f3feb4d80b756c337f7a55cd4f78c50fe786330d0aaede7cfd + languageName: node + linkType: hard + "@vitest/runner@npm:3.2.4": version: 3.2.4 resolution: "@vitest/runner@npm:3.2.4" @@ -3039,6 +3620,16 @@ __metadata: languageName: node linkType: hard +"@vitest/runner@npm:4.0.16": + version: 4.0.16 + resolution: "@vitest/runner@npm:4.0.16" + dependencies: + "@vitest/utils": "npm:4.0.16" + pathe: "npm:^2.0.3" + checksum: 10/2aed39bb46ba747bd4fd5acf081e9e500192fec19c1887399f6a1701bbfdab05f3d3b45c00e4af5b90a0832853c959a0f64e676b05c67f5457b7c6984f844aa2 + languageName: node + linkType: hard + "@vitest/snapshot@npm:3.2.4": version: 3.2.4 resolution: "@vitest/snapshot@npm:3.2.4" @@ -3050,6 +3641,17 @@ __metadata: languageName: node linkType: hard +"@vitest/snapshot@npm:4.0.16": + version: 4.0.16 + resolution: "@vitest/snapshot@npm:4.0.16" + dependencies: + "@vitest/pretty-format": "npm:4.0.16" + magic-string: "npm:^0.30.21" + pathe: "npm:^2.0.3" + checksum: 10/30f2977c96645c018b9d1f658e758f4f886ac63966dca909e9f736d6c9d6d0a6dabdeaedf9abcc13e1000458e4069283632c0140033972847dc1f4b4ac38e076 + languageName: node + linkType: hard + "@vitest/spy@npm:3.2.4": version: 3.2.4 resolution: "@vitest/spy@npm:3.2.4" @@ -3059,6 +3661,13 @@ __metadata: languageName: node linkType: hard +"@vitest/spy@npm:4.0.16": + version: 4.0.16 + resolution: "@vitest/spy@npm:4.0.16" + checksum: 10/76cbabfdd77adf16904d5c128de67abca650bbc2ed36acc68fca548dc51844c7fc1ac516e384d07341b25ae39318c7c2feb499ffa7283a1a838f762cb0cda6ab + languageName: node + linkType: hard + "@vitest/ui@npm:^3.2.4": version: 3.2.4 resolution: "@vitest/ui@npm:3.2.4" @@ -3087,6 +3696,16 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:4.0.16": + version: 4.0.16 + resolution: "@vitest/utils@npm:4.0.16" + dependencies: + "@vitest/pretty-format": "npm:4.0.16" + tinyrainbow: "npm:^3.0.3" + checksum: 10/07fb3c96867656ff080df7ae6056a8dc23931d0f8bc16e15994c576c580dc6e2dcf71af0964fee197ea7eea4f4ad72c256f56cd3b81599f9e0ba63a228968d50 + languageName: node + linkType: hard + "abbrev@npm:^3.0.0": version: 3.0.1 resolution: "abbrev@npm:3.0.1" @@ -3191,6 +3810,13 @@ __metadata: languageName: node linkType: hard +"ansis@npm:^4.2.0": + version: 4.2.0 + resolution: "ansis@npm:4.2.0" + checksum: 10/493e15fad267bd6e3e275d6886c3b3c96a075784d9eae3e16d16383d488e94cc3deb1b357e1246f572599767360548ef9e5b7eab9b72e4ee3f7bad9ce6bc8797 + languageName: node + linkType: hard + "argparse@npm:^2.0.1": version: 2.0.1 resolution: "argparse@npm:2.0.1" @@ -3259,6 +3885,16 @@ __metadata: languageName: node linkType: hard +"ast-kit@npm:^2.2.0": + version: 2.2.0 + resolution: "ast-kit@npm:2.2.0" + dependencies: + "@babel/parser": "npm:^7.28.5" + pathe: "npm:^2.0.3" + checksum: 10/82cf2a8c2d5eadce177a62c1461b7374be5269e6d771e67d83f99d89ad5f75a4c3ba1d3bd71c931a2ecca061769240ed7968ddcee581697e7304375135ab2106 + languageName: node + linkType: hard + "asynckit@npm:^0.4.0": version: 0.4.0 resolution: "asynckit@npm:0.4.0" @@ -3309,6 +3945,13 @@ __metadata: languageName: node linkType: hard +"birpc@npm:^4.0.0": + version: 4.0.0 + resolution: "birpc@npm:4.0.0" + checksum: 10/f4418e2a0451f41eb6f20b3c9a4d6c007b335bbc0fe1d6eddf44bc2fd2d44f26594a3b0a24fc43107b0288a2a56959507b37478faed20e3da0d2587ed0fa1557 + languageName: node + linkType: hard + "blurhash@npm:^2.0.5": version: 2.0.5 resolution: "blurhash@npm:2.0.5" @@ -3439,6 +4082,13 @@ __metadata: languageName: node linkType: hard +"chai@npm:^6.2.1": + version: 6.2.1 + resolution: "chai@npm:6.2.1" + checksum: 10/f7917749e2468bd3a17ee4769b680e440002960c1294dd11c6d3ad102b5db9ea1a43e3ad9462b7b0f1502e5c845a6e39ce63db9de1def782e44652018c48acb7 + languageName: node + linkType: hard + "chalk@npm:^4.0.0": version: 4.1.2 resolution: "chalk@npm:4.1.2" @@ -3489,6 +4139,15 @@ __metadata: languageName: node linkType: hard +"chokidar@npm:^5.0.0": + version: 5.0.0 + resolution: "chokidar@npm:5.0.0" + dependencies: + readdirp: "npm:^5.0.0" + checksum: 10/a1c2a4ee6ee81ba6409712c295a47be055fb9de1186dfbab33c1e82f28619de962ba02fc5f9d433daaedc96c35747460d8b2079ac2907de2c95e3f7cce913113 + languageName: node + linkType: hard + "chownr@npm:^3.0.0": version: 3.0.0 resolution: "chownr@npm:3.0.0" @@ -3559,6 +4218,19 @@ __metadata: languageName: unknown linkType: soft +"code-link@workspace:plugins/code-link": + version: 0.0.0-use.local + resolution: "code-link@workspace:plugins/code-link" + dependencies: + "@code-link/shared": "workspace:*" + "@types/react": "npm:^18.3.24" + "@types/react-dom": "npm:^18.3.7" + framer-plugin: "npm:3.9.0-beta.0" + react: "npm:^18.3.1" + react-dom: "npm:^18.3.1" + languageName: unknown + linkType: soft + "code-versions@workspace:plugins/code-versions": version: 0.0.0-use.local resolution: "code-versions@workspace:plugins/code-versions" @@ -3638,6 +4310,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^14.0.2": + version: 14.0.2 + resolution: "commander@npm:14.0.2" + checksum: 10/2d202db5e5f9bb770112a3c1579b893d17ac6f6d932183077308bdd96d0f87f0bbe6a68b5b9ed2cf3b2514be6bb7de637480703c0e2db9741ee1b383237deb26 + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -3853,6 +4532,13 @@ __metadata: languageName: node linkType: hard +"defu@npm:^6.1.4": + version: 6.1.4 + resolution: "defu@npm:6.1.4" + checksum: 10/aeffdb47300f45b4fdef1c5bd3880ac18ea7a1fd5b8a8faf8df29350ff03bf16dd34f9800205cab513d476e4c0a3783aa0cff0a433aff0ac84a67ddc4c8a2d64 + languageName: node + linkType: hard + "delayed-stream@npm:~1.0.0": version: 1.0.0 resolution: "delayed-stream@npm:1.0.0" @@ -3985,6 +4671,18 @@ __metadata: languageName: unknown linkType: soft +"dts-resolver@npm:^2.1.3": + version: 2.1.3 + resolution: "dts-resolver@npm:2.1.3" + peerDependencies: + oxc-resolver: ">=11.0.0" + peerDependenciesMeta: + oxc-resolver: + optional: true + checksum: 10/9dfa79be6f5a4dabc318274a6069cc237e3121307afa604bada4e8cbbf5c30403d916ec49059ce473b18fed1a28eb1d13353bb0fb82c4231b5cb4d332ff12f51 + languageName: node + linkType: hard + "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -4013,6 +4711,13 @@ __metadata: languageName: node linkType: hard +"empathic@npm:^2.0.0": + version: 2.0.0 + resolution: "empathic@npm:2.0.0" + checksum: 10/90f47d93f8d1db3aa00ce1bfae2940bf76379dbb34bd562edbd92c3564a173cb1d6bd3cadb645fad0224839c25886abde801155d9b972dda6add7a5cc8b35d48 + languageName: node + linkType: hard + "encoding-sniffer@npm:^0.2.1": version: 0.2.1 resolution: "encoding-sniffer@npm:0.2.1" @@ -4192,6 +4897,95 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:^0.27.0, esbuild@npm:~0.27.0": + version: 0.27.2 + resolution: "esbuild@npm:0.27.2" + dependencies: + "@esbuild/aix-ppc64": "npm:0.27.2" + "@esbuild/android-arm": "npm:0.27.2" + "@esbuild/android-arm64": "npm:0.27.2" + "@esbuild/android-x64": "npm:0.27.2" + "@esbuild/darwin-arm64": "npm:0.27.2" + "@esbuild/darwin-x64": "npm:0.27.2" + "@esbuild/freebsd-arm64": "npm:0.27.2" + "@esbuild/freebsd-x64": "npm:0.27.2" + "@esbuild/linux-arm": "npm:0.27.2" + "@esbuild/linux-arm64": "npm:0.27.2" + "@esbuild/linux-ia32": "npm:0.27.2" + "@esbuild/linux-loong64": "npm:0.27.2" + "@esbuild/linux-mips64el": "npm:0.27.2" + "@esbuild/linux-ppc64": "npm:0.27.2" + "@esbuild/linux-riscv64": "npm:0.27.2" + "@esbuild/linux-s390x": "npm:0.27.2" + "@esbuild/linux-x64": "npm:0.27.2" + "@esbuild/netbsd-arm64": "npm:0.27.2" + "@esbuild/netbsd-x64": "npm:0.27.2" + "@esbuild/openbsd-arm64": "npm:0.27.2" + "@esbuild/openbsd-x64": "npm:0.27.2" + "@esbuild/openharmony-arm64": "npm:0.27.2" + "@esbuild/sunos-x64": "npm:0.27.2" + "@esbuild/win32-arm64": "npm:0.27.2" + "@esbuild/win32-ia32": "npm:0.27.2" + "@esbuild/win32-x64": "npm:0.27.2" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10/7f1229328b0efc63c4184a61a7eb303df1e99818cc1d9e309fb92600703008e69821e8e984e9e9f54a627da14e0960d561db3a93029482ef96dc82dd267a60c2 + languageName: node + linkType: hard + "escalade@npm:^3.1.1, escalade@npm:^3.2.0": version: 3.2.0 resolution: "escalade@npm:3.2.0" @@ -4364,6 +5158,13 @@ __metadata: languageName: node linkType: hard +"expect-type@npm:^1.2.2": + version: 1.3.0 + resolution: "expect-type@npm:1.3.0" + checksum: 10/a5fada3d0c621649261f886e7d93e6bf80ce26d8a86e5d517e38301b8baec8450ab2cb94ba6e7a0a6bf2fc9ee55f54e1b06938ef1efa52ddcfeffbfa01acbbcc + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.2 resolution: "exponential-backoff@npm:3.1.2" @@ -4540,6 +5341,27 @@ __metadata: languageName: node linkType: hard +"framer-code-link@workspace:packages/code-link-cli": + version: 0.0.0-use.local + resolution: "framer-code-link@workspace:packages/code-link-cli" + dependencies: + "@code-link/shared": "workspace:*" + "@types/node": "npm:^22.19.2" + "@types/ws": "npm:^8.18.1" + "@typescript/ata": "npm:^0.9.8" + chokidar: "npm:^5.0.0" + commander: "npm:^14.0.2" + prettier: "npm:^3.7.4" + tsdown: "npm:^0.17.4" + tsx: "npm:^4.21.0" + typescript: "npm:^5.9.3" + vitest: "npm:^4.0.15" + ws: "npm:^8.18.3" + bin: + framer-code-link: ./dist/index.mjs + languageName: unknown + linkType: soft + "framer-motion@npm:^12.23.12": version: 12.23.12 resolution: "framer-motion@npm:12.23.12" @@ -4593,6 +5415,16 @@ __metadata: languageName: node linkType: hard +"framer-plugin@npm:3.9.0-beta.0": + version: 3.9.0-beta.0 + resolution: "framer-plugin@npm:3.9.0-beta.0" + peerDependencies: + react: ^18.2.0 + react-dom: ^18.2.0 + checksum: 10/bb41b0770a723e557d970de1f0ea8cfb9fda247b8c2c8e4176cf9c2a77467ee51e5d9f1a793eedd93fb4ec41645dab9c507880d05f5e3ba50ee56c71f1626555 + languageName: node + linkType: hard + "framer-plugin@npm:3.9.0-beta.1": version: 3.9.0-beta.1 resolution: "framer-plugin@npm:3.9.0-beta.1" @@ -4682,6 +5514,15 @@ __metadata: languageName: node linkType: hard +"get-tsconfig@npm:^4.13.0, get-tsconfig@npm:^4.7.5": + version: 4.13.0 + resolution: "get-tsconfig@npm:4.13.0" + dependencies: + resolve-pkg-maps: "npm:^1.0.0" + checksum: 10/3603c6da30e312636e4c20461e779114c9126601d1eca70ee4e36e3e3c00e3c21892d2d920027333afa2cc9e20998a436b14abe03a53cde40742581cb0e9ceb2 + languageName: node + linkType: hard + "glob-parent@npm:^5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" @@ -4851,6 +5692,13 @@ __metadata: languageName: node linkType: hard +"hookable@npm:^5.5.3": + version: 5.5.3 + resolution: "hookable@npm:5.5.3" + checksum: 10/c6cec06f693e99a8f8ebd55592efc68042b472a4a04522dde384620d9a2cd7f422003357bf5688525f4bb14454bb0e4188a26db847fb1f1e06875958dfc61cde + languageName: node + linkType: hard + "htmlparser2@npm:^10.0.0": version: 10.0.0 resolution: "htmlparser2@npm:10.0.0" @@ -4958,6 +5806,13 @@ __metadata: languageName: node linkType: hard +"import-without-cache@npm:^0.2.3": + version: 0.2.4 + resolution: "import-without-cache@npm:0.2.4" + checksum: 10/ac263dab133301d0df521bb04f219bc697b749146669eb85bbf96c61279b698d7f053fb833c5def67b2329f2dfad51f4132ed2de2d696f572f98a4b8a222b1ee + languageName: node + linkType: hard + "imurmurhash@npm:^0.1.4": version: 0.1.4 resolution: "imurmurhash@npm:0.1.4" @@ -5094,6 +5949,15 @@ __metadata: languageName: node linkType: hard +"jsesc@npm:^3.0.2": + version: 3.1.0 + resolution: "jsesc@npm:3.1.0" + bin: + jsesc: bin/jsesc + checksum: 10/20bd37a142eca5d1794f354db8f1c9aeb54d85e1f5c247b371de05d23a9751ecd7bd3a9c4fc5298ea6fa09a100dafb4190fa5c98c6610b75952c3487f3ce7967 + languageName: node + linkType: hard + "json-buffer@npm:3.0.1": version: 3.0.1 resolution: "json-buffer@npm:3.0.1" @@ -5336,6 +6200,15 @@ __metadata: languageName: node linkType: hard +"magic-string@npm:^0.30.21": + version: 0.30.21 + resolution: "magic-string@npm:0.30.21" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10/57d5691f41ed40d962d8bd300148114f53db67fadbff336207db10a99f2bdf4a1be9cac3a68ee85dba575912ee1d4402e4396408196ec2d3afd043b076156221 + languageName: node + linkType: hard + "make-fetch-happen@npm:^14.0.3": version: 14.0.3 resolution: "make-fetch-happen@npm:14.0.3" @@ -5685,6 +6558,13 @@ __metadata: languageName: node linkType: hard +"obug@npm:^2.1.1": + version: 2.1.1 + resolution: "obug@npm:2.1.1" + checksum: 10/bdcf9213361786688019345f3452b95a1dc73710e4b403c82a1994b98bad6abc31b26cb72a482128c5fd53ea9daf6fbb7d0e0e7b2b7e9c8be6d779deeccee07f + languageName: node + linkType: hard + "ogl@npm:^1.0.11": version: 1.0.11 resolution: "ogl@npm:1.0.11" @@ -5971,6 +6851,15 @@ __metadata: languageName: node linkType: hard +"prettier@npm:^3.7.4": + version: 3.7.4 + resolution: "prettier@npm:3.7.4" + bin: + prettier: bin/prettier.cjs + checksum: 10/b4d00ea13baed813cb777c444506632fb10faaef52dea526cacd03085f01f6db11fc969ccebedf05bf7d93c3960900994c6adf1b150e28a31afd5cfe7089b313 + languageName: node + linkType: hard + "pretty-format@npm:^27.0.2": version: 27.5.1 resolution: "pretty-format@npm:27.5.1" @@ -6038,6 +6927,13 @@ __metadata: languageName: node linkType: hard +"quansync@npm:^1.0.0": + version: 1.0.0 + resolution: "quansync@npm:1.0.0" + checksum: 10/fba7a8e87ae8ed99648aba16ce5fbe0fb8a1ae00b18407447f0273feab413b6e50f1fcdfb106e88da700766c80d89c4303e2f0685baee2f10f055e6b2a5879cf + languageName: node + linkType: hard + "queue-microtask@npm:^1.2.2": version: 1.2.3 resolution: "queue-microtask@npm:1.2.3" @@ -6331,6 +7227,13 @@ __metadata: languageName: node linkType: hard +"readdirp@npm:^5.0.0": + version: 5.0.0 + resolution: "readdirp@npm:5.0.0" + checksum: 10/a17a591b51d8b912083660df159e8bd17305dc1a9ef27c869c818bd95ff59e3a6496f97e91e724ef433e789d559d24e39496ea1698822eb5719606dc9c1a923d + languageName: node + linkType: hard + "recharts@npm:^3.2.0": version: 3.2.0 resolution: "recharts@npm:3.2.0" @@ -6449,6 +7352,13 @@ __metadata: languageName: node linkType: hard +"resolve-pkg-maps@npm:^1.0.0": + version: 1.0.0 + resolution: "resolve-pkg-maps@npm:1.0.0" + checksum: 10/0763150adf303040c304009231314d1e84c6e5ebfa2d82b7d94e96a6e82bacd1dcc0b58ae257315f3c8adb89a91d8d0f12928241cba2df1680fbe6f60bf99b0e + languageName: node + linkType: hard + "retry@npm:^0.12.0": version: 0.12.0 resolution: "retry@npm:0.12.0" @@ -6463,6 +7373,142 @@ __metadata: languageName: node linkType: hard +"rolldown-plugin-dts@npm:^0.18.3": + version: 0.18.4 + resolution: "rolldown-plugin-dts@npm:0.18.4" + dependencies: + "@babel/generator": "npm:^7.28.5" + "@babel/parser": "npm:^7.28.5" + "@babel/types": "npm:^7.28.5" + ast-kit: "npm:^2.2.0" + birpc: "npm:^4.0.0" + dts-resolver: "npm:^2.1.3" + get-tsconfig: "npm:^4.13.0" + magic-string: "npm:^0.30.21" + obug: "npm:^2.1.1" + peerDependencies: + "@ts-macro/tsc": ^0.3.6 + "@typescript/native-preview": ">=7.0.0-dev.20250601.1" + rolldown: ^1.0.0-beta.51 + typescript: ^5.0.0 + vue-tsc: ~3.1.0 + peerDependenciesMeta: + "@ts-macro/tsc": + optional: true + "@typescript/native-preview": + optional: true + typescript: + optional: true + vue-tsc: + optional: true + checksum: 10/d6157bdfa742272550314b530468c937cf621ad9292b85b0b158ae6f02ac936e739935ff8dd4ca1070a77eb3f52cc8a00e3a4cf6da1c8d0fc470574070cbf620 + languageName: node + linkType: hard + +"rolldown@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "rolldown@npm:1.0.0-beta.53" + dependencies: + "@oxc-project/types": "npm:=0.101.0" + "@rolldown/binding-android-arm64": "npm:1.0.0-beta.53" + "@rolldown/binding-darwin-arm64": "npm:1.0.0-beta.53" + "@rolldown/binding-darwin-x64": "npm:1.0.0-beta.53" + "@rolldown/binding-freebsd-x64": "npm:1.0.0-beta.53" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0-beta.53" + "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0-beta.53" + "@rolldown/binding-linux-arm64-musl": "npm:1.0.0-beta.53" + "@rolldown/binding-linux-x64-gnu": "npm:1.0.0-beta.53" + "@rolldown/binding-linux-x64-musl": "npm:1.0.0-beta.53" + "@rolldown/binding-openharmony-arm64": "npm:1.0.0-beta.53" + "@rolldown/binding-wasm32-wasi": "npm:1.0.0-beta.53" + "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0-beta.53" + "@rolldown/binding-win32-x64-msvc": "npm:1.0.0-beta.53" + "@rolldown/pluginutils": "npm:1.0.0-beta.53" + dependenciesMeta: + "@rolldown/binding-android-arm64": + optional: true + "@rolldown/binding-darwin-arm64": + optional: true + "@rolldown/binding-darwin-x64": + optional: true + "@rolldown/binding-freebsd-x64": + optional: true + "@rolldown/binding-linux-arm-gnueabihf": + optional: true + "@rolldown/binding-linux-arm64-gnu": + optional: true + "@rolldown/binding-linux-arm64-musl": + optional: true + "@rolldown/binding-linux-x64-gnu": + optional: true + "@rolldown/binding-linux-x64-musl": + optional: true + "@rolldown/binding-openharmony-arm64": + optional: true + "@rolldown/binding-wasm32-wasi": + optional: true + "@rolldown/binding-win32-arm64-msvc": + optional: true + "@rolldown/binding-win32-x64-msvc": + optional: true + bin: + rolldown: bin/cli.mjs + checksum: 10/40713f7a30061a01dd9f921e5aeff7ca7bf17adb0b10b57e742e805f95a8e89862d35731c89f296ffc92716ce02d9b9d4ba13d7a0e888b806e5625e5400855ed + languageName: node + linkType: hard + +"rolldown@npm:1.0.0-beta.55": + version: 1.0.0-beta.55 + resolution: "rolldown@npm:1.0.0-beta.55" + dependencies: + "@oxc-project/types": "npm:=0.103.0" + "@rolldown/binding-android-arm64": "npm:1.0.0-beta.55" + "@rolldown/binding-darwin-arm64": "npm:1.0.0-beta.55" + "@rolldown/binding-darwin-x64": "npm:1.0.0-beta.55" + "@rolldown/binding-freebsd-x64": "npm:1.0.0-beta.55" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0-beta.55" + "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0-beta.55" + "@rolldown/binding-linux-arm64-musl": "npm:1.0.0-beta.55" + "@rolldown/binding-linux-x64-gnu": "npm:1.0.0-beta.55" + "@rolldown/binding-linux-x64-musl": "npm:1.0.0-beta.55" + "@rolldown/binding-openharmony-arm64": "npm:1.0.0-beta.55" + "@rolldown/binding-wasm32-wasi": "npm:1.0.0-beta.55" + "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0-beta.55" + "@rolldown/binding-win32-x64-msvc": "npm:1.0.0-beta.55" + "@rolldown/pluginutils": "npm:1.0.0-beta.55" + dependenciesMeta: + "@rolldown/binding-android-arm64": + optional: true + "@rolldown/binding-darwin-arm64": + optional: true + "@rolldown/binding-darwin-x64": + optional: true + "@rolldown/binding-freebsd-x64": + optional: true + "@rolldown/binding-linux-arm-gnueabihf": + optional: true + "@rolldown/binding-linux-arm64-gnu": + optional: true + "@rolldown/binding-linux-arm64-musl": + optional: true + "@rolldown/binding-linux-x64-gnu": + optional: true + "@rolldown/binding-linux-x64-musl": + optional: true + "@rolldown/binding-openharmony-arm64": + optional: true + "@rolldown/binding-wasm32-wasi": + optional: true + "@rolldown/binding-win32-arm64-msvc": + optional: true + "@rolldown/binding-win32-x64-msvc": + optional: true + bin: + rolldown: bin/cli.mjs + checksum: 10/74e194192baee0b4021f7f3d8e2c2b284bf1976d455af4f67799215a5418ddd430e43cd64b663bf52abdc675ca55c09d246fbf00f1b8fecc40fe3f8dc787b9a4 + languageName: node + linkType: hard + "rollup@npm:^4.43.0": version: 4.50.2 resolution: "rollup@npm:4.50.2" @@ -6600,6 +7646,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.7.3": + version: 7.7.3 + resolution: "semver@npm:7.7.3" + bin: + semver: bin/semver.js + checksum: 10/8dbc3168e057a38fc322af909c7f5617483c50caddba135439ff09a754b20bdd6482a5123ff543dad4affa488ecf46ec5fb56d61312ad20bb140199b88dfaea9 + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -6699,6 +7754,13 @@ __metadata: languageName: node linkType: hard +"std-env@npm:^3.10.0": + version: 3.10.0 + resolution: "std-env@npm:3.10.0" + checksum: 10/19c9cda4f370b1ffae2b8b08c72167d8c3e5cfa972aaf5c6873f85d0ed2faa729407f5abb194dc33380708c00315002febb6f1e1b484736bfcf9361ad366013a + languageName: node + linkType: hard + "std-env@npm:^3.9.0": version: 3.9.0 resolution: "std-env@npm:3.9.0" @@ -6870,6 +7932,13 @@ __metadata: languageName: node linkType: hard +"tinyexec@npm:^1.0.2": + version: 1.0.2 + resolution: "tinyexec@npm:1.0.2" + checksum: 10/cb709ed4240e873d3816e67f851d445f5676e0ae3a52931a60ff571d93d388da09108c8057b62351766133ee05ff3159dd56c3a0fbd39a5933c6639ce8771405 + languageName: node + linkType: hard + "tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.14, tinyglobby@npm:^0.2.15": version: 0.2.15 resolution: "tinyglobby@npm:0.2.15" @@ -6894,6 +7963,13 @@ __metadata: languageName: node linkType: hard +"tinyrainbow@npm:^3.0.3": + version: 3.0.3 + resolution: "tinyrainbow@npm:3.0.3" + checksum: 10/169cc63c15e1378674180f3207c82c05bfa58fc79992e48792e8d97b4b759012f48e95297900ede24a81f0087cf329a0d85bb81109739eacf03c650127b3f6c1 + languageName: node + linkType: hard + "tinyspy@npm:^4.0.3": version: 4.0.3 resolution: "tinyspy@npm:4.0.3" @@ -6924,6 +8000,15 @@ __metadata: languageName: node linkType: hard +"tree-kill@npm:^1.2.2": + version: 1.2.2 + resolution: "tree-kill@npm:1.2.2" + bin: + tree-kill: cli.js + checksum: 10/49117f5f410d19c84b0464d29afb9642c863bc5ba40fcb9a245d474c6d5cc64d1b177a6e6713129eb346b40aebb9d4631d967517f9fbe8251c35b21b13cd96c7 + languageName: node + linkType: hard + "ts-api-utils@npm:^2.1.0": version: 2.1.0 resolution: "ts-api-utils@npm:2.1.0" @@ -6940,6 +8025,51 @@ __metadata: languageName: node linkType: hard +"tsdown@npm:^0.17.4": + version: 0.17.4 + resolution: "tsdown@npm:0.17.4" + dependencies: + ansis: "npm:^4.2.0" + cac: "npm:^6.7.14" + defu: "npm:^6.1.4" + empathic: "npm:^2.0.0" + hookable: "npm:^5.5.3" + import-without-cache: "npm:^0.2.3" + obug: "npm:^2.1.1" + rolldown: "npm:1.0.0-beta.53" + rolldown-plugin-dts: "npm:^0.18.3" + semver: "npm:^7.7.3" + tinyexec: "npm:^1.0.2" + tinyglobby: "npm:^0.2.15" + tree-kill: "npm:^1.2.2" + unconfig-core: "npm:^7.4.2" + unrun: "npm:^0.2.19" + peerDependencies: + "@arethetypeswrong/core": ^0.18.1 + "@vitejs/devtools": ^0.0.0-alpha.19 + publint: ^0.3.0 + typescript: ^5.0.0 + unplugin-lightningcss: ^0.4.0 + unplugin-unused: ^0.5.0 + peerDependenciesMeta: + "@arethetypeswrong/core": + optional: true + "@vitejs/devtools": + optional: true + publint: + optional: true + typescript: + optional: true + unplugin-lightningcss: + optional: true + unplugin-unused: + optional: true + bin: + tsdown: dist/run.mjs + checksum: 10/1fe104c1e0297177774c9e46a0bdbaa17526509c74161b4fe3d7d30468eab509528055a2b24427b57ac8d0aad4e84e87388195b929aec6c1419d903829ba6d63 + languageName: node + linkType: hard + "tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.7.0, tslib@npm:^2.8.0": version: 2.8.1 resolution: "tslib@npm:2.8.1" @@ -6947,6 +8077,22 @@ __metadata: languageName: node linkType: hard +"tsx@npm:^4.21.0": + version: 4.21.0 + resolution: "tsx@npm:4.21.0" + dependencies: + esbuild: "npm:~0.27.0" + fsevents: "npm:~2.3.3" + get-tsconfig: "npm:^4.7.5" + dependenciesMeta: + fsevents: + optional: true + bin: + tsx: dist/cli.mjs + checksum: 10/7afedeff855ba98c47dc28b33d7e8e253c4dc1f791938db402d79c174bdf806b897c1a5f91e5b1259c112520c816f826b4c5d98f0bad7e95b02dec66fedb64d2 + languageName: node + linkType: hard + "turbo-darwin-64@npm:2.5.6": version: 2.5.6 resolution: "turbo-darwin-64@npm:2.5.6" @@ -7052,6 +8198,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:^5.9.3": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10/c089d9d3da2729fd4ac517f9b0e0485914c4b3c26f80dc0cffcb5de1719a17951e92425d55db59515c1a7ddab65808466debb864d0d56dcf43f27007d0709594 + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A^5.9.2#optional!builtin": version: 5.9.2 resolution: "typescript@patch:typescript@npm%3A5.9.2#optional!builtin::version=5.9.2&hash=5786d5" @@ -7062,6 +8218,26 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A^5.9.3#optional!builtin": + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10/696e1b017bc2635f4e0c94eb4435357701008e2f272f553d06e35b494b8ddc60aa221145e286c28ace0c89ee32827a28c2040e3a69bdc108b1a5dc8fb40b72e3 + languageName: node + linkType: hard + +"unconfig-core@npm:^7.4.2": + version: 7.4.2 + resolution: "unconfig-core@npm:7.4.2" + dependencies: + "@quansync/fs": "npm:^1.0.0" + quansync: "npm:^1.0.0" + checksum: 10/837d196508e11be4b182560448f07ca7506db9d2f607d43738f6d64d6a3182a6c71b73339de94ea05ee60cfcdfd29e59e0806902267967ffe522cae60b5b4b4d + languageName: node + linkType: hard + "undici-types@npm:~6.21.0": version: 6.21.0 resolution: "undici-types@npm:6.21.0" @@ -7101,6 +8277,22 @@ __metadata: languageName: node linkType: hard +"unrun@npm:^0.2.19": + version: 0.2.20 + resolution: "unrun@npm:0.2.20" + dependencies: + rolldown: "npm:1.0.0-beta.55" + peerDependencies: + synckit: ^0.11.11 + peerDependenciesMeta: + synckit: + optional: true + bin: + unrun: dist/cli.mjs + checksum: 10/a0d33e12f8a7eb9efcbbe5c402de4a4136263881e919c01b12a54559da41c7f9932c22cbaffb5172ca18245c8b193cd5c115062fd0c362a983bd52779e269b02 + languageName: node + linkType: hard + "unsplash@workspace:plugins/unsplash": version: 0.0.0-use.local resolution: "unsplash@workspace:plugins/unsplash" @@ -7324,6 +8516,61 @@ __metadata: languageName: node linkType: hard +"vite@npm:^6.0.0 || ^7.0.0": + version: 7.3.0 + resolution: "vite@npm:7.3.0" + dependencies: + esbuild: "npm:^0.27.0" + fdir: "npm:^6.5.0" + fsevents: "npm:~2.3.3" + picomatch: "npm:^4.0.3" + postcss: "npm:^8.5.6" + rollup: "npm:^4.43.0" + tinyglobby: "npm:^0.2.15" + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + jiti: ">=1.21.0" + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10/044490133aaf4cc024700995edaac10ff182262c721a44a8ac7839207b3e5af435c8b9dd8ee4658dc0f47147fa4211632cca3120177aa4bab99a7cb095e5149e + languageName: node + linkType: hard + "vite@npm:^7.1.11": version: 7.1.11 resolution: "vite@npm:7.1.11" @@ -7435,6 +8682,65 @@ __metadata: languageName: node linkType: hard +"vitest@npm:^4.0.15": + version: 4.0.16 + resolution: "vitest@npm:4.0.16" + dependencies: + "@vitest/expect": "npm:4.0.16" + "@vitest/mocker": "npm:4.0.16" + "@vitest/pretty-format": "npm:4.0.16" + "@vitest/runner": "npm:4.0.16" + "@vitest/snapshot": "npm:4.0.16" + "@vitest/spy": "npm:4.0.16" + "@vitest/utils": "npm:4.0.16" + es-module-lexer: "npm:^1.7.0" + expect-type: "npm:^1.2.2" + magic-string: "npm:^0.30.21" + obug: "npm:^2.1.1" + pathe: "npm:^2.0.3" + picomatch: "npm:^4.0.3" + std-env: "npm:^3.10.0" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^1.0.2" + tinyglobby: "npm:^0.2.15" + tinyrainbow: "npm:^3.0.3" + vite: "npm:^6.0.0 || ^7.0.0" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@opentelemetry/api": ^1.9.0 + "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 4.0.16 + "@vitest/browser-preview": 4.0.16 + "@vitest/browser-webdriverio": 4.0.16 + "@vitest/ui": 4.0.16 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@opentelemetry/api": + optional: true + "@types/node": + optional: true + "@vitest/browser-playwright": + optional: true + "@vitest/browser-preview": + optional: true + "@vitest/browser-webdriverio": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + bin: + vitest: vitest.mjs + checksum: 10/22b3806988ab186be4a6a133903a70c62835198e8e749f6ed751957d23bc1e3f0466e310a1a79d0b70a354b2e308e574486191eb39711257b3fe61e4fe00d1c8 + languageName: node + linkType: hard + "webidl-conversions@npm:^3.0.0": version: 3.0.1 resolution: "webidl-conversions@npm:3.0.1" @@ -7551,6 +8857,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.18.3": + version: 8.18.3 + resolution: "ws@npm:8.18.3" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10/725964438d752f0ab0de582cd48d6eeada58d1511c3f613485b5598a83680bedac6187c765b0fe082e2d8cc4341fc57707c813ae780feee82d0c5efe6a4c61b6 + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8"