@ambitresearch/genie 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (301) hide show
  1. package/README.md +15 -0
  2. package/dist/auth/bearer.d.ts +49 -0
  3. package/dist/auth/bearer.d.ts.map +1 -0
  4. package/dist/auth/bearer.js +159 -0
  5. package/dist/auth/bearer.js.map +1 -0
  6. package/dist/auth/oauth/authorize.d.ts +19 -0
  7. package/dist/auth/oauth/authorize.d.ts.map +1 -0
  8. package/dist/auth/oauth/authorize.js +111 -0
  9. package/dist/auth/oauth/authorize.js.map +1 -0
  10. package/dist/auth/oauth/config.d.ts +10 -0
  11. package/dist/auth/oauth/config.d.ts.map +1 -0
  12. package/dist/auth/oauth/config.js +16 -0
  13. package/dist/auth/oauth/config.js.map +1 -0
  14. package/dist/auth/oauth/dcr.d.ts +15 -0
  15. package/dist/auth/oauth/dcr.d.ts.map +1 -0
  16. package/dist/auth/oauth/dcr.js +38 -0
  17. package/dist/auth/oauth/dcr.js.map +1 -0
  18. package/dist/auth/oauth/jwt.d.ts +13 -0
  19. package/dist/auth/oauth/jwt.d.ts.map +1 -0
  20. package/dist/auth/oauth/jwt.js +46 -0
  21. package/dist/auth/oauth/jwt.js.map +1 -0
  22. package/dist/auth/oauth/metadata.d.ts +9 -0
  23. package/dist/auth/oauth/metadata.d.ts.map +1 -0
  24. package/dist/auth/oauth/metadata.js +19 -0
  25. package/dist/auth/oauth/metadata.js.map +1 -0
  26. package/dist/auth/oauth/router.d.ts +32 -0
  27. package/dist/auth/oauth/router.d.ts.map +1 -0
  28. package/dist/auth/oauth/router.js +159 -0
  29. package/dist/auth/oauth/router.js.map +1 -0
  30. package/dist/auth/oauth/store.d.ts +55 -0
  31. package/dist/auth/oauth/store.d.ts.map +1 -0
  32. package/dist/auth/oauth/store.js +63 -0
  33. package/dist/auth/oauth/store.js.map +1 -0
  34. package/dist/auth/oauth/token.d.ts +21 -0
  35. package/dist/auth/oauth/token.d.ts.map +1 -0
  36. package/dist/auth/oauth/token.js +77 -0
  37. package/dist/auth/oauth/token.js.map +1 -0
  38. package/dist/auth/oidc/group-policy.d.ts +43 -0
  39. package/dist/auth/oidc/group-policy.d.ts.map +1 -0
  40. package/dist/auth/oidc/group-policy.js +54 -0
  41. package/dist/auth/oidc/group-policy.js.map +1 -0
  42. package/dist/auth/oidc/verifier.d.ts +42 -0
  43. package/dist/auth/oidc/verifier.d.ts.map +1 -0
  44. package/dist/auth/oidc/verifier.js +145 -0
  45. package/dist/auth/oidc/verifier.js.map +1 -0
  46. package/dist/auth/token-cli.d.ts +9 -0
  47. package/dist/auth/token-cli.d.ts.map +1 -0
  48. package/dist/auth/token-cli.js +98 -0
  49. package/dist/auth/token-cli.js.map +1 -0
  50. package/dist/cli.d.ts +3 -0
  51. package/dist/cli.d.ts.map +1 -0
  52. package/dist/cli.js +149 -0
  53. package/dist/cli.js.map +1 -0
  54. package/dist/config/redact.d.ts +20 -0
  55. package/dist/config/redact.d.ts.map +1 -0
  56. package/dist/config/redact.js +77 -0
  57. package/dist/config/redact.js.map +1 -0
  58. package/dist/config/secrets.d.ts +100 -0
  59. package/dist/config/secrets.d.ts.map +1 -0
  60. package/dist/config/secrets.js +130 -0
  61. package/dist/config/secrets.js.map +1 -0
  62. package/dist/framework/html.d.ts +103 -0
  63. package/dist/framework/html.d.ts.map +1 -0
  64. package/dist/framework/html.js +271 -0
  65. package/dist/framework/html.js.map +1 -0
  66. package/dist/framework/interface.d.ts +130 -0
  67. package/dist/framework/interface.d.ts.map +1 -0
  68. package/dist/framework/interface.js +112 -0
  69. package/dist/framework/interface.js.map +1 -0
  70. package/dist/framework/react.d.ts +133 -0
  71. package/dist/framework/react.d.ts.map +1 -0
  72. package/dist/framework/react.js +243 -0
  73. package/dist/framework/react.js.map +1 -0
  74. package/dist/framework/vue.d.ts +99 -0
  75. package/dist/framework/vue.d.ts.map +1 -0
  76. package/dist/framework/vue.js +375 -0
  77. package/dist/framework/vue.js.map +1 -0
  78. package/dist/index.d.ts +3 -0
  79. package/dist/index.d.ts.map +1 -0
  80. package/dist/index.js +3 -0
  81. package/dist/index.js.map +1 -0
  82. package/dist/llm/client.d.ts +81 -0
  83. package/dist/llm/client.d.ts.map +1 -0
  84. package/dist/llm/client.js +141 -0
  85. package/dist/llm/client.js.map +1 -0
  86. package/dist/llm/component-response.d.ts +111 -0
  87. package/dist/llm/component-response.d.ts.map +1 -0
  88. package/dist/llm/component-response.js +167 -0
  89. package/dist/llm/component-response.js.map +1 -0
  90. package/dist/llm/generated-files.d.ts +10 -0
  91. package/dist/llm/generated-files.d.ts.map +1 -0
  92. package/dist/llm/generated-files.js +26 -0
  93. package/dist/llm/generated-files.js.map +1 -0
  94. package/dist/llm/prompts/CHANGELOG.md +70 -0
  95. package/dist/llm/prompts/generate-component.system.md +99 -0
  96. package/dist/llm/prompts/refine-component.system.md +112 -0
  97. package/dist/llm/prompts.d.ts +66 -0
  98. package/dist/llm/prompts.d.ts.map +1 -0
  99. package/dist/llm/prompts.js +78 -0
  100. package/dist/llm/prompts.js.map +1 -0
  101. package/dist/llm/retry.d.ts +183 -0
  102. package/dist/llm/retry.d.ts.map +1 -0
  103. package/dist/llm/retry.js +475 -0
  104. package/dist/llm/retry.js.map +1 -0
  105. package/dist/llm/schema.d.ts +129 -0
  106. package/dist/llm/schema.d.ts.map +1 -0
  107. package/dist/llm/schema.js +147 -0
  108. package/dist/llm/schema.js.map +1 -0
  109. package/dist/llm/validate.d.ts +107 -0
  110. package/dist/llm/validate.d.ts.map +1 -0
  111. package/dist/llm/validate.js +174 -0
  112. package/dist/llm/validate.js.map +1 -0
  113. package/dist/manifest/compiler.d.ts +112 -0
  114. package/dist/manifest/compiler.d.ts.map +1 -0
  115. package/dist/manifest/compiler.js +306 -0
  116. package/dist/manifest/compiler.js.map +1 -0
  117. package/dist/manifest/ensure.d.ts +46 -0
  118. package/dist/manifest/ensure.d.ts.map +1 -0
  119. package/dist/manifest/ensure.js +49 -0
  120. package/dist/manifest/ensure.js.map +1 -0
  121. package/dist/manifest/index.d.ts +13 -0
  122. package/dist/manifest/index.d.ts.map +1 -0
  123. package/dist/manifest/index.js +13 -0
  124. package/dist/manifest/index.js.map +1 -0
  125. package/dist/middleware/plan-guard.d.ts +167 -0
  126. package/dist/middleware/plan-guard.d.ts.map +1 -0
  127. package/dist/middleware/plan-guard.js +190 -0
  128. package/dist/middleware/plan-guard.js.map +1 -0
  129. package/dist/plans/index.d.ts +71 -0
  130. package/dist/plans/index.d.ts.map +1 -0
  131. package/dist/plans/index.js +252 -0
  132. package/dist/plans/index.js.map +1 -0
  133. package/dist/schemas/component.schema.json +114 -0
  134. package/dist/server.d.ts +96 -0
  135. package/dist/server.d.ts.map +1 -0
  136. package/dist/server.js +258 -0
  137. package/dist/server.js.map +1 -0
  138. package/dist/store/empty-manifest.d.ts +58 -0
  139. package/dist/store/empty-manifest.d.ts.map +1 -0
  140. package/dist/store/empty-manifest.js +59 -0
  141. package/dist/store/empty-manifest.js.map +1 -0
  142. package/dist/store/git-host.d.ts +120 -0
  143. package/dist/store/git-host.d.ts.map +1 -0
  144. package/dist/store/git-host.js +796 -0
  145. package/dist/store/git-host.js.map +1 -0
  146. package/dist/store/index.d.ts +5 -0
  147. package/dist/store/index.d.ts.map +1 -0
  148. package/dist/store/index.js +4 -0
  149. package/dist/store/index.js.map +1 -0
  150. package/dist/store/interface.d.ts +322 -0
  151. package/dist/store/interface.d.ts.map +1 -0
  152. package/dist/store/interface.js +100 -0
  153. package/dist/store/interface.js.map +1 -0
  154. package/dist/store/kit-files.d.ts +88 -0
  155. package/dist/store/kit-files.d.ts.map +1 -0
  156. package/dist/store/kit-files.js +199 -0
  157. package/dist/store/kit-files.js.map +1 -0
  158. package/dist/store/local.d.ts +70 -0
  159. package/dist/store/local.d.ts.map +1 -0
  160. package/dist/store/local.js +651 -0
  161. package/dist/store/local.js.map +1 -0
  162. package/dist/store/manifest.d.ts +44 -0
  163. package/dist/store/manifest.d.ts.map +1 -0
  164. package/dist/store/manifest.js +116 -0
  165. package/dist/store/manifest.js.map +1 -0
  166. package/dist/store/viewer-assets.d.ts +20 -0
  167. package/dist/store/viewer-assets.d.ts.map +1 -0
  168. package/dist/store/viewer-assets.js +118 -0
  169. package/dist/store/viewer-assets.js.map +1 -0
  170. package/dist/sync/anchor.d.ts +125 -0
  171. package/dist/sync/anchor.d.ts.map +1 -0
  172. package/dist/sync/anchor.js +210 -0
  173. package/dist/sync/anchor.js.map +1 -0
  174. package/dist/sync/index.d.ts +15 -0
  175. package/dist/sync/index.d.ts.map +1 -0
  176. package/dist/sync/index.js +15 -0
  177. package/dist/sync/index.js.map +1 -0
  178. package/dist/sync/orchestrator.d.ts +160 -0
  179. package/dist/sync/orchestrator.d.ts.map +1 -0
  180. package/dist/sync/orchestrator.js +288 -0
  181. package/dist/sync/orchestrator.js.map +1 -0
  182. package/dist/tools/bind_kit.d.ts +29 -0
  183. package/dist/tools/bind_kit.d.ts.map +1 -0
  184. package/dist/tools/bind_kit.js +70 -0
  185. package/dist/tools/bind_kit.js.map +1 -0
  186. package/dist/tools/conjure.d.ts +141 -0
  187. package/dist/tools/conjure.d.ts.map +1 -0
  188. package/dist/tools/conjure.js +655 -0
  189. package/dist/tools/conjure.js.map +1 -0
  190. package/dist/tools/conjure_screen.d.ts +174 -0
  191. package/dist/tools/conjure_screen.d.ts.map +1 -0
  192. package/dist/tools/conjure_screen.js +458 -0
  193. package/dist/tools/conjure_screen.js.map +1 -0
  194. package/dist/tools/create_kit.d.ts +32 -0
  195. package/dist/tools/create_kit.d.ts.map +1 -0
  196. package/dist/tools/create_kit.js +131 -0
  197. package/dist/tools/create_kit.js.map +1 -0
  198. package/dist/tools/create_project.d.ts +267 -0
  199. package/dist/tools/create_project.d.ts.map +1 -0
  200. package/dist/tools/create_project.js +546 -0
  201. package/dist/tools/create_project.js.map +1 -0
  202. package/dist/tools/delete_files.d.ts +93 -0
  203. package/dist/tools/delete_files.d.ts.map +1 -0
  204. package/dist/tools/delete_files.js +276 -0
  205. package/dist/tools/delete_files.js.map +1 -0
  206. package/dist/tools/delete_project.d.ts +43 -0
  207. package/dist/tools/delete_project.d.ts.map +1 -0
  208. package/dist/tools/delete_project.js +118 -0
  209. package/dist/tools/delete_project.js.map +1 -0
  210. package/dist/tools/get_kit.d.ts +28 -0
  211. package/dist/tools/get_kit.d.ts.map +1 -0
  212. package/dist/tools/get_kit.js +95 -0
  213. package/dist/tools/get_kit.js.map +1 -0
  214. package/dist/tools/get_project.d.ts +37 -0
  215. package/dist/tools/get_project.d.ts.map +1 -0
  216. package/dist/tools/get_project.js +71 -0
  217. package/dist/tools/get_project.js.map +1 -0
  218. package/dist/tools/list_components.d.ts +42 -0
  219. package/dist/tools/list_components.d.ts.map +1 -0
  220. package/dist/tools/list_components.js +125 -0
  221. package/dist/tools/list_components.js.map +1 -0
  222. package/dist/tools/list_files.d.ts +32 -0
  223. package/dist/tools/list_files.d.ts.map +1 -0
  224. package/dist/tools/list_files.js +109 -0
  225. package/dist/tools/list_files.js.map +1 -0
  226. package/dist/tools/list_kits.d.ts +14 -0
  227. package/dist/tools/list_kits.d.ts.map +1 -0
  228. package/dist/tools/list_kits.js +41 -0
  229. package/dist/tools/list_kits.js.map +1 -0
  230. package/dist/tools/list_projects.d.ts +23 -0
  231. package/dist/tools/list_projects.d.ts.map +1 -0
  232. package/dist/tools/list_projects.js +66 -0
  233. package/dist/tools/list_projects.js.map +1 -0
  234. package/dist/tools/plan.d.ts +16 -0
  235. package/dist/tools/plan.d.ts.map +1 -0
  236. package/dist/tools/plan.js +146 -0
  237. package/dist/tools/plan.js.map +1 -0
  238. package/dist/tools/preview.d.ts +289 -0
  239. package/dist/tools/preview.d.ts.map +1 -0
  240. package/dist/tools/preview.js +672 -0
  241. package/dist/tools/preview.js.map +1 -0
  242. package/dist/tools/read_file.d.ts +40 -0
  243. package/dist/tools/read_file.d.ts.map +1 -0
  244. package/dist/tools/read_file.js +114 -0
  245. package/dist/tools/read_file.js.map +1 -0
  246. package/dist/tools/refine.d.ts +220 -0
  247. package/dist/tools/refine.d.ts.map +1 -0
  248. package/dist/tools/refine.js +501 -0
  249. package/dist/tools/refine.js.map +1 -0
  250. package/dist/tools/validate.d.ts +74 -0
  251. package/dist/tools/validate.d.ts.map +1 -0
  252. package/dist/tools/validate.js +224 -0
  253. package/dist/tools/validate.js.map +1 -0
  254. package/dist/tools/write_files.d.ts +158 -0
  255. package/dist/tools/write_files.d.ts.map +1 -0
  256. package/dist/tools/write_files.js +541 -0
  257. package/dist/tools/write_files.js.map +1 -0
  258. package/dist/transport.d.ts +77 -0
  259. package/dist/transport.d.ts.map +1 -0
  260. package/dist/transport.js +448 -0
  261. package/dist/transport.js.map +1 -0
  262. package/dist/ui/card-asset-broker.d.ts +42 -0
  263. package/dist/ui/card-asset-broker.d.ts.map +1 -0
  264. package/dist/ui/card-asset-broker.js +479 -0
  265. package/dist/ui/card-asset-broker.js.map +1 -0
  266. package/dist/ui/grid-resource.d.ts +252 -0
  267. package/dist/ui/grid-resource.d.ts.map +1 -0
  268. package/dist/ui/grid-resource.js +680 -0
  269. package/dist/ui/grid-resource.js.map +1 -0
  270. package/dist/ui/viewer-static/index.html +95 -0
  271. package/dist/ui/viewer-static/viewer.css +451 -0
  272. package/dist/ui/viewer-static/viewer.js +1405 -0
  273. package/dist/validate/full-scan.d.ts +87 -0
  274. package/dist/validate/full-scan.d.ts.map +1 -0
  275. package/dist/validate/full-scan.js +299 -0
  276. package/dist/validate/full-scan.js.map +1 -0
  277. package/dist/validate/index.d.ts +15 -0
  278. package/dist/validate/index.d.ts.map +1 -0
  279. package/dist/validate/index.js +18 -0
  280. package/dist/validate/index.js.map +1 -0
  281. package/dist/validate/marker.d.ts +89 -0
  282. package/dist/validate/marker.d.ts.map +1 -0
  283. package/dist/validate/marker.js +87 -0
  284. package/dist/validate/marker.js.map +1 -0
  285. package/dist/validate/phash.d.ts +135 -0
  286. package/dist/validate/phash.d.ts.map +1 -0
  287. package/dist/validate/phash.js +307 -0
  288. package/dist/validate/phash.js.map +1 -0
  289. package/dist/validate/render.d.ts +38 -0
  290. package/dist/validate/render.d.ts.map +1 -0
  291. package/dist/validate/render.js +155 -0
  292. package/dist/validate/render.js.map +1 -0
  293. package/dist/watch/index.d.ts +12 -0
  294. package/dist/watch/index.d.ts.map +1 -0
  295. package/dist/watch/index.js +12 -0
  296. package/dist/watch/index.js.map +1 -0
  297. package/dist/watch/watcher.d.ts +65 -0
  298. package/dist/watch/watcher.d.ts.map +1 -0
  299. package/dist/watch/watcher.js +238 -0
  300. package/dist/watch/watcher.js.map +1 -0
  301. package/package.json +70 -0
