comprehende 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,16 +25,25 @@ pnpm typecheck
25
25
  pnpm build
26
26
  pnpm pack:smoke
27
27
  pnpm sync:skill
28
+ pnpm release:skill
28
29
  pnpm dev -- --help
29
30
  ```
30
31
 
31
32
  `pnpm build` emits `dist/cli` and `dist/ui`. `prepack` runs that build, so `pnpm pack` / `pnpm publish` always ship the UI.
32
33
 
33
- `pnpm dev` and `pnpm exec` run with this package as cwd, so they only make sense when _this_ repo is the one under review. To review a different project from a checkout, `cd` into it and run `npx comprehende@0.2.0` (or `node /path/to/comprehende/dist/cli/main.js` after `pnpm build`).
34
+ `pnpm sync:skill` copies the JSON Schema into `skills-next/comprehende/`, pins `npx comprehende@<version>` there, and mirrors that tree into `.agents/skills/comprehende` so agents in this checkout use the next skill. It does not touch `skills/comprehende/`.
35
+
36
+ `comprehende serve` and `comprehende export` share one UI and one git payload layer. Serve computes those payloads on each request. Export writes the same JSON (and image bytes) next to the UI so any static file server can host the review.
37
+
38
+ `pnpm dev` and `pnpm exec` run with this package as cwd, so they only make sense when _this_ repo is the one under review. To review a different project from a checkout, `cd` into it and run `npx comprehende@0.4.0` (or `node /path/to/comprehende/dist/cli/main.js` after `pnpm build`).
34
39
 
35
40
  ## Release
36
41
 
37
- Bump `version` in `package.json` when the CLI or UI changes, then run `pnpm sync:skill` so the skill pin matches. Pre-commit and `pnpm test` fail if the staged package version and skill pin differ. Do not bump for skill-only edits.
42
+ Edit the skill in `skills-next/comprehende/`. `npx skills add` reads `skills/comprehende/` only.
43
+
44
+ Bump `version` in `package.json` when the CLI or UI changes, then run `pnpm sync:skill` so the next skill pin matches. Pre-commit and `pnpm test` fail if the staged package version and next skill pin differ. Do not bump for skill-only edits.
45
+
46
+ When that next skill should ship with `npx skills add`, run `pnpm release:skill`. That copies `skills-next/comprehende/` onto `skills/comprehende/`. Run it in the same change that publishes a new CLI. Then `npx skills add` installs instructions that match the package they pin.
38
47
 
39
48
  Push to `main`. CI packs and tests the tarball on every change. If the version is not on npm yet, CI publishes it. Skill-only commits keep the same version, so they do not publish.
40
49
 
@@ -48,4 +57,16 @@ cd fixtures/repo
48
57
  node ../../dist/cli/main.js serve --data ../example/review.json
49
58
  ```
50
59
 
