@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,796 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHostStore — shared adapter for any git host (GitHub / Gitea / GitLab).
|
|
3
|
+
*
|
|
4
|
+
* AC4: Uses a git-host SDK against an operator-configured base URL.
|
|
5
|
+
* plan = branch `plan/${planId}`, commit on `commitPlan`, no auto-merge.
|
|
6
|
+
* AC6: Honours its token from env (`GENIE_GIT_TOKEN`), fails fast with a
|
|
7
|
+
* clear error if missing.
|
|
8
|
+
*
|
|
9
|
+
* The adapter communicates with the git host via its HTTP REST API (Gitea is
|
|
10
|
+
* the reference instance). No git host is privileged in the abstraction and
|
|
11
|
+
* no provider URL is baked into the code.
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { createReadStream } from "node:fs";
|
|
15
|
+
import { FileTooLargeError, KitAlreadyExistsError, KIT_TYPE, MAX_FILE_BYTES, MissingCredentialError, NotFoundError, RollbackIncompleteError, WriteFailedError, } from "./interface.js";
|
|
16
|
+
import { buildIgnoreMatcher, classifyFileContent, isSafeKitId, parseGenieignore, sriSha256, } from "./kit-files.js";
|
|
17
|
+
import { serializeEmptyManifest } from "./empty-manifest.js";
|
|
18
|
+
import { MANIFEST_PATH, selectComponents } from "./manifest.js";
|
|
19
|
+
import { loadViewerAssets } from "./viewer-assets.js";
|
|
20
|
+
function resolveToken(cfg) {
|
|
21
|
+
const token = cfg.token ?? process.env["GENIE_GIT_TOKEN"];
|
|
22
|
+
if (!token)
|
|
23
|
+
throw new MissingCredentialError("GENIE_GIT_TOKEN");
|
|
24
|
+
return token;
|
|
25
|
+
}
|
|
26
|
+
/** Human-readable message for a caught error (for WriteFailedError causes). */
|
|
27
|
+
function describeGitError(error, fallback) {
|
|
28
|
+
if (error instanceof Error)
|
|
29
|
+
return error.message;
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Read a file fully into a Buffer by streaming (`createReadStream`), so the
|
|
34
|
+
* `write_files` "never buffer a whole localPath in memory ahead of time"
|
|
35
|
+
* property is preserved up to the point the contents API forces a single
|
|
36
|
+
* base64 body. The tool has already containment-checked `sourcePath`.
|
|
37
|
+
*/
|
|
38
|
+
async function readStreamToBuffer(sourcePath) {
|
|
39
|
+
const chunks = [];
|
|
40
|
+
const stream = createReadStream(sourcePath);
|
|
41
|
+
for await (const chunk of stream) {
|
|
42
|
+
chunks.push(chunk);
|
|
43
|
+
}
|
|
44
|
+
return Buffer.concat(chunks);
|
|
45
|
+
}
|
|
46
|
+
class GitHostApiError extends Error {
|
|
47
|
+
status;
|
|
48
|
+
constructor(status, statusText, body) {
|
|
49
|
+
super(`Git host API error: ${status} ${statusText} — ${body}`);
|
|
50
|
+
this.status = status;
|
|
51
|
+
this.name = "GitHostApiError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Build an EISDIR-shaped error for `deleteFile` on a directory / non-file
|
|
56
|
+
* target. LocalFs's `unlink` throws a native `NodeJS.ErrnoException` with
|
|
57
|
+
* `code: "EISDIR"` for the same case; the `delete_files` tool reads `error.code`
|
|
58
|
+
* to compose its `DeleteFailed` message. Emitting an identically-`code`d error
|
|
59
|
+
* keeps that failure byte-identical across adapters (RFC G-5 / DRO-568) instead
|
|
60
|
+
* of GitHost silently no-op'ing where LocalFs hard-fails.
|
|
61
|
+
*/
|
|
62
|
+
function eisdirError(target) {
|
|
63
|
+
const err = new Error(`EISDIR: illegal operation on a directory, unlink '${target}'`);
|
|
64
|
+
err.code = "EISDIR";
|
|
65
|
+
return err;
|
|
66
|
+
}
|
|
67
|
+
async function apiRequest(opts) {
|
|
68
|
+
const url = `${opts.baseUrl}${opts.path}`;
|
|
69
|
+
const res = await fetch(url, {
|
|
70
|
+
method: opts.method,
|
|
71
|
+
headers: {
|
|
72
|
+
Authorization: `token ${opts.token}`,
|
|
73
|
+
"Content-Type": "application/json",
|
|
74
|
+
Accept: "application/json",
|
|
75
|
+
},
|
|
76
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
77
|
+
});
|
|
78
|
+
if (res.status === 404) {
|
|
79
|
+
throw new NotFoundError("resource", opts.path);
|
|
80
|
+
}
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
const text = await res.text().catch(() => "");
|
|
83
|
+
throw new GitHostApiError(res.status, res.statusText, text);
|
|
84
|
+
}
|
|
85
|
+
if (res.status === 204)
|
|
86
|
+
return undefined;
|
|
87
|
+
return res.json();
|
|
88
|
+
}
|
|
89
|
+
// ─── GitHostKitStore ─────────────────────────────────────────────────────────
|
|
90
|
+
/**
|
|
91
|
+
* Maps kits to repositories on the git host.
|
|
92
|
+
* A kitId is the repository name under the configured owner.
|
|
93
|
+
*/
|
|
94
|
+
export class GitHostKitStore {
|
|
95
|
+
baseUrl;
|
|
96
|
+
token;
|
|
97
|
+
owner;
|
|
98
|
+
kitMetaPath = ".kit.json";
|
|
99
|
+
constructor(config) {
|
|
100
|
+
this.baseUrl = config.baseUrl.replace(/\/$/, "");
|
|
101
|
+
this.token = resolveToken(config);
|
|
102
|
+
this.owner = config.owner;
|
|
103
|
+
}
|
|
104
|
+
api(method, path, body) {
|
|
105
|
+
return apiRequest({
|
|
106
|
+
method,
|
|
107
|
+
path,
|
|
108
|
+
body,
|
|
109
|
+
baseUrl: this.baseUrl,
|
|
110
|
+
token: this.token,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
repoPath(kitId) {
|
|
114
|
+
return `/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(kitId)}`;
|
|
115
|
+
}
|
|
116
|
+
async readKitMeta(kitId) {
|
|
117
|
+
try {
|
|
118
|
+
const entry = await this.api("GET", `${this.repoPath(kitId)}/contents/${this.kitMetaPath}`);
|
|
119
|
+
if (!entry.content || entry.encoding !== "base64") {
|
|
120
|
+
throw new Error(`Kit metadata for "${kitId}" returned no base64 content.`);
|
|
121
|
+
}
|
|
122
|
+
const meta = JSON.parse(Buffer.from(entry.content, "base64").toString("utf-8"));
|
|
123
|
+
// The repository name (kitId) is authoritative for the kit's identity —
|
|
124
|
+
// it is the path every subsequent API call routes through. A manually
|
|
125
|
+
// edited or corrupted .kit.json could embed a divergent `id`; trusting it
|
|
126
|
+
// would make listKits/getKit return an ID that resolves to no repo. So we
|
|
127
|
+
// take only the human-readable fields (name, createdAt) from the file.
|
|
128
|
+
return {
|
|
129
|
+
id: kitId,
|
|
130
|
+
name: meta.name,
|
|
131
|
+
type: KIT_TYPE,
|
|
132
|
+
createdAt: meta.createdAt,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (error instanceof NotFoundError)
|
|
137
|
+
return undefined;
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async writeKitMeta(meta) {
|
|
142
|
+
const content = Buffer.from(JSON.stringify(meta, null, 2)).toString("base64");
|
|
143
|
+
await this.api("POST", `${this.repoPath(meta.id)}/contents/${this.kitMetaPath}`, {
|
|
144
|
+
content,
|
|
145
|
+
message: `kit: create ${meta.name}`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Create a single file on the repo's default branch via one contents-API
|
|
150
|
+
* POST. Shares `writeKitMeta`'s "fresh repo, no prior content at this path"
|
|
151
|
+
* assumption — used by `createKit` for both `.kit.json` and (DRO-764) the
|
|
152
|
+
* viewer's static scaffold, all written into a repo `auto_init` just
|
|
153
|
+
* created moments earlier under names (`index.html`/`viewer.js`/
|
|
154
|
+
* `viewer.css`/`.kit.json`) that don't collide with a host's own auto-init
|
|
155
|
+
* output (README/LICENSE/.gitignore).
|
|
156
|
+
*/
|
|
157
|
+
async writeRepoFile(kitId, path, content) {
|
|
158
|
+
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
|
|
159
|
+
await this.api("POST", `${this.repoPath(kitId)}/contents/${encodedPath}`, {
|
|
160
|
+
content: content.toString("base64"),
|
|
161
|
+
message: `kit: scaffold ${path}`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
async listKits() {
|
|
165
|
+
const repos = await this.api("GET", `/repos/search?q=&owner=${encodeURIComponent(this.owner)}&limit=50`);
|
|
166
|
+
// Gitea returns { data: [...] } wrapper for search
|
|
167
|
+
const list = Array.isArray(repos)
|
|
168
|
+
? repos
|
|
169
|
+
: (repos.data ?? []);
|
|
170
|
+
// Each repo needs its own .kit.json read. Fetch them concurrently so the
|
|
171
|
+
// total time is bounded by the slowest request rather than the sum of all
|
|
172
|
+
// N — the previous serial loop was an N+1 latency trap at limit=50.
|
|
173
|
+
return Promise.all(list.map(async (repo) => {
|
|
174
|
+
const meta = await this.readKitMeta(repo.name);
|
|
175
|
+
return (meta ?? {
|
|
176
|
+
id: repo.name,
|
|
177
|
+
name: repo.name,
|
|
178
|
+
type: KIT_TYPE,
|
|
179
|
+
createdAt: repo.created_at,
|
|
180
|
+
});
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
async getKit(kitId) {
|
|
184
|
+
try {
|
|
185
|
+
const repo = await this.api("GET", this.repoPath(kitId));
|
|
186
|
+
const meta = await this.readKitMeta(kitId);
|
|
187
|
+
if (meta)
|
|
188
|
+
return meta;
|
|
189
|
+
return {
|
|
190
|
+
id: repo.name,
|
|
191
|
+
name: repo.name,
|
|
192
|
+
type: KIT_TYPE,
|
|
193
|
+
createdAt: repo.created_at,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
if (e instanceof NotFoundError)
|
|
198
|
+
throw new NotFoundError("Kit", kitId);
|
|
199
|
+
throw e;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async listFiles(kitId) {
|
|
203
|
+
// Same shared kitId rule LocalFs applies, so the two adapters cannot drift
|
|
204
|
+
// (AC1). A git host maps each kit to a SEPARATE repo — `repoPath("")` is a
|
|
205
|
+
// nonexistent repo, not a shared-root escape — so an unsafe id is simply a
|
|
206
|
+
// missing kit here; rejecting it up front keeps the contract identical
|
|
207
|
+
// (NotFoundError) without an extra 404 round-trip.
|
|
208
|
+
if (!isSafeKitId(kitId))
|
|
209
|
+
throw new NotFoundError("Kit", kitId);
|
|
210
|
+
// Repo metadata doubles as the kit-existence check AND the `lastModified`
|
|
211
|
+
// parity source (updated_at → created_at). See KitFileEntry's doc comment
|
|
212
|
+
// for why git-host entries carry a per-repo, not per-file, timestamp.
|
|
213
|
+
const repo = await this.getRepo(kitId);
|
|
214
|
+
const lastModified = repo.updated_at ?? repo.created_at;
|
|
215
|
+
// Enumerate every tracked file path (recursive contents walk).
|
|
216
|
+
const paths = await this.listTree(kitId, "");
|
|
217
|
+
// Apply the SAME exclusion LocalFs uses — default dirs (node_modules/.git/
|
|
218
|
+
// dist/.genie-tmp) + a committed `.genieignore` + the `.kit.json` marker —
|
|
219
|
+
// so a listing is filtered identically whichever adapter backs it.
|
|
220
|
+
const ignore = buildIgnoreMatcher(await this.readIgnorePatterns(kitId));
|
|
221
|
+
const visible = paths.filter((path) => path !== this.kitMetaPath && !ignore(path));
|
|
222
|
+
// A git host exposes no dependable per-file sha256 (its `sha` is a git blob
|
|
223
|
+
// SHA-1, wrong algorithm AND wrong shape for the `sha256-…` SRI contract),
|
|
224
|
+
// so we fetch each surviving file's bytes to compute a real SRI hash + true
|
|
225
|
+
// byte size. This is an intentional N-read fan-out — the honest cost of
|
|
226
|
+
// producing byte-identical `list_files` output across adapters. Ignored
|
|
227
|
+
// files were filtered out ABOVE, so their bytes are never fetched.
|
|
228
|
+
const entries = await Promise.all(visible.map(async (path) => {
|
|
229
|
+
const bytes = await this.fetchFileBytes(kitId, path);
|
|
230
|
+
return { path, size: bytes.length, hash: sriSha256(bytes), lastModified };
|
|
231
|
+
}));
|
|
232
|
+
return entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
233
|
+
}
|
|
234
|
+
async listComponents(params) {
|
|
235
|
+
// getKit throws NotFoundError for an unknown kit, which propagates naturally
|
|
236
|
+
// and keeps "kit missing" distinct from "kit present but no manifest yet".
|
|
237
|
+
await this.getKit(params.kitId);
|
|
238
|
+
// Fetch the compiled card index (D-D) from the kit's default branch. It is
|
|
239
|
+
// absent until the M3-03 compiler writes it → a 404 (surfaced by readFile as
|
|
240
|
+
// NotFoundError) maps to `undefined`, which selectComponents turns into []
|
|
241
|
+
// (AC8). Other errors (auth, 5xx, oversize) propagate.
|
|
242
|
+
//
|
|
243
|
+
// `readFile` returns the rich `KitFileContent` shape (M1-14a-1a / DRO-540),
|
|
244
|
+
// not a bare string. `.genie/manifest.json` is JSON (a textual MIME), so the
|
|
245
|
+
// shared classifier decodes it to a utf-8 `content` string; a base64 result
|
|
246
|
+
// is decoded defensively so `selectComponents` always parses real JSON text.
|
|
247
|
+
let raw;
|
|
248
|
+
try {
|
|
249
|
+
const file = await this.readFile(params.kitId, MANIFEST_PATH);
|
|
250
|
+
raw =
|
|
251
|
+
file.encoding === "base64"
|
|
252
|
+
? Buffer.from(file.content, "base64").toString("utf-8")
|
|
253
|
+
: file.content;
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
if (e instanceof NotFoundError) {
|
|
257
|
+
raw = undefined;
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
throw e;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return selectComponents(params.kitId, raw, params.group);
|
|
264
|
+
}
|
|
265
|
+
/** Fetch repo metadata, mapping a 404 to NotFoundError("Kit"). */
|
|
266
|
+
async getRepo(kitId) {
|
|
267
|
+
try {
|
|
268
|
+
return await this.api("GET", this.repoPath(kitId));
|
|
269
|
+
}
|
|
270
|
+
catch (e) {
|
|
271
|
+
if (e instanceof NotFoundError)
|
|
272
|
+
throw new NotFoundError("Kit", kitId);
|
|
273
|
+
throw e;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Read the kit's committed `.genieignore` (default branch) into active
|
|
278
|
+
* pattern lines. Absent file → no extra patterns (default-dir exclusion still
|
|
279
|
+
* applies via buildIgnoreMatcher).
|
|
280
|
+
*/
|
|
281
|
+
async readIgnorePatterns(kitId) {
|
|
282
|
+
try {
|
|
283
|
+
const entry = await this.fetchContentEntry(kitId, ".genieignore");
|
|
284
|
+
if (entry.type !== "file" || !entry.content)
|
|
285
|
+
return [];
|
|
286
|
+
const raw = entry.encoding === "base64"
|
|
287
|
+
? Buffer.from(entry.content, "base64").toString("utf-8")
|
|
288
|
+
: entry.content;
|
|
289
|
+
return parseGenieignore(raw);
|
|
290
|
+
}
|
|
291
|
+
catch (e) {
|
|
292
|
+
if (e instanceof NotFoundError)
|
|
293
|
+
return [];
|
|
294
|
+
throw e;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async listTree(kitId, dirPath) {
|
|
298
|
+
const path = dirPath
|
|
299
|
+
? `/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(kitId)}/contents/${dirPath}`
|
|
300
|
+
: `/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(kitId)}/contents`;
|
|
301
|
+
let entries;
|
|
302
|
+
try {
|
|
303
|
+
entries = await this.api("GET", path);
|
|
304
|
+
}
|
|
305
|
+
catch (e) {
|
|
306
|
+
if (e instanceof NotFoundError)
|
|
307
|
+
throw new NotFoundError("Kit", kitId);
|
|
308
|
+
throw e;
|
|
309
|
+
}
|
|
310
|
+
const files = [];
|
|
311
|
+
for (const entry of entries) {
|
|
312
|
+
if (entry.type === "file") {
|
|
313
|
+
files.push(entry.path);
|
|
314
|
+
}
|
|
315
|
+
else if (entry.type === "dir") {
|
|
316
|
+
const children = await this.listTree(kitId, entry.path);
|
|
317
|
+
files.push(...children);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return files;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* GET a single contents entry, mapping a 404 to NotFoundError("File").
|
|
324
|
+
* Encodes each path segment to handle spaces, #, ?, etc.
|
|
325
|
+
*/
|
|
326
|
+
async fetchContentEntry(kitId, path) {
|
|
327
|
+
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
|
|
328
|
+
try {
|
|
329
|
+
return await this.api("GET", `/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(kitId)}/contents/${encodedPath}`);
|
|
330
|
+
}
|
|
331
|
+
catch (e) {
|
|
332
|
+
if (e instanceof NotFoundError)
|
|
333
|
+
throw new NotFoundError("File", `${kitId}/${path}`);
|
|
334
|
+
throw e;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/** Fetch a file's raw bytes off the default branch (base64 or utf-8 payload). */
|
|
338
|
+
async fetchFileBytes(kitId, path) {
|
|
339
|
+
const entry = await this.fetchContentEntry(kitId, path);
|
|
340
|
+
if (entry.type !== "file" || !entry.content)
|
|
341
|
+
return Buffer.alloc(0);
|
|
342
|
+
return entry.encoding === "base64"
|
|
343
|
+
? Buffer.from(entry.content, "base64")
|
|
344
|
+
: Buffer.from(entry.content, "utf-8");
|
|
345
|
+
}
|
|
346
|
+
async readFile(kitId, path) {
|
|
347
|
+
// Reject an unsafe kitId up front, mirroring LocalFs (AC1/AC-SEC). Each kit
|
|
348
|
+
// is its own repo here so `""` cannot cross kits, but keeping the SAME guard
|
|
349
|
+
// on both adapters is what prevents the rule from silently drifting.
|
|
350
|
+
if (!isSafeKitId(kitId))
|
|
351
|
+
throw new NotFoundError("Kit", kitId);
|
|
352
|
+
const entry = await this.fetchContentEntry(kitId, path);
|
|
353
|
+
// Validate that this is a file, not a directory
|
|
354
|
+
if (entry.type !== "file") {
|
|
355
|
+
throw new NotFoundError("File", `${kitId}/${path}`);
|
|
356
|
+
}
|
|
357
|
+
if (entry.size && entry.size > MAX_FILE_BYTES) {
|
|
358
|
+
throw new FileTooLargeError(path, entry.size);
|
|
359
|
+
}
|
|
360
|
+
// Validate that content is actually present
|
|
361
|
+
if (!entry.content) {
|
|
362
|
+
throw new Error(`File "${kitId}/${path}" returned no content from the git host API.`);
|
|
363
|
+
}
|
|
364
|
+
const bytes = entry.encoding === "base64"
|
|
365
|
+
? Buffer.from(entry.content, "base64")
|
|
366
|
+
: Buffer.from(entry.content, "utf-8");
|
|
367
|
+
// Fallback size check when entry.size was not provided by the API.
|
|
368
|
+
if (bytes.length > MAX_FILE_BYTES) {
|
|
369
|
+
throw new FileTooLargeError(path, bytes.length);
|
|
370
|
+
}
|
|
371
|
+
// Shared classifier decides utf-8 vs base64 + the MIME type — identical to
|
|
372
|
+
// LocalFsKitStore, so a `read_file` result is byte-identical across adapters
|
|
373
|
+
// (a binary blob on the git host comes back base64, text comes back utf-8).
|
|
374
|
+
return classifyFileContent(path, bytes);
|
|
375
|
+
}
|
|
376
|
+
async deleteFile(kitId, path) {
|
|
377
|
+
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
|
|
378
|
+
// The contents-API DELETE needs the file's current blob SHA. A missing file
|
|
379
|
+
// OR a missing kit (repo) both 404 → the idempotent "already absent" no-op,
|
|
380
|
+
// matching LocalFs (which never pre-stats the kit). Deletes hit the DEFAULT
|
|
381
|
+
// branch — the readable surface `readFile`/`listFiles` see — NOT a plan
|
|
382
|
+
// branch (plan-branch edits are the openPlan/commitPlan path).
|
|
383
|
+
let sha;
|
|
384
|
+
try {
|
|
385
|
+
const existing = await this.api("GET", `/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(kitId)}/contents/${encodedPath}`);
|
|
386
|
+
// A directory (contents-API array response) or any non-`file` entry
|
|
387
|
+
// (submodule, symlink) is NOT a deletable file. LocalFs's `unlink` on a
|
|
388
|
+
// directory throws a native EISDIR, which the `delete_files` tool maps to
|
|
389
|
+
// `DeleteFailed`. GitHost must raise the SAME native-shaped error rather
|
|
390
|
+
// than silently no-op'ing to `{ existed: false }` — otherwise an
|
|
391
|
+
// authorized `deletes: ["adir"]` plan yields `DeleteFailed` on LocalFs but
|
|
392
|
+
// a silent success on GitHost, breaking byte-identical cross-adapter
|
|
393
|
+
// parity (RFC G-5). The `interface.ts` deleteFile contract already mandates
|
|
394
|
+
// this ("a hard removal failure that is NOT 'already absent' … propagates
|
|
395
|
+
// as the adapter's native error"). See DRO-568.
|
|
396
|
+
if (Array.isArray(existing) || existing.type !== "file") {
|
|
397
|
+
throw eisdirError(`${kitId}/${path}`);
|
|
398
|
+
}
|
|
399
|
+
sha = existing.sha ?? "";
|
|
400
|
+
}
|
|
401
|
+
catch (e) {
|
|
402
|
+
if (e instanceof NotFoundError)
|
|
403
|
+
return { existed: false };
|
|
404
|
+
throw e;
|
|
405
|
+
}
|
|
406
|
+
await this.api("DELETE", `/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(kitId)}/contents/${encodedPath}`, { message: `kit: delete ${path}`, sha });
|
|
407
|
+
return { existed: true };
|
|
408
|
+
}
|
|
409
|
+
async writeFiles(kitId, ops) {
|
|
410
|
+
// Writes land on the DEFAULT branch — the readable surface readFile/
|
|
411
|
+
// listFiles/deleteFile see — NOT a plan branch (a git host has no
|
|
412
|
+
// rename-transaction; the plan is the capability grant, resolved tool-side,
|
|
413
|
+
// and openPlan/commitPlan are the separate plan-branch path). A git host
|
|
414
|
+
// can't offer filesystem-atomic multi-file writes, so we honour the
|
|
415
|
+
// all-or-nothing CONTRACT the way the host allows: capture each
|
|
416
|
+
// destination's prior blob state, apply every write, and on any failure
|
|
417
|
+
// unwind by restoring captured blobs / deleting files this call created.
|
|
418
|
+
// Rollback failures are collected (never short-circuited) and surfaced as
|
|
419
|
+
// RollbackIncompleteError — the same guarantee shape as LocalFs.
|
|
420
|
+
// Resolve each op's bytes first (streaming a `sourcePath` off disk so a
|
|
421
|
+
// large localPath upload never fully buffers beyond this base64 encode,
|
|
422
|
+
// which the contents API requires anyway). A read failure here aborts
|
|
423
|
+
// before any remote write — nothing has landed yet.
|
|
424
|
+
const resolved = [];
|
|
425
|
+
for (const op of ops) {
|
|
426
|
+
try {
|
|
427
|
+
const bytes = "sourcePath" in op ? await readStreamToBuffer(op.sourcePath) : op.content;
|
|
428
|
+
resolved.push({ path: op.path, contentB64: bytes.toString("base64") });
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
throw new WriteFailedError(op.path, describeGitError(error, "read failed"));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// Snapshot prior state per path so we can restore on rollback:
|
|
435
|
+
// { existed: true, sha, contentB64 } — restore via PUT with this content.
|
|
436
|
+
// { existed: false } — created by us; delete on rollback.
|
|
437
|
+
const priors = [];
|
|
438
|
+
const committed = [];
|
|
439
|
+
try {
|
|
440
|
+
for (const { path, contentB64 } of resolved) {
|
|
441
|
+
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
|
|
442
|
+
const prior = await this.getContentForWrite(kitId, encodedPath);
|
|
443
|
+
priors.push({ path, encodedPath, ...prior });
|
|
444
|
+
const body = {
|
|
445
|
+
content: contentB64,
|
|
446
|
+
message: `kit: write ${path}`,
|
|
447
|
+
};
|
|
448
|
+
if (prior.existed && prior.sha)
|
|
449
|
+
body["sha"] = prior.sha;
|
|
450
|
+
try {
|
|
451
|
+
const res = await this.api(prior.existed ? "PUT" : "POST", `/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(kitId)}/contents/${encodedPath}`, body);
|
|
452
|
+
committed.push({ path, encodedPath, sha: res.content.sha });
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
throw new WriteFailedError(path, describeGitError(error, "contents-API write failed"));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
const rollbackFailures = await this.unwriteFiles(kitId, committed, priors);
|
|
461
|
+
if (rollbackFailures.length > 0) {
|
|
462
|
+
throw new RollbackIncompleteError(describeGitError(error, "commit failed"), rollbackFailures);
|
|
463
|
+
}
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
466
|
+
return { writtenPaths: ops.map((o) => o.path) };
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Fetch a path's current blob state on the default branch for a write:
|
|
470
|
+
* `{ existed: true, sha, contentB64 }` if present (needed to update AND to
|
|
471
|
+
* restore on rollback), `{ existed: false }` if absent (we'd be creating it).
|
|
472
|
+
* A directory at the path is treated as "not a writable file" and surfaces as
|
|
473
|
+
* absent — the subsequent write then fails and rolls back, matching LocalFs's
|
|
474
|
+
* directory-target refusal.
|
|
475
|
+
*/
|
|
476
|
+
async getContentForWrite(kitId, encodedPath) {
|
|
477
|
+
try {
|
|
478
|
+
const existing = await this.api("GET", `/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(kitId)}/contents/${encodedPath}`);
|
|
479
|
+
if (Array.isArray(existing) || existing.type !== "file") {
|
|
480
|
+
return { existed: false };
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
existed: true,
|
|
484
|
+
sha: existing.sha ?? "",
|
|
485
|
+
contentB64: existing.encoding === "base64" ? existing.content : undefined,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
if (error instanceof NotFoundError)
|
|
490
|
+
return { existed: false };
|
|
491
|
+
throw error;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Unwind a partially-applied writeFiles batch. For each committed file: if it
|
|
496
|
+
* pre-existed, PUT its captured content back; if we created it, DELETE it.
|
|
497
|
+
* Every step is attempted even if an earlier one fails — failures are
|
|
498
|
+
* collected and returned so the caller can raise RollbackIncompleteError
|
|
499
|
+
* rather than mask an incomplete restore.
|
|
500
|
+
*/
|
|
501
|
+
async unwriteFiles(kitId, committed, priors) {
|
|
502
|
+
const failures = [];
|
|
503
|
+
const priorByPath = new Map(priors.map((p) => [p.path, p]));
|
|
504
|
+
// Undo in reverse commit order so a path committed twice (impossible today —
|
|
505
|
+
// the tool rejects duplicates — but cheap insurance) restores oldest-last.
|
|
506
|
+
for (const c of [...committed].reverse()) {
|
|
507
|
+
const prior = priorByPath.get(c.path);
|
|
508
|
+
try {
|
|
509
|
+
if (prior?.existed && prior.contentB64 !== undefined) {
|
|
510
|
+
// Restore the captured blob. Needs the CURRENT sha (what we just
|
|
511
|
+
// wrote) as the update base.
|
|
512
|
+
await this.api("PUT", `/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(kitId)}/contents/${c.encodedPath}`, { content: prior.contentB64, message: `kit: rollback ${c.path}`, sha: c.sha });
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
// We created it (or couldn't capture its content) — delete it.
|
|
516
|
+
await this.api("DELETE", `/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(kitId)}/contents/${c.encodedPath}`, { message: `kit: rollback delete ${c.path}`, sha: c.sha });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
failures.push(`failed to roll back "${c.path}": ${describeGitError(error, "unknown error")}`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return failures;
|
|
524
|
+
}
|
|
525
|
+
async createKit(name, kitId) {
|
|
526
|
+
const repoName = kitId ?? name;
|
|
527
|
+
try {
|
|
528
|
+
const repo = await this.api("POST", `/orgs/${encodeURIComponent(this.owner)}/repos`, {
|
|
529
|
+
name: repoName,
|
|
530
|
+
auto_init: true,
|
|
531
|
+
private: true,
|
|
532
|
+
});
|
|
533
|
+
const meta = {
|
|
534
|
+
id: repo.name,
|
|
535
|
+
name,
|
|
536
|
+
type: KIT_TYPE,
|
|
537
|
+
createdAt: repo.created_at,
|
|
538
|
+
};
|
|
539
|
+
await this.writeKitMeta(meta);
|
|
540
|
+
// DRO-764 AC1 — scaffold the viewer's static shell into the new repo's
|
|
541
|
+
// default branch, same as LocalFsKitStore does for its kit dir. Runs
|
|
542
|
+
// AFTER writeKitMeta so `.kit.json` (the kit-existence marker `getKit`/
|
|
543
|
+
// `readKitMeta` key off) is already committed by the time any viewer
|
|
544
|
+
// file lands; a failure scaffolding the viewer assets below still
|
|
545
|
+
// leaves a valid, gettable kit — matching the local adapter's
|
|
546
|
+
// best-effort semantics (`loadViewerAssets` degrades to `[]` rather
|
|
547
|
+
// than throwing when neither packaged nor optional assets are usable).
|
|
548
|
+
const viewerAssets = await loadViewerAssets();
|
|
549
|
+
for (const asset of viewerAssets) {
|
|
550
|
+
await this.writeRepoFile(meta.id, asset.path, asset.content);
|
|
551
|
+
}
|
|
552
|
+
// DRO-764 AC3 — seed an empty `.genie/manifest.json` (see
|
|
553
|
+
// `empty-manifest.ts`'s header) so the file:// / localhost-Vite
|
|
554
|
+
// vehicles' manifest fetch resolves to a valid empty manifest instead
|
|
555
|
+
// of 404ing. Same byte shape `LocalFsKitStore` seeds, same
|
|
556
|
+
// best-effort posture: this repo already has a valid `.kit.json` by
|
|
557
|
+
// this point, so a failure here still leaves a gettable kit.
|
|
558
|
+
await this.writeRepoFile(meta.id, MANIFEST_PATH, Buffer.from(serializeEmptyManifest(name), "utf-8"));
|
|
559
|
+
return meta;
|
|
560
|
+
}
|
|
561
|
+
catch (err) {
|
|
562
|
+
// Check if it's a 409 Conflict indicating repo already exists
|
|
563
|
+
if (err instanceof GitHostApiError && err.status === 409) {
|
|
564
|
+
throw new KitAlreadyExistsError(repoName);
|
|
565
|
+
}
|
|
566
|
+
throw err;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
async openPlan(kitId, ops) {
|
|
570
|
+
// Validate kit exists
|
|
571
|
+
await this.getKit(kitId);
|
|
572
|
+
const planId = randomUUID();
|
|
573
|
+
const branchName = `plan/${planId}`;
|
|
574
|
+
// Get the default branch SHA to branch from
|
|
575
|
+
const repo = await this.api("GET", `/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(kitId)}`);
|
|
576
|
+
const defaultBranch = repo.default_branch ?? "main";
|
|
577
|
+
// Create the plan branch
|
|
578
|
+
await this.api("POST", `/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(kitId)}/branches`, {
|
|
579
|
+
new_branch_name: branchName,
|
|
580
|
+
old_branch_name: defaultBranch,
|
|
581
|
+
});
|
|
582
|
+
// Apply initial operations
|
|
583
|
+
if (ops.length > 0) {
|
|
584
|
+
await this.applyOps(kitId, branchName, ops);
|
|
585
|
+
}
|
|
586
|
+
return planId;
|
|
587
|
+
}
|
|
588
|
+
async commitPlan(kitId, planId, ops) {
|
|
589
|
+
const branchName = `plan/${planId}`;
|
|
590
|
+
// Verify branch exists by trying to get it
|
|
591
|
+
try {
|
|
592
|
+
await this.api("GET", `/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(kitId)}/branches/${encodeURIComponent(branchName)}`);
|
|
593
|
+
}
|
|
594
|
+
catch (e) {
|
|
595
|
+
if (e instanceof NotFoundError)
|
|
596
|
+
throw new NotFoundError("Plan", planId);
|
|
597
|
+
throw e;
|
|
598
|
+
}
|
|
599
|
+
await this.applyOps(kitId, branchName, ops);
|
|
600
|
+
}
|
|
601
|
+
async closePlan(kitId, planId) {
|
|
602
|
+
const branchName = `plan/${planId}`;
|
|
603
|
+
try {
|
|
604
|
+
await this.api("DELETE", `/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(kitId)}/branches/${encodeURIComponent(branchName)}`);
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
// Idempotent — branch already deleted or never existed
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
async applyOps(kitId, branch, ops) {
|
|
611
|
+
for (const op of ops) {
|
|
612
|
+
// Validate path for traversal-like segments
|
|
613
|
+
const pathSegments = op.path.split("/");
|
|
614
|
+
for (const segment of pathSegments) {
|
|
615
|
+
if (segment === ".." || segment === "." || segment === "") {
|
|
616
|
+
throw new Error(`Invalid path in file operation: "${op.path}" contains traversal or empty segments.`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
// Encode each path segment to handle spaces, #, ?, etc.
|
|
620
|
+
// Consistent with readFile()'s encoding strategy.
|
|
621
|
+
const encodedPath = pathSegments.map(encodeURIComponent).join("/");
|
|
622
|
+
if (op.kind === "write") {
|
|
623
|
+
const content = Buffer.from(op.content).toString("base64");
|
|
624
|
+
// Try to get existing file for its SHA (needed for update)
|
|
625
|
+
let existingSha;
|
|
626
|
+
try {
|
|
627
|
+
const existing = await this.api("GET", `/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(kitId)}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`);
|
|
628
|
+
// The Gitea API returns sha at the top level for file content
|
|
629
|
+
existingSha = existing.sha;
|
|
630
|
+
}
|
|
631
|
+
catch (e) {
|
|
632
|
+
// Only "file doesn't exist" should fall through to create.
|
|
633
|
+
// Rethrow other failures (auth, network, server errors) so callers
|
|
634
|
+
// aren't silently misled into creating an overwrite.
|
|
635
|
+
if (!(e instanceof NotFoundError))
|
|
636
|
+
throw e;
|
|
637
|
+
}
|
|
638
|
+
const body = {
|
|
639
|
+
content,
|
|
640
|
+
message: `plan: write ${op.path}`,
|
|
641
|
+
branch,
|
|
642
|
+
};
|
|
643
|
+
if (existingSha)
|
|
644
|
+
body["sha"] = existingSha;
|
|
645
|
+
await this.api(existingSha ? "PUT" : "POST", `/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(kitId)}/contents/${encodedPath}`, body);
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
// Delete: need the file SHA
|
|
649
|
+
let sha;
|
|
650
|
+
try {
|
|
651
|
+
const existing = await this.api("GET", `/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(kitId)}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`);
|
|
652
|
+
sha = existing.sha ?? "";
|
|
653
|
+
}
|
|
654
|
+
catch (e) {
|
|
655
|
+
// File doesn't exist — idempotent delete, nothing to do.
|
|
656
|
+
if (e instanceof NotFoundError)
|
|
657
|
+
continue;
|
|
658
|
+
// Surface transient API/auth failures rather than silently skipping.
|
|
659
|
+
throw e;
|
|
660
|
+
}
|
|
661
|
+
await this.api("DELETE", `/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(kitId)}/contents/${encodedPath}`, {
|
|
662
|
+
message: `plan: delete ${op.path}`,
|
|
663
|
+
branch,
|
|
664
|
+
sha,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
// ─── GitHostProjectStore ─────────────────────────────────────────────────────
|
|
671
|
+
/**
|
|
672
|
+
* Stores project metadata in a dedicated repo (`_genie-projects`) as JSON files.
|
|
673
|
+
* Each project is a file `projects/<projectId>.json`.
|
|
674
|
+
*/
|
|
675
|
+
export class GitHostProjectStore {
|
|
676
|
+
baseUrl;
|
|
677
|
+
token;
|
|
678
|
+
owner;
|
|
679
|
+
metaRepo = "_genie-projects";
|
|
680
|
+
constructor(config) {
|
|
681
|
+
this.baseUrl = config.baseUrl.replace(/\/$/, "");
|
|
682
|
+
this.token = resolveToken(config);
|
|
683
|
+
this.owner = config.owner;
|
|
684
|
+
}
|
|
685
|
+
api(method, path, body) {
|
|
686
|
+
return apiRequest({
|
|
687
|
+
method,
|
|
688
|
+
path,
|
|
689
|
+
body,
|
|
690
|
+
baseUrl: this.baseUrl,
|
|
691
|
+
token: this.token,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
projectPath(projectId) {
|
|
695
|
+
return `projects/${projectId}.json`;
|
|
696
|
+
}
|
|
697
|
+
async ensureMetaRepo() {
|
|
698
|
+
try {
|
|
699
|
+
await this.api("GET", `/repos/${encodeURIComponent(this.owner)}/${this.metaRepo}`);
|
|
700
|
+
}
|
|
701
|
+
catch (e) {
|
|
702
|
+
// Only auto-create on NotFoundError; rethrow other failures
|
|
703
|
+
if (e instanceof NotFoundError) {
|
|
704
|
+
await this.api("POST", `/orgs/${encodeURIComponent(this.owner)}/repos`, {
|
|
705
|
+
name: this.metaRepo,
|
|
706
|
+
auto_init: true,
|
|
707
|
+
private: true,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
else {
|
|
711
|
+
throw e;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
async listProjects() {
|
|
716
|
+
await this.ensureMetaRepo();
|
|
717
|
+
let entries;
|
|
718
|
+
try {
|
|
719
|
+
entries = await this.api("GET", `/repos/${encodeURIComponent(this.owner)}/${this.metaRepo}/contents/projects`);
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
return [];
|
|
723
|
+
}
|
|
724
|
+
const projects = [];
|
|
725
|
+
for (const entry of entries) {
|
|
726
|
+
if (entry.type !== "file" || !entry.name.endsWith(".json"))
|
|
727
|
+
continue;
|
|
728
|
+
try {
|
|
729
|
+
const file = await this.api("GET", `/repos/${encodeURIComponent(this.owner)}/${this.metaRepo}/contents/${entry.path}`);
|
|
730
|
+
if (file.content && file.encoding === "base64") {
|
|
731
|
+
const data = JSON.parse(Buffer.from(file.content, "base64").toString("utf-8"));
|
|
732
|
+
projects.push(data);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
// Skip malformed entries
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return projects;
|
|
740
|
+
}
|
|
741
|
+
async getProject(projectId) {
|
|
742
|
+
await this.ensureMetaRepo();
|
|
743
|
+
try {
|
|
744
|
+
const file = await this.api("GET", `/repos/${encodeURIComponent(this.owner)}/${this.metaRepo}/contents/${this.projectPath(projectId)}`);
|
|
745
|
+
if (file.content && file.encoding === "base64") {
|
|
746
|
+
return JSON.parse(Buffer.from(file.content, "base64").toString("utf-8"));
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
catch {
|
|
750
|
+
// fall through to NotFoundError
|
|
751
|
+
}
|
|
752
|
+
throw new NotFoundError("Project", projectId);
|
|
753
|
+
}
|
|
754
|
+
async createProject(name) {
|
|
755
|
+
await this.ensureMetaRepo();
|
|
756
|
+
const id = randomUUID();
|
|
757
|
+
const meta = {
|
|
758
|
+
id,
|
|
759
|
+
name,
|
|
760
|
+
createdAt: new Date().toISOString(),
|
|
761
|
+
};
|
|
762
|
+
const content = Buffer.from(JSON.stringify(meta, null, 2)).toString("base64");
|
|
763
|
+
await this.api("POST", `/repos/${encodeURIComponent(this.owner)}/${this.metaRepo}/contents/${this.projectPath(id)}`, { content, message: `project: create ${name}` });
|
|
764
|
+
return meta;
|
|
765
|
+
}
|
|
766
|
+
async deleteProject(projectId) {
|
|
767
|
+
await this.ensureMetaRepo();
|
|
768
|
+
let sha;
|
|
769
|
+
try {
|
|
770
|
+
const file = await this.api("GET", `/repos/${encodeURIComponent(this.owner)}/${this.metaRepo}/contents/${this.projectPath(projectId)}`);
|
|
771
|
+
sha = file.sha;
|
|
772
|
+
}
|
|
773
|
+
catch {
|
|
774
|
+
throw new NotFoundError("Project", projectId);
|
|
775
|
+
}
|
|
776
|
+
await this.api("DELETE", `/repos/${encodeURIComponent(this.owner)}/${this.metaRepo}/contents/${this.projectPath(projectId)}`, { message: `project: delete ${projectId}`, sha });
|
|
777
|
+
}
|
|
778
|
+
async bindKit(projectId, kitId) {
|
|
779
|
+
const meta = await this.getProject(projectId);
|
|
780
|
+
meta.kitId = kitId;
|
|
781
|
+
await this.updateProjectMeta(projectId, { ...meta });
|
|
782
|
+
}
|
|
783
|
+
async recordScreen(projectId, screenRef) {
|
|
784
|
+
const meta = (await this.getProject(projectId));
|
|
785
|
+
const screens = meta.screens ?? [];
|
|
786
|
+
screens.push(screenRef);
|
|
787
|
+
await this.updateProjectMeta(projectId, { ...meta, screens });
|
|
788
|
+
}
|
|
789
|
+
async updateProjectMeta(projectId, meta) {
|
|
790
|
+
const file = await this.api("GET", `/repos/${encodeURIComponent(this.owner)}/${this.metaRepo}/contents/${this.projectPath(projectId)}`);
|
|
791
|
+
const sha = file.sha;
|
|
792
|
+
const content = Buffer.from(JSON.stringify(meta, null, 2)).toString("base64");
|
|
793
|
+
await this.api("PUT", `/repos/${encodeURIComponent(this.owner)}/${this.metaRepo}/contents/${this.projectPath(projectId)}`, { content, message: `project: update ${projectId}`, sha });
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
//# sourceMappingURL=git-host.js.map
|