@penpair/core 0.1.3 → 0.2.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 +8 -12
- package/dist/approvals.d.ts +32 -0
- package/dist/approvals.js +99 -0
- package/dist/atomic-output.d.ts +5 -0
- package/dist/atomic-output.js +28 -0
- package/dist/audit-rules.d.ts +60 -0
- package/dist/audit-rules.js +177 -0
- package/dist/backup-note.d.ts +4 -0
- package/dist/backup-note.js +28 -0
- package/dist/baseline-coverage.d.ts +25 -0
- package/dist/baseline-coverage.js +114 -0
- package/dist/baselines-config.d.ts +25 -0
- package/dist/baselines-config.js +52 -3
- package/dist/baselines.d.ts +37 -8
- package/dist/baselines.js +132 -52
- package/dist/cli-arguments.d.ts +7 -0
- package/dist/cli-arguments.js +35 -0
- package/dist/cli-main.d.ts +13 -1
- package/dist/cli-main.js +770 -139
- package/dist/client.d.ts +26 -5
- package/dist/client.js +46 -20
- package/dist/command-result.d.ts +159 -0
- package/dist/command-result.js +179 -0
- package/dist/coordination.d.ts +15 -0
- package/dist/coordination.js +122 -0
- package/dist/credentials.d.ts +21 -0
- package/dist/credentials.js +61 -0
- package/dist/deep-check.js +9 -5
- package/dist/design-spec.d.ts +55 -0
- package/dist/design-spec.js +135 -0
- package/dist/endpoint.d.ts +8 -1
- package/dist/endpoint.js +48 -4
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +8 -4
- package/dist/export.d.ts +18 -0
- package/dist/export.js +42 -7
- package/dist/file-archive.d.ts +16 -0
- package/dist/file-archive.js +32 -0
- package/dist/file-guard.d.ts +8 -0
- package/dist/file-guard.js +40 -0
- package/dist/formats.d.ts +4 -0
- package/dist/formats.js +28 -0
- package/dist/index.d.ts +33 -1
- package/dist/index.js +33 -1
- package/dist/inspection.d.ts +106 -0
- package/dist/inspection.js +344 -0
- package/dist/instance/config-schema.d.ts +422 -0
- package/dist/instance/config-schema.js +76 -0
- package/dist/instance/config.d.ts +29 -0
- package/dist/instance/config.js +102 -11
- package/dist/instance/connect.d.ts +10 -1
- package/dist/instance/connect.js +53 -12
- package/dist/instance/connection.d.ts +14 -0
- package/dist/instance/connection.js +13 -2
- package/dist/instance/init.js +1 -1
- package/dist/instance/setup.js +1 -1
- package/dist/instance/tty.js +3 -1
- package/dist/journal.d.ts +77 -0
- package/dist/journal.js +260 -0
- package/dist/kit-smoke.d.ts +31 -0
- package/dist/kit-smoke.js +83 -0
- package/dist/kit-source.d.ts +2 -0
- package/dist/kit-source.js +309 -0
- package/dist/kit.d.ts +25 -0
- package/dist/kit.js +90 -0
- package/dist/layout.d.ts +15 -0
- package/dist/layout.js +79 -0
- package/dist/live-smoke.d.ts +8 -0
- package/dist/live-smoke.js +14 -0
- package/dist/mcp-server.d.ts +12 -0
- package/dist/mcp-server.js +102 -0
- package/dist/native-api.d.ts +15 -0
- package/dist/native-api.js +42 -0
- package/dist/onboard.d.ts +10 -0
- package/dist/onboard.js +15 -0
- package/dist/operation.d.ts +20 -1
- package/dist/operation.js +113 -13
- package/dist/png.d.ts +5 -0
- package/dist/png.js +7 -0
- package/dist/portable-tokens.d.ts +22 -0
- package/dist/portable-tokens.js +134 -0
- package/dist/probe.d.ts +6 -2
- package/dist/probe.js +19 -3
- package/dist/project-input.d.ts +6 -0
- package/dist/project-input.js +51 -0
- package/dist/project-snippets.js +12 -7
- package/dist/project-status.d.ts +24 -0
- package/dist/project-status.js +79 -0
- package/dist/registry.d.ts +2 -1
- package/dist/registry.js +17 -3
- package/dist/run-file.d.ts +26 -0
- package/dist/run-file.js +100 -0
- package/dist/shots.d.ts +43 -0
- package/dist/shots.js +90 -0
- package/dist/skill-instructions.d.ts +13 -0
- package/dist/skill-instructions.js +75 -0
- package/dist/skills.d.ts +7 -0
- package/dist/skills.js +59 -32
- package/dist/snippet.d.ts +3 -2
- package/dist/snippet.js +12 -8
- 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 +71 -40
- package/dist/structure-snapshot.d.ts +73 -0
- package/dist/structure-snapshot.js +171 -0
- package/dist/token-edit.d.ts +53 -0
- package/dist/token-edit.js +197 -0
- package/dist/tokens-diff.d.ts +19 -6
- package/dist/tokens-diff.js +89 -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 +12 -40
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +56 -0
- package/skills/penpair-design/references/project-facts.md +1 -1
- package/skills/penpair-design/references/write-protocol.md +15 -3
- package/skills/penpair-handoff/SKILL.md +14 -0
- package/skills/penpair-handoff/references/handoff-flow.md +11 -0
- package/skills/penpair-recipes/SKILL.md +16 -0
- package/skills/penpair-recipes/references/build-flow.md +11 -0
- package/skills/penpair-setup/SKILL.md +6 -2
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { resolveProjectOutput } from "./project-input.js";
|
|
5
|
+
import { pngSize } from "./png.js";
|
|
6
|
+
import { validateBaselines } from "./baselines-config.js";
|
|
7
|
+
import { observationHash } from "./journal.js";
|
|
8
|
+
export const designCaseKey = (route, value) => observationHash({ route, viewport: { width: value.viewport.width, height: value.viewport.height }, theme: value.theme, state: value.state, fixture: value.fixture });
|
|
9
|
+
export function baselineCoverage(config, results = []) {
|
|
10
|
+
const rows = [];
|
|
11
|
+
for (const requirement of config.coverage?.required ?? []) {
|
|
12
|
+
const key = designCaseKey(requirement.route, requirement);
|
|
13
|
+
const unsupported = config.coverage?.unsupported?.find(value => designCaseKey(value.route, value) === key);
|
|
14
|
+
const frames = config.frames.filter(frame => frame.route && frame.case && designCaseKey(frame.route, frame.case) === key);
|
|
15
|
+
const frameIds = frames.map(frame => frame.id);
|
|
16
|
+
if (unsupported) {
|
|
17
|
+
rows.push({ requirement, status: "unsupported", frameIds, reason: unsupported.reason });
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (!frames.length) {
|
|
21
|
+
rows.push({ requirement, status: "missing-case", frameIds });
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const captures = frames.flatMap(frame => { const found = results.find(result => result.id === frame.id); return found ? [{ frame, result: found }] : []; });
|
|
25
|
+
if (!captures.length || captures.every(({ result }) => !result.approved)) {
|
|
26
|
+
rows.push({ requirement, status: "missing-capture", frameIds });
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const approved = captures.some(({ frame, result }) => {
|
|
30
|
+
const evidence = result.approved && "case" in result.approved ? result.approved : undefined;
|
|
31
|
+
return evidence?.approval?.kind !== "forced-reference" && !["missing", "unstable", "case-mismatch"].includes(result.status) && evidence?.case && observationHash(evidence.case) === observationHash(frame.case) && result.sha256 === evidence.sha256 && evidence.frameId === result.frameId && evidence.file === result.file && (!result.captured || evidence.fileId === result.captured.fileId && evidence.pageId === result.captured.pageId) && evidence.designSize?.width === frame.case.viewport.width && evidence.designSize?.height === frame.case.viewport.height && result.imageWidth === Math.round(frame.case.viewport.width * evidence.scale) && result.imageHeight === Math.round(frame.case.viewport.height * evidence.scale) && (!frame.case.activeThemeIds || evidence.activeThemeIds && observationHash([...frame.case.activeThemeIds].sort()) === observationHash([...evidence.activeThemeIds].sort()));
|
|
32
|
+
});
|
|
33
|
+
const changed = approved && captures.some(({ result }) => result.status === "changed" && result.captured && result.approved && result.captured.sha256 !== result.approved.sha256);
|
|
34
|
+
rows.push({ requirement, status: changed ? "changed-unapproved" : approved ? "approved-design-intent" : "unapproved-metadata", frameIds, ...(!approved ? { reason: captures.flatMap(({ result }) => result.problems ?? []).join("; ") || "Approved case/viewport/theme evidence is absent or mismatched" } : {}) });
|
|
35
|
+
}
|
|
36
|
+
const unknown = config.frames.flatMap(frame => {
|
|
37
|
+
const fields = [];
|
|
38
|
+
if (!frame.case)
|
|
39
|
+
fields.push("viewport", "theme", "state", "fixture", "font environment");
|
|
40
|
+
else {
|
|
41
|
+
if (frame.case.fonts === undefined)
|
|
42
|
+
fields.push("declared fonts absent");
|
|
43
|
+
else if (frame.case.fonts.length)
|
|
44
|
+
fields.push("font loading/version not observed");
|
|
45
|
+
if (frame.case.activeThemeIds === undefined)
|
|
46
|
+
fields.push("theme activation not asserted; label is design intent");
|
|
47
|
+
}
|
|
48
|
+
const result = results.find(result => result.id === frame.id);
|
|
49
|
+
if (frame.case && !result?.captured?.renderEnvironment)
|
|
50
|
+
fields.push("renderer environment not observed");
|
|
51
|
+
return fields.length ? [{ id: frame.id, fields }] : [];
|
|
52
|
+
});
|
|
53
|
+
return { schemaVersion: 1, kind: "baseline-coverage", configured: Boolean(config.coverage), designCasesComplete: rows.length > 0 && rows.every(row => row.status === "approved-design-intent"), rows, unknown, browserVerification: "not-run", accessibility: "not-assessed" };
|
|
54
|
+
}
|
|
55
|
+
async function boundedRead(file, maximum) {
|
|
56
|
+
const handle = await open(file, "r");
|
|
57
|
+
try {
|
|
58
|
+
const info = await handle.stat();
|
|
59
|
+
if (!info.isFile() || info.size > maximum)
|
|
60
|
+
throw new Error("Artifact exceeds the coverage read budget or is not a regular file");
|
|
61
|
+
return await handle.readFile();
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
await handle.close();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Offline coverage validates the approved image bytes, never only a manifest's claims. */
|
|
68
|
+
export async function readBaselineCoverage(root, config, expectedFileId) {
|
|
69
|
+
config = structuredClone(config);
|
|
70
|
+
validateBaselines(config, message => { throw new Error(message); });
|
|
71
|
+
const file = await resolveProjectOutput(root, path.join(config.root, "penpair-baselines.json"));
|
|
72
|
+
let manifest;
|
|
73
|
+
try {
|
|
74
|
+
manifest = JSON.parse((await boundedRead(file, 16 * 1024 * 1024)).toString("utf8"));
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error.code !== "ENOENT")
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
if (!manifest)
|
|
81
|
+
return baselineCoverage(config);
|
|
82
|
+
if (manifest.schemaVersion !== 2 || !Array.isArray(manifest.frames) || manifest.frames.length > 1000)
|
|
83
|
+
throw new Error("Coverage needs a bounded schema-2 baseline manifest; regenerate a reviewed capture");
|
|
84
|
+
if (expectedFileId && manifest.fileId !== expectedFileId)
|
|
85
|
+
throw new Error("Coverage manifest belongs to a different pinned file");
|
|
86
|
+
const ids = new Set();
|
|
87
|
+
for (const value of manifest.frames) {
|
|
88
|
+
if (!value || typeof value.id !== "string" || !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(value.id) || ids.has(value.id) || !["new", "same", "changed", "missing", "unstable", "case-mismatch"].includes(value.status) || typeof value.file !== "string" || !value.file)
|
|
89
|
+
throw new Error("Malformed baseline frame records");
|
|
90
|
+
ids.add(value.id);
|
|
91
|
+
}
|
|
92
|
+
const results = manifest.frames.map(value => ({ ...value }));
|
|
93
|
+
for (const result of results) {
|
|
94
|
+
if (!result.approved)
|
|
95
|
+
continue;
|
|
96
|
+
try {
|
|
97
|
+
if (typeof result.file !== "string" || path.isAbsolute(result.file) || result.file.split(/[\\/]/).includes(".."))
|
|
98
|
+
throw new Error("Approved image path leaves its baseline folder");
|
|
99
|
+
if ("fileId" in result.approved && result.approved.fileId !== manifest.fileId)
|
|
100
|
+
throw new Error("Approved image provenance belongs to a different file");
|
|
101
|
+
const target = await resolveProjectOutput(root, path.join(config.root, result.file));
|
|
102
|
+
const bytes = await boundedRead(target, 64 * 1024 * 1024), size = pngSize(bytes);
|
|
103
|
+
if (!size || size.width < 1 || size.height < 1 || createHash("sha256").update(bytes).digest("hex") !== result.approved.sha256 || result.sha256 !== result.approved.sha256)
|
|
104
|
+
throw new Error("Approved PNG bytes do not match their manifest");
|
|
105
|
+
result.imageWidth = size.width;
|
|
106
|
+
result.imageHeight = size.height;
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
result.status = "case-mismatch";
|
|
110
|
+
result.problems = [error instanceof Error ? error.message : String(error)];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return baselineCoverage(config, results);
|
|
114
|
+
}
|
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
/** Declared design intent. It does not switch themes or establish browser behavior. */
|
|
2
|
+
export interface BaselineCase {
|
|
3
|
+
viewport: {
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
};
|
|
7
|
+
theme: string;
|
|
8
|
+
state: string;
|
|
9
|
+
fixture: string;
|
|
10
|
+
fonts?: string[];
|
|
11
|
+
/** Optional exact active native theme IDs expected during capture. */
|
|
12
|
+
activeThemeIds?: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface CoverageRequirement extends Pick<BaselineCase, "viewport" | "theme" | "state" | "fixture"> {
|
|
15
|
+
route: string;
|
|
16
|
+
}
|
|
17
|
+
export interface CoverageConfig {
|
|
18
|
+
required: CoverageRequirement[];
|
|
19
|
+
unsupported?: (CoverageRequirement & {
|
|
20
|
+
reason: string;
|
|
21
|
+
})[];
|
|
22
|
+
}
|
|
1
23
|
/** One frame of a Penpot page that becomes an approved reference image. */
|
|
2
24
|
export interface BaselineFrame {
|
|
3
25
|
/** The exact name of a top-level frame on the page. */
|
|
@@ -6,6 +28,7 @@ export interface BaselineFrame {
|
|
|
6
28
|
id: string;
|
|
7
29
|
/** The route in the implementation this frame is the design of. Frames without one are exported but not given a check. */
|
|
8
30
|
route?: string;
|
|
31
|
+
case?: BaselineCase;
|
|
9
32
|
}
|
|
10
33
|
/** What `penpair baselines` exports: the frames of one page, as images in a folder of the project. */
|
|
11
34
|
export interface BaselinesConfig {
|
|
@@ -15,5 +38,7 @@ export interface BaselinesConfig {
|
|
|
15
38
|
frames: BaselineFrame[];
|
|
16
39
|
/** Allowed share of differing pixels in the generated checks (default 0.05). */
|
|
17
40
|
threshold?: number;
|
|
41
|
+
coverage?: CoverageConfig;
|
|
18
42
|
}
|
|
19
43
|
export declare function validateBaselines(value: unknown, fail: (message: string) => never): BaselinesConfig;
|
|
44
|
+
export declare function validateBaselineCase(value: unknown, fail: (message: string) => never, extraKeys?: string[]): BaselineCase;
|
package/dist/baselines-config.js
CHANGED
|
@@ -8,8 +8,8 @@ export function validateBaselines(value, fail) {
|
|
|
8
8
|
fail("baselines.page must be the name of a page");
|
|
9
9
|
if (typeof v.root !== "string" || v.root.length === 0 || path.isAbsolute(v.root) || v.root.split(/[\\/]/).includes(".."))
|
|
10
10
|
fail("baselines.root must be a relative folder that stays inside the project");
|
|
11
|
-
if (!Array.isArray(v.frames) || v.frames.length === 0)
|
|
12
|
-
fail("baselines.frames must list at least one frame");
|
|
11
|
+
if (!Array.isArray(v.frames) || v.frames.length === 0 || v.frames.length > 1000)
|
|
12
|
+
fail("baselines.frames must list at least one frame, at most 1000");
|
|
13
13
|
const seen = new Set();
|
|
14
14
|
for (const entry of v.frames) {
|
|
15
15
|
const f = entry;
|
|
@@ -22,10 +22,59 @@ export function validateBaselines(value, fail) {
|
|
|
22
22
|
if (seen.has(f.id))
|
|
23
23
|
fail(`baselines.frames id ${JSON.stringify(f.id)} is used twice`);
|
|
24
24
|
seen.add(f.id);
|
|
25
|
+
if (f.case !== undefined) {
|
|
26
|
+
validateBaselineCase(f.case, fail);
|
|
27
|
+
if (typeof f.route !== "string" || !f.route)
|
|
28
|
+
fail("baseline case metadata requires an implementation route");
|
|
29
|
+
}
|
|
25
30
|
if (f.route !== undefined && (typeof f.route !== "string" || f.route.length === 0))
|
|
26
31
|
fail("baselines.frames route must be a non-empty string");
|
|
27
32
|
}
|
|
28
|
-
if (v.
|
|
33
|
+
if (v.coverage !== undefined) {
|
|
34
|
+
const coverage = v.coverage;
|
|
35
|
+
if (!coverage || typeof coverage !== "object" || Array.isArray(coverage) || !Array.isArray(coverage.required) || !coverage.required.length || coverage.required.length > 1000 || coverage.unsupported !== undefined && (!Array.isArray(coverage.unsupported) || coverage.unsupported.length > 1000))
|
|
36
|
+
fail("baselines.coverage needs 1–1000 required cases and bounded unsupported cases");
|
|
37
|
+
const required = new Set();
|
|
38
|
+
const key = (value) => JSON.stringify([value.route, value.viewport.width, value.viewport.height, value.theme, value.state, value.fixture]);
|
|
39
|
+
for (const value of coverage.required) {
|
|
40
|
+
validateBaselineCase(value, fail, ["route"]);
|
|
41
|
+
if (typeof value.route !== "string" || !value.route)
|
|
42
|
+
fail("coverage cases need a route");
|
|
43
|
+
if (required.has(key(value)))
|
|
44
|
+
fail("Duplicate required coverage case");
|
|
45
|
+
required.add(key(value));
|
|
46
|
+
}
|
|
47
|
+
const unsupported = new Set();
|
|
48
|
+
for (const value of coverage.unsupported ?? []) {
|
|
49
|
+
validateBaselineCase(value, fail, ["route", "reason"]);
|
|
50
|
+
if (typeof value.route !== "string" || !value.route || typeof value.reason !== "string" || !value.reason.trim())
|
|
51
|
+
fail("unsupported cases need route and reason");
|
|
52
|
+
if (!required.has(key(value)) || unsupported.has(key(value)))
|
|
53
|
+
fail("unsupported cases must uniquely reference required cases");
|
|
54
|
+
unsupported.add(key(value));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (v.threshold !== undefined && (typeof v.threshold !== "number" || !Number.isFinite(v.threshold) || v.threshold < 0 || v.threshold > 1))
|
|
29
58
|
fail("baselines.threshold must be a number from 0 to 1");
|
|
30
59
|
return v;
|
|
31
60
|
}
|
|
61
|
+
export function validateBaselineCase(value, fail, extraKeys = []) {
|
|
62
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
63
|
+
return fail("baseline case must be an object");
|
|
64
|
+
const entry = value;
|
|
65
|
+
const allowed = new Set(["viewport", "theme", "state", "fixture", ...(extraKeys.length ? [] : ["fonts", "activeThemeIds"]), ...extraKeys]);
|
|
66
|
+
if (Object.keys(entry).some(key => !allowed.has(key)))
|
|
67
|
+
fail("Unknown baseline case field; capture constraints belong on frame cases");
|
|
68
|
+
if (entry.viewport && Object.keys(entry.viewport).some(key => !["width", "height"].includes(key)))
|
|
69
|
+
fail("Viewport accepts width and height only");
|
|
70
|
+
for (const key of ["theme", "state", "fixture"])
|
|
71
|
+
if (typeof entry[key] !== "string" || !entry[key].trim() || entry[key].length > 200 || /[\x00-\x1f]/.test(entry[key]))
|
|
72
|
+
fail(`baseline case ${key} needs a short explicit label`);
|
|
73
|
+
for (const key of ["width", "height"])
|
|
74
|
+
if (!entry.viewport || !Number.isInteger(entry.viewport[key]) || entry.viewport[key] < 1 || entry.viewport[key] > 16384)
|
|
75
|
+
fail("baseline case viewport dimensions must be integers from 1 to 16384");
|
|
76
|
+
for (const key of ["fonts", "activeThemeIds"])
|
|
77
|
+
if (entry[key] !== undefined && (!Array.isArray(entry[key]) || entry[key].length > 100 || entry[key].some(value => typeof value !== "string" || !value.trim()) || new Set(entry[key]).size !== entry[key].length))
|
|
78
|
+
fail(`baseline case ${key} must contain at most 100 unique nonempty strings`);
|
|
79
|
+
return entry;
|
|
80
|
+
}
|
package/dist/baselines.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
export { pngSize } from "./png.js";
|
|
1
2
|
import type { PenpotToolClient } from "./client.js";
|
|
2
|
-
import type
|
|
3
|
-
import { type
|
|
3
|
+
import { type BaselineCoverage } from "./baseline-coverage.js";
|
|
4
|
+
import { type BaselineCase, type BaselinesConfig } from "./baselines-config.js";
|
|
5
|
+
import type { LayoutResult } from "./layout.js";
|
|
6
|
+
import { type CaptureIdentity, type ExportOptions } from "./export.js";
|
|
4
7
|
export * from "./baselines-config.js";
|
|
5
8
|
export type BaselineStatus =
|
|
6
9
|
/** No image in the folder yet. */
|
|
@@ -12,8 +15,31 @@ export type BaselineStatus =
|
|
|
12
15
|
/** The export never settled (two identical renders in a row), so it is not trusted. */
|
|
13
16
|
| "unstable"
|
|
14
17
|
/** The page has no top-level frame with that name. */
|
|
15
|
-
| "missing";
|
|
18
|
+
| "missing" | "case-mismatch";
|
|
19
|
+
export interface ArtifactProvenance extends CaptureIdentity {
|
|
20
|
+
approval?: {
|
|
21
|
+
kind: "owner-words";
|
|
22
|
+
id: string;
|
|
23
|
+
recordHash: string;
|
|
24
|
+
} | {
|
|
25
|
+
kind: "forced-reference";
|
|
26
|
+
reason: string;
|
|
27
|
+
};
|
|
28
|
+
case?: BaselineCase;
|
|
29
|
+
layout?: LayoutResult;
|
|
30
|
+
sha256: string;
|
|
31
|
+
capturedAt: string;
|
|
32
|
+
toolVersion: string;
|
|
33
|
+
file: string;
|
|
34
|
+
}
|
|
16
35
|
export interface BaselineResult {
|
|
36
|
+
case?: BaselineCase;
|
|
37
|
+
problems?: string[];
|
|
38
|
+
captured?: ArtifactProvenance;
|
|
39
|
+
approved?: ArtifactProvenance | {
|
|
40
|
+
legacy: true;
|
|
41
|
+
sha256: string;
|
|
42
|
+
};
|
|
17
43
|
id: string;
|
|
18
44
|
frame: string;
|
|
19
45
|
frameId?: string;
|
|
@@ -30,6 +56,10 @@ export interface BaselineResult {
|
|
|
30
56
|
imageHeight?: number;
|
|
31
57
|
}
|
|
32
58
|
export interface BaselinesReport {
|
|
59
|
+
coverage: BaselineCoverage;
|
|
60
|
+
complete: boolean;
|
|
61
|
+
fileId: string;
|
|
62
|
+
pageId: string;
|
|
33
63
|
page: string;
|
|
34
64
|
penpotFile: string;
|
|
35
65
|
revn: number;
|
|
@@ -38,16 +68,14 @@ export interface BaselinesReport {
|
|
|
38
68
|
wrote: string[];
|
|
39
69
|
}
|
|
40
70
|
export interface BaselinesOptions extends ExportOptions {
|
|
71
|
+
signal?: AbortSignal;
|
|
41
72
|
/** Write images that are new. Without it nothing is written, whatever is found. */
|
|
42
73
|
apply?: boolean;
|
|
43
74
|
/** Also replace images whose design has changed. This is the act of approving the new design as the reference; it is the owner's call. */
|
|
44
75
|
update?: boolean;
|
|
76
|
+
/** Explicit waiver for a changed baseline; retained as a forced reference, never an owner approval. */
|
|
77
|
+
forceReason?: string;
|
|
45
78
|
}
|
|
46
|
-
/** Pixel size from a PNG's header (the IHDR chunk), or nothing when the bytes are not a PNG. */
|
|
47
|
-
export declare function pngSize(buffer: Buffer): {
|
|
48
|
-
width: number;
|
|
49
|
-
height: number;
|
|
50
|
-
} | undefined;
|
|
51
79
|
/**
|
|
52
80
|
* Exports the configured frames of a page as images into the project's folder: the approved design that an implementation is
|
|
53
81
|
* checked against. Looks first; writes only with `apply`, and replaces an image that differs only with `update`, so a change of
|
|
@@ -69,4 +97,5 @@ export declare function reflectionCases(config: BaselinesConfig, results: Baseli
|
|
|
69
97
|
}[];
|
|
70
98
|
}>;
|
|
71
99
|
visualSmoke: Record<string, unknown>[];
|
|
100
|
+
pendingCases: Record<string, unknown>[];
|
|
72
101
|
};
|
package/dist/baselines.js
CHANGED
|
@@ -1,24 +1,28 @@
|
|
|
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
|
+
throw error;
|
|
22
26
|
}
|
|
23
27
|
}
|
|
24
28
|
/**
|
|
@@ -27,16 +31,34 @@ async function readIfExists(file) {
|
|
|
27
31
|
* the design never silently becomes the new reference.
|
|
28
32
|
*/
|
|
29
33
|
export async function exportBaselines(client, projectRoot, config, options = {}) {
|
|
34
|
+
if (options.forceReason !== undefined && (!options.update || !options.apply || !options.forceReason.trim() || options.forceReason.length > 2000))
|
|
35
|
+
throw new Error("A nonempty force reason requires apply and update");
|
|
36
|
+
config = structuredClone(config);
|
|
37
|
+
validateBaselines(config, message => { throw new Error(message); });
|
|
30
38
|
const listing = await runOnPage(client, frames({ page: config.page }), config.page, options);
|
|
31
|
-
|
|
39
|
+
if (listing.ok !== true || typeof listing.fileId !== "string" || !listing.fileId || typeof listing.pageId !== "string" || !listing.pageId || !Number.isInteger(listing.revn) || !Array.isArray(listing.frames))
|
|
40
|
+
throw new Error("Baseline listing lacks file/page/revision evidence");
|
|
41
|
+
const found = listing.frames;
|
|
32
42
|
const results = [];
|
|
33
43
|
const wrote = [];
|
|
34
|
-
const folder =
|
|
44
|
+
const folder = await resolveProjectOutput(projectRoot, config.root);
|
|
45
|
+
const manifestPath = await resolveProjectOutput(projectRoot, path.join(config.root, "penpair-baselines.json"));
|
|
46
|
+
const previousBytes = await readIfExists(manifestPath);
|
|
47
|
+
const previous = previousBytes ? JSON.parse(previousBytes.toString("utf8")) : undefined;
|
|
48
|
+
if (previous && (previous.schemaVersion !== undefined && previous.schemaVersion !== 2 || !Array.isArray(previous.frames)))
|
|
49
|
+
throw new Error("Existing baseline manifest is invalid; preserve and inspect it");
|
|
50
|
+
const runId = randomUUID();
|
|
51
|
+
const generation = `captures/${runId}`;
|
|
52
|
+
const captures = new Map();
|
|
35
53
|
for (const wanted of config.frames) {
|
|
36
|
-
|
|
54
|
+
options.signal?.throwIfAborted();
|
|
55
|
+
const old = previous?.frames?.find((entry) => entry.id === wanted.id);
|
|
56
|
+
const file = old?.file ?? `${wanted.id}.png`;
|
|
57
|
+
if (path.isAbsolute(file) || file.split(/[\\/]/).includes(".."))
|
|
58
|
+
throw new Error("Existing baseline path leaves its folder");
|
|
37
59
|
const matches = found.filter((f) => f.name === wanted.frame);
|
|
38
|
-
if (matches.length
|
|
39
|
-
results.push({ id: wanted.id, frame: wanted.frame, file, status: "missing", written: false });
|
|
60
|
+
if (!matches.length) {
|
|
61
|
+
results.push({ ...(wanted.case ? { case: wanted.case } : {}), id: wanted.id, frame: wanted.frame, file, status: "missing", written: false });
|
|
40
62
|
continue;
|
|
41
63
|
}
|
|
42
64
|
if (matches.length > 1)
|
|
@@ -44,48 +66,99 @@ export async function exportBaselines(client, projectRoot, config, options = {})
|
|
|
44
66
|
const frame = matches[0];
|
|
45
67
|
options.onStep?.(`exporting ${wanted.frame}`);
|
|
46
68
|
const exported = await exportShapePng(client, frame.id, { ...options, page: config.page, stable: true });
|
|
47
|
-
const base = { id: wanted.id, frame: wanted.frame, frameId: frame.id, width: frame.width, height: frame.height, file };
|
|
69
|
+
const base = { ...(wanted.case ? { case: wanted.case } : {}), id: wanted.id, frame: wanted.frame, frameId: frame.id, width: frame.width, height: frame.height, file };
|
|
70
|
+
if (exported.capture.fileId !== listing.fileId || exported.capture.pageId !== listing.pageId || exported.capture.revision !== listing.revn)
|
|
71
|
+
throw new Error("Baseline source changed during capture; nothing was published");
|
|
48
72
|
if (!exported.stable) {
|
|
49
73
|
results.push({ ...base, status: "unstable", written: false });
|
|
50
74
|
continue;
|
|
51
75
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
76
|
+
if (!pngSize(exported.png))
|
|
77
|
+
throw new Error("Baseline export is not a PNG with dimensions");
|
|
78
|
+
const existing = await readIfExists(await resolveProjectOutput(projectRoot, path.join(config.root, file)));
|
|
79
|
+
if (old && (!existing || old.sha256 !== sha(existing) || old.approved && old.approved.sha256 !== old.sha256))
|
|
80
|
+
throw new Error("Approved baseline bytes no longer match their manifest; inspect before replacing");
|
|
81
|
+
const captured = { ...(wanted.case ? { case: wanted.case } : {}), layout: exported.layout, ...exported.capture, sha256: sha(exported.png), capturedAt: new Date().toISOString(), toolVersion: version, file: `${generation}/${wanted.id}.png` };
|
|
82
|
+
const caseProblems = [];
|
|
83
|
+
if (wanted.case) {
|
|
84
|
+
const viewport = wanted.case.viewport, size = pngSize(exported.png);
|
|
85
|
+
if (!exported.capture.designSize || exported.capture.designSize.width !== viewport.width || exported.capture.designSize.height !== viewport.height)
|
|
86
|
+
caseProblems.push("Observed frame dimensions do not match the declared viewport");
|
|
87
|
+
if (size.width !== Math.round(viewport.width * exported.capture.scale) || size.height !== Math.round(viewport.height * exported.capture.scale))
|
|
88
|
+
caseProblems.push("PNG dimensions do not match declared viewport and capture scale");
|
|
89
|
+
if (wanted.case.activeThemeIds && (!exported.capture.activeThemeIds || observationHash([...wanted.case.activeThemeIds].sort()) !== observationHash([...exported.capture.activeThemeIds].sort())))
|
|
90
|
+
caseProblems.push("Active native theme IDs do not match the capture assertion");
|
|
91
|
+
}
|
|
92
|
+
if (caseProblems.length) {
|
|
93
|
+
results.push({ ...base, status: "case-mismatch", written: false, captured, approved: old?.approved, sha256: old?.sha256, problems: caseProblems });
|
|
94
|
+
continue;
|
|
61
95
|
}
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
96
|
+
const oldCase = old?.approved && "case" in old.approved ? old.approved.case : undefined;
|
|
97
|
+
const status = !existing ? "new" : sha(existing) === captured.sha256 && observationHash(oldCase ?? null) === observationHash(wanted.case ?? null) && (!old?.approved || "legacy" in old.approved || old.approved.fileId === captured.fileId && old.approved.pageId === captured.pageId && old.approved.frameId === captured.frameId) ? "same" : "changed";
|
|
98
|
+
const promoteWaiver = status === "same" && old?.approved && "approval" in old.approved && old.approved.approval?.kind === "forced-reference" && options.update === true;
|
|
99
|
+
if ((status === "changed" || promoteWaiver) && options.update && options.apply) {
|
|
100
|
+
const record = await matchingApproval(projectRoot, exported.capture, captured.sha256, wanted.case);
|
|
101
|
+
if (record)
|
|
102
|
+
captured.approval = { kind: "owner-words", id: record.id, recordHash: record.hash };
|
|
103
|
+
else if (options.forceReason)
|
|
104
|
+
captured.approval = { kind: "forced-reference", reason: options.forceReason };
|
|
105
|
+
else
|
|
106
|
+
throw new Error("Changed baseline requires matching exact-artifact owner words (penpair approve), or an explicit --force-reason waiver; previous artifacts preserved");
|
|
107
|
+
}
|
|
108
|
+
const approve = status === "new" || status === "changed" && options.update === true || Boolean(promoteWaiver);
|
|
109
|
+
const approved = approve ? captured : old?.approved ?? { legacy: true, sha256: sha(existing) };
|
|
110
|
+
const image = approve ? exported.png : existing;
|
|
111
|
+
const size = pngSize(image);
|
|
112
|
+
results.push({ ...base, file: approve && options.apply ? captured.file : file, status, written: false, captured,
|
|
113
|
+
approved: options.apply || !approve ? approved : undefined, sha256: sha(image), bytes: image.length, imageWidth: size?.width, imageHeight: size?.height });
|
|
114
|
+
captures.set(wanted.id, exported.png);
|
|
66
115
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
116
|
+
const after = await runOnPage(client, frames({ page: config.page }), config.page, options);
|
|
117
|
+
const source = (value) => ({ fileId: value.fileId, pageId: value.pageId, revision: value.revn, frames: value.frames });
|
|
118
|
+
if (observationHash(source(listing)) !== observationHash(source(after)))
|
|
119
|
+
throw new Error("Baseline source changed before publication; previous artifacts were preserved");
|
|
120
|
+
const complete = results.length === config.frames.length && results.every((result) => result.status !== "missing" && result.status !== "unstable" && result.status !== "case-mismatch");
|
|
121
|
+
if (options.apply && complete) {
|
|
122
|
+
const evidence = JSON.stringify({ config, results, forceReason: options.forceReason });
|
|
123
|
+
if (options.redact && options.redact(evidence) !== evidence)
|
|
124
|
+
throw new Error("Baseline evidence requires credential redaction; previous artifacts preserved");
|
|
125
|
+
options.signal?.throwIfAborted();
|
|
126
|
+
options.onStep?.("publishing complete capture");
|
|
127
|
+
options.signal?.throwIfAborted();
|
|
128
|
+
const captureFolder = await resolveProjectOutput(projectRoot, path.join(config.root, "captures"));
|
|
129
|
+
await mkdir(captureFolder, { recursive: true });
|
|
130
|
+
const staged = path.join(folder, `.stage-${runId}`);
|
|
131
|
+
await mkdir(staged);
|
|
132
|
+
try {
|
|
133
|
+
for (const [id, bytes] of captures) {
|
|
134
|
+
options.signal?.throwIfAborted();
|
|
135
|
+
await atomicWriteFile(path.join(staged, `${id}.png`), bytes);
|
|
136
|
+
options.onStep?.(`staging ${id}`);
|
|
137
|
+
}
|
|
138
|
+
const cases = reflectionCases(config, results);
|
|
139
|
+
await writeFile(path.join(staged, "reflection-cases.json"), `${JSON.stringify({ ...cases, captureId: runId }, null, 2)}\n`, { flag: "wx" });
|
|
140
|
+
options.signal?.throwIfAborted();
|
|
141
|
+
await rename(staged, path.join(captureFolder, runId));
|
|
142
|
+
// Immutable generation first. The canonical manifest is the commit point and is published last.
|
|
143
|
+
await atomicWriteFile(path.join(folder, "reflection-cases.json"), `${JSON.stringify({ ...cases, captureId: runId }, null, 2)}\n`, { replace: true });
|
|
144
|
+
options.signal?.throwIfAborted();
|
|
145
|
+
for (const result of results)
|
|
146
|
+
result.written = result.file === result.captured?.file && (result.status === "new" || options.update === true);
|
|
147
|
+
const manifest = { schemaVersion: 2, complete: true, captureId: runId, penpotFile: listing.file, fileId: listing.fileId, page: config.page, pageId: listing.pageId,
|
|
148
|
+
coverage: baselineCoverage(config, results), capturedRevision: listing.revn, casesFile: `${generation}/reflection-cases.json`, note: "Capture provenance and approved provenance are separate. Changed references require exact-artifact owner words or a recorded forced-reference waiver.", frames: results };
|
|
149
|
+
await atomicWriteFile(path.join(folder, "penpair-baselines.json"), `${JSON.stringify(manifest, null, 2)}\n`, { replace: true });
|
|
150
|
+
for (const result of results) {
|
|
151
|
+
result.written = result.file === result.captured?.file && (result.status === "new" || options.update === true);
|
|
152
|
+
if (result.written)
|
|
153
|
+
wrote.push(path.join(config.root, result.file));
|
|
154
|
+
}
|
|
155
|
+
wrote.push(path.join(config.root, "reflection-cases.json"), path.join(config.root, "penpair-baselines.json"));
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
await rm(staged, { recursive: true, force: true });
|
|
77
159
|
}
|
|
78
160
|
}
|
|
79
|
-
return { page: config.page, penpotFile: String(listing.file
|
|
80
|
-
}
|
|
81
|
-
function manifestOf(config, listing, results) {
|
|
82
|
-
return {
|
|
83
|
-
note: "Written by `penpair baselines`. The images are the approved design for each frame; change one only on purpose (`penpair baselines --apply --update`).",
|
|
84
|
-
penpotFile: listing.file,
|
|
85
|
-
page: config.page,
|
|
86
|
-
revn: listing.revn,
|
|
87
|
-
frames: results.map((r) => ({ id: r.id, frame: r.frame, frameId: r.frameId, width: r.imageWidth, height: r.imageHeight, file: r.file, sha256: r.sha256, bytes: r.bytes })),
|
|
88
|
-
};
|
|
161
|
+
return { coverage: baselineCoverage(config, results), complete, fileId: listing.fileId, pageId: listing.pageId, page: config.page, penpotFile: String(listing.file), revn: Number(listing.revn), results, wrote };
|
|
89
162
|
}
|
|
90
163
|
/**
|
|
91
164
|
* Visual cases for Reflection, one per frame that names a route: the image's exact size is the viewport (so the capture and the
|
|
@@ -93,12 +166,18 @@ function manifestOf(config, listing, results) {
|
|
|
93
166
|
* Review-only (`strict: false`): a difference is shown to a person, not failed.
|
|
94
167
|
*/
|
|
95
168
|
export function reflectionCases(config, results) {
|
|
96
|
-
const routes =
|
|
169
|
+
const routes = Object.create(null);
|
|
97
170
|
const visualSmoke = [];
|
|
171
|
+
const pendingCases = [];
|
|
98
172
|
for (const wanted of config.frames) {
|
|
99
173
|
const result = results.find((r) => r.id === wanted.id);
|
|
100
174
|
if (!wanted.route || !result || result.imageWidth === undefined || result.imageHeight === undefined)
|
|
101
175
|
continue;
|
|
176
|
+
if (wanted.case) {
|
|
177
|
+
const approvedCase = result.approved && "case" in result.approved ? result.approved.case : undefined;
|
|
178
|
+
pendingCases.push({ id: wanted.id, route: wanted.route, case: approvedCase ?? wanted.case, baseline: result.file, ref: `penpair:${wanted.id}`, approvedProvenance: result.approved ?? null, captured: result.captured ?? null, approvedMetadata: Boolean(approvedCase && observationHash(approvedCase) === observationHash(wanted.case)), reason: "Explicit theme/state/fixture/font setup requires an implementation provider mapping", browserVerification: "not-run" });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
102
181
|
const viewport = { name: `design-${result.imageWidth}x${result.imageHeight}`, width: result.imageWidth, height: result.imageHeight };
|
|
103
182
|
const entry = (routes[wanted.route] ??= { viewports: [] });
|
|
104
183
|
if (!entry.viewports.some((v) => v.name === viewport.name))
|
|
@@ -108,7 +187,7 @@ export function reflectionCases(config, results) {
|
|
|
108
187
|
route: wanted.route,
|
|
109
188
|
viewport: viewport.name,
|
|
110
189
|
baselineRoot: config.root,
|
|
111
|
-
baseline:
|
|
190
|
+
baseline: result.file,
|
|
112
191
|
threshold: { maxDiffPixelRatio: config.threshold ?? 0.05 },
|
|
113
192
|
strict: false,
|
|
114
193
|
ref: `penpair:${wanted.id}`,
|
|
@@ -118,5 +197,6 @@ export function reflectionCases(config, results) {
|
|
|
118
197
|
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
198
|
routes,
|
|
120
199
|
visualSmoke,
|
|
200
|
+
pendingCases,
|
|
121
201
|
};
|
|
122
202
|
}
|
|
@@ -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,35 @@
|
|
|
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({ length: b.length + 1 }, (_, index) => index);
|
|
8
|
+
for (let i = 1; i <= a.length; i++) {
|
|
9
|
+
const next = [i];
|
|
10
|
+
for (let j = 1; j <= b.length; j++)
|
|
11
|
+
next[j] = Math.min(next[j - 1] + 1, previous[j] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
12
|
+
previous = next;
|
|
13
|
+
}
|
|
14
|
+
return previous[b.length];
|
|
15
|
+
}
|
|
16
|
+
/** Suggestions are explanatory only; never reinterpret a supplied option. */
|
|
17
|
+
export function parseCommandArgs(config) {
|
|
18
|
+
try {
|
|
19
|
+
return parseArgs(config);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
const value = error;
|
|
23
|
+
if (!value.code?.startsWith("ERR_PARSE_ARGS"))
|
|
24
|
+
throw error;
|
|
25
|
+
const unknown = /Unknown option ['"]--([^'"]+)['"]/.exec(value.message ?? "")?.[1];
|
|
26
|
+
let hint = "";
|
|
27
|
+
if (unknown && unknown.length <= 100) {
|
|
28
|
+
const candidates = Object.keys(config.options ?? {}).map((name) => ({ name, distance: distance(unknown.toLowerCase(), name.toLowerCase()) })).sort((a, b) => a.distance - b.distance || a.name.localeCompare(b.name));
|
|
29
|
+
const best = candidates[0];
|
|
30
|
+
if (best && best.distance <= Math.max(2, Math.ceil(unknown.length / 2)))
|
|
31
|
+
hint = ` Did you mean --${best.name}?`;
|
|
32
|
+
}
|
|
33
|
+
throw new CommandArgumentError((value.message ?? "Invalid command arguments") + hint);
|
|
34
|
+
}
|
|
35
|
+
}
|
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>;
|