51
- `pnpm fixture` writes a tiny git repo to `fixtures/repo` (gitignored) and a refs-only `fixtures/example/review.json`. Serve with cwd set to `fixtures/repo`.
60
+ Export a static copy (cwd still `fixtures/repo`):
61
+
62
+ ```sh
63
+ node ../../dist/cli/main.js export --data ../example/review.json --out ../../fixtures/site
64
+ ```
65
+
66
+ The folder has the UI plus frozen `api/*.json` payloads and image bytes. There is no git in that folder. Host it with any static file server:
67
+
68
+ ```sh
69
+ python3 -m http.server --directory ../../fixtures/site 8080
70
+ ```
71
+
72
+ `pnpm fixture` writes a tiny git repo to `fixtures/repo` (gitignored) and a refs-only `fixtures/example/review.json`. Serve or export with cwd set to `fixtures/repo`.
@@ -0,0 +1,8 @@
1
+ export class ApiError extends Error {
2
+ status;
3
+ constructor(status, message) {
4
+ super(message);
5
+ this.name = "ApiError";
6
+ this.status = status;
7
+ }
8
+ }
@@ -0,0 +1,295 @@
1
+ import { loadDocument } from "../cli/commands.js";
2
+ import { blameFile } from "../git/blame.js";
3
+ import { readImageBlob } from "../git/blob.js";
4
+ import { fileLanguage, filePatchFromGit, readPathDiff, resolveSource, toHunkRef } from "../git/diff.js";
5
+ import { GitError } from "../git/exec.js";
6
+ import { listCommits } from "../git/log.js";
7
+ import { mergeBase } from "../git/repo.js";
8
+ import { showFile } from "../git/show.js";
9
+ import { coverReview } from "../review/coverage.js";
10
+ import { isLockfilePath } from "../schema/lockfile.js";
11
+ import { ApiError } from "./error.js";
12
+ export async function openReview(cwd, dataPath) {
13
+ const document = await loadDocument(dataPath);
14
+ const resolvedRefs = await resolveSource(cwd, document.source.baseRef, document.source.headRef);
15
+ const { files, coverage } = await coverReview(cwd, document);
16
+ const [mergeBaseSha, commits] = await Promise.all([
17
+ mergeBase(cwd, document.source.baseRef, document.source.headRef),
18
+ listCommits(cwd, document.source.baseRef, document.source.headRef),
19
+ ]);
20
+ return {
21
+ cwd,
22
+ document,
23
+ resolved: {
24
+ baseRef: document.source.baseRef,
25
+ headRef: document.source.headRef,
26
+ range: document.source.range ?? `${document.source.baseRef}...${document.source.headRef}`,
27
+ baseSha: resolvedRefs.baseSha,
28
+ headSha: resolvedRefs.headSha,
29
+ },
30
+ files,
31
+ coverage,
32
+ mergeBaseSha,
33
+ commits,
34
+ };
35
+ }
36
+ export function reviewPayload(ctx) {
37
+ const { document, resolved, files, coverage, commits } = ctx;
38
+ const lockfiles = lockfileFiles(files);
39
+ return {
40
+ document,
41
+ resolved,
42
+ coverage: {
43
+ totalHunks: coverage.totalHunks,
44
+ assignedHunks: coverage.assignedHunks,
45
+ unassignedCount: coverage.unassigned.length,
46
+ staleCount: coverage.stale.length,
47
+ },
48
+ groups: coverage.groups
49
+ .slice()
50
+ .sort((a, b) => a.group.suggestedOrder - b.group.suggestedOrder || a.group.id.localeCompare(b.group.id))
51
+ .map((group) => ({
52
+ id: group.group.id,
53
+ title: group.group.title,
54
+ why: group.group.why,
55
+ summary: group.group.summary,
56
+ lookFor: group.group.lookFor ?? [],
57
+ dependsOn: group.group.dependsOn ?? [],
58
+ part: group.group.part,
59
+ suggestedOrder: group.group.suggestedOrder,
60
+ hunkCount: group.hunks.length,
61
+ staleCount: group.stale.length,
62
+ files: uniquePaths(group.hunks),
63
+ })),
64
+ unassigned: {
65
+ hunkCount: coverage.unassigned.length,
66
+ files: uniquePaths(coverage.unassigned),
67
+ },
68
+ lockfiles: {
69
+ fileCount: lockfiles.length,
70
+ files: lockfiles.map((file) => file.path),
71
+ },
72
+ stale: coverage.stale,
73
+ files: files.map((file) => {
74
+ const entry = {
75
+ path: file.path,
76
+ status: file.status,
77
+ binary: file.binary,
78
+ image: file.image,
79
+ hunkCount: file.hunks.length,
80
+ };
81
+ if (file.oldPath !== undefined) {
82
+ entry.oldPath = file.oldPath;
83
+ }
84
+ return entry;
85
+ }),
86
+ skipped: files.filter((file) => file.binary && !file.image).map((file) => ({ path: file.path, reason: "binary" })),
87
+ commits,
88
+ };
89
+ }
90
+ export function hunksPayload(ctx, groupId) {
91
+ if (groupId === "") {
92
+ throw new ApiError(400, "missing group");
93
+ }
94
+ if (groupId === "unassigned") {
95
+ return serializeLayer(ctx.files, ctx.coverage.unassigned);
96
+ }
97
+ if (groupId === "lockfiles") {
98
+ return serializeLockfiles(ctx.files);
99
+ }
100
+ const group = ctx.coverage.groups.find((item) => item.group.id === groupId);
101
+ if (group === undefined) {
102
+ throw new ApiError(404, `unknown group "${groupId}"`);
103
+ }
104
+ return serializeLayer(ctx.files, group.hunks);
105
+ }
106
+ export async function filePayload(ctx, path, side) {
107
+ const file = findFile(ctx.files, path);
108
+ const lookup = side === "old" ? (file.oldPath ?? file.path) : file.path;
109
+ assertSideExists(file, side);
110
+ const ref = side === "old" ? ctx.mergeBaseSha : ctx.document.source.headRef;
111
+ try {
112
+ const content = await showFile(ctx.cwd, ref, lookup);
113
+ return { path: lookup, ref, side, content, language: fileLanguage(lookup) };
114
+ }
115
+ catch (error) {
116
+ throw new ApiError(404, error instanceof Error ? error.message : "file not found");
117
+ }
118
+ }
119
+ export async function blamePayload(ctx, path, side) {
120
+ const file = findFile(ctx.files, path);
121
+ const lookup = side === "old" ? (file.oldPath ?? file.path) : file.path;
122
+ assertSideExists(file, side);
123
+ const ref = side === "old" ? ctx.mergeBaseSha : ctx.document.source.headRef;
124
+ try {
125
+ const lines = await blameFile(ctx.cwd, ref, lookup);
126
+ return { path: lookup, ref, side, lines };
127
+ }
128
+ catch (error) {
129
+ throw new ApiError(404, error instanceof Error ? error.message : "blame not available");
130
+ }
131
+ }
132
+ export async function imagePayload(ctx, path, side) {
133
+ const file = findFile(ctx.files, path);
134
+ if (!file.image) {
135
+ throw new ApiError(404, "path is not an image in the live diff");
136
+ }
137
+ const lookup = side === "old" ? (file.oldPath ?? file.path) : file.path;
138
+ assertSideExists(file, side);
139
+ const ref = side === "old" ? ctx.mergeBaseSha : ctx.document.source.headRef;
140
+ try {
141
+ const blob = await readImageBlob(ctx.cwd, ref, lookup);
142
+ if (!blob.ok) {
143
+ throw new ApiError(404, `Git LFS object sha256:${blob.oid} is not in this clone`);
144
+ }
145
+ return { encoding: "bytes", mediaType: blob.mediaType, body: blob.bytes };
146
+ }
147
+ catch (error) {
148
+ if (error instanceof ApiError) {
149
+ throw error;
150
+ }
151
+ throw new ApiError(404, error instanceof Error ? error.message : "image not found");
152
+ }
153
+ }
154
+ export async function renderResource(ctx, resource) {
155
+ switch (resource.kind) {
156
+ case "review":
157
+ return { encoding: "json", body: reviewPayload(ctx) };
158
+ case "hunks":
159
+ return { encoding: "json", body: hunksPayload(ctx, resource.group) };
160
+ case "file":
161
+ return { encoding: "json", body: await filePayload(ctx, resource.path, resource.side) };
162
+ case "blame":
163
+ return { encoding: "json", body: await blamePayload(ctx, resource.path, resource.side) };
164
+ case "image":
165
+ return imagePayload(ctx, resource.path, resource.side);
166
+ case "patch":
167
+ return { encoding: "json", body: await patchPayload(ctx, resource.path) };
168
+ }
169
+ }
170
+ export function listResources(ctx) {
171
+ const resources = [
172
+ { kind: "review" },
173
+ { kind: "hunks", group: "unassigned" },
174
+ { kind: "hunks", group: "lockfiles" },
175
+ ];
176
+ for (const group of ctx.document.groups) {
177
+ resources.push({ kind: "hunks", group: group.id });
178
+ }
179
+ for (const file of ctx.files) {
180
+ if (file.image) {
181
+ for (const side of sidesFor(file)) {
182
+ resources.push({ kind: "image", path: file.path, side });
183
+ }
184
+ continue;
185
+ }
186
+ if (isLockfilePath(file.path) && !file.binary) {
187
+ resources.push({ kind: "patch", path: file.path });
188
+ }
189
+ for (const side of sidesFor(file)) {
190
+ resources.push({ kind: "file", path: file.path, side });
191
+ resources.push({ kind: "blame", path: file.path, side });
192
+ }
193
+ }
194
+ return resources;
195
+ }
196
+ function sidesFor(file) {
197
+ if (file.status === "added") {
198
+ return ["new"];
199
+ }
200
+ if (file.status === "deleted") {
201
+ return ["old"];
202
+ }
203
+ return ["old", "new"];
204
+ }
205
+ function serializeLayer(files, hunks) {
206
+ const groups = [];
207
+ for (const hunk of hunks) {
208
+ const existing = groups.find((group) => group.file.path === hunk.path);
209
+ if (existing !== undefined) {
210
+ existing.hunks.push(hunk);
211
+ continue;
212
+ }
213
+ const file = files.find((item) => item.path === hunk.path);
214
+ if (file === undefined) {
215
+ continue;
216
+ }
217
+ groups.push({ file, hunks: [hunk] });
218
+ }
219
+ const serializedFiles = groups.map(({ file, hunks: fileHunks }) => serializeLayerFile(file, fileHunks, true));
220
+ return { hunks: hunks.map(serializeHunk), files: serializedFiles };
221
+ }
222
+ function serializeLockfiles(files) {
223
+ const serializedFiles = lockfileFiles(files).map((file) => serializeLayerFile(file, [], true));
224
+ return { hunks: [], files: serializedFiles };
225
+ }
226
+ function lockfileFiles(files) {
227
+ return files.filter((file) => isLockfilePath(file.path) && !file.binary && !file.image);
228
+ }
229
+ async function patchPayload(ctx, path) {
230
+ const file = findFile(ctx.files, path);
231
+ if (!isLockfilePath(file.path) || file.binary || file.image) {
232
+ throw new ApiError(404, "path is not a deferred lockfile");
233
+ }
234
+ const live = await readPathDiff(ctx.cwd, ctx.document.source.baseRef, ctx.document.source.headRef, file.path);
235
+ if (live === undefined) {
236
+ throw new ApiError(404, `no live diff for ${path}`);
237
+ }
238
+ return serializeLayerFile(live, live.hunks, false);
239
+ }
240
+ function serializeLayerFile(file, fileHunks, deferLockfile) {
241
+ const lockfile = isLockfilePath(file.path) && !file.binary && !file.image;
242
+ const deferred = deferLockfile && lockfile;
243
+ const next = {
244
+ path: file.path,
245
+ kind: file.image ? "image" : lockfile ? "lockfile" : "text",
246
+ status: file.status,
247
+ patch: deferred ? "" : file.image ? file.headerPatch : filePatchFromGit(file, fileHunks),
248
+ hunks: fileHunks.map(serializeHunk),
249
+ };
250
+ if (file.oldPath !== undefined) {
251
+ next.oldPath = file.oldPath;
252
+ }
253
+ if (file.added !== undefined) {
254
+ next.added = file.added;
255
+ }
256
+ if (file.removed !== undefined) {
257
+ next.removed = file.removed;
258
+ }
259
+ return next;
260
+ }
261
+ function serializeHunk(hunk) {
262
+ return {
263
+ ...toHunkRef(hunk),
264
+ header: hunk.header,
265
+ language: fileLanguage(hunk.path),
266
+ lines: hunk.lines,
267
+ };
268
+ }
269
+ function findFile(files, path) {
270
+ const file = files.find((item) => item.path === path || item.oldPath === path);
271
+ if (file === undefined) {
272
+ throw new ApiError(404, `path is not in the live diff: ${path}`);
273
+ }
274
+ return file;
275
+ }
276
+ function assertSideExists(file, side) {
277
+ if (side === "old" && file.status === "added") {
278
+ throw new ApiError(404, "file did not exist on the base side");
279
+ }
280
+ if (side === "new" && file.status === "deleted") {
281
+ throw new ApiError(404, "file does not exist on the head side");
282
+ }
283
+ }
284
+ function uniquePaths(hunks) {
285
+ return [...new Set(hunks.map((hunk) => hunk.path))];
286
+ }
287
+ export function snapshotJson(body) {
288
+ return `${JSON.stringify(body)}\n`;
289
+ }
290
+ export function isUnavailableSnapshot(error) {
291
+ if (error instanceof ApiError) {
292
+ return error.status === 404 || error.status === 400;
293
+ }
294
+ return error instanceof GitError;
295
+ }
@@ -0,0 +1,114 @@
1
+ export function apiHref(resource) {
2
+ switch (resource.kind) {
3
+ case "review":
4
+ return "api/review.json";
5
+ case "hunks":
6
+ return `api/hunks/${encodeURIComponent(resource.group)}.json`;
7
+ case "file":
8
+ return `api/files/${resource.side}/${encodeFilePath(resource.path)}.json`;
9
+ case "blame":
10
+ return `api/blame/${resource.side}/${encodeFilePath(resource.path)}.json`;
11
+ case "image":
12
+ return `api/images/${resource.side}/${encodeFilePath(resource.path)}`;
13
+ case "patch":
14
+ return `api/patches/${encodeFilePath(resource.path)}.json`;
15
+ }
16
+ }
17
+ /** Decoded relative path under the site root. Static hosts map encoded request URLs onto this. */
18
+ export function apiFsRel(resource) {
19
+ switch (resource.kind) {
20
+ case "review":
21
+ return "api/review.json";
22
+ case "hunks":
23
+ return `api/hunks/${encodeURIComponent(resource.group)}.json`;
24
+ case "file":
25
+ return `api/files/${resource.side}/${resource.path}.json`;
26
+ case "blame":
27
+ return `api/blame/${resource.side}/${resource.path}.json`;
28
+ case "image":
29
+ return `api/images/${resource.side}/${resource.path}`;
30
+ case "patch":
31
+ return `api/patches/${resource.path}.json`;
32
+ }
33
+ }
34
+ export function parseApiPath(pathname) {
35
+ const parts = pathname.split("/").filter((part) => part !== "").map(decodeSegment);
36
+ if (parts[0] !== "api" || parts[1] === undefined) {
37
+ return undefined;
38
+ }
39
+ if (parts[1] === "review.json" && parts.length === 2) {
40
+ return { kind: "review" };
41
+ }
42
+ if (parts[1] === "hunks" && parts.length === 3 && parts[2] !== undefined) {
43
+ const group = jsonStem(parts[2]);
44
+ if (group === undefined) {
45
+ return undefined;
46
+ }
47
+ return { kind: "hunks", group };
48
+ }
49
+ if (parts[1] === "images" && parts.length >= 4 && parts[2] !== undefined) {
50
+ const side = parts[2];
51
+ if (side !== "old" && side !== "new") {
52
+ return undefined;
53
+ }
54
+ const path = parts.slice(3).join("/");
55
+ if (!isRepoPath(path)) {
56
+ return undefined;
57
+ }
58
+ return { kind: "image", path, side };
59
+ }
60
+ if (parts[1] === "patches" && parts.length >= 3) {
61
+ const rest = parts.slice(2);
62
+ const last = rest.at(-1);
63
+ const stem = last === undefined ? undefined : jsonStem(last);
64
+ if (stem === undefined) {
65
+ return undefined;
66
+ }
67
+ rest[rest.length - 1] = stem;
68
+ const path = rest.join("/");
69
+ if (!isRepoPath(path)) {
70
+ return undefined;
71
+ }
72
+ return { kind: "patch", path };
73
+ }
74
+ if ((parts[1] === "files" || parts[1] === "blame") && parts.length >= 4 && parts[2] !== undefined) {
75
+ const side = parts[2];
76
+ if (side !== "old" && side !== "new") {
77
+ return undefined;
78
+ }
79
+ const rest = parts.slice(3);
80
+ const last = rest.at(-1);
81
+ const stem = last === undefined ? undefined : jsonStem(last);
82
+ if (stem === undefined) {
83
+ return undefined;
84
+ }
85
+ rest[rest.length - 1] = stem;
86
+ const path = rest.join("/");
87
+ if (!isRepoPath(path)) {
88
+ return undefined;
89
+ }
90
+ return { kind: parts[1] === "files" ? "file" : "blame", path, side };
91
+ }
92
+ return undefined;
93
+ }
94
+ function encodeFilePath(path) {
95
+ return path.split("/").map(encodeURIComponent).join("/");
96
+ }
97
+ function decodeSegment(segment) {
98
+ try {
99
+ return decodeURIComponent(segment);
100
+ }
101
+ catch {
102
+ return segment;
103
+ }
104
+ }
105
+ function jsonStem(file) {
106
+ if (!file.endsWith(".json")) {
107
+ return undefined;
108
+ }
109
+ const stem = file.slice(0, -".json".length);
110
+ return stem === "" ? undefined : stem;
111
+ }
112
+ function isRepoPath(path) {
113
+ return path !== "" && !path.startsWith("/") && !path.includes("\0") && !path.split("/").includes("..");
114
+ }
@@ -0,0 +1,45 @@
1
+ import { cp, mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { findPackageRoot } from "../package-root.js";
5
+ import { isUnavailableSnapshot, listResources, openReview, renderResource, snapshotJson, } from "./live.js";
6
+ import { apiFsRel } from "./paths.js";
7
+ export async function exportStaticSite(opts) {
8
+ const outDir = resolve(opts.outDir);
9
+ const uiRoot = opts.uiRoot ?? join(findPackageRoot(), "dist/ui");
10
+ if (!existsSync(uiRoot)) {
11
+ throw new Error("UI is missing from this install. Reinstall comprehende from npm, or from a git checkout run `pnpm build`.");
12
+ }
13
+ if (existsSync(join(outDir, ".git"))) {
14
+ throw new Error(`refusing to write export into a git repository: ${outDir}`);
15
+ }
16
+ const ctx = opts.ctx ?? (await openReview(opts.cwd, opts.dataPath));
17
+ await mkdir(outDir, { recursive: true });
18
+ await rm(join(outDir, "assets"), { recursive: true, force: true });
19
+ await rm(join(outDir, "api"), { recursive: true, force: true });
20
+ await cp(uiRoot, outDir, { recursive: true });
21
+ const apiFiles = [];
22
+ for (const resource of listResources(ctx)) {
23
+ let body;
24
+ try {
25
+ body = await renderResource(ctx, resource);
26
+ }
27
+ catch (error) {
28
+ if (isUnavailableSnapshot(error)) {
29
+ continue;
30
+ }
31
+ throw error;
32
+ }
33
+ const rel = apiFsRel(resource);
34
+ const abs = join(outDir, ...rel.split("/"));
35
+ await mkdir(dirname(abs), { recursive: true });
36
+ if (body.encoding === "json") {
37
+ await writeFile(abs, snapshotJson(body.body));
38
+ }
39
+ else {
40
+ await writeFile(abs, Buffer.from(body.body));
41
+ }
42
+ apiFiles.push(rel);
43
+ }
44
+ return { outDir, apiFiles };
45
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli/args.js CHANGED
@@ -1,4 +1,4 @@
1
- const COMMANDS = new Set(["index", "validate", "serve"]);
1
+ const COMMANDS = new Set(["index", "validate", "serve", "export"]);
2
2
  export const USAGE = `Usage: comprehende <command> [options]
3
3
 
4
4
  Run inside the git repository under review. Cwd is the repo.
@@ -13,16 +13,21 @@ Commands:
13
13
  serve --data <review.json> [--port <n>] [--open]
14
14
  Serve the local UI on 127.0.0.1 (re-reads git on each request)
15
15
 
16
+ export --data <review.json> --out <dir>
17
+ Write a static site (same UI + frozen git payloads). No server after that.
18
+
16
19
  Options:
17
20
  --base <ref> Base ref (default: origin/HEAD or main/master)
18
21
  --head <ref> Head ref (default: HEAD)
19
22
  --data <path> Review document path
23
+ --out <dir> Output directory for export
20
24
  --port <n> Listen port (default: 4567, 0 for ephemeral)
21
25
  --open Open the UI in a browser
22
26
  -h, --help Show this help
23
27
  -v, --version Show version
24
28
 
25
29
  Diffs always come from git in cwd. The review document is interpretation only.
30
+ Export is a point-in-time copy. Rebase or new commits need a new export.
26
31
  `;
27
32
  export function parseArgv(argv, cwd = process.cwd()) {
28
33
  const args = argv[0] === "--" ? argv.slice(1) : [...argv];
@@ -52,6 +57,7 @@ export function parseArgv(argv, cwd = process.cwd()) {
52
57
  base: flag(rest, "--base"),
53
58
  head: flag(rest, "--head"),
54
59
  data: flag(rest, "--data"),
60
+ out: flag(rest, "--out"),
55
61
  port,
56
62
  open: rest.includes("--open"),
57
63
  };
@@ -28,6 +28,12 @@ export function resolveDataPath(data, cwd) {
28
28
  }
29
29
  return isAbsolute(data) ? data : resolve(cwd, data);
30
30
  }
31
+ export function resolveOutPath(out, cwd) {
32
+ if (out === undefined) {
33
+ throw new Error("missing --out <dir>");
34
+ }
35
+ return isAbsolute(out) ? out : resolve(cwd, out);
36
+ }
31
37
  export async function cmdValidate(cwd, dataPath) {
32
38
  const document = await loadDocument(dataPath);
33
39
  await resolveSource(cwd, document.source.baseRef, document.source.headRef);
package/dist/cli/main.js CHANGED
@@ -4,9 +4,10 @@ import { realpathSync } from "node:fs";
4
4
  import { resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { parseArgv, USAGE } from "./args.js";
7
- import { cmdIndex, cmdValidate, loadDocument, resolveDataPath } from "./commands.js";
8
- import { resolveSource } from "../git/diff.js";
9
- import { coverReview, coverageErrors } from "../review/coverage.js";
7
+ import { cmdIndex, cmdValidate, resolveDataPath, resolveOutPath } from "./commands.js";
8
+ import { exportStaticSite } from "../api/snapshot.js";
9
+ import { openReview } from "../api/live.js";
10
+ import { coverageErrors } from "../review/coverage.js";
10
11
  import { assertWorkTree } from "../git/repo.js";
11
12
  import { readPackageVersion } from "../package-root.js";
12
13
  import { startServer } from "../server/http.js";
@@ -41,13 +42,8 @@ export async function run(argv) {
41
42
  }
42
43
  case "serve": {
43
44
  const dataPath = resolveDataPath(request.data, request.cwd);
44
- const document = await loadDocument(dataPath);
45
- await resolveSource(request.cwd, document.source.baseRef, document.source.headRef);
46
- const { coverage } = await coverReview(request.cwd, document);
47
- const problems = coverageErrors(coverage);
48
- if (problems.length > 0) {
49
- console.error(`coverage issues (serve continues; git wins, unassigned/stale are visible):\n${problems.join("\n\n")}`);
50
- }
45
+ const ctx = await openReview(request.cwd, dataPath);
46
+ warnCoverage(ctx.coverage, "serve continues; git wins, unassigned/stale are visible");
51
47
  const running = await startServer({ cwd: request.cwd, dataPath, port: request.port });
52
48
  console.log(running.url);
53
49
  console.error(`serving ${dataPath} cwd=${request.cwd} localhost only`);
@@ -57,6 +53,16 @@ export async function run(argv) {
57
53
  await waitForClose(running.server);
58
54
  return 0;
59
55
  }
56
+ case "export": {
57
+ const dataPath = resolveDataPath(request.data, request.cwd);
58
+ const outDir = resolveOutPath(request.out, request.cwd);
59
+ const ctx = await openReview(request.cwd, dataPath);
60
+ warnCoverage(ctx.coverage, "export continues; git wins, unassigned/stale are visible");
61
+ const result = await exportStaticSite({ cwd: request.cwd, dataPath, outDir, ctx });
62
+ console.log(result.outDir);
63
+ console.error(`exported ${dataPath} ${result.apiFiles.length} api files no git in the folder`);
64
+ return 0;
65
+ }
60
66
  }
61
67
  }
62
68
  catch (error) {
@@ -64,6 +70,12 @@ export async function run(argv) {
64
70
  return 1;
65
71
  }
66
72
  }
73
+ function warnCoverage(coverage, note) {
74
+ const problems = coverageErrors(coverage);
75
+ if (problems.length > 0) {
76
+ console.error(`coverage issues (${note}):\n${problems.join("\n\n")}`);
77
+ }
78
+ }
67
79
  function waitForClose(server) {
68
80
  return new Promise((resolve) => {
69
81
  const shutdown = () => {
@@ -0,0 +1,21 @@
1
+ import { imageMediaType } from "../schema/image.js";
2
+ import { gitBuffer } from "./exec.js";
3
+ import { parseLfsPointer, readLfsObject } from "./lfs.js";
4
+ import { assertSafePath, assertSafeRef } from "./repo.js";
5
+ export async function readBlob(cwd, ref, path) {
6
+ assertSafeRef(ref);
7
+ assertSafePath(path);
8
+ return gitBuffer(cwd, ["cat-file", "blob", `${ref}:${path}`]);
9
+ }
10
+ export async function readImageBlob(cwd, ref, path) {
11
+ const blob = await readBlob(cwd, ref, path);
12
+ const pointer = parseLfsPointer(blob);
13
+ if (pointer === undefined) {
14
+ return { ok: true, bytes: blob, mediaType: imageMediaType(path), lfs: false };
15
+ }
16
+ const object = await readLfsObject(cwd, pointer);
17
+ if (object === undefined) {
18
+ return { ok: false, oid: pointer.oid };
19
+ }
20
+ return { ok: true, bytes: object, mediaType: imageMediaType(path), lfs: true };
21
+ }