@penpair/core 0.1.3 → 0.3.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/dist/adapters.d.ts +16 -1
- package/dist/adapters.js +62 -19
- package/dist/approvals.d.ts +32 -0
- package/dist/approvals.js +213 -0
- package/dist/atomic-output.d.ts +5 -0
- package/dist/atomic-output.js +33 -0
- package/dist/audit-rules.d.ts +60 -0
- package/dist/audit-rules.js +443 -0
- package/dist/backup-note.d.ts +4 -0
- package/dist/backup-note.js +49 -0
- package/dist/baseline-coverage.d.ts +25 -0
- package/dist/baseline-coverage.js +248 -0
- package/dist/baselines-config.d.ts +25 -0
- package/dist/baselines-config.js +150 -11
- package/dist/baselines.d.ts +37 -8
- package/dist/baselines.js +362 -59
- package/dist/cli-arguments.d.ts +7 -0
- package/dist/cli-arguments.js +47 -0
- package/dist/cli-main.d.ts +13 -1
- package/dist/cli-main.js +2107 -247
- package/dist/client.d.ts +30 -6
- package/dist/client.js +160 -35
- package/dist/command-result.d.ts +159 -0
- package/dist/command-result.js +468 -0
- package/dist/coordination.d.ts +15 -0
- package/dist/coordination.js +184 -0
- package/dist/credentials.d.ts +21 -0
- package/dist/credentials.js +105 -0
- package/dist/deep-check.js +57 -9
- package/dist/design-spec.d.ts +55 -0
- package/dist/design-spec.js +275 -0
- package/dist/endpoint.d.ts +8 -1
- package/dist/endpoint.js +133 -21
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +8 -4
- package/dist/export.d.ts +18 -0
- package/dist/export.js +113 -10
- package/dist/file-archive.d.ts +16 -0
- package/dist/file-archive.js +58 -0
- package/dist/file-guard.d.ts +8 -0
- package/dist/file-guard.js +58 -0
- package/dist/formats.d.ts +4 -0
- package/dist/formats.js +229 -0
- package/dist/index.d.ts +34 -1
- package/dist/index.js +34 -1
- package/dist/inspection.d.ts +106 -0
- package/dist/inspection.js +657 -0
- package/dist/instance/config-schema.d.ts +422 -0
- package/dist/instance/config-schema.js +435 -0
- package/dist/instance/config.d.ts +29 -0
- package/dist/instance/config.js +191 -29
- package/dist/instance/connect.d.ts +10 -1
- package/dist/instance/connect.js +178 -30
- package/dist/instance/connection.d.ts +39 -4
- package/dist/instance/connection.js +198 -14
- package/dist/instance/gitignore.js +5 -2
- package/dist/instance/init.js +32 -7
- package/dist/instance/runner.js +32 -10
- package/dist/instance/setup.js +46 -13
- package/dist/instance/tty.js +28 -8
- package/dist/journal.d.ts +87 -0
- package/dist/journal.js +423 -0
- package/dist/kit-smoke.d.ts +31 -0
- package/dist/kit-smoke.js +145 -0
- package/dist/kit-source.d.ts +2 -0
- package/dist/kit-source.js +279 -0
- package/dist/kit.d.ts +25 -0
- package/dist/kit.js +158 -0
- package/dist/layout-policy.d.ts +2 -0
- package/dist/layout-policy.js +86 -0
- package/dist/layout.d.ts +21 -0
- package/dist/layout.js +36 -0
- package/dist/live-smoke.d.ts +8 -0
- package/dist/live-smoke.js +23 -0
- package/dist/local-profile.js +2 -1
- package/dist/mcp-server.d.ts +12 -0
- package/dist/mcp-server.js +415 -0
- package/dist/native-api.d.ts +15 -0
- package/dist/native-api.js +78 -0
- package/dist/onboard.d.ts +10 -0
- package/dist/onboard.js +41 -0
- package/dist/operation.d.ts +22 -3
- package/dist/operation.js +341 -31
- package/dist/page-session.d.ts +23 -0
- package/dist/page-session.js +142 -0
- package/dist/png.d.ts +5 -0
- package/dist/png.js +22 -0
- package/dist/portable-tokens.d.ts +22 -0
- package/dist/portable-tokens.js +331 -0
- package/dist/probe.d.ts +6 -2
- package/dist/probe.js +64 -11
- package/dist/project-input.d.ts +6 -0
- package/dist/project-input.js +65 -0
- package/dist/project-snippets.js +29 -12
- package/dist/project-status.d.ts +24 -0
- package/dist/project-status.js +188 -0
- package/dist/registry.d.ts +2 -1
- package/dist/registry.js +224 -53
- package/dist/run-file.d.ts +26 -0
- package/dist/run-file.js +199 -0
- package/dist/shots.d.ts +43 -0
- package/dist/shots.js +172 -0
- package/dist/skill-instructions.d.ts +13 -0
- package/dist/skill-instructions.js +144 -0
- package/dist/skills.d.ts +7 -0
- package/dist/skills.js +163 -46
- package/dist/snippet.d.ts +3 -2
- package/dist/snippet.js +33 -9
- package/dist/snippets/archive.d.ts +9 -0
- package/dist/snippets/archive.js +10 -4
- package/dist/snippets/read.d.ts +1 -1
- package/dist/snippets/read.js +78 -41
- package/dist/structure-snapshot.d.ts +85 -0
- package/dist/structure-snapshot.js +525 -0
- package/dist/token-edit.d.ts +53 -0
- package/dist/token-edit.js +352 -0
- package/dist/token-projection.d.ts +56 -0
- package/dist/token-projection.js +286 -0
- package/dist/tokens-diff.d.ts +19 -6
- package/dist/tokens-diff.js +182 -18
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/penpair-design/SKILL.md +20 -45
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +58 -2
- package/skills/penpair-design/references/project-facts.md +3 -1
- package/skills/penpair-design/references/write-protocol.md +15 -3
- package/skills/penpair-handoff/SKILL.md +16 -0
- package/skills/penpair-handoff/references/comparison-companion.md +13 -0
- package/skills/penpair-handoff/references/handoff-flow.md +13 -0
- package/skills/penpair-recipes/SKILL.md +21 -0
- package/skills/penpair-recipes/references/build-flow.md +11 -0
- package/skills/penpair-setup/SKILL.md +8 -2
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -3
- package/dist/docker/lifecycle.d.ts +0 -96
- package/dist/docker/lifecycle.js +0 -273
- package/dist/docker/secrets.d.ts +0 -11
- package/dist/docker/secrets.js +0 -38
- package/dist/docker/template.d.ts +0 -8
- package/dist/docker/template.js +0 -158
- package/dist/docker-profile.d.ts +0 -114
- package/dist/docker-profile.js +0 -18
- package/dist/instance/lifecycle.d.ts +0 -129
- package/dist/instance/lifecycle.js +0 -336
- package/dist/instance/secrets.d.ts +0 -11
- package/dist/instance/secrets.js +0 -38
- package/dist/instance/template.d.ts +0 -8
- package/dist/instance/template.js +0 -158
package/dist/baselines.js
CHANGED
|
@@ -1,24 +1,29 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { matchingApproval } from "./approvals.js";
|
|
2
|
+
import { pngSize } from "./png.js";
|
|
3
|
+
export { pngSize } from "./png.js";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { mkdir, readFile, writeFile, rename, rm } from "node:fs/promises";
|
|
3
6
|
import path from "node:path";
|
|
7
|
+
import { baselineCoverage } from "./baseline-coverage.js";
|
|
8
|
+
import { resolveProjectOutput } from "./project-input.js";
|
|
9
|
+
import { validateBaselines } from "./baselines-config.js";
|
|
10
|
+
import { version } from "./version.js";
|
|
11
|
+
import { atomicWriteFile } from "./atomic-output.js";
|
|
12
|
+
import { observationHash } from "./journal.js";
|
|
4
13
|
import { exportShapePng } from "./export.js";
|
|
5
14
|
import { runOnPage } from "./operation.js";
|
|
6
15
|
import { frames } from "./snippets/read.js";
|
|
7
16
|
export * from "./baselines-config.js";
|
|
8
17
|
const sha = (buffer) => createHash("sha256").update(buffer).digest("hex");
|
|
9
|
-
/** Pixel size from a PNG's header (the IHDR chunk), or nothing when the bytes are not a PNG. */
|
|
10
|
-
export function pngSize(buffer) {
|
|
11
|
-
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
12
|
-
if (buffer.length < 24 || !buffer.subarray(0, 8).equals(signature) || buffer.subarray(12, 16).toString("ascii") !== "IHDR")
|
|
13
|
-
return undefined;
|
|
14
|
-
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
|
15
|
-
}
|
|
16
18
|
async function readIfExists(file) {
|
|
17
19
|
try {
|
|
18
20
|
return await readFile(file);
|
|
19
21
|
}
|
|
20
|
-
catch {
|
|
21
|
-
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error.code === "ENOENT") {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
throw error;
|
|
22
27
|
}
|
|
23
28
|
}
|
|
24
29
|
/**
|
|
@@ -27,64 +32,331 @@ async function readIfExists(file) {
|
|
|
27
32
|
* the design never silently becomes the new reference.
|
|
28
33
|
*/
|
|
29
34
|
export async function exportBaselines(client, projectRoot, config, options = {}) {
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
if (options.forceReason !== undefined &&
|
|
36
|
+
(!options.update ||
|
|
37
|
+
!options.apply ||
|
|
38
|
+
!options.forceReason.trim() ||
|
|
39
|
+
options.forceReason.length > 2000)) {
|
|
40
|
+
throw new Error("A nonempty force reason requires apply and update");
|
|
41
|
+
}
|
|
42
|
+
config = structuredClone(config);
|
|
43
|
+
validateBaselines(config, (message) => {
|
|
44
|
+
throw new Error(message);
|
|
45
|
+
});
|
|
46
|
+
const listing = await runOnPage(client, frames({
|
|
47
|
+
page: config.page,
|
|
48
|
+
}), config.page, options);
|
|
49
|
+
if (listing.ok !== true ||
|
|
50
|
+
typeof listing.fileId !== "string" ||
|
|
51
|
+
!listing.fileId ||
|
|
52
|
+
typeof listing.pageId !== "string" ||
|
|
53
|
+
!listing.pageId ||
|
|
54
|
+
!Number.isInteger(listing.revn) ||
|
|
55
|
+
!Array.isArray(listing.frames)) {
|
|
56
|
+
throw new Error("Baseline listing lacks file/page/revision evidence");
|
|
57
|
+
}
|
|
58
|
+
const found = listing.frames;
|
|
32
59
|
const results = [];
|
|
33
60
|
const wrote = [];
|
|
34
|
-
const folder =
|
|
61
|
+
const folder = await resolveProjectOutput(projectRoot, config.root);
|
|
62
|
+
const manifestPath = await resolveProjectOutput(projectRoot, path.join(config.root, "penpair-baselines.json"));
|
|
63
|
+
const previousBytes = await readIfExists(manifestPath);
|
|
64
|
+
const previous = previousBytes
|
|
65
|
+
? JSON.parse(previousBytes.toString("utf8"))
|
|
66
|
+
: undefined;
|
|
67
|
+
if (previous &&
|
|
68
|
+
((previous.schemaVersion !== undefined && previous.schemaVersion !== 2) ||
|
|
69
|
+
!Array.isArray(previous.frames))) {
|
|
70
|
+
throw new Error("Existing baseline manifest is invalid; preserve and inspect it");
|
|
71
|
+
}
|
|
72
|
+
const runId = randomUUID();
|
|
73
|
+
const generation = `captures/${runId}`;
|
|
74
|
+
const captures = new Map();
|
|
35
75
|
for (const wanted of config.frames) {
|
|
36
|
-
|
|
76
|
+
options.signal?.throwIfAborted();
|
|
77
|
+
const old = previous?.frames?.find((entry) => entry.id === wanted.id);
|
|
78
|
+
const file = old?.file ?? `${wanted.id}.png`;
|
|
79
|
+
if (path.isAbsolute(file) || file.split(/[\\/]/).includes("..")) {
|
|
80
|
+
throw new Error("Existing baseline path leaves its folder");
|
|
81
|
+
}
|
|
37
82
|
const matches = found.filter((f) => f.name === wanted.frame);
|
|
38
|
-
if (matches.length
|
|
39
|
-
results.push({
|
|
83
|
+
if (!matches.length) {
|
|
84
|
+
results.push({
|
|
85
|
+
...(wanted.case
|
|
86
|
+
? {
|
|
87
|
+
case: wanted.case,
|
|
88
|
+
}
|
|
89
|
+
: {}),
|
|
90
|
+
id: wanted.id,
|
|
91
|
+
frame: wanted.frame,
|
|
92
|
+
file,
|
|
93
|
+
status: "missing",
|
|
94
|
+
written: false,
|
|
95
|
+
});
|
|
40
96
|
continue;
|
|
41
97
|
}
|
|
42
|
-
if (matches.length > 1)
|
|
98
|
+
if (matches.length > 1) {
|
|
43
99
|
throw new Error(`more than one top-level frame on ${config.page} is named ${JSON.stringify(wanted.frame)}; rename one so the design reference is unambiguous`);
|
|
100
|
+
}
|
|
44
101
|
const frame = matches[0];
|
|
45
102
|
options.onStep?.(`exporting ${wanted.frame}`);
|
|
46
|
-
const exported = await exportShapePng(client, frame.id, {
|
|
47
|
-
|
|
103
|
+
const exported = await exportShapePng(client, frame.id, {
|
|
104
|
+
...options,
|
|
105
|
+
page: config.page,
|
|
106
|
+
stable: true,
|
|
107
|
+
});
|
|
108
|
+
const base = {
|
|
109
|
+
...(wanted.case
|
|
110
|
+
? {
|
|
111
|
+
case: wanted.case,
|
|
112
|
+
}
|
|
113
|
+
: {}),
|
|
114
|
+
id: wanted.id,
|
|
115
|
+
frame: wanted.frame,
|
|
116
|
+
frameId: frame.id,
|
|
117
|
+
width: frame.width,
|
|
118
|
+
height: frame.height,
|
|
119
|
+
file,
|
|
120
|
+
};
|
|
121
|
+
if (exported.capture.fileId !== listing.fileId ||
|
|
122
|
+
exported.capture.pageId !== listing.pageId ||
|
|
123
|
+
exported.capture.revision !== listing.revn) {
|
|
124
|
+
throw new Error("Baseline source changed during capture; nothing was published");
|
|
125
|
+
}
|
|
48
126
|
if (!exported.stable) {
|
|
49
|
-
results.push({
|
|
127
|
+
results.push({
|
|
128
|
+
...base,
|
|
129
|
+
status: "unstable",
|
|
130
|
+
written: false,
|
|
131
|
+
});
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (!pngSize(exported.png)) {
|
|
135
|
+
throw new Error("Baseline export is not a PNG with dimensions");
|
|
136
|
+
}
|
|
137
|
+
const existing = await readIfExists(await resolveProjectOutput(projectRoot, path.join(config.root, file)));
|
|
138
|
+
if (old &&
|
|
139
|
+
(!existing ||
|
|
140
|
+
old.sha256 !== sha(existing) ||
|
|
141
|
+
(old.approved && old.approved.sha256 !== old.sha256))) {
|
|
142
|
+
throw new Error("Approved baseline bytes no longer match their manifest; inspect before replacing");
|
|
143
|
+
}
|
|
144
|
+
const captured = {
|
|
145
|
+
...(wanted.case
|
|
146
|
+
? {
|
|
147
|
+
case: wanted.case,
|
|
148
|
+
}
|
|
149
|
+
: {}),
|
|
150
|
+
layout: exported.layout,
|
|
151
|
+
...exported.capture,
|
|
152
|
+
sha256: sha(exported.png),
|
|
153
|
+
capturedAt: new Date().toISOString(),
|
|
154
|
+
toolVersion: version,
|
|
155
|
+
file: `${generation}/${wanted.id}.png`,
|
|
156
|
+
};
|
|
157
|
+
const caseProblems = [];
|
|
158
|
+
if (wanted.case) {
|
|
159
|
+
const viewport = wanted.case.viewport;
|
|
160
|
+
const size = pngSize(exported.png);
|
|
161
|
+
if (!exported.capture.designSize ||
|
|
162
|
+
exported.capture.designSize.width !== viewport.width ||
|
|
163
|
+
exported.capture.designSize.height !== viewport.height) {
|
|
164
|
+
caseProblems.push("Observed frame dimensions do not match the declared viewport");
|
|
165
|
+
}
|
|
166
|
+
if (size.width !== Math.round(viewport.width * exported.capture.scale) ||
|
|
167
|
+
size.height !== Math.round(viewport.height * exported.capture.scale)) {
|
|
168
|
+
caseProblems.push("PNG dimensions do not match declared viewport and capture scale");
|
|
169
|
+
}
|
|
170
|
+
if (wanted.case.activeThemeIds &&
|
|
171
|
+
(!exported.capture.activeThemeIds ||
|
|
172
|
+
observationHash([
|
|
173
|
+
...wanted.case.activeThemeIds,
|
|
174
|
+
].sort()) !==
|
|
175
|
+
observationHash([
|
|
176
|
+
...exported.capture.activeThemeIds,
|
|
177
|
+
].sort()))) {
|
|
178
|
+
caseProblems.push("Active native theme IDs do not match the capture assertion");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (caseProblems.length) {
|
|
182
|
+
results.push({
|
|
183
|
+
...base,
|
|
184
|
+
status: "case-mismatch",
|
|
185
|
+
written: false,
|
|
186
|
+
captured,
|
|
187
|
+
approved: old?.approved,
|
|
188
|
+
sha256: old?.sha256,
|
|
189
|
+
problems: caseProblems,
|
|
190
|
+
});
|
|
50
191
|
continue;
|
|
51
192
|
}
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
193
|
+
const oldCase = old?.approved && "case" in old.approved ? old.approved.case : undefined;
|
|
194
|
+
const status = !existing
|
|
195
|
+
? "new"
|
|
196
|
+
: sha(existing) === captured.sha256 &&
|
|
197
|
+
observationHash(oldCase ?? null) === observationHash(wanted.case ?? null) &&
|
|
198
|
+
(!old?.approved ||
|
|
199
|
+
"legacy" in old.approved ||
|
|
200
|
+
(old.approved.fileId === captured.fileId &&
|
|
201
|
+
old.approved.pageId === captured.pageId &&
|
|
202
|
+
old.approved.frameId === captured.frameId))
|
|
203
|
+
? "same"
|
|
204
|
+
: "changed";
|
|
205
|
+
const promoteWaiver = status === "same" &&
|
|
206
|
+
old?.approved &&
|
|
207
|
+
"approval" in old.approved &&
|
|
208
|
+
old.approved.approval?.kind === "forced-reference" &&
|
|
209
|
+
options.update === true;
|
|
210
|
+
if ((status === "changed" || promoteWaiver) && options.update && options.apply) {
|
|
211
|
+
const record = await matchingApproval(projectRoot, exported.capture, captured.sha256, wanted.case);
|
|
212
|
+
if (record) {
|
|
213
|
+
captured.approval = {
|
|
214
|
+
kind: "owner-words",
|
|
215
|
+
id: record.id,
|
|
216
|
+
recordHash: record.hash,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
else if (options.forceReason) {
|
|
220
|
+
captured.approval = {
|
|
221
|
+
kind: "forced-reference",
|
|
222
|
+
reason: options.forceReason,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
throw new Error("Changed baseline requires matching exact-artifact owner words (penpair approve), or an explicit --force-reason waiver; previous artifacts preserved");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const approve = status === "new" ||
|
|
230
|
+
(status === "changed" && options.update === true) ||
|
|
231
|
+
Boolean(promoteWaiver);
|
|
232
|
+
const approved = approve
|
|
233
|
+
? captured
|
|
234
|
+
: (old?.approved ?? {
|
|
235
|
+
legacy: true,
|
|
236
|
+
sha256: sha(existing),
|
|
237
|
+
});
|
|
238
|
+
const image = approve ? exported.png : existing;
|
|
239
|
+
const size = pngSize(image);
|
|
240
|
+
results.push({
|
|
241
|
+
...base,
|
|
242
|
+
file: approve && options.apply ? captured.file : file,
|
|
243
|
+
status,
|
|
244
|
+
written: false,
|
|
245
|
+
captured,
|
|
246
|
+
approved: options.apply || !approve ? approved : undefined,
|
|
247
|
+
sha256: sha(image),
|
|
248
|
+
bytes: image.length,
|
|
249
|
+
imageWidth: size?.width,
|
|
250
|
+
imageHeight: size?.height,
|
|
251
|
+
});
|
|
252
|
+
captures.set(wanted.id, exported.png);
|
|
253
|
+
}
|
|
254
|
+
const after = await runOnPage(client, frames({
|
|
255
|
+
page: config.page,
|
|
256
|
+
}), config.page, options);
|
|
257
|
+
const source = (value) => ({
|
|
258
|
+
fileId: value.fileId,
|
|
259
|
+
pageId: value.pageId,
|
|
260
|
+
revision: value.revn,
|
|
261
|
+
frames: value.frames,
|
|
262
|
+
});
|
|
263
|
+
if (observationHash(source(listing)) !== observationHash(source(after))) {
|
|
264
|
+
throw new Error("Baseline source changed before publication; previous artifacts were preserved");
|
|
66
265
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
266
|
+
const complete = results.length === config.frames.length &&
|
|
267
|
+
results.every((result) => result.status !== "missing" &&
|
|
268
|
+
result.status !== "unstable" &&
|
|
269
|
+
result.status !== "case-mismatch");
|
|
270
|
+
if (options.apply && complete) {
|
|
271
|
+
const evidence = JSON.stringify({
|
|
272
|
+
config,
|
|
273
|
+
results,
|
|
274
|
+
forceReason: options.forceReason,
|
|
275
|
+
});
|
|
276
|
+
if (options.redact && options.redact(evidence) !== evidence) {
|
|
277
|
+
throw new Error("Baseline evidence requires credential redaction; previous artifacts preserved");
|
|
278
|
+
}
|
|
279
|
+
options.signal?.throwIfAborted();
|
|
280
|
+
options.onStep?.("publishing complete capture");
|
|
281
|
+
options.signal?.throwIfAborted();
|
|
282
|
+
const captureFolder = await resolveProjectOutput(projectRoot, path.join(config.root, "captures"));
|
|
283
|
+
await mkdir(captureFolder, {
|
|
284
|
+
recursive: true,
|
|
285
|
+
});
|
|
286
|
+
const staged = path.join(folder, `.stage-${runId}`);
|
|
287
|
+
await mkdir(staged);
|
|
288
|
+
try {
|
|
289
|
+
for (const [id, bytes] of captures) {
|
|
290
|
+
options.signal?.throwIfAborted();
|
|
291
|
+
await atomicWriteFile(path.join(staged, `${id}.png`), bytes);
|
|
292
|
+
options.onStep?.(`staging ${id}`);
|
|
293
|
+
}
|
|
294
|
+
const cases = reflectionCases(config, results);
|
|
295
|
+
await writeFile(path.join(staged, "reflection-cases.json"), `${JSON.stringify({
|
|
296
|
+
...cases,
|
|
297
|
+
captureId: runId,
|
|
298
|
+
}, null, 2)}\n`, {
|
|
299
|
+
flag: "wx",
|
|
300
|
+
});
|
|
301
|
+
options.signal?.throwIfAborted();
|
|
302
|
+
await rename(staged, path.join(captureFolder, runId));
|
|
303
|
+
// Immutable generation first. The canonical manifest is the commit point and is published last.
|
|
304
|
+
await atomicWriteFile(path.join(folder, "reflection-cases.json"), `${JSON.stringify({
|
|
305
|
+
...cases,
|
|
306
|
+
captureId: runId,
|
|
307
|
+
}, null, 2)}\n`, {
|
|
308
|
+
replace: true,
|
|
309
|
+
});
|
|
310
|
+
options.signal?.throwIfAborted();
|
|
311
|
+
for (const result of results) {
|
|
312
|
+
result.written =
|
|
313
|
+
result.file === result.captured?.file &&
|
|
314
|
+
(result.status === "new" || options.update === true);
|
|
315
|
+
}
|
|
316
|
+
const manifest = {
|
|
317
|
+
schemaVersion: 2,
|
|
318
|
+
complete: true,
|
|
319
|
+
captureId: runId,
|
|
320
|
+
penpotFile: listing.file,
|
|
321
|
+
fileId: listing.fileId,
|
|
322
|
+
page: config.page,
|
|
323
|
+
pageId: listing.pageId,
|
|
324
|
+
coverage: baselineCoverage(config, results),
|
|
325
|
+
capturedRevision: listing.revn,
|
|
326
|
+
casesFile: `${generation}/reflection-cases.json`,
|
|
327
|
+
note: "Capture provenance and approved provenance are separate. Changed references require exact-artifact owner words or a recorded forced-reference waiver.",
|
|
328
|
+
frames: results,
|
|
329
|
+
};
|
|
330
|
+
await atomicWriteFile(path.join(folder, "penpair-baselines.json"), `${JSON.stringify(manifest, null, 2)}\n`, {
|
|
331
|
+
replace: true,
|
|
332
|
+
});
|
|
333
|
+
for (const result of results) {
|
|
334
|
+
result.written =
|
|
335
|
+
result.file === result.captured?.file &&
|
|
336
|
+
(result.status === "new" || options.update === true);
|
|
337
|
+
if (result.written) {
|
|
338
|
+
wrote.push(path.join(config.root, result.file));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
wrote.push(path.join(config.root, "reflection-cases.json"), path.join(config.root, "penpair-baselines.json"));
|
|
342
|
+
}
|
|
343
|
+
finally {
|
|
344
|
+
await rm(staged, {
|
|
345
|
+
recursive: true,
|
|
346
|
+
force: true,
|
|
347
|
+
});
|
|
77
348
|
}
|
|
78
349
|
}
|
|
79
|
-
return { page: config.page, penpotFile: String(listing.file ?? ""), revn: Number(listing.revn ?? 0), results, wrote };
|
|
80
|
-
}
|
|
81
|
-
function manifestOf(config, listing, results) {
|
|
82
350
|
return {
|
|
83
|
-
|
|
84
|
-
|
|
351
|
+
coverage: baselineCoverage(config, results),
|
|
352
|
+
complete,
|
|
353
|
+
fileId: listing.fileId,
|
|
354
|
+
pageId: listing.pageId,
|
|
85
355
|
page: config.page,
|
|
86
|
-
|
|
87
|
-
|
|
356
|
+
penpotFile: String(listing.file),
|
|
357
|
+
revn: Number(listing.revn),
|
|
358
|
+
results,
|
|
359
|
+
wrote,
|
|
88
360
|
};
|
|
89
361
|
}
|
|
90
362
|
/**
|
|
@@ -93,23 +365,53 @@ function manifestOf(config, listing, results) {
|
|
|
93
365
|
* Review-only (`strict: false`): a difference is shown to a person, not failed.
|
|
94
366
|
*/
|
|
95
367
|
export function reflectionCases(config, results) {
|
|
96
|
-
const routes =
|
|
368
|
+
const routes = Object.create(null);
|
|
97
369
|
const visualSmoke = [];
|
|
370
|
+
const pendingCases = [];
|
|
98
371
|
for (const wanted of config.frames) {
|
|
99
372
|
const result = results.find((r) => r.id === wanted.id);
|
|
100
|
-
if (!wanted.route ||
|
|
373
|
+
if (!wanted.route ||
|
|
374
|
+
!result ||
|
|
375
|
+
result.imageWidth === undefined ||
|
|
376
|
+
result.imageHeight === undefined) {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (wanted.case) {
|
|
380
|
+
const approvedCase = result.approved && "case" in result.approved ? result.approved.case : undefined;
|
|
381
|
+
pendingCases.push({
|
|
382
|
+
id: wanted.id,
|
|
383
|
+
route: wanted.route,
|
|
384
|
+
case: approvedCase ?? wanted.case,
|
|
385
|
+
baseline: result.file,
|
|
386
|
+
ref: `penpair:${wanted.id}`,
|
|
387
|
+
approvedProvenance: result.approved ?? null,
|
|
388
|
+
captured: result.captured ?? null,
|
|
389
|
+
approvedMetadata: Boolean(approvedCase && observationHash(approvedCase) === observationHash(wanted.case)),
|
|
390
|
+
reason: "Explicit theme/state/fixture/font setup requires an implementation provider mapping",
|
|
391
|
+
browserVerification: "not-run",
|
|
392
|
+
});
|
|
101
393
|
continue;
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
394
|
+
}
|
|
395
|
+
const viewport = {
|
|
396
|
+
name: `design-${result.imageWidth}x${result.imageHeight}`,
|
|
397
|
+
width: result.imageWidth,
|
|
398
|
+
height: result.imageHeight,
|
|
399
|
+
};
|
|
400
|
+
const entry = (routes[wanted.route] ??= {
|
|
401
|
+
viewports: [],
|
|
402
|
+
});
|
|
403
|
+
if (!entry.viewports.some((v) => v.name === viewport.name)) {
|
|
105
404
|
entry.viewports.push(viewport);
|
|
405
|
+
}
|
|
106
406
|
visualSmoke.push({
|
|
107
407
|
id: wanted.id,
|
|
108
408
|
route: wanted.route,
|
|
109
409
|
viewport: viewport.name,
|
|
110
410
|
baselineRoot: config.root,
|
|
111
|
-
baseline:
|
|
112
|
-
threshold: {
|
|
411
|
+
baseline: result.file,
|
|
412
|
+
threshold: {
|
|
413
|
+
maxDiffPixelRatio: config.threshold ?? 0.05,
|
|
414
|
+
},
|
|
113
415
|
strict: false,
|
|
114
416
|
ref: `penpair:${wanted.id}`,
|
|
115
417
|
});
|
|
@@ -118,5 +420,6 @@ export function reflectionCases(config, results) {
|
|
|
118
420
|
note: "Generated by `penpair baselines`. Add each route's viewports to that route in your Reflection config and spread `visualSmoke` into the browser contract; baselineRoot is relative to the Reflection config's folder.",
|
|
119
421
|
routes,
|
|
120
422
|
visualSmoke,
|
|
423
|
+
pendingCases,
|
|
121
424
|
};
|
|
122
425
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { parseArgs, type ParseArgsConfig } from "node:util";
|
|
2
|
+
export declare class CommandArgumentError extends Error {
|
|
3
|
+
readonly code = "invalid-arguments";
|
|
4
|
+
readonly remedy = "Use the command's supported flags and required values; see penpair --help.";
|
|
5
|
+
}
|
|
6
|
+
/** Suggestions are explanatory only; never reinterpret a supplied option. */
|
|
7
|
+
export declare function parseCommandArgs<T extends ParseArgsConfig>(config: T): ReturnType<typeof parseArgs<T>>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
export class CommandArgumentError extends Error {
|
|
3
|
+
code = "invalid-arguments";
|
|
4
|
+
remedy = "Use the command's supported flags and required values; see penpair --help.";
|
|
5
|
+
}
|
|
6
|
+
function distance(a, b) {
|
|
7
|
+
let previous = Array.from({
|
|
8
|
+
length: b.length + 1,
|
|
9
|
+
}, (_, index) => index);
|
|
10
|
+
for (let i = 1; i <= a.length; i++) {
|
|
11
|
+
const next = [
|
|
12
|
+
i,
|
|
13
|
+
];
|
|
14
|
+
for (let j = 1; j <= b.length; j++) {
|
|
15
|
+
next[j] = Math.min(next[j - 1] + 1, previous[j] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
16
|
+
}
|
|
17
|
+
previous = next;
|
|
18
|
+
}
|
|
19
|
+
return previous[b.length];
|
|
20
|
+
}
|
|
21
|
+
/** Suggestions are explanatory only; never reinterpret a supplied option. */
|
|
22
|
+
export function parseCommandArgs(config) {
|
|
23
|
+
try {
|
|
24
|
+
return parseArgs(config);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
const value = error;
|
|
28
|
+
if (!value.code?.startsWith("ERR_PARSE_ARGS")) {
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
const unknown = /Unknown option ['"]--([^'"]+)['"]/.exec(value.message ?? "")?.[1];
|
|
32
|
+
let hint = "";
|
|
33
|
+
if (unknown && unknown.length <= 100) {
|
|
34
|
+
const candidates = Object.keys(config.options ?? {})
|
|
35
|
+
.map((name) => ({
|
|
36
|
+
name,
|
|
37
|
+
distance: distance(unknown.toLowerCase(), name.toLowerCase()),
|
|
38
|
+
}))
|
|
39
|
+
.sort((a, b) => a.distance - b.distance || a.name.localeCompare(b.name));
|
|
40
|
+
const best = candidates[0];
|
|
41
|
+
if (best && best.distance <= Math.max(2, Math.ceil(unknown.length / 2))) {
|
|
42
|
+
hint = ` Did you mean --${best.name}?`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
throw new CommandArgumentError((value.message ?? "Invalid command arguments") + hint);
|
|
46
|
+
}
|
|
47
|
+
}
|
package/dist/cli-main.d.ts
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
|
+
import { type CommandResult } from "./command-result.js";
|
|
2
|
+
import { type ProjectConfig } from "./instance/config.js";
|
|
1
3
|
import { type Adapter } from "./adapters.js";
|
|
2
4
|
import { type LocalProfile } from "./local-profile.js";
|
|
3
5
|
export interface CliOptions {
|
|
6
|
+
/** Explicit request-scoped project root; never changes the process working directory. */
|
|
7
|
+
root?: string;
|
|
8
|
+
/** Refuse unrelated global client endpoint fallback (used by the stdio server). */
|
|
9
|
+
projectEndpointOnly?: boolean;
|
|
10
|
+
/** Immutable server target: every loaded project configuration must still match. */
|
|
11
|
+
pinnedConfig?: ProjectConfig;
|
|
12
|
+
strictFileGuard?: boolean;
|
|
4
13
|
/** Adapters already loaded (the command loads the project's own from penpair.config.json). */
|
|
5
14
|
adapters?: Adapter[];
|
|
15
|
+
configuredAdapters?: string[];
|
|
6
16
|
/** A way to run Penpot on this computer, from a package that provides one (`@penpair/docker`). Without it those commands explain where they come from. */
|
|
7
17
|
local?: LocalProfile;
|
|
8
18
|
}
|
|
9
|
-
|
|
19
|
+
export declare function runCommand(argv: string[], options?: CliOptions & {
|
|
20
|
+
signal?: AbortSignal;
|
|
21
|
+
}): Promise<CommandResult>;
|
|
10
22
|
export declare function runCli(argv: string[], options?: CliOptions): Promise<number>;
|