@a2f0/agent-tool 0.1.31 → 0.1.32
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 +4 -2
- package/package.json +10 -2
- package/src/cli.ts +17 -0
- package/src/config.ts +4 -0
- package/src/images/build.ts +2 -45
- package/src/images/png.ts +10 -0
- package/src/permissions/catalog.ts +1 -1
- package/src/screenshots/build.ts +271 -0
- package/src/screenshots/config.ts +94 -0
- package/src/screenshots/gallery.css +528 -0
- package/src/screenshots/gallery.ts +713 -0
- package/src/screenshots/manifest.ts +176 -0
- package/src/system/files.ts +46 -0
package/README.md
CHANGED
|
@@ -4,8 +4,8 @@ agent-tool is a command-line tool and a set of portable skills for repositories
|
|
|
4
4
|
where Claude Code, Codex, or OpenCode make the changes. It provides shared Git,
|
|
5
5
|
GitHub, review, validation, and release workflows: independent agent reviews of
|
|
6
6
|
a committed branch, guarded pushes and squash merges of the exact reviewed
|
|
7
|
-
commit, package versioning, brand image rendering,
|
|
8
|
-
commands. Repository policy stays in `agent-tool.json`; the portable skills
|
|
7
|
+
commit, package versioning, brand image rendering, screenshot staging with a
|
|
8
|
+
framework-free gallery, and compact output for noisy commands. Repository policy stays in `agent-tool.json`; the portable skills
|
|
9
9
|
coordinate independently reviewed changes.
|
|
10
10
|
|
|
11
11
|
## Quick start
|
|
@@ -85,6 +85,8 @@ validation suite, and release procedures.
|
|
|
85
85
|
- [Development](docs/development.md): setup, source layout, validation, and release
|
|
86
86
|
procedures for this package.
|
|
87
87
|
- [Images](docs/images.md): declarative brand sources and generated outputs.
|
|
88
|
+
- [Screenshots](docs/screenshots.md): staging captured screenshots into a
|
|
89
|
+
website, and the gallery and carousel web components that show them.
|
|
88
90
|
- [Permissions](docs/permissions.md): which commands run without approval in
|
|
89
91
|
Claude Code and Codex, and the dependency commands that enforce it.
|
|
90
92
|
- [Dependency maintenance](docs/dependency-upgrades.md): active compatibility
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a2f0/agent-tool",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.32",
|
|
4
4
|
"packageManager": "bun@1.4.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Portable cross-agent review, PR workflows, and shared skills",
|
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
"bin": {
|
|
16
16
|
"agent-tool": "src/index.ts"
|
|
17
17
|
},
|
|
18
|
+
"exports": {
|
|
19
|
+
"./package.json": "./package.json",
|
|
20
|
+
"./screenshots/gallery": "./src/screenshots/gallery.ts",
|
|
21
|
+
"./screenshots/gallery.css": "./src/screenshots/gallery.css",
|
|
22
|
+
"./screenshots/manifest": "./src/screenshots/manifest.ts"
|
|
23
|
+
},
|
|
18
24
|
"files": [
|
|
19
25
|
"src",
|
|
20
26
|
"skills",
|
|
@@ -29,12 +35,14 @@
|
|
|
29
35
|
"agent:run": "bun src/index.ts run",
|
|
30
36
|
"agent:logs": "bun src/index.ts logs",
|
|
31
37
|
"test": "bun test --parallel src scripts",
|
|
32
|
-
"typecheck": "tsc --noEmit",
|
|
38
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.browser.json",
|
|
33
39
|
"generate": "bun scripts/build/bundle-skills.ts",
|
|
34
40
|
"build": "bun run generate && bun build --compile --minify src/index.ts --outfile dist/agent-tool"
|
|
35
41
|
},
|
|
36
42
|
"devDependencies": {
|
|
43
|
+
"@happy-dom/global-registrator": "21.0.4",
|
|
37
44
|
"@types/bun": "1.4.3",
|
|
45
|
+
"happy-dom": "21.0.4",
|
|
38
46
|
"typescript": "7.0.2"
|
|
39
47
|
},
|
|
40
48
|
"dependencies": {
|
package/src/cli.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { doctor } from "./doctor";
|
|
|
6
6
|
import { checkTerraformPlan } from "./dependencies/terraformPlan";
|
|
7
7
|
import { dependencyDiff } from "./dependencies/diff";
|
|
8
8
|
import { checkGraph } from "./graph/check";
|
|
9
|
+
import { buildScreenshots, describeScreenshots } from "./screenshots/build";
|
|
9
10
|
import { review } from "./harnesses";
|
|
10
11
|
import { runAgentToolAction } from "./runAgentToolAction";
|
|
11
12
|
import { BUNDLED_SKILLS } from "./skills/bundled";
|
|
@@ -77,6 +78,7 @@ Usage: agent-tool [--repo <directory>] [--config <file>] <command>
|
|
|
77
78
|
permissions check [--harness <all|claude|codex>]
|
|
78
79
|
images list [target...] JSON files each image target writes
|
|
79
80
|
images build [target...] [--out <dir>] Render assets/brand/*.svg per agent-tool.json
|
|
81
|
+
screenshots build [target...] [--out <dir>] [--json] Stage guarded captures and a gallery manifest
|
|
80
82
|
graph check Import cycles and layer violations per agent-tool.json
|
|
81
83
|
init Create agent-tool.json without overwriting
|
|
82
84
|
config show
|
|
@@ -157,6 +159,21 @@ export function main(argv = process.argv.slice(2)): number | Promise<number> {
|
|
|
157
159
|
output(action === "list" ? listImages(images, names) : await buildImages(process.cwd(), images, names, out)); return 0;
|
|
158
160
|
});
|
|
159
161
|
}
|
|
162
|
+
if (command === "screenshots") {
|
|
163
|
+
const action = args.shift();
|
|
164
|
+
usage(action === "build", "Usage: agent-tool screenshots build [target...] [--out <dir>] [--json]");
|
|
165
|
+
const names: string[] = [];
|
|
166
|
+
let out: string | undefined, json = false;
|
|
167
|
+
while (args.length) {
|
|
168
|
+
const value = args.shift()!;
|
|
169
|
+
if (value === "--out") { usage(out === undefined && !!args[0] && !args[0].startsWith("--"), "--out requires one directory."); out = args.shift(); }
|
|
170
|
+
else if (value === "--json") { usage(!json, "Duplicate --json."); json = true; }
|
|
171
|
+
else { usage(!value.startsWith("-"), `Unknown screenshots argument '${value}'.`); names.push(value); }
|
|
172
|
+
}
|
|
173
|
+
const receipt = buildScreenshots(process.cwd(), loadConfig().screenshots, names, out);
|
|
174
|
+
if (json) output(receipt); else writeSync(1, describeScreenshots(receipt));
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
160
177
|
if (command === "graph") {
|
|
161
178
|
usage(args.length === 1 && args[0] === "check", "Usage: agent-tool graph check");
|
|
162
179
|
const report = checkGraph(process.cwd(), loadConfig().graph);
|
package/src/config.ts
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { GRAPH_DEFAULTS, parseGraph, type GraphConfig } from "./graph/config";
|
|
4
4
|
import { parseImages, type ImagesConfig } from "./images/config";
|
|
5
5
|
import { CLASSES, type PermissionClass } from "./permissions/catalog";
|
|
6
|
+
import { parseScreenshots, SCREENSHOTS_DEFAULTS, type ScreenshotsConfig } from "./screenshots/config";
|
|
6
7
|
import { WORKFLOW_DEFAULTS, validateWorkflowConfig, type WorkflowConfig } from "./workflows/config";
|
|
7
8
|
|
|
8
9
|
export interface RequiredCheck {
|
|
@@ -18,6 +19,7 @@ export interface AgentToolConfig extends WorkflowConfig {
|
|
|
18
19
|
versions: { packages: string[] | null; bundles: string[]; lockfile: "bun.lock" | null; validate: string[][]; commitMessage: string };
|
|
19
20
|
pr: { rejectClaudeBranding: boolean };
|
|
20
21
|
images: ImagesConfig;
|
|
22
|
+
screenshots: ScreenshotsConfig;
|
|
21
23
|
graph: GraphConfig;
|
|
22
24
|
permissions: { executables: Record<string, PermissionClass> };
|
|
23
25
|
}
|
|
@@ -38,6 +40,7 @@ export const DEFAULT_CONFIG: AgentToolConfig = {
|
|
|
38
40
|
versions: { packages: null, bundles: [], lockfile: "bun.lock", validate: [], commitMessage: "chore: bump package versions" },
|
|
39
41
|
pr: { rejectClaudeBranding: false },
|
|
40
42
|
images: { targets: {} },
|
|
43
|
+
screenshots: structuredClone(SCREENSHOTS_DEFAULTS),
|
|
41
44
|
graph: structuredClone(GRAPH_DEFAULTS),
|
|
42
45
|
permissions: { executables: {} },
|
|
43
46
|
};
|
|
@@ -72,6 +75,7 @@ export function parseConfig(source: string): AgentToolConfig {
|
|
|
72
75
|
Object.assign(result[section], settings);
|
|
73
76
|
}
|
|
74
77
|
if (input.images !== undefined) result.images = parseImages(input.images);
|
|
78
|
+
if (input.screenshots !== undefined) result.screenshots = parseScreenshots(input.screenshots);
|
|
75
79
|
if (input.graph !== undefined) result.graph = parseGraph(input.graph);
|
|
76
80
|
if (input.branch !== undefined && !Object.hasOwn(input.branch as object, "enforce")) result.branch.enforce = true;
|
|
77
81
|
const invalid = (setting: string): never => { throw new Error(`Invalid config setting '${setting}'.`); };
|
package/src/images/build.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { initWasm, Resvg } from "@resvg/resvg-wasm";
|
|
2
2
|
import wasm from "@resvg/resvg-wasm/index_bg.wasm" with { type: "file" };
|
|
3
|
-
import {
|
|
4
|
-
import { closeSync, constants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
5
4
|
import path from "node:path";
|
|
6
5
|
import { composeSvg, type Canvas } from "./compose";
|
|
7
6
|
import { expand, format, SOURCE_DIRECTORY, verbatim, type ImageOutput, type ImagesConfig, type ImageTarget } from "./config";
|
|
8
7
|
import { encodeIco, encodePng, fromPremultiplied, type Pixels } from "./png";
|
|
9
8
|
import { parseSvg, type SvgSource } from "./svg";
|
|
9
|
+
import { assertNoLinks, replaceFile } from "../system/files";
|
|
10
10
|
|
|
11
11
|
let ready: Promise<void> | undefined;
|
|
12
12
|
|
|
@@ -85,49 +85,6 @@ function select(config: ImagesConfig, names: string[], writable: boolean): [stri
|
|
|
85
85
|
return selected.map(name => [name, config.targets[name]!]);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/** Refuses symbolic links below the base, so a committed link cannot redirect a write outside it. */
|
|
89
|
-
function assertNoLinks(base: string, destination: string): void {
|
|
90
|
-
let current = base;
|
|
91
|
-
for (const part of path.relative(base, destination).split(path.sep)) {
|
|
92
|
-
current = path.join(current, part);
|
|
93
|
-
let link: boolean;
|
|
94
|
-
try { link = lstatSync(current).isSymbolicLink(); } catch { return; }
|
|
95
|
-
if (link) throw new Error(`Refusing to write through symbolic link ${path.relative(base, current)}.`);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Replaces a file atomically through an unpredictable, exclusively created
|
|
101
|
-
* temporary name, so no committed link can stand in for it. Sandboxed build
|
|
102
|
-
* phases, such as Xcode's, may write only their declared outputs; there the
|
|
103
|
-
* file is rewritten in place, refusing to follow a link.
|
|
104
|
-
*/
|
|
105
|
-
export function replaceFile(destination: string, bytes: Uint8Array): void {
|
|
106
|
-
const temporary = `${destination}.${randomUUID()}.tmp`;
|
|
107
|
-
let created = false;
|
|
108
|
-
try {
|
|
109
|
-
writeFileSync(temporary, bytes, { flag: "wx" });
|
|
110
|
-
created = true;
|
|
111
|
-
renameSync(temporary, destination);
|
|
112
|
-
} catch (error) {
|
|
113
|
-
if (created || !["EACCES", "EPERM"].includes((error as NodeJS.ErrnoException).code ?? "")) {
|
|
114
|
-
// Best effort: the original error matters more than a temporary file left behind.
|
|
115
|
-
try { rmSync(temporary, { force: true }); } catch {}
|
|
116
|
-
throw error;
|
|
117
|
-
}
|
|
118
|
-
// Nothing was created, and a sandbox may refuse even an attempt to remove an undeclared path.
|
|
119
|
-
const descriptor = openSync(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW);
|
|
120
|
-
try {
|
|
121
|
-
// A hard link would share the file with a path outside the output, so only a sole link is rewritten.
|
|
122
|
-
const stats = fstatSync(descriptor);
|
|
123
|
-
if (!stats.isFile() || stats.nlink !== 1) throw new Error(`Refusing to rewrite ${destination}, which is not a file with a single link.`);
|
|
124
|
-
ftruncateSync(descriptor, 0);
|
|
125
|
-
// writeFileSync on a descriptor repeats short writes until every byte is written.
|
|
126
|
-
writeFileSync(descriptor, bytes);
|
|
127
|
-
} finally { closeSync(descriptor); }
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
88
|
function directory(name: string, target: ImageTarget, out: string | undefined): string {
|
|
132
89
|
const dir = out ?? target.dir;
|
|
133
90
|
if (dir === null) throw new Error(`Image target '${name}' has no dir; pass --out <directory>.`);
|
package/src/images/png.ts
CHANGED
|
@@ -88,6 +88,16 @@ export function encodePng({ width, height, channels, data }: Pixels): Uint8Array
|
|
|
88
88
|
]);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
92
|
+
|
|
93
|
+
/** Pixel size from a PNG's header: the signature, then IHDR's big-endian width and height. */
|
|
94
|
+
export function pngSize(bytes: Uint8Array): { width: number; height: number } | null {
|
|
95
|
+
if (bytes.length < 24 || SIGNATURE.some((byte, index) => bytes[index] !== byte) || new TextDecoder().decode(bytes.subarray(12, 16)) !== "IHDR") return null;
|
|
96
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
97
|
+
const width = view.getUint32(16), height = view.getUint32(20);
|
|
98
|
+
return width > 0 && height > 0 ? { width, height } : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
91
101
|
/** A Windows icon that stores one PNG per size, which every current browser reads. */
|
|
92
102
|
export function encodeIco(images: readonly Pixels[]): Uint8Array {
|
|
93
103
|
const pngs = images.map(encodePng);
|
|
@@ -21,7 +21,7 @@ export interface CatalogEntry { command: string; class: PermissionClass; applyCl
|
|
|
21
21
|
export const CATALOG: CatalogEntry[] = [
|
|
22
22
|
...["--help", "--version", "logs", "runs list", "doctor", "config show", "branch check", "branch status", "commits check", "checks binaries", "repo context", "policy check", "toolchain check", "ci gate", "release plan", "release verify", "pr context", "pr checks", "pr logs", "pr preflight", "pr threads list", "versions plan", "versions check", "dependencies check-terraform-plan", "dependencies diff", "skills list", "skills check", "hooks check", "images list", "graph check", "permissions show", "permissions check", "proc list"]
|
|
23
23
|
.map(command => ({ command, class: "read" as const })),
|
|
24
|
-
...["bun install", "bun update", "bun remove", "bun test", "branch create", "versions bump", "versions prepare", "versions resolve-conflicts", "images build", "review", "init", "proc stop"]
|
|
24
|
+
...["bun install", "bun update", "bun remove", "bun test", "branch create", "versions bump", "versions prepare", "versions resolve-conflicts", "images build", "screenshots build", "review", "init", "proc stop"]
|
|
25
25
|
.map(command => ({ command, class: "local" as const })),
|
|
26
26
|
{ command: "skills install", class: "read", applyClass: "local" },
|
|
27
27
|
{ command: "hooks install", class: "read", applyClass: "local" },
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmdirSync, rmSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pngSize } from "../images/png";
|
|
5
|
+
import { assertNoLinks, replaceFile } from "../system/files";
|
|
6
|
+
import type { ScreenshotsConfig, ScreenshotTarget } from "./config";
|
|
7
|
+
import type { ScreenshotCapture, ScreenshotEntry, ScreenshotManifest } from "./manifest";
|
|
8
|
+
|
|
9
|
+
/** The record the take-screenshots skill keeps beside a shared capture. */
|
|
10
|
+
export const CAPTURE_RECORD = "capture.json";
|
|
11
|
+
const MANIFEST = "manifest.json";
|
|
12
|
+
const IMAGES = "img";
|
|
13
|
+
|
|
14
|
+
interface CaptureRecord { status?: unknown; commit?: unknown; dirty?: unknown; finishedAt?: unknown; args?: unknown }
|
|
15
|
+
export interface CaptureSummary extends ScreenshotCapture { partial: string[] }
|
|
16
|
+
|
|
17
|
+
function readRecord(source: string): string | undefined {
|
|
18
|
+
try { return readFileSync(path.join(source, CAPTURE_RECORD), "utf8"); } catch (error) {
|
|
19
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT" || (error as NodeJS.ErrnoException).code === "ENOTDIR") return undefined;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The shared-capture guard. Every capture writes a `running` record before it
|
|
26
|
+
* touches an image, so a `complete` record that is unchanged before and after
|
|
27
|
+
* copying proves no capture overlapped the copy. A plain directory without a
|
|
28
|
+
* record (CI, an unlinked clone) copies as is; a linked, non-empty store
|
|
29
|
+
* without one is refused, since its images name no commit.
|
|
30
|
+
*/
|
|
31
|
+
export function checkCapture(root: string, source: string, raw: string | undefined): CaptureSummary | null {
|
|
32
|
+
if (raw === undefined) {
|
|
33
|
+
// Linked anywhere below the repository root, as `.screenshots` or through a parent directory.
|
|
34
|
+
const linked = resolved(source) !== path.join(resolved(root), path.relative(root, source));
|
|
35
|
+
let contents: string[] = [];
|
|
36
|
+
try { contents = readdirSync(source); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
37
|
+
if (linked && contents.length > 0) throw new Error("The shared screenshots have no capture record; capture once through the take-screenshots skill.");
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
let record: CaptureRecord;
|
|
41
|
+
try { record = JSON.parse(raw) as CaptureRecord; } catch { throw new Error(`The shared screenshot capture record ${CAPTURE_RECORD} is not JSON; recapture.`); }
|
|
42
|
+
if (!record || typeof record !== "object") throw new Error(`The shared screenshot capture record ${CAPTURE_RECORD} is not an object; recapture.`);
|
|
43
|
+
if (record.status !== "complete") throw new Error(`The shared screenshot capture is ${String(record.status)}; wait for it or recapture.`);
|
|
44
|
+
if (typeof record.commit !== "string" || !/^[0-9a-f]{40}$/.test(record.commit)) throw new Error("The shared screenshot capture record names no commit; recapture.");
|
|
45
|
+
if (record.dirty !== false) throw new Error("The shared screenshots were captured from uncommitted changes; recapture from a commit.");
|
|
46
|
+
const partial = Array.isArray(record.args) ? record.args.map(String) : [];
|
|
47
|
+
return { commit: record.commit, finishedAt: typeof record.finishedAt === "string" ? record.finishedAt : null, partial };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function select(config: ScreenshotsConfig, names: string[], out: string | undefined): [string, ScreenshotTarget][] {
|
|
51
|
+
const available = Object.keys(config.targets);
|
|
52
|
+
if (!available.length) throw new Error("agent-tool.json defines no screenshots.targets.");
|
|
53
|
+
for (const name of names) if (!config.targets[name]) throw new Error(`Unknown screenshots target '${name}'; expected one of: ${available.join(", ")}.`);
|
|
54
|
+
if (out !== undefined && new Set(names).size !== 1) throw new Error("--out needs exactly one screenshots target.");
|
|
55
|
+
const selected = names.length ? [...new Set(names)] : available.filter(name => config.targets[name]!.dir !== null);
|
|
56
|
+
if (!selected.length) throw new Error("No screenshots target has a dir; name one and pass --out <directory>.");
|
|
57
|
+
return selected.map(name => [name, config.targets[name]!]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The path with every existing ancestor's links resolved, so overlap checks see where writes really land. */
|
|
61
|
+
function resolved(file: string): string {
|
|
62
|
+
const parts: string[] = [];
|
|
63
|
+
let current = path.resolve(file);
|
|
64
|
+
for (;;) {
|
|
65
|
+
try { return path.join(realpathSync(current), ...parts.reverse()); } catch (error) {
|
|
66
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
67
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
|
|
68
|
+
}
|
|
69
|
+
const parent = path.dirname(current);
|
|
70
|
+
if (parent === current) return path.join(current, ...parts.reverse());
|
|
71
|
+
parts.push(path.basename(current));
|
|
72
|
+
current = parent;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Whether either path is the other or contains it. */
|
|
76
|
+
function overlaps(first: string, second: string): boolean {
|
|
77
|
+
const a = first.toLowerCase(), b = second.toLowerCase();
|
|
78
|
+
return a === b || a.startsWith(`${b}${path.sep}`) || b.startsWith(`${a}${path.sep}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The first link anywhere in the capture tree whose target overlaps an output, following linked directories once. */
|
|
82
|
+
function linkedInto(source: string, outputs: string[]): string | undefined {
|
|
83
|
+
const visited = new Set<string>();
|
|
84
|
+
const walk = (dir: string): string | undefined => {
|
|
85
|
+
let real: string;
|
|
86
|
+
try { real = realpathSync(dir); } catch { return undefined; }
|
|
87
|
+
if (visited.has(real)) return undefined;
|
|
88
|
+
visited.add(real);
|
|
89
|
+
let names: string[];
|
|
90
|
+
try { names = readdirSync(dir); } catch { return undefined; }
|
|
91
|
+
for (const name of names) {
|
|
92
|
+
const entry = path.join(dir, name), stats = lstatSync(entry, { throwIfNoEntry: false });
|
|
93
|
+
if (stats?.isSymbolicLink() && outputs.some(output => overlaps(resolved(entry), output))) return entry;
|
|
94
|
+
if (stats?.isDirectory() || (stats?.isSymbolicLink() && lstatSync(resolved(entry), { throwIfNoEntry: false })?.isDirectory())) {
|
|
95
|
+
const found = walk(entry);
|
|
96
|
+
if (found) return found;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
};
|
|
101
|
+
return walk(source);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sameBytes(file: string, bytes: Uint8Array): boolean {
|
|
105
|
+
const stats = lstatSync(file, { throwIfNoEntry: false });
|
|
106
|
+
return !!stats?.isFile() && stats.size === bytes.length && Buffer.compare(readFileSync(file), bytes) === 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Files below `dir`, relative to it, without following links; links count as files to remove. */
|
|
110
|
+
function ownedFiles(dir: string): string[] {
|
|
111
|
+
const stats = lstatSync(dir, { throwIfNoEntry: false });
|
|
112
|
+
if (!stats) return [];
|
|
113
|
+
if (!stats.isDirectory()) return [""];
|
|
114
|
+
return readdirSync(dir).sort().flatMap(name => ownedFiles(path.join(dir, name)).map(file => file ? path.join(name, file) : name));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function removeEmptyDirectories(dir: string, keep: boolean): void {
|
|
118
|
+
const stats = lstatSync(dir, { throwIfNoEntry: false });
|
|
119
|
+
if (!stats?.isDirectory()) return;
|
|
120
|
+
for (const name of readdirSync(dir)) removeEmptyDirectories(path.join(dir, name), false);
|
|
121
|
+
if (!keep && readdirSync(dir).length === 0) rmdirSync(dir);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Removes everything the target owns: the manifest and the image tree. Other files in the directory stay. */
|
|
125
|
+
function removeOutputs(dir: string): void {
|
|
126
|
+
rmSync(path.join(dir, MANIFEST), { force: true });
|
|
127
|
+
rmSync(path.join(dir, IMAGES), { force: true, recursive: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface Staged {
|
|
131
|
+
name: string;
|
|
132
|
+
dir: string;
|
|
133
|
+
absolute: string;
|
|
134
|
+
manifest: ScreenshotManifest;
|
|
135
|
+
written: string[];
|
|
136
|
+
unchanged: string[];
|
|
137
|
+
keep: Set<string>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function stageTarget(root: string, source: string, name: string, target: ScreenshotTarget, dir: string, base: string, capture: CaptureSummary | null): Staged {
|
|
141
|
+
const absolute = path.resolve(root, dir);
|
|
142
|
+
const staged: Staged = { name, dir, absolute, manifest: { schemaVersion: 1, projects: [], themes: [], screens: [], entries: [], capture: null }, written: [], unchanged: [], keep: new Set() };
|
|
143
|
+
const entries: ScreenshotEntry[] = [];
|
|
144
|
+
for (const project of target.projects) {
|
|
145
|
+
for (const theme of target.themes) {
|
|
146
|
+
for (const screen of target.screens) {
|
|
147
|
+
const file = path.join(source, project.dir, theme, `${screen.name}.png`);
|
|
148
|
+
let bytes: Uint8Array;
|
|
149
|
+
try { bytes = readFileSync(file); } catch (error) {
|
|
150
|
+
if (["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) continue;
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
const size = pngSize(bytes);
|
|
154
|
+
if (!size) throw new Error(`${path.relative(root, file)} is not a PNG image.`);
|
|
155
|
+
const relative = path.join(IMAGES, project.name, theme, `${screen.name}.png`);
|
|
156
|
+
const destination = path.join(absolute, relative);
|
|
157
|
+
assertNoLinks(base, destination);
|
|
158
|
+
if (sameBytes(destination, bytes)) staged.unchanged.push(path.join(dir, relative));
|
|
159
|
+
else {
|
|
160
|
+
mkdirSync(path.dirname(destination), { recursive: true });
|
|
161
|
+
replaceFile(destination, bytes);
|
|
162
|
+
staged.written.push(path.join(dir, relative));
|
|
163
|
+
}
|
|
164
|
+
staged.keep.add(relative);
|
|
165
|
+
const version = createHash("sha256").update(bytes).digest("hex").slice(0, 8);
|
|
166
|
+
entries.push({ project: project.name, theme, name: screen.name, src: `${target.urlPrefix}${IMAGES}/${project.name}/${theme}/${screen.name}.png?v=${version}`, ...size });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const has = (key: keyof ScreenshotEntry, value: string) => entries.some(entry => entry[key] === value);
|
|
171
|
+
staged.manifest = {
|
|
172
|
+
schemaVersion: 1,
|
|
173
|
+
projects: target.projects.filter(project => has("project", project.name)).map(({ name, label, frame }) => frame ? { name, label, frame } : { name, label }),
|
|
174
|
+
themes: target.themes.filter(theme => has("theme", theme)).map(theme => ({ name: theme, label: theme.charAt(0).toUpperCase() + theme.slice(1).replaceAll("-", " ") })),
|
|
175
|
+
screens: target.screens.filter(screen => has("name", screen.name)).map(({ name, label, alt }) => alt ? { name, label, alt } : { name, label }),
|
|
176
|
+
entries,
|
|
177
|
+
capture: capture && { commit: capture.commit, finishedAt: capture.finishedAt },
|
|
178
|
+
};
|
|
179
|
+
return staged;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface ScreenshotsTargetReceipt {
|
|
183
|
+
name: string;
|
|
184
|
+
dir: string;
|
|
185
|
+
manifest: string;
|
|
186
|
+
entries: number;
|
|
187
|
+
projects: string[];
|
|
188
|
+
written: string[];
|
|
189
|
+
unchanged: number;
|
|
190
|
+
removed: string[];
|
|
191
|
+
}
|
|
192
|
+
export interface ScreenshotsReceipt { source: string; capture: CaptureSummary | null; targets: ScreenshotsTargetReceipt[] }
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Stages the selected targets from the capture source. Images are copied only
|
|
196
|
+
* when their bytes differ, files the target owns but no longer lists are
|
|
197
|
+
* removed, and the manifest is written last, atomically, once the capture
|
|
198
|
+
* record is confirmed unchanged. Any failure removes the targets' outputs, so
|
|
199
|
+
* a site never builds from a partial or unverified gallery.
|
|
200
|
+
*/
|
|
201
|
+
export function buildScreenshots(root: string, config: ScreenshotsConfig, names: string[], out?: string, hooks: { afterCopy?: () => void } = {}): ScreenshotsReceipt {
|
|
202
|
+
const selected = select(config, names, out);
|
|
203
|
+
const source = path.resolve(root, config.source);
|
|
204
|
+
// The source is usually a link to the shared store; compare where both really are.
|
|
205
|
+
const sources = [source, resolved(source)];
|
|
206
|
+
const claimed: { name: string; dirs: string[] }[] = [];
|
|
207
|
+
const plans = selected.map(([name, target]) => {
|
|
208
|
+
const dir = out ?? target.dir;
|
|
209
|
+
if (dir === null) throw new Error(`Screenshots target '${name}' has no dir; pass --out <directory>.`);
|
|
210
|
+
const absolute = path.resolve(root, dir);
|
|
211
|
+
const dirs = [absolute, resolved(absolute)];
|
|
212
|
+
// Pruning or cleanup in an overlapping directory would delete captures or another target's images.
|
|
213
|
+
if (dirs.some(item => sources.some(other => overlaps(item, other)))) throw new Error(`Screenshots target '${name}' overlaps the capture source.`);
|
|
214
|
+
const owner = claimed.find(other => other.dirs.some(item => dirs.some(mine => overlaps(item, mine))));
|
|
215
|
+
if (owner) throw new Error(`Screenshots targets '${owner.name}' and '${name}' write overlapping directories.`);
|
|
216
|
+
claimed.push({ name, dirs });
|
|
217
|
+
// An explicit --out is the caller's choice; configured directories are checked from the repository root.
|
|
218
|
+
const base = out === undefined ? root : absolute;
|
|
219
|
+
assertNoLinks(base, path.join(absolute, IMAGES));
|
|
220
|
+
assertNoLinks(base, path.join(absolute, MANIFEST));
|
|
221
|
+
return { name, target, dir, absolute, base };
|
|
222
|
+
});
|
|
223
|
+
// A capture linked into an output would be pruned or cleaned up with it.
|
|
224
|
+
const outputs = plans.flatMap(plan => [plan.absolute, resolved(plan.absolute)]);
|
|
225
|
+
const linked = linkedInto(source, outputs);
|
|
226
|
+
if (linked) throw new Error(`${path.relative(root, linked)} links into a screenshots output directory.`);
|
|
227
|
+
let staged: Staged[] = [];
|
|
228
|
+
try {
|
|
229
|
+
const before = readRecord(source);
|
|
230
|
+
const capture = checkCapture(root, source, before);
|
|
231
|
+
staged = plans.map(plan => stageTarget(root, source, plan.name, plan.target, plan.dir, plan.base, capture));
|
|
232
|
+
hooks.afterCopy?.();
|
|
233
|
+
if (readRecord(source) !== before) throw new Error("A screenshot capture started while copying; build again once it finishes.");
|
|
234
|
+
const targets = staged.map(item => {
|
|
235
|
+
const removed = ownedFiles(path.join(item.absolute, IMAGES)).filter(file => !item.keep.has(path.join(IMAGES, file)));
|
|
236
|
+
for (const file of removed) rmSync(path.join(item.absolute, IMAGES, file), { force: true });
|
|
237
|
+
removeEmptyDirectories(path.join(item.absolute, IMAGES), item.keep.size > 0);
|
|
238
|
+
if (!item.keep.size) rmSync(path.join(item.absolute, IMAGES), { force: true, recursive: true });
|
|
239
|
+
const manifestFile = path.join(item.absolute, MANIFEST);
|
|
240
|
+
const bytes = Buffer.from(`${JSON.stringify(item.manifest, null, 2)}\n`);
|
|
241
|
+
mkdirSync(item.absolute, { recursive: true });
|
|
242
|
+
if (sameBytes(manifestFile, bytes)) item.unchanged.push(path.join(item.dir, MANIFEST));
|
|
243
|
+
else { replaceFile(manifestFile, bytes); item.written.push(path.join(item.dir, MANIFEST)); }
|
|
244
|
+
return {
|
|
245
|
+
name: item.name, dir: item.dir, manifest: path.join(item.dir, MANIFEST), entries: item.manifest.entries.length,
|
|
246
|
+
projects: item.manifest.projects.map(project => project.name), written: item.written, unchanged: item.unchanged.length,
|
|
247
|
+
removed: removed.map(file => path.join(item.dir, IMAGES, file)),
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
return { source: config.source, capture, targets };
|
|
251
|
+
} catch (error) {
|
|
252
|
+
for (const plan of plans) if (existsSync(plan.absolute)) removeOutputs(plan.absolute);
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** One line per target for people; `--json` prints the receipt instead. */
|
|
258
|
+
export function describeScreenshots(receipt: ScreenshotsReceipt): string {
|
|
259
|
+
const lines: string[] = [];
|
|
260
|
+
if (receipt.capture) {
|
|
261
|
+
const partial = receipt.capture.partial.length ? ` (partial: ${receipt.capture.partial.join(" ")})` : "";
|
|
262
|
+
lines.push(`Screenshots from ${receipt.capture.commit.slice(0, 10)}, captured ${receipt.capture.finishedAt ?? "at an unrecorded time"}${partial}.`);
|
|
263
|
+
}
|
|
264
|
+
for (const target of receipt.targets) {
|
|
265
|
+
const counts = `${target.written.length} written, ${target.unchanged} unchanged, ${target.removed.length} removed`;
|
|
266
|
+
lines.push(target.entries
|
|
267
|
+
? `${target.name}: staged ${target.entries} screenshot(s) across ${target.projects.join(", ")} into ${target.dir} (${counts}).`
|
|
268
|
+
: `${target.name}: no screenshots in ${receipt.source}; wrote an empty manifest to ${target.manifest} (${counts}).`);
|
|
269
|
+
}
|
|
270
|
+
return `${lines.join("\n")}\n`;
|
|
271
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** The data-only `screenshots` section of agent-tool.json: captured screens staged into a site. */
|
|
2
|
+
|
|
3
|
+
export type Frame = "phone" | "tablet" | "none";
|
|
4
|
+
export interface ScreenshotProject {
|
|
5
|
+
name: string;
|
|
6
|
+
label: string;
|
|
7
|
+
/** The capture directory under the source, when it differs from the name. */
|
|
8
|
+
dir: string;
|
|
9
|
+
frame: Frame | null;
|
|
10
|
+
}
|
|
11
|
+
export interface ScreenshotScreen { name: string; label: string; alt: string | null }
|
|
12
|
+
export interface ScreenshotTarget {
|
|
13
|
+
dir: string | null;
|
|
14
|
+
urlPrefix: string;
|
|
15
|
+
projects: ScreenshotProject[];
|
|
16
|
+
themes: string[];
|
|
17
|
+
screens: ScreenshotScreen[];
|
|
18
|
+
}
|
|
19
|
+
export interface ScreenshotsConfig { source: string; targets: Record<string, ScreenshotTarget> }
|
|
20
|
+
|
|
21
|
+
export const SCREENSHOTS_DEFAULTS: ScreenshotsConfig = { source: ".screenshots", targets: {} };
|
|
22
|
+
const NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
23
|
+
const TARGET = /^[a-z][a-z0-9-]*$/;
|
|
24
|
+
const FRAMES: Frame[] = ["phone", "tablet", "none"];
|
|
25
|
+
|
|
26
|
+
type Json = Record<string, unknown>;
|
|
27
|
+
function fail(setting: string): never { throw new Error(`Invalid config setting '${setting}'.`); }
|
|
28
|
+
function object(value: unknown, setting: string, allowed?: string[]): Json {
|
|
29
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) fail(setting);
|
|
30
|
+
if (allowed) for (const key of Object.keys(value)) if (!allowed.includes(key)) throw new Error(`Unknown ${setting} setting '${key}'.`);
|
|
31
|
+
return value as Json;
|
|
32
|
+
}
|
|
33
|
+
function text(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0 && !/[\r\n]/.test(value); }
|
|
34
|
+
/** Repository-relative and inside it: no absolute paths, backslashes, or dot segments. */
|
|
35
|
+
function relativePath(value: unknown): value is string {
|
|
36
|
+
return typeof value === "string" && !value.startsWith("/") && !value.includes("\\") && value.split("/").every(part => part && part !== "." && part !== "..");
|
|
37
|
+
}
|
|
38
|
+
export function titleCase(name: string): string {
|
|
39
|
+
return name.split("-").map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseProjects(value: unknown, setting: string): ScreenshotProject[] {
|
|
43
|
+
const entries = Object.entries(object(value, setting));
|
|
44
|
+
if (!entries.length) fail(setting);
|
|
45
|
+
return entries.map(([name, entry]) => {
|
|
46
|
+
if (!NAME.test(name)) fail(`${setting}.${name}`);
|
|
47
|
+
if (text(entry)) return { name, label: entry, dir: name, frame: null };
|
|
48
|
+
const project = object(entry, `${setting}.${name}`, ["label", "dir", "frame"]);
|
|
49
|
+
if (project.label !== undefined && !text(project.label)) fail(`${setting}.${name}.label`);
|
|
50
|
+
if (project.dir !== undefined && (typeof project.dir !== "string" || !NAME.test(project.dir))) fail(`${setting}.${name}.dir`);
|
|
51
|
+
if (project.frame !== undefined && !FRAMES.includes(project.frame as Frame)) fail(`${setting}.${name}.frame`);
|
|
52
|
+
return { name, label: (project.label as string | undefined) ?? titleCase(name), dir: (project.dir as string | undefined) ?? name, frame: (project.frame as Frame | undefined) ?? null };
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseScreens(value: unknown, setting: string): ScreenshotScreen[] {
|
|
57
|
+
if (!Array.isArray(value) || !value.length) fail(setting);
|
|
58
|
+
const screens = value.map((entry, index): ScreenshotScreen => {
|
|
59
|
+
if (typeof entry === "string") {
|
|
60
|
+
if (!NAME.test(entry)) fail(`${setting}[${index}]`);
|
|
61
|
+
return { name: entry, label: titleCase(entry), alt: null };
|
|
62
|
+
}
|
|
63
|
+
const screen = object(entry, `${setting}[${index}]`, ["name", "label", "alt"]);
|
|
64
|
+
if (typeof screen.name !== "string" || !NAME.test(screen.name)) fail(`${setting}[${index}].name`);
|
|
65
|
+
if (screen.label !== undefined && !text(screen.label)) fail(`${setting}[${index}].label`);
|
|
66
|
+
if (screen.alt !== undefined && !text(screen.alt)) fail(`${setting}[${index}].alt`);
|
|
67
|
+
return { name: screen.name, label: (screen.label as string | undefined) ?? titleCase(screen.name), alt: (screen.alt as string | undefined) ?? null };
|
|
68
|
+
});
|
|
69
|
+
const repeated = screens.find((screen, index) => screens.findIndex(other => other.name === screen.name) !== index);
|
|
70
|
+
if (repeated) throw new Error(`${setting} lists '${repeated.name}' more than once.`);
|
|
71
|
+
return screens;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function parseScreenshots(value: unknown): ScreenshotsConfig {
|
|
75
|
+
const input = object(value, "screenshots", ["source", "targets"]);
|
|
76
|
+
if (input.source !== undefined && !relativePath(input.source)) fail("screenshots.source");
|
|
77
|
+
const result: ScreenshotsConfig = { source: (input.source as string | undefined) ?? SCREENSHOTS_DEFAULTS.source, targets: {} };
|
|
78
|
+
for (const [name, entry] of Object.entries(object(input.targets ?? {}, "screenshots.targets"))) {
|
|
79
|
+
const setting = `screenshots.targets.${name}`;
|
|
80
|
+
if (!TARGET.test(name)) fail(setting);
|
|
81
|
+
const target = object(entry, setting, ["dir", "urlPrefix", "projects", "themes", "screens"]);
|
|
82
|
+
if (target.dir !== undefined && !relativePath(target.dir)) fail(`${setting}.dir`);
|
|
83
|
+
// A URL path or absolute URL that image names are appended to; no characters that need escaping in HTML or CSS.
|
|
84
|
+
if (typeof target.urlPrefix !== "string" || !/^(?:\/|https:\/\/[^/\s]+\/)[^\s"'<>\\()]*$/.test(target.urlPrefix) || !target.urlPrefix.endsWith("/")) fail(`${setting}.urlPrefix`);
|
|
85
|
+
const projects = parseProjects(target.projects, `${setting}.projects`);
|
|
86
|
+
const dirs = projects.map(project => project.dir);
|
|
87
|
+
const shared = dirs.find((dir, index) => dirs.indexOf(dir) !== index);
|
|
88
|
+
if (shared) throw new Error(`${setting}.projects read capture directory '${shared}' more than once.`);
|
|
89
|
+
const themes = target.themes ?? ["light", "dark"];
|
|
90
|
+
if (!Array.isArray(themes) || !themes.length || themes.some(theme => typeof theme !== "string" || !NAME.test(theme)) || new Set(themes).size !== themes.length) fail(`${setting}.themes`);
|
|
91
|
+
result.targets[name] = { dir: (target.dir as string | undefined) ?? null, urlPrefix: target.urlPrefix, projects, themes: themes as string[], screens: parseScreens(target.screens, `${setting}.screens`) };
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|