@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.
- package/README.md +15 -0
- package/dist/auth/bearer.d.ts +49 -0
- package/dist/auth/bearer.d.ts.map +1 -0
- package/dist/auth/bearer.js +159 -0
- package/dist/auth/bearer.js.map +1 -0
- package/dist/auth/oauth/authorize.d.ts +19 -0
- package/dist/auth/oauth/authorize.d.ts.map +1 -0
- package/dist/auth/oauth/authorize.js +111 -0
- package/dist/auth/oauth/authorize.js.map +1 -0
- package/dist/auth/oauth/config.d.ts +10 -0
- package/dist/auth/oauth/config.d.ts.map +1 -0
- package/dist/auth/oauth/config.js +16 -0
- package/dist/auth/oauth/config.js.map +1 -0
- package/dist/auth/oauth/dcr.d.ts +15 -0
- package/dist/auth/oauth/dcr.d.ts.map +1 -0
- package/dist/auth/oauth/dcr.js +38 -0
- package/dist/auth/oauth/dcr.js.map +1 -0
- package/dist/auth/oauth/jwt.d.ts +13 -0
- package/dist/auth/oauth/jwt.d.ts.map +1 -0
- package/dist/auth/oauth/jwt.js +46 -0
- package/dist/auth/oauth/jwt.js.map +1 -0
- package/dist/auth/oauth/metadata.d.ts +9 -0
- package/dist/auth/oauth/metadata.d.ts.map +1 -0
- package/dist/auth/oauth/metadata.js +19 -0
- package/dist/auth/oauth/metadata.js.map +1 -0
- package/dist/auth/oauth/router.d.ts +32 -0
- package/dist/auth/oauth/router.d.ts.map +1 -0
- package/dist/auth/oauth/router.js +159 -0
- package/dist/auth/oauth/router.js.map +1 -0
- package/dist/auth/oauth/store.d.ts +55 -0
- package/dist/auth/oauth/store.d.ts.map +1 -0
- package/dist/auth/oauth/store.js +63 -0
- package/dist/auth/oauth/store.js.map +1 -0
- package/dist/auth/oauth/token.d.ts +21 -0
- package/dist/auth/oauth/token.d.ts.map +1 -0
- package/dist/auth/oauth/token.js +77 -0
- package/dist/auth/oauth/token.js.map +1 -0
- package/dist/auth/oidc/group-policy.d.ts +43 -0
- package/dist/auth/oidc/group-policy.d.ts.map +1 -0
- package/dist/auth/oidc/group-policy.js +54 -0
- package/dist/auth/oidc/group-policy.js.map +1 -0
- package/dist/auth/oidc/verifier.d.ts +42 -0
- package/dist/auth/oidc/verifier.d.ts.map +1 -0
- package/dist/auth/oidc/verifier.js +145 -0
- package/dist/auth/oidc/verifier.js.map +1 -0
- package/dist/auth/token-cli.d.ts +9 -0
- package/dist/auth/token-cli.d.ts.map +1 -0
- package/dist/auth/token-cli.js +98 -0
- package/dist/auth/token-cli.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +149 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/redact.d.ts +20 -0
- package/dist/config/redact.d.ts.map +1 -0
- package/dist/config/redact.js +77 -0
- package/dist/config/redact.js.map +1 -0
- package/dist/config/secrets.d.ts +100 -0
- package/dist/config/secrets.d.ts.map +1 -0
- package/dist/config/secrets.js +130 -0
- package/dist/config/secrets.js.map +1 -0
- package/dist/framework/html.d.ts +103 -0
- package/dist/framework/html.d.ts.map +1 -0
- package/dist/framework/html.js +271 -0
- package/dist/framework/html.js.map +1 -0
- package/dist/framework/interface.d.ts +130 -0
- package/dist/framework/interface.d.ts.map +1 -0
- package/dist/framework/interface.js +112 -0
- package/dist/framework/interface.js.map +1 -0
- package/dist/framework/react.d.ts +133 -0
- package/dist/framework/react.d.ts.map +1 -0
- package/dist/framework/react.js +243 -0
- package/dist/framework/react.js.map +1 -0
- package/dist/framework/vue.d.ts +99 -0
- package/dist/framework/vue.d.ts.map +1 -0
- package/dist/framework/vue.js +375 -0
- package/dist/framework/vue.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/llm/client.d.ts +81 -0
- package/dist/llm/client.d.ts.map +1 -0
- package/dist/llm/client.js +141 -0
- package/dist/llm/client.js.map +1 -0
- package/dist/llm/component-response.d.ts +111 -0
- package/dist/llm/component-response.d.ts.map +1 -0
- package/dist/llm/component-response.js +167 -0
- package/dist/llm/component-response.js.map +1 -0
- package/dist/llm/generated-files.d.ts +10 -0
- package/dist/llm/generated-files.d.ts.map +1 -0
- package/dist/llm/generated-files.js +26 -0
- package/dist/llm/generated-files.js.map +1 -0
- package/dist/llm/prompts/CHANGELOG.md +70 -0
- package/dist/llm/prompts/generate-component.system.md +99 -0
- package/dist/llm/prompts/refine-component.system.md +112 -0
- package/dist/llm/prompts.d.ts +66 -0
- package/dist/llm/prompts.d.ts.map +1 -0
- package/dist/llm/prompts.js +78 -0
- package/dist/llm/prompts.js.map +1 -0
- package/dist/llm/retry.d.ts +183 -0
- package/dist/llm/retry.d.ts.map +1 -0
- package/dist/llm/retry.js +475 -0
- package/dist/llm/retry.js.map +1 -0
- package/dist/llm/schema.d.ts +129 -0
- package/dist/llm/schema.d.ts.map +1 -0
- package/dist/llm/schema.js +147 -0
- package/dist/llm/schema.js.map +1 -0
- package/dist/llm/validate.d.ts +107 -0
- package/dist/llm/validate.d.ts.map +1 -0
- package/dist/llm/validate.js +174 -0
- package/dist/llm/validate.js.map +1 -0
- package/dist/manifest/compiler.d.ts +112 -0
- package/dist/manifest/compiler.d.ts.map +1 -0
- package/dist/manifest/compiler.js +306 -0
- package/dist/manifest/compiler.js.map +1 -0
- package/dist/manifest/ensure.d.ts +46 -0
- package/dist/manifest/ensure.d.ts.map +1 -0
- package/dist/manifest/ensure.js +49 -0
- package/dist/manifest/ensure.js.map +1 -0
- package/dist/manifest/index.d.ts +13 -0
- package/dist/manifest/index.d.ts.map +1 -0
- package/dist/manifest/index.js +13 -0
- package/dist/manifest/index.js.map +1 -0
- package/dist/middleware/plan-guard.d.ts +167 -0
- package/dist/middleware/plan-guard.d.ts.map +1 -0
- package/dist/middleware/plan-guard.js +190 -0
- package/dist/middleware/plan-guard.js.map +1 -0
- package/dist/plans/index.d.ts +71 -0
- package/dist/plans/index.d.ts.map +1 -0
- package/dist/plans/index.js +252 -0
- package/dist/plans/index.js.map +1 -0
- package/dist/schemas/component.schema.json +114 -0
- package/dist/server.d.ts +96 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +258 -0
- package/dist/server.js.map +1 -0
- package/dist/store/empty-manifest.d.ts +58 -0
- package/dist/store/empty-manifest.d.ts.map +1 -0
- package/dist/store/empty-manifest.js +59 -0
- package/dist/store/empty-manifest.js.map +1 -0
- package/dist/store/git-host.d.ts +120 -0
- package/dist/store/git-host.d.ts.map +1 -0
- package/dist/store/git-host.js +796 -0
- package/dist/store/git-host.js.map +1 -0
- package/dist/store/index.d.ts +5 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store/index.js +4 -0
- package/dist/store/index.js.map +1 -0
- package/dist/store/interface.d.ts +322 -0
- package/dist/store/interface.d.ts.map +1 -0
- package/dist/store/interface.js +100 -0
- package/dist/store/interface.js.map +1 -0
- package/dist/store/kit-files.d.ts +88 -0
- package/dist/store/kit-files.d.ts.map +1 -0
- package/dist/store/kit-files.js +199 -0
- package/dist/store/kit-files.js.map +1 -0
- package/dist/store/local.d.ts +70 -0
- package/dist/store/local.d.ts.map +1 -0
- package/dist/store/local.js +651 -0
- package/dist/store/local.js.map +1 -0
- package/dist/store/manifest.d.ts +44 -0
- package/dist/store/manifest.d.ts.map +1 -0
- package/dist/store/manifest.js +116 -0
- package/dist/store/manifest.js.map +1 -0
- package/dist/store/viewer-assets.d.ts +20 -0
- package/dist/store/viewer-assets.d.ts.map +1 -0
- package/dist/store/viewer-assets.js +118 -0
- package/dist/store/viewer-assets.js.map +1 -0
- package/dist/sync/anchor.d.ts +125 -0
- package/dist/sync/anchor.d.ts.map +1 -0
- package/dist/sync/anchor.js +210 -0
- package/dist/sync/anchor.js.map +1 -0
- package/dist/sync/index.d.ts +15 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +15 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/sync/orchestrator.d.ts +160 -0
- package/dist/sync/orchestrator.d.ts.map +1 -0
- package/dist/sync/orchestrator.js +288 -0
- package/dist/sync/orchestrator.js.map +1 -0
- package/dist/tools/bind_kit.d.ts +29 -0
- package/dist/tools/bind_kit.d.ts.map +1 -0
- package/dist/tools/bind_kit.js +70 -0
- package/dist/tools/bind_kit.js.map +1 -0
- package/dist/tools/conjure.d.ts +141 -0
- package/dist/tools/conjure.d.ts.map +1 -0
- package/dist/tools/conjure.js +655 -0
- package/dist/tools/conjure.js.map +1 -0
- package/dist/tools/conjure_screen.d.ts +174 -0
- package/dist/tools/conjure_screen.d.ts.map +1 -0
- package/dist/tools/conjure_screen.js +458 -0
- package/dist/tools/conjure_screen.js.map +1 -0
- package/dist/tools/create_kit.d.ts +32 -0
- package/dist/tools/create_kit.d.ts.map +1 -0
- package/dist/tools/create_kit.js +131 -0
- package/dist/tools/create_kit.js.map +1 -0
- package/dist/tools/create_project.d.ts +267 -0
- package/dist/tools/create_project.d.ts.map +1 -0
- package/dist/tools/create_project.js +546 -0
- package/dist/tools/create_project.js.map +1 -0
- package/dist/tools/delete_files.d.ts +93 -0
- package/dist/tools/delete_files.d.ts.map +1 -0
- package/dist/tools/delete_files.js +276 -0
- package/dist/tools/delete_files.js.map +1 -0
- package/dist/tools/delete_project.d.ts +43 -0
- package/dist/tools/delete_project.d.ts.map +1 -0
- package/dist/tools/delete_project.js +118 -0
- package/dist/tools/delete_project.js.map +1 -0
- package/dist/tools/get_kit.d.ts +28 -0
- package/dist/tools/get_kit.d.ts.map +1 -0
- package/dist/tools/get_kit.js +95 -0
- package/dist/tools/get_kit.js.map +1 -0
- package/dist/tools/get_project.d.ts +37 -0
- package/dist/tools/get_project.d.ts.map +1 -0
- package/dist/tools/get_project.js +71 -0
- package/dist/tools/get_project.js.map +1 -0
- package/dist/tools/list_components.d.ts +42 -0
- package/dist/tools/list_components.d.ts.map +1 -0
- package/dist/tools/list_components.js +125 -0
- package/dist/tools/list_components.js.map +1 -0
- package/dist/tools/list_files.d.ts +32 -0
- package/dist/tools/list_files.d.ts.map +1 -0
- package/dist/tools/list_files.js +109 -0
- package/dist/tools/list_files.js.map +1 -0
- package/dist/tools/list_kits.d.ts +14 -0
- package/dist/tools/list_kits.d.ts.map +1 -0
- package/dist/tools/list_kits.js +41 -0
- package/dist/tools/list_kits.js.map +1 -0
- package/dist/tools/list_projects.d.ts +23 -0
- package/dist/tools/list_projects.d.ts.map +1 -0
- package/dist/tools/list_projects.js +66 -0
- package/dist/tools/list_projects.js.map +1 -0
- package/dist/tools/plan.d.ts +16 -0
- package/dist/tools/plan.d.ts.map +1 -0
- package/dist/tools/plan.js +146 -0
- package/dist/tools/plan.js.map +1 -0
- package/dist/tools/preview.d.ts +289 -0
- package/dist/tools/preview.d.ts.map +1 -0
- package/dist/tools/preview.js +672 -0
- package/dist/tools/preview.js.map +1 -0
- package/dist/tools/read_file.d.ts +40 -0
- package/dist/tools/read_file.d.ts.map +1 -0
- package/dist/tools/read_file.js +114 -0
- package/dist/tools/read_file.js.map +1 -0
- package/dist/tools/refine.d.ts +220 -0
- package/dist/tools/refine.d.ts.map +1 -0
- package/dist/tools/refine.js +501 -0
- package/dist/tools/refine.js.map +1 -0
- package/dist/tools/validate.d.ts +74 -0
- package/dist/tools/validate.d.ts.map +1 -0
- package/dist/tools/validate.js +224 -0
- package/dist/tools/validate.js.map +1 -0
- package/dist/tools/write_files.d.ts +158 -0
- package/dist/tools/write_files.d.ts.map +1 -0
- package/dist/tools/write_files.js +541 -0
- package/dist/tools/write_files.js.map +1 -0
- package/dist/transport.d.ts +77 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +448 -0
- package/dist/transport.js.map +1 -0
- package/dist/ui/card-asset-broker.d.ts +42 -0
- package/dist/ui/card-asset-broker.d.ts.map +1 -0
- package/dist/ui/card-asset-broker.js +479 -0
- package/dist/ui/card-asset-broker.js.map +1 -0
- package/dist/ui/grid-resource.d.ts +252 -0
- package/dist/ui/grid-resource.d.ts.map +1 -0
- package/dist/ui/grid-resource.js +680 -0
- package/dist/ui/grid-resource.js.map +1 -0
- package/dist/ui/viewer-static/index.html +95 -0
- package/dist/ui/viewer-static/viewer.css +451 -0
- package/dist/ui/viewer-static/viewer.js +1405 -0
- package/dist/validate/full-scan.d.ts +87 -0
- package/dist/validate/full-scan.d.ts.map +1 -0
- package/dist/validate/full-scan.js +299 -0
- package/dist/validate/full-scan.js.map +1 -0
- package/dist/validate/index.d.ts +15 -0
- package/dist/validate/index.d.ts.map +1 -0
- package/dist/validate/index.js +18 -0
- package/dist/validate/index.js.map +1 -0
- package/dist/validate/marker.d.ts +89 -0
- package/dist/validate/marker.d.ts.map +1 -0
- package/dist/validate/marker.js +87 -0
- package/dist/validate/marker.js.map +1 -0
- package/dist/validate/phash.d.ts +135 -0
- package/dist/validate/phash.d.ts.map +1 -0
- package/dist/validate/phash.js +307 -0
- package/dist/validate/phash.js.map +1 -0
- package/dist/validate/render.d.ts +38 -0
- package/dist/validate/render.d.ts.map +1 -0
- package/dist/validate/render.js +155 -0
- package/dist/validate/render.js.map +1 -0
- package/dist/watch/index.d.ts +12 -0
- package/dist/watch/index.d.ts.map +1 -0
- package/dist/watch/index.js +12 -0
- package/dist/watch/index.js.map +1 -0
- package/dist/watch/watcher.d.ts +65 -0
- package/dist/watch/watcher.d.ts.map +1 -0
- package/dist/watch/watcher.js +238 -0
- package/dist/watch/watcher.js.map +1 -0
- 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
|