@@ -0,0 +1,651 @@
1
+ /**
2
+ * LocalFsStore — solo-dev adapter.
3
+ *
4
+ * AC3: Stores kits under `${GENIE_HOME ?? ~/.genie}/kits/<kitId>/`
5
+ * and projects under `${GENIE_HOME ?? ~/.genie}/projects/<projectId>/`.
6
+ * Each plan is a temp staging directory.
7
+ */
8
+ import { createHash, randomUUID } from "node:crypto";
9
+ import { createReadStream, createWriteStream } from "node:fs";
10
+ import { mkdir, mkdtemp, readdir, readFile, rename, rm, stat, unlink, writeFile, } from "node:fs/promises";
11
+ import { homedir } from "node:os";
12
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
13
+ import { pipeline } from "node:stream/promises";
14
+ import { FileTooLargeError, KitAlreadyExistsError, KIT_TYPE, MAX_FILE_BYTES, NotFoundError, RollbackIncompleteError, WriteFailedError, } from "./interface.js";
15
+ import { buildIgnoreMatcher, classifyFileContent, isSafeKitId, parseGenieignore, } from "./kit-files.js";
16
+ import { serializeEmptyManifest } from "./empty-manifest.js";
17
+ import { MANIFEST_PATH, selectComponents } from "./manifest.js";
18
+ import { loadViewerAssets } from "./viewer-assets.js";
19
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
20
+ function genieHome() {
21
+ return process.env["GENIE_HOME"] ?? join(homedir(), ".genie");
22
+ }
23
+ async function ensureDir(dir) {
24
+ await mkdir(dir, { recursive: true });
25
+ }
26
+ /**
27
+ * Resolve a path within a base directory and verify it doesn't escape.
28
+ * Prevents path traversal attacks (e.g. "../../etc/passwd").
29
+ *
30
+ * Uses path-segment-aware checks so legitimate names beginning with
31
+ * the literal "..". (e.g. "..foo/bar") are not falsely rejected.
32
+ */
33
+ function safePath(baseDir, userPath) {
34
+ const resolved = resolve(baseDir, userPath);
35
+ const rel = relative(baseDir, resolved);
36
+ // The relative path escapes baseDir only when it IS ".." itself,
37
+ // starts with ".." followed by a path separator, or is absolute.
38
+ if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) {
39
+ throw new Error(`Path traversal denied: "${userPath}" resolves outside the allowed directory.`);
40
+ }
41
+ return resolved;
42
+ }
43
+ /**
44
+ * Recursively walk a kit directory into rich `KitFileEntry` records
45
+ * (kit-root-relative forward-slash `path`, byte `size`, `sha256-…` SRI `hash`,
46
+ * ISO-8601 `lastModified`). The `.kit.json` marker and any path the `ignore`
47
+ * matcher rejects (default dirs + `.genieignore`) are skipped. Symlinks and
48
+ * other non-regular entries are ignored. Unsorted — the caller sorts by path.
49
+ */
50
+ async function walkKitFiles(dir, root, ignore) {
51
+ const entries = await readdir(dir, { withFileTypes: true });
52
+ const files = [];
53
+ for (const entry of entries) {
54
+ const absolutePath = join(dir, entry.name);
55
+ const relativePath = relative(root, absolutePath).replaceAll("\\", "/");
56
+ if (relativePath === ".kit.json" || ignore(relativePath))
57
+ continue;
58
+ if (entry.isDirectory()) {
59
+ files.push(...(await walkKitFiles(absolutePath, root, ignore)));
60
+ continue;
61
+ }
62
+ if (!entry.isFile())
63
+ continue;
64
+ // Size comes from stat; the SRI hash is STREAMED (createReadStream piped
65
+ // through the hash) rather than read into a full buffer, so peak hashing
66
+ // memory is bounded by the stream's highWaterMark (~64 KiB), not the
67
+ // largest file's size (AC2, DRO-581). The digest is byte-identical to the
68
+ // prior `sriSha256(await readFile(...))` — same bytes, same hash, same
69
+ // order — which the streamed-vs-full-buffer regression test pins down.
70
+ const [stats, hash] = await Promise.all([stat(absolutePath), hashFileStream(absolutePath)]);
71
+ files.push({
72
+ path: relativePath,
73
+ size: stats.size,
74
+ hash,
75
+ lastModified: stats.mtime.toISOString(),
76
+ });
77
+ }
78
+ return files;
79
+ }
80
+ /**
81
+ * Compute a file's `sha256-<base64>` SRI hash by STREAMING it through the hash
82
+ * in chunks, so peak memory stays bounded by the read-stream buffer (default
83
+ * highWaterMark, ~64 KiB) rather than the largest single file's size (AC2).
84
+ *
85
+ * The digest is byte-identical to hashing the full buffer with
86
+ * `createHash("sha256").update(bytes)` (i.e. the shared `sriSha256(bytes)`):
87
+ * piping the stream feeds the SAME bytes to the SAME hash in the SAME order, so
88
+ * a >64 KiB multi-chunk file, an empty file (zero chunks), and a binary file
89
+ * all produce the identical digest — pinned by the streamed-vs-full-buffer
90
+ * regression test. `pipeline` surfaces a mid-read stream error as a rejection
91
+ * instead of leaving a dangling read stream. RFC G-5's byte-identical-across-
92
+ * adapters contract is preserved because the git-host adapter still calls
93
+ * `sriSha256` over the same bytes, and both forms yield the same string.
94
+ */
95
+ async function hashFileStream(absolutePath) {
96
+ const hash = createHash("sha256");
97
+ await pipeline(createReadStream(absolutePath), hash);
98
+ return `sha256-${hash.digest("base64")}`;
99
+ }
100
+ /** ENOENT (missing file) and ENOTDIR (a parent component is a file) both mean
101
+ * "the path is not there". */
102
+ function isMissingPathError(error) {
103
+ return (error instanceof Error &&
104
+ "code" in error &&
105
+ (error.code === "ENOENT" || error.code === "ENOTDIR"));
106
+ }
107
+ /**
108
+ * Stage every op's new content under a fresh `<kitDir>/.genie-tmp/<rand>/`
109
+ * (streaming `sourcePath` ops through a hash so a large file is never fully
110
+ * buffered), then commit via rename-to-temp + rename-back. Nothing under
111
+ * `kitDir` is touched until every op has staged successfully. Staging inside
112
+ * `kitDir` (not `os.tmpdir()`) is load-bearing: `rename()` is only atomic
113
+ * within one filesystem, and a kit dir + `/tmp` are commonly different mounts.
114
+ */
115
+ async function stageAndCommit(kitDir, ops) {
116
+ const genieTmpRoot = join(kitDir, ".genie-tmp");
117
+ await mkdir(genieTmpRoot, { recursive: true });
118
+ const stagingRoot = await mkdtemp(join(genieTmpRoot, `${randomUUID()}-`));
119
+ const backupRoot = join(stagingRoot, "backup");
120
+ try {
121
+ // Phase 1 — stage new content. Real destinations are untouched so far.
122
+ const staged = [];
123
+ for (const op of ops) {
124
+ // Defense-in-depth traversal guard: the tool already rejects `..`/absolute
125
+ // paths, but the store must not trust that blindly (a future direct caller
126
+ // could bypass the tool). safePath throws if `op.path` escapes kitDir.
127
+ const destPath = safePath(kitDir, op.path);
128
+ const stagedPath = join(stagingRoot, `${staged.length}`);
129
+ if ("sourcePath" in op) {
130
+ await streamCopy(op.sourcePath, stagedPath, op.path);
131
+ }
132
+ else {
133
+ await writeStaged(stagedPath, op.content, op.path);
134
+ }
135
+ staged.push({ publicPath: op.path, destPath, stagedPath });
136
+ }
137
+ await commitStaged(staged, backupRoot, kitDir);
138
+ return { writtenPaths: ops.map((o) => o.path) };
139
+ }
140
+ finally {
141
+ await rm(stagingRoot, { recursive: true, force: true });
142
+ }
143
+ }
144
+ /**
145
+ * Commit every staged file via rename-to-temp + rename-back.
146
+ * 1. Ensure every destination's parent dir exists.
147
+ * 2. Back up (rename away) any destination that already exists.
148
+ * 3. Rename each staged file into its real destination.
149
+ * 4. On any failure in step 3: remove whatever committed, restore every backup,
150
+ * then throw — collecting (never short-circuiting on) rollback failures so a
151
+ * second failure surfaces as RollbackIncompleteError rather than masking the
152
+ * incomplete restore.
153
+ */
154
+ async function commitStaged(staged, backupRoot, kitDir) {
155
+ await mkdir(backupRoot, { recursive: true });
156
+ for (const { destPath, publicPath } of staged) {
157
+ try {
158
+ await mkdir(dirname(destPath), { recursive: true });
159
+ }
160
+ catch (error) {
161
+ throw new WriteFailedError(publicPath, describeError(error, "failed to create destination directory"));
162
+ }
163
+ }
164
+ const backedUp = [];
165
+ const committed = [];
166
+ try {
167
+ for (const { destPath, publicPath } of staged) {
168
+ const backupPath = join(backupRoot, `${backedUp.length}`);
169
+ const hadExisting = await tryRenameIfExists(destPath, backupPath, publicPath);
170
+ if (hadExisting)
171
+ backedUp.push({ destPath, backupPath });
172
+ }
173
+ for (const { destPath, stagedPath, publicPath } of staged) {
174
+ try {
175
+ await rename(stagedPath, destPath);
176
+ }
177
+ catch (error) {
178
+ throw new WriteFailedError(relativeOrAbsolute(kitDir, destPath, publicPath), describeError(error, "rename failed"));
179
+ }
180
+ committed.push(destPath);
181
+ }
182
+ }
183
+ catch (error) {
184
+ const rollbackFailures = [];
185
+ for (const destPath of committed) {
186
+ try {
187
+ await rm(destPath, { force: true });
188
+ }
189
+ catch (rmError) {
190
+ rollbackFailures.push(`failed to remove committed file "${relativeOrAbsolute(kitDir, destPath, destPath)}": ` +
191
+ describeError(rmError, "unknown error"));
192
+ }
193
+ }
194
+ for (const { destPath, backupPath } of backedUp) {
195
+ try {
196
+ await rename(backupPath, destPath);
197
+ }
198
+ catch (restoreError) {
199
+ rollbackFailures.push(`failed to restore backup for "${relativeOrAbsolute(kitDir, destPath, destPath)}": ` +
200
+ describeError(restoreError, "unknown error"));
201
+ }
202
+ }
203
+ if (rollbackFailures.length > 0) {
204
+ throw new RollbackIncompleteError(describeError(error, "commit failed"), rollbackFailures);
205
+ }
206
+ throw error;
207
+ }
208
+ }
209
+ /**
210
+ * Rename `destPath` to `backupPath` if it exists. Returns whether a backup was
211
+ * made (`false` when `destPath` didn't exist). Refuses (WriteFailedError,
212
+ * triggering the normal rollback) when `destPath` is a DIRECTORY: `write_files`
213
+ * writes files, never replaces a directory, and letting a directory move into
214
+ * the backup slot then be deleted by the caller's cleanup would silently
215
+ * destroy it (a Copilot review finding on PR #106).
216
+ */
217
+ async function tryRenameIfExists(destPath, backupPath, publicPath) {
218
+ const existing = await statIfExists(destPath);
219
+ if (existing?.isDirectory()) {
220
+ throw new WriteFailedError(publicPath, `destination "${destPath}" already exists and is a directory, not a file`);
221
+ }
222
+ try {
223
+ await rename(destPath, backupPath);
224
+ return true;
225
+ }
226
+ catch (error) {
227
+ if (isEnoent(error))
228
+ return false;
229
+ throw new WriteFailedError(publicPath, describeError(error, "failed to back up existing file"));
230
+ }
231
+ }
232
+ /** `stat`, or `undefined` if the path doesn't exist. Other errors propagate. */
233
+ async function statIfExists(path) {
234
+ try {
235
+ return await stat(path);
236
+ }
237
+ catch (error) {
238
+ if (isEnoent(error))
239
+ return undefined;
240
+ throw error;
241
+ }
242
+ }
243
+ function relativeOrAbsolute(baseDir, destPath, fallback) {
244
+ const rel = relative(baseDir, destPath);
245
+ return rel.length > 0 ? rel : fallback;
246
+ }
247
+ async function writeStaged(stagedPath, content, publicPath) {
248
+ try {
249
+ await writeFile(stagedPath, content);
250
+ }
251
+ catch (error) {
252
+ throw new WriteFailedError(publicPath, describeError(error, "write failed"));
253
+ }
254
+ }
255
+ /**
256
+ * Stream `sourcePath` into `stagedPath` through a SHA-256 pass-through, so a
257
+ * `localPath`-sourced write never loads a full file into memory regardless of
258
+ * size. The hash isn't surfaced (no AC calls for it) but proves the data
259
+ * genuinely streamed end-to-end rather than being buffered.
260
+ */
261
+ async function streamCopy(sourcePath, stagedPath, publicPath) {
262
+ try {
263
+ const hash = createHash("sha256");
264
+ const source = createReadStream(sourcePath);
265
+ const dest = createWriteStream(stagedPath);
266
+ source.on("data", (chunk) => hash.update(chunk));
267
+ await pipeline(source, dest);
268
+ }
269
+ catch (error) {
270
+ throw new WriteFailedError(publicPath, describeError(error, "read failed"));
271
+ }
272
+ }
273
+ function describeError(error, fallback) {
274
+ if (error instanceof Error)
275
+ return error.message;
276
+ return fallback;
277
+ }
278
+ function isEnoent(error) {
279
+ return (typeof error === "object" && error !== null && error.code === "ENOENT");
280
+ }
281
+ /** Read and parse a JSON metadata file, or return undefined. */
282
+ async function readMeta(filePath) {
283
+ try {
284
+ const raw = await readFile(filePath, "utf-8");
285
+ return JSON.parse(raw);
286
+ }
287
+ catch {
288
+ return undefined;
289
+ }
290
+ }
291
+ // ─── LocalFsKitStore ─────────────────────────────────────────────────────────
292
+ export class LocalFsKitStore {
293
+ baseDir;
294
+ plansDir;
295
+ constructor(baseDir) {
296
+ this.baseDir = baseDir ?? join(genieHome(), "kits");
297
+ this.plansDir = join(genieHome(), "plans");
298
+ }
299
+ kitDir(kitId) {
300
+ return join(this.baseDir, kitId);
301
+ }
302
+ /**
303
+ * Resolve a kitId to its on-disk directory for the READ verbs
304
+ * (`listFiles`/`readFile`), rejecting unsafe ids BEFORE the join (AC-SEC,
305
+ * DRO-581). This is the store-layer half of the shared `isSafeKitId` rule and
306
+ * the defense-in-depth guard behind each tool's own kitId check: a
307
+ * programmatic caller that bypasses the tool must not be able to pass `""`
308
+ * (whose `join(baseDir, "")` is the kits ROOT — letting a crafted `path` like
309
+ * `other-kit/secret.txt` read a SIBLING kit), `.`/`..`, or a separator.
310
+ *
311
+ * An unsafe id names no valid kit, so it surfaces as the SAME `NotFoundError`
312
+ * a genuinely-missing kit would — this never leaks a sibling's bytes and adds
313
+ * no new error type to the `KitStore` contract (AC4). The write/plan verbs
314
+ * (`createKit`/`deleteFile`/`openPlan`) keep using `kitDir` directly: their
315
+ * ids are server-minted or already plan-gated, and their behavior is
316
+ * unchanged.
317
+ */
318
+ safeKitDir(kitId) {
319
+ if (!isSafeKitId(kitId))
320
+ throw new NotFoundError("Kit", kitId);
321
+ return this.kitDir(kitId);
322
+ }
323
+ kitMetaPath(kitId) {
324
+ return join(this.kitDir(kitId), ".kit.json");
325
+ }
326
+ planDir(kitId, planId) {
327
+ return join(this.plansDir, kitId, planId);
328
+ }
329
+ async listKits() {
330
+ await ensureDir(this.baseDir);
331
+ const entries = await readdir(this.baseDir, { withFileTypes: true });
332
+ const kits = [];
333
+ for (const entry of entries) {
334
+ if (!entry.isDirectory())
335
+ continue;
336
+ const meta = await readMeta(this.kitMetaPath(entry.name));
337
+ if (meta?.type === KIT_TYPE) {
338
+ kits.push({
339
+ id: meta.id,
340
+ name: meta.name,
341
+ type: KIT_TYPE,
342
+ createdAt: meta.createdAt,
343
+ });
344
+ }
345
+ }
346
+ return kits;
347
+ }
348
+ async getKit(kitId) {
349
+ const meta = await readMeta(this.kitMetaPath(kitId));
350
+ if (!meta || meta.type !== KIT_TYPE)
351
+ throw new NotFoundError("Kit", kitId);
352
+ return {
353
+ id: meta.id,
354
+ name: meta.name,
355
+ type: KIT_TYPE,
356
+ createdAt: meta.createdAt,
357
+ };
358
+ }
359
+ async listFiles(kitId) {
360
+ const dir = this.safeKitDir(kitId);
361
+ try {
362
+ await stat(dir);
363
+ }
364
+ catch {
365
+ throw new NotFoundError("Kit", kitId);
366
+ }
367
+ const ignore = buildIgnoreMatcher(await this.readIgnorePatterns(dir));
368
+ const files = await walkKitFiles(dir, dir, ignore);
369
+ return files.sort((a, b) => a.path.localeCompare(b.path));
370
+ }
371
+ /**
372
+ * Read the kit's `.genieignore` (if any) into active pattern lines. Absent
373
+ * file → no extra patterns (the default-dir exclusion still applies).
374
+ */
375
+ async readIgnorePatterns(kitDir) {
376
+ try {
377
+ const raw = await readFile(join(kitDir, ".genieignore"), "utf8");
378
+ return parseGenieignore(raw);
379
+ }
380
+ catch (error) {
381
+ if (isMissingPathError(error))
382
+ return [];
383
+ throw error;
384
+ }
385
+ }
386
+ async listComponents(params) {
387
+ const { kitId, group } = params;
388
+ // Validate kit exists (throws NotFoundError) before touching the manifest,
389
+ // so "kit missing" and "kit present but no components yet" stay distinct.
390
+ await this.getKit(kitId);
391
+ // The compiled card index (D-D). Absent until the M3-03 compiler writes it
392
+ // (or on a brand-new kit) → selectComponents maps `undefined` to [] (AC8).
393
+ // A path-traversal-safe join is unnecessary here: MANIFEST_PATH is a fixed
394
+ // constant, not user input.
395
+ const manifestFile = join(this.kitDir(kitId), MANIFEST_PATH);
396
+ let raw;
397
+ try {
398
+ raw = await readFile(manifestFile, "utf-8");
399
+ }
400
+ catch (err) {
401
+ // Only a genuinely-absent manifest (ENOENT) means "no components yet" →
402
+ // undefined, which selectComponents maps to [] (AC8). Any other IO error
403
+ // (EACCES, EISDIR, transient failures) is a real operability problem and
404
+ // must propagate rather than be masked as a silently-empty listing. This
405
+ // mirrors GitHostStore, which only maps NotFoundError (404) to undefined.
406
+ if (err.code === "ENOENT") {
407
+ raw = undefined;
408
+ }
409
+ else {
410
+ throw err;
411
+ }
412
+ }
413
+ return selectComponents(kitId, raw, group);
414
+ }
415
+ async readFile(kitId, path) {
416
+ // Reject an unsafe kitId (incl. "" → the kits root, which would let `path`
417
+ // read across sibling kits) BEFORE resolving the kit dir (AC-SEC).
418
+ const kitDir = this.safeKitDir(kitId);
419
+ try {
420
+ await stat(kitDir);
421
+ }
422
+ catch {
423
+ throw new NotFoundError("Kit", kitId);
424
+ }
425
+ // Now check if file exists
426
+ const filePath = safePath(kitDir, path);
427
+ let fileStats;
428
+ try {
429
+ fileStats = await stat(filePath);
430
+ }
431
+ catch {
432
+ throw new NotFoundError("File", `${kitId}/${path}`);
433
+ }
434
+ // A directory target is not a readable file.
435
+ if (!fileStats.isFile()) {
436
+ throw new NotFoundError("File", `${kitId}/${path}`);
437
+ }
438
+ if (fileStats.size > MAX_FILE_BYTES) {
439
+ throw new FileTooLargeError(path, fileStats.size);
440
+ }
441
+ // Read raw bytes and let the shared classifier decide utf-8 vs base64 and
442
+ // the MIME type — the exact logic the pre-store `read_file` tool ran, now
443
+ // shared with GitHostKitStore so a read is byte-identical across adapters.
444
+ const bytes = await readFile(filePath);
445
+ return classifyFileContent(path, bytes);
446
+ }
447
+ async deleteFile(kitId, path) {
448
+ // A missing kit is the same idempotent no-op as a missing file: the tool's
449
+ // plan-gating has already authorized the path, and "not there" is the
450
+ // silent-retry case, never a hard error. So we do NOT pre-stat the kit dir.
451
+ const kitDir = this.kitDir(kitId);
452
+ const filePath = safePath(kitDir, path);
453
+ try {
454
+ await unlink(filePath);
455
+ return { existed: true };
456
+ }
457
+ catch (error) {
458
+ if (isMissingPathError(error))
459
+ return { existed: false };
460
+ throw error; // EISDIR / EPERM / … → tool maps to DeleteFailed.
461
+ }
462
+ }
463
+ async writeFiles(kitId, ops) {
464
+ // Destination = the kit dir (the readable surface list_files/read_file see),
465
+ // matching how deleteFile targets the same dir. The `write_files` tool has
466
+ // already glob-gated every path, checked the byte cap, streamed-source
467
+ // containment, and rejected duplicates — so this only performs the atomic
468
+ // commit. `ensureDir` (not getKit) because a brand-new kit dir is a valid
469
+ // write target, and a `sourcePath` op streams rather than buffering.
470
+ const kitDir = this.kitDir(kitId);
471
+ await ensureDir(kitDir);
472
+ return stageAndCommit(kitDir, ops);
473
+ }
474
+ async createKit(name, kitId) {
475
+ const id = kitId ?? randomUUID();
476
+ const dir = this.kitDir(id);
477
+ // Defensive check: fail fast if kit directory already exists
478
+ try {
479
+ await stat(dir);
480
+ throw new KitAlreadyExistsError(id);
481
+ }
482
+ catch (err) {
483
+ if (err instanceof KitAlreadyExistsError)
484
+ throw err;
485
+ // Directory doesn't exist — proceed with creation
486
+ }
487
+ await ensureDir(dir);
488
+ const meta = {
489
+ id,
490
+ name,
491
+ type: KIT_TYPE,
492
+ createdAt: new Date().toISOString(),
493
+ };
494
+ // Atomic write with exclusive flag to catch races
495
+ try {
496
+ await writeFile(this.kitMetaPath(id), JSON.stringify(meta, null, 2), {
497
+ flag: "wx",
498
+ });
499
+ }
500
+ catch (err) {
501
+ if (err.code === "EEXIST") {
502
+ throw new KitAlreadyExistsError(id);
503
+ }
504
+ throw err;
505
+ }
506
+ // DRO-764 AC1 — scaffold the viewer's static shell into the new kit root
507
+ // so file:// / localhost-Vite / ui://genie/grid all have something to
508
+ // render immediately, with zero manual copying. `dir` was just created
509
+ // above and is not yet visible to any other caller (the `.kit.json`
510
+ // exclusive-write above is the only publication point `getKit`/`listKits`
511
+ // key off), so plain per-file writes are safe here — no concurrent writer
512
+ // can be racing this directory. `loadViewerAssets` prefers the shell
513
+ // bundled into the server package and degrades to `[]` (never throws) only
514
+ // when neither that payload nor the optional viewer package is available.
515
+ const viewerAssets = await loadViewerAssets();
516
+ await Promise.all(viewerAssets.map((asset) => writeFile(join(dir, asset.path), asset.content)));
517
+ // DRO-764 AC3 — seed an empty `.genie/manifest.json` so the file:// /
518
+ // localhost-Vite vehicles' `fetch(".genie/manifest.json")` resolves
519
+ // immediately to a valid, empty manifest (→ the `.ds-empty` state)
520
+ // instead of rejecting (→ the `.ds-error` state) — see
521
+ // `empty-manifest.ts`'s header for why a missing `file://` resource is a
522
+ // REJECTED fetch, not a 404 Response. The M3-03 compiler transparently
523
+ // overwrites this the moment any component is actually added.
524
+ await mkdir(join(dir, ".genie"), { recursive: true });
525
+ await writeFile(join(dir, MANIFEST_PATH), serializeEmptyManifest(name), "utf-8");
526
+ return {
527
+ id: meta.id,
528
+ name: meta.name,
529
+ type: KIT_TYPE,
530
+ createdAt: meta.createdAt,
531
+ };
532
+ }
533
+ async openPlan(kitId, ops) {
534
+ // Validate kit exists
535
+ await this.getKit(kitId);
536
+ const planId = randomUUID();
537
+ const dir = this.planDir(kitId, planId);
538
+ await ensureDir(dir);
539
+ // Apply initial operations
540
+ await this.applyOps(dir, ops);
541
+ return planId;
542
+ }
543
+ async commitPlan(kitId, planId, ops) {
544
+ const dir = this.planDir(kitId, planId);
545
+ try {
546
+ await stat(dir);
547
+ }
548
+ catch {
549
+ throw new NotFoundError("Plan", planId);
550
+ }
551
+ await this.applyOps(dir, ops);
552
+ }
553
+ async closePlan(kitId, planId) {
554
+ const dir = this.planDir(kitId, planId);
555
+ try {
556
+ await rm(dir, { recursive: true, force: true });
557
+ }
558
+ catch {
559
+ // Idempotent — already removed
560
+ }
561
+ }
562
+ async applyOps(dir, ops) {
563
+ for (const op of ops) {
564
+ const target = safePath(dir, op.path);
565
+ if (op.kind === "write") {
566
+ await ensureDir(join(target, ".."));
567
+ await writeFile(target, op.content);
568
+ }
569
+ else {
570
+ await rm(target, { force: true });
571
+ }
572
+ }
573
+ }
574
+ }
575
+ // ─── LocalFsProjectStore ─────────────────────────────────────────────────────
576
+ export class LocalFsProjectStore {
577
+ baseDir;
578
+ constructor(baseDir) {
579
+ this.baseDir = baseDir ?? join(genieHome(), "projects");
580
+ }
581
+ projectDir(projectId) {
582
+ return join(this.baseDir, projectId);
583
+ }
584
+ metaPath(projectId) {
585
+ return join(this.projectDir(projectId), ".project.json");
586
+ }
587
+ async listProjects() {
588
+ await ensureDir(this.baseDir);
589
+ const entries = await readdir(this.baseDir, { withFileTypes: true });
590
+ const projects = [];
591
+ for (const entry of entries) {
592
+ if (!entry.isDirectory())
593
+ continue;
594
+ const meta = await readMeta(this.metaPath(entry.name));
595
+ if (meta) {
596
+ projects.push({
597
+ id: meta.id,
598
+ name: meta.name,
599
+ kitId: meta.kitId,
600
+ createdAt: meta.createdAt,
601
+ });
602
+ }
603
+ }
604
+ return projects;
605
+ }
606
+ async getProject(projectId) {
607
+ const meta = await readMeta(this.metaPath(projectId));
608
+ if (!meta)
609
+ throw new NotFoundError("Project", projectId);
610
+ return {
611
+ id: meta.id,
612
+ name: meta.name,
613
+ kitId: meta.kitId,
614
+ createdAt: meta.createdAt,
615
+ };
616
+ }
617
+ async createProject(name) {
618
+ const id = randomUUID();
619
+ const dir = this.projectDir(id);
620
+ await ensureDir(dir);
621
+ const meta = {
622
+ id,
623
+ name,
624
+ screens: [],
625
+ createdAt: new Date().toISOString(),
626
+ };
627
+ await writeFile(this.metaPath(id), JSON.stringify(meta, null, 2));
628
+ return { id: meta.id, name: meta.name, createdAt: meta.createdAt };
629
+ }
630
+ async deleteProject(projectId) {
631
+ const meta = await readMeta(this.metaPath(projectId));
632
+ if (!meta)
633
+ throw new NotFoundError("Project", projectId);
634
+ await rm(this.projectDir(projectId), { recursive: true, force: true });
635
+ }
636
+ async bindKit(projectId, kitId) {
637
+ const meta = await readMeta(this.metaPath(projectId));
638
+ if (!meta)
639
+ throw new NotFoundError("Project", projectId);
640
+ meta.kitId = kitId;
641
+ await writeFile(this.metaPath(projectId), JSON.stringify(meta, null, 2));
642
+ }
643
+ async recordScreen(projectId, screenRef) {
644
+ const meta = await readMeta(this.metaPath(projectId));
645
+ if (!meta)
646
+ throw new NotFoundError("Project", projectId);
647
+ meta.screens.push(screenRef);
648
+ await writeFile(this.metaPath(projectId), JSON.stringify(meta, null, 2));
649
+ }
650
+ }
651
+ //# sourceMappingURL=local.js.map