@penpair/core 0.3.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters.d.ts +1 -1
- package/dist/adapters.js +3 -0
- package/dist/approvals.d.ts +4 -1
- package/dist/approvals.js +7 -4
- package/dist/baseline-coverage.js +3 -3
- package/dist/baselines-config.d.ts +3 -0
- package/dist/baselines-config.js +3 -0
- package/dist/baselines.d.ts +7 -0
- package/dist/baselines.js +90 -21
- package/dist/bounded-file.d.ts +2 -0
- package/dist/bounded-file.js +38 -0
- package/dist/cli-main.js +64 -3
- package/dist/design-commands/context.d.ts +9 -0
- package/dist/design-commands/context.js +1 -0
- package/dist/design-commands/index.d.ts +3 -0
- package/dist/design-commands/index.js +7 -0
- package/dist/design-commands/map-command.d.ts +4 -0
- package/dist/design-commands/map-command.js +685 -0
- package/dist/design-commands/release-command.d.ts +3 -0
- package/dist/design-commands/release-command.js +239 -0
- package/dist/design-releases/artifact-paths.d.ts +6 -0
- package/dist/design-releases/artifact-paths.js +82 -0
- package/dist/design-releases/assemble.d.ts +3 -0
- package/dist/design-releases/assemble.js +176 -0
- package/dist/design-releases/check.d.ts +16 -0
- package/dist/design-releases/check.js +323 -0
- package/dist/design-releases/context.d.ts +2 -0
- package/dist/design-releases/context.js +119 -0
- package/dist/design-releases/contract-graph.d.ts +13 -0
- package/dist/design-releases/contract-graph.js +75 -0
- package/dist/design-releases/fidelity.d.ts +23 -0
- package/dist/design-releases/fidelity.js +328 -0
- package/dist/design-releases/finalize.d.ts +4 -0
- package/dist/design-releases/finalize.js +201 -0
- package/dist/design-releases/index.d.ts +8 -0
- package/dist/design-releases/index.js +7 -0
- package/dist/design-releases/live.d.ts +22 -0
- package/dist/design-releases/live.js +97 -0
- package/dist/design-releases/snippets.d.ts +5 -0
- package/dist/design-releases/snippets.js +89 -0
- package/dist/design-releases/store.d.ts +10 -0
- package/dist/design-releases/store.js +205 -0
- package/dist/design-releases/types.d.ts +161 -0
- package/dist/design-releases/types.js +1 -0
- package/dist/design-releases/validation.d.ts +6 -0
- package/dist/design-releases/validation.js +384 -0
- package/dist/design-releases/verify.d.ts +3 -0
- package/dist/design-releases/verify.js +148 -0
- package/dist/image-codec.d.ts +3 -0
- package/dist/image-codec.js +78 -0
- package/dist/image-evidence.d.ts +15 -0
- package/dist/image-evidence.js +121 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/inspection.js +13 -5
- package/dist/instance/config-schema.d.ts +4 -0
- package/dist/instance/config-schema.js +7 -0
- package/dist/map-penpot/content-validation.d.ts +3 -0
- package/dist/map-penpot/content-validation.js +57 -0
- package/dist/map-penpot/content.d.ts +22 -0
- package/dist/map-penpot/content.js +173 -0
- package/dist/map-penpot/details.d.ts +4 -0
- package/dist/map-penpot/details.js +84 -0
- package/dist/map-penpot/evidence-error.d.ts +5 -0
- package/dist/map-penpot/evidence-error.js +9 -0
- package/dist/map-penpot/icons.d.ts +5 -0
- package/dist/map-penpot/icons.js +79 -0
- package/dist/map-penpot/import-content.d.ts +2 -0
- package/dist/map-penpot/import-content.js +150 -0
- package/dist/map-penpot/import.d.ts +5 -0
- package/dist/map-penpot/import.js +255 -0
- package/dist/map-penpot/index.d.ts +11 -0
- package/dist/map-penpot/index.js +10 -0
- package/dist/map-penpot/path-geometry.d.ts +5 -0
- package/dist/map-penpot/path-geometry.js +51 -0
- package/dist/map-penpot/presentation.d.ts +20 -0
- package/dist/map-penpot/presentation.js +205 -0
- package/dist/map-penpot/projection.d.ts +8 -0
- package/dist/map-penpot/projection.js +176 -0
- package/dist/map-penpot/publish.d.ts +4 -0
- package/dist/map-penpot/publish.js +301 -0
- package/dist/map-penpot/release-maps.d.ts +21 -0
- package/dist/map-penpot/release-maps.js +104 -0
- package/dist/map-penpot/snippets.d.ts +4 -0
- package/dist/map-penpot/snippets.js +164 -0
- package/dist/map-penpot/status-labels.d.ts +7 -0
- package/dist/map-penpot/status-labels.js +7 -0
- package/dist/map-penpot/types.d.ts +161 -0
- package/dist/map-penpot/types.js +1 -0
- package/dist/maps/annotation-layout.d.ts +24 -0
- package/dist/maps/annotation-layout.js +85 -0
- package/dist/maps/context.d.ts +13 -0
- package/dist/maps/context.js +207 -0
- package/dist/maps/index.d.ts +9 -0
- package/dist/maps/index.js +9 -0
- package/dist/maps/layout.d.ts +35 -0
- package/dist/maps/layout.js +364 -0
- package/dist/maps/proposal.d.ts +5 -0
- package/dist/maps/proposal.js +67 -0
- package/dist/maps/render.d.ts +4 -0
- package/dist/maps/render.js +331 -0
- package/dist/maps/review.d.ts +16 -0
- package/dist/maps/review.js +90 -0
- package/dist/maps/store.d.ts +21 -0
- package/dist/maps/store.js +222 -0
- package/dist/maps/types.d.ts +206 -0
- package/dist/maps/types.js +1 -0
- package/dist/maps/validation.d.ts +10 -0
- package/dist/maps/validation.js +375 -0
- package/dist/skill-routing.js +4 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
- package/skills/penpair-design-release/SKILL.md +55 -0
- package/skills/penpair-design-release/agents/openai.yaml +6 -0
- package/skills/penpair-design-release/references/workflow.md +55 -0
- package/skills/penpair-diagrams/SKILL.md +52 -0
- package/skills/penpair-diagrams/agents/openai.yaml +6 -0
- package/skills/penpair-diagrams/references/workflow.md +84 -0
- package/skills/penpair-handoff/SKILL.md +7 -0
- package/skills/penpair-handoff/references/handoff-flow.md +7 -1
- package/skills/penpair-integration-map/SKILL.md +44 -0
- package/skills/penpair-integration-map/agents/openai.yaml +6 -0
- package/skills/penpair-integration-map/references/workflow.md +33 -0
- package/skills/penpair-journey-map/SKILL.md +42 -0
- package/skills/penpair-journey-map/agents/openai.yaml +6 -0
- package/skills/penpair-journey-map/references/workflow.md +35 -0
- package/skills/penpair-verify/SKILL.md +7 -0
- package/skills/penpair-verify/references/browser-evidence.md +10 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { parseCommandArgs, CommandArgumentError } from "../cli-arguments.js";
|
|
4
|
+
import { atomicWriteFile } from "../atomic-output.js";
|
|
5
|
+
import { readProjectBytes } from "../bounded-file.js";
|
|
6
|
+
import { resolveProjectOutput } from "../project-input.js";
|
|
7
|
+
import { createArtifactReview } from "../maps/index.js";
|
|
8
|
+
import { planRelease, assembleRelease, verifyRelease, finalizeRelease, checkRelease, releaseContext, releaseDesignHash, readDesignRelease, writeDesignRelease, } from "../design-releases/index.js";
|
|
9
|
+
const releaseUsage = "design-release plan|assemble|verify|finalize|check|context --from FILE [--out FILE]";
|
|
10
|
+
async function readReleaseJson(context, file) {
|
|
11
|
+
return JSON.parse((await readProjectBytes(context.root, file, 16 * 1024 * 1024)).toString("utf8"));
|
|
12
|
+
}
|
|
13
|
+
async function writeReleaseOutput(context, file, data) {
|
|
14
|
+
const encoded = JSON.stringify(data, null, 2) + "\n";
|
|
15
|
+
if (context.redact(encoded) !== encoded) {
|
|
16
|
+
throw new Error("Release output requires credential redaction; no artifact was published");
|
|
17
|
+
}
|
|
18
|
+
const output = await resolveProjectOutput(context.root, file);
|
|
19
|
+
await mkdir(path.dirname(output), {
|
|
20
|
+
recursive: true,
|
|
21
|
+
});
|
|
22
|
+
await atomicWriteFile(output, encoded);
|
|
23
|
+
}
|
|
24
|
+
function validateReceiptInput(raw) {
|
|
25
|
+
if (!Array.isArray(raw) || raw.length > 100) {
|
|
26
|
+
throw new CommandArgumentError("--receipts must contain an array of at most 100 explicit sourceId/targetFrameId pairs");
|
|
27
|
+
}
|
|
28
|
+
return raw.map((entry) => {
|
|
29
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
30
|
+
throw new CommandArgumentError("Every transfer receipt needs sourceId and targetFrameId");
|
|
31
|
+
}
|
|
32
|
+
const record = entry;
|
|
33
|
+
if (typeof record.sourceId !== "string" ||
|
|
34
|
+
!record.sourceId ||
|
|
35
|
+
typeof record.targetFrameId !== "string" ||
|
|
36
|
+
!record.targetFrameId ||
|
|
37
|
+
Object.keys(record).some((key) => key !== "sourceId" && key !== "targetFrameId")) {
|
|
38
|
+
throw new CommandArgumentError("Every transfer receipt needs only sourceId and targetFrameId");
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
sourceId: record.sourceId,
|
|
42
|
+
targetFrameId: record.targetFrameId,
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** CLI entrypoint: local planning stays offline and all live work uses a strictly pinned connection. */
|
|
47
|
+
export async function runDesignReleaseCommand(args, context) {
|
|
48
|
+
const parsed = parseCommandArgs({
|
|
49
|
+
args,
|
|
50
|
+
allowPositionals: true,
|
|
51
|
+
options: {
|
|
52
|
+
from: {
|
|
53
|
+
type: "string",
|
|
54
|
+
},
|
|
55
|
+
out: {
|
|
56
|
+
type: "string",
|
|
57
|
+
},
|
|
58
|
+
receipts: {
|
|
59
|
+
type: "string",
|
|
60
|
+
},
|
|
61
|
+
apply: {
|
|
62
|
+
type: "boolean",
|
|
63
|
+
},
|
|
64
|
+
live: {
|
|
65
|
+
type: "boolean",
|
|
66
|
+
},
|
|
67
|
+
draft: {
|
|
68
|
+
type: "boolean",
|
|
69
|
+
},
|
|
70
|
+
resume: {
|
|
71
|
+
type: "string",
|
|
72
|
+
},
|
|
73
|
+
by: {
|
|
74
|
+
type: "string",
|
|
75
|
+
},
|
|
76
|
+
note: {
|
|
77
|
+
type: "string",
|
|
78
|
+
},
|
|
79
|
+
json: {
|
|
80
|
+
type: "boolean",
|
|
81
|
+
},
|
|
82
|
+
help: {
|
|
83
|
+
type: "boolean",
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
const { values, positionals } = parsed;
|
|
88
|
+
const [action] = positionals;
|
|
89
|
+
const allowed = {
|
|
90
|
+
plan: [],
|
|
91
|
+
assemble: [
|
|
92
|
+
"apply",
|
|
93
|
+
"resume",
|
|
94
|
+
],
|
|
95
|
+
verify: [
|
|
96
|
+
"receipts",
|
|
97
|
+
],
|
|
98
|
+
finalize: [
|
|
99
|
+
"apply",
|
|
100
|
+
"resume",
|
|
101
|
+
"by",
|
|
102
|
+
"note",
|
|
103
|
+
],
|
|
104
|
+
check: [
|
|
105
|
+
"live",
|
|
106
|
+
],
|
|
107
|
+
context: [
|
|
108
|
+
"draft",
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
if (values.help) {
|
|
112
|
+
context.output.log(releaseUsage);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
if (!action || !Object.hasOwn(allowed, action) || positionals.length !== 1 || !values.from) {
|
|
116
|
+
throw new CommandArgumentError(releaseUsage);
|
|
117
|
+
}
|
|
118
|
+
const acceptedFlags = new Set([
|
|
119
|
+
"from",
|
|
120
|
+
"out",
|
|
121
|
+
"json",
|
|
122
|
+
...allowed[action],
|
|
123
|
+
]);
|
|
124
|
+
for (const flag of Object.keys(values)) {
|
|
125
|
+
if (!acceptedFlags.has(flag)) {
|
|
126
|
+
throw new CommandArgumentError(`--${flag} is not supported by design-release ${action}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (values.resume && !values.apply) {
|
|
130
|
+
throw new CommandArgumentError("--resume requires --apply");
|
|
131
|
+
}
|
|
132
|
+
if (action === "finalize" &&
|
|
133
|
+
((values.apply && (!values.by?.trim() || !values.note?.trim())) ||
|
|
134
|
+
(!values.apply && (values.by || values.note)))) {
|
|
135
|
+
throw new CommandArgumentError("Finalization previews by default; --apply requires --by and --note with the owner's exact words");
|
|
136
|
+
}
|
|
137
|
+
if (action === "plan") {
|
|
138
|
+
const planned = await planRelease(context.root, await readReleaseJson(context, values.from), {
|
|
139
|
+
redact: context.redact,
|
|
140
|
+
});
|
|
141
|
+
if (values.out) {
|
|
142
|
+
await writeDesignRelease(context.root, values.out, planned, {
|
|
143
|
+
redact: context.redact,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
context.output.result(planned);
|
|
147
|
+
context.output.log(JSON.stringify(planned, null, 2));
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
const release = await readDesignRelease(context.root, values.from);
|
|
151
|
+
const options = {
|
|
152
|
+
apply: values.apply === true,
|
|
153
|
+
resume: !!values.resume,
|
|
154
|
+
runId: values.resume,
|
|
155
|
+
pointerPath: values.out ?? values.from,
|
|
156
|
+
redact: (value) => context.redact(value),
|
|
157
|
+
onStep: (message) => context.output.error(`… ${message}`),
|
|
158
|
+
};
|
|
159
|
+
if (action === "assemble") {
|
|
160
|
+
const assembled = await context.withPenpot((client) => assembleRelease(client, context.root, release, options));
|
|
161
|
+
if (values.apply) {
|
|
162
|
+
await writeDesignRelease(context.root, values.out ?? values.from, assembled, {
|
|
163
|
+
redact: context.redact,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
else if (values.out) {
|
|
167
|
+
await writeReleaseOutput(context, values.out, assembled);
|
|
168
|
+
}
|
|
169
|
+
context.output.result(assembled);
|
|
170
|
+
context.output.log(JSON.stringify(assembled, null, 2));
|
|
171
|
+
return assembled.status === "final" || assembled.status === "verified" ? 0 : 3;
|
|
172
|
+
}
|
|
173
|
+
if (action === "verify") {
|
|
174
|
+
if (!values.receipts) {
|
|
175
|
+
throw new CommandArgumentError("design-release verify requires --receipts FILE with explicit source/target frame IDs");
|
|
176
|
+
}
|
|
177
|
+
const receipts = validateReceiptInput(await readReleaseJson(context, values.receipts));
|
|
178
|
+
const verified = await context.withPenpot((client) => verifyRelease(client, context.root, release, receipts, options));
|
|
179
|
+
await writeDesignRelease(context.root, values.out ?? values.from, verified, {
|
|
180
|
+
redact: context.redact,
|
|
181
|
+
});
|
|
182
|
+
context.output.result(verified);
|
|
183
|
+
context.output.log(JSON.stringify(verified, null, 2));
|
|
184
|
+
return verified.status === "verified" ? 0 : 3;
|
|
185
|
+
}
|
|
186
|
+
if (action === "finalize") {
|
|
187
|
+
if (!values.apply) {
|
|
188
|
+
const checked = await checkRelease(undefined, context.root, release);
|
|
189
|
+
const preview = {
|
|
190
|
+
...checked,
|
|
191
|
+
recorded: false,
|
|
192
|
+
artifactHash: releaseDesignHash(release),
|
|
193
|
+
requiresLiveVerification: true,
|
|
194
|
+
};
|
|
195
|
+
if (values.out) {
|
|
196
|
+
await writeReleaseOutput(context, values.out, preview);
|
|
197
|
+
}
|
|
198
|
+
context.output.result(preview);
|
|
199
|
+
context.output.log(JSON.stringify(preview, null, 2));
|
|
200
|
+
return checked.valid ? 0 : 3;
|
|
201
|
+
}
|
|
202
|
+
const review = createArtifactReview({
|
|
203
|
+
artifactKind: "design-release",
|
|
204
|
+
artifactId: release.id,
|
|
205
|
+
artifactHash: releaseDesignHash(release),
|
|
206
|
+
by: values.by,
|
|
207
|
+
note: values.note,
|
|
208
|
+
});
|
|
209
|
+
const finalized = await context.withPenpot((client) => finalizeRelease(client, context.root, release, review, options));
|
|
210
|
+
context.output.result(finalized);
|
|
211
|
+
context.output.log(JSON.stringify(finalized, null, 2));
|
|
212
|
+
return finalized.status === "final" ? 0 : 3;
|
|
213
|
+
}
|
|
214
|
+
if (action === "check") {
|
|
215
|
+
const checked = values.live
|
|
216
|
+
? await context.withPenpot((client) => checkRelease(client, context.root, release))
|
|
217
|
+
: await checkRelease(undefined, context.root, release);
|
|
218
|
+
if (values.out) {
|
|
219
|
+
await writeReleaseOutput(context, values.out, checked);
|
|
220
|
+
}
|
|
221
|
+
context.output.result(checked);
|
|
222
|
+
context.output.log(JSON.stringify(checked, null, 2));
|
|
223
|
+
return checked.valid
|
|
224
|
+
? 0
|
|
225
|
+
: checked.reasons.some((reason) => /changed|differ|drift/i.test(reason))
|
|
226
|
+
? 2
|
|
227
|
+
: 3;
|
|
228
|
+
}
|
|
229
|
+
if (release.status !== "final" && !values.draft) {
|
|
230
|
+
throw new CommandArgumentError("Release context requires a finalized release; use --draft to inspect explicitly unapproved context");
|
|
231
|
+
}
|
|
232
|
+
const generated = await releaseContext(context.root, release);
|
|
233
|
+
if (values.out) {
|
|
234
|
+
await writeReleaseOutput(context, values.out, generated);
|
|
235
|
+
}
|
|
236
|
+
context.output.result(generated);
|
|
237
|
+
context.output.log(JSON.stringify(generated, null, 2));
|
|
238
|
+
return generated.ready ? 0 : 3;
|
|
239
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ReleaseArtifactRef } from "./types.js";
|
|
2
|
+
export declare const maximumArtifactBytes: number;
|
|
3
|
+
export declare function hashBytes(data: string | Uint8Array): string;
|
|
4
|
+
export declare function artifactPath(root: string, relative: string, createParents?: boolean): Promise<string>;
|
|
5
|
+
export declare function readArtifact(root: string, reference: ReleaseArtifactRef, maximum?: number): Promise<Buffer>;
|
|
6
|
+
export declare function saveArtifact(root: string, relative: string, bytes: string | Uint8Array, redact?: (text: string) => string): Promise<ReleaseArtifactRef>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, realpath } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { atomicWriteFile } from "../atomic-output.js";
|
|
5
|
+
import { readProjectBytes } from "../bounded-file.js";
|
|
6
|
+
export const maximumArtifactBytes = 16 * 1024 * 1024;
|
|
7
|
+
export function hashBytes(data) {
|
|
8
|
+
return createHash("sha256").update(data).digest("hex");
|
|
9
|
+
}
|
|
10
|
+
export async function artifactPath(root, relative, createParents = false) {
|
|
11
|
+
if (typeof relative !== "string" ||
|
|
12
|
+
!relative ||
|
|
13
|
+
relative.length > 1024 ||
|
|
14
|
+
path.isAbsolute(relative) ||
|
|
15
|
+
relative.includes("\\") ||
|
|
16
|
+
relative.includes("\0") ||
|
|
17
|
+
relative.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
|
|
18
|
+
throw new Error("Release artifact path must be a bounded project-relative path without traversal");
|
|
19
|
+
}
|
|
20
|
+
let current = await realpath(root);
|
|
21
|
+
const segments = relative.split("/");
|
|
22
|
+
for (const [index, segment] of segments.entries()) {
|
|
23
|
+
current = path.join(current, segment);
|
|
24
|
+
const parent = index < segments.length - 1;
|
|
25
|
+
try {
|
|
26
|
+
if (parent && createParents) {
|
|
27
|
+
await mkdir(current, {
|
|
28
|
+
recursive: false,
|
|
29
|
+
}).catch((error) => {
|
|
30
|
+
if (error.code !== "EEXIST") {
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const info = await lstat(current);
|
|
36
|
+
if (info.isSymbolicLink() || (parent ? !info.isDirectory() : !info.isFile())) {
|
|
37
|
+
throw new Error("Release artifact paths must use real directories and regular files");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code !== "ENOENT" || (parent && createParents)) {
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
if (parent && !createParents) {
|
|
45
|
+
return path.join(current, ...segments.slice(index + 1));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return current;
|
|
50
|
+
}
|
|
51
|
+
export async function readArtifact(root, reference, maximum = maximumArtifactBytes) {
|
|
52
|
+
await artifactPath(root, reference.path);
|
|
53
|
+
const bytes = await readProjectBytes(root, reference.path, maximum);
|
|
54
|
+
if (bytes.length > maximum || hashBytes(bytes) !== reference.sha256) {
|
|
55
|
+
throw new Error(`Release artifact hash mismatch: ${reference.path}`);
|
|
56
|
+
}
|
|
57
|
+
return bytes;
|
|
58
|
+
}
|
|
59
|
+
export async function saveArtifact(root, relative, bytes, redact) {
|
|
60
|
+
const text = typeof bytes === "string" ? bytes : Buffer.from(bytes).toString("utf8");
|
|
61
|
+
if (redact && redact(text) !== text) {
|
|
62
|
+
throw new Error("Release evidence contains protected data and cannot be persisted");
|
|
63
|
+
}
|
|
64
|
+
const destination = await artifactPath(root, relative, true);
|
|
65
|
+
const sha256 = hashBytes(bytes);
|
|
66
|
+
try {
|
|
67
|
+
await atomicWriteFile(destination, bytes);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error.code !== "EEXIST") {
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
const actual = await readProjectBytes(root, relative, Math.max(bytes.length, 1));
|
|
74
|
+
if (hashBytes(actual) !== sha256) {
|
|
75
|
+
throw new Error("Immutable release artifact already exists with different bytes");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
path: relative,
|
|
80
|
+
sha256,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { exportShapePng } from "../export.js";
|
|
3
|
+
import { runJournaledSteps, readJournalTarget, observationHash, readJournal } from "../journal.js";
|
|
4
|
+
import { withPageSession, rememberPage } from "../page-session.js";
|
|
5
|
+
import { runSnippet } from "../snippet.js";
|
|
6
|
+
import { assembleReleaseMaps } from "../map-penpot/index.js";
|
|
7
|
+
import { saveArtifact } from "./artifact-paths.js";
|
|
8
|
+
import { decodedPixels, inspectionContentHash } from "./fidelity.js";
|
|
9
|
+
import { assertSourceUnchanged, captureReleaseScreen, observeRelease, releaseClient, } from "./live.js";
|
|
10
|
+
import { readReleaseContract, assertReleaseContracts } from "./store.js";
|
|
11
|
+
import { assembleReleaseSnippet } from "./snippets.js";
|
|
12
|
+
import { validateDesignRelease } from "./validation.js";
|
|
13
|
+
export async function assembleRelease(client, root, raw, options = {}) {
|
|
14
|
+
const release = validateDesignRelease(raw);
|
|
15
|
+
const encoded = JSON.stringify(release);
|
|
16
|
+
if (options.redact && options.redact(encoded) !== encoded) {
|
|
17
|
+
throw new Error("Protected data in release record; no native writes applied");
|
|
18
|
+
}
|
|
19
|
+
if (release.status === "final" || release.status === "verified") {
|
|
20
|
+
throw new Error("Verified or finalized design releases cannot be reassembled");
|
|
21
|
+
}
|
|
22
|
+
await assertReleaseContracts(root, release.contracts, false, undefined, release.fileId);
|
|
23
|
+
const contracts = await Promise.all(release.contracts.map((reference) => readReleaseContract(root, reference)));
|
|
24
|
+
for (const contract of contracts) {
|
|
25
|
+
const encoded = JSON.stringify(contract);
|
|
26
|
+
if (options.redact && options.redact(encoded) !== encoded) {
|
|
27
|
+
throw new Error("Protected data in release map contract; no native writes applied");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
client = releaseClient(client, release);
|
|
31
|
+
return withPageSession(client, async () => {
|
|
32
|
+
for (const source of release.sources) {
|
|
33
|
+
await assertSourceUnchanged(client, root, release, source);
|
|
34
|
+
}
|
|
35
|
+
const initial = await observeRelease(client, release);
|
|
36
|
+
if (initial.marker && initial.marker.status !== "draft") {
|
|
37
|
+
throw new Error("Release version is already finalized remotely");
|
|
38
|
+
}
|
|
39
|
+
if (!options.apply) {
|
|
40
|
+
return release;
|
|
41
|
+
}
|
|
42
|
+
const runId = options.runId ?? randomUUID();
|
|
43
|
+
const complete = (value) => Boolean(value.pageId &&
|
|
44
|
+
value.overviewFrameId &&
|
|
45
|
+
value.marker?.status === "draft" &&
|
|
46
|
+
value.marker.ownerKey === release.target.ownerKey);
|
|
47
|
+
await runJournaledSteps([
|
|
48
|
+
{
|
|
49
|
+
key: "page-and-overview",
|
|
50
|
+
label: "Create owned release page and draft overview",
|
|
51
|
+
signature: assembleReleaseSnippet(release).code,
|
|
52
|
+
initial,
|
|
53
|
+
inspect: () => observeRelease(client, release),
|
|
54
|
+
apply: async () => {
|
|
55
|
+
const result = await runSnippet(client, assembleReleaseSnippet(release), {
|
|
56
|
+
retries: 0,
|
|
57
|
+
effect: "write",
|
|
58
|
+
});
|
|
59
|
+
if (typeof result.pageId !== "string") {
|
|
60
|
+
throw new Error("Assembly did not return page identity");
|
|
61
|
+
}
|
|
62
|
+
rememberPage(client, {
|
|
63
|
+
fileId: release.fileId,
|
|
64
|
+
pageId: result.pageId,
|
|
65
|
+
});
|
|
66
|
+
return result;
|
|
67
|
+
},
|
|
68
|
+
verify: complete,
|
|
69
|
+
reconcile: (_before, current) => complete(current)
|
|
70
|
+
? "complete"
|
|
71
|
+
: current.marker && current.marker.status !== "draft"
|
|
72
|
+
? "conflict"
|
|
73
|
+
: "pending",
|
|
74
|
+
},
|
|
75
|
+
], {
|
|
76
|
+
root,
|
|
77
|
+
runId,
|
|
78
|
+
resume: options.resume,
|
|
79
|
+
operation: "design-release-assemble",
|
|
80
|
+
input: {
|
|
81
|
+
id: release.id,
|
|
82
|
+
version: release.productVersion,
|
|
83
|
+
ownerKey: release.target.ownerKey,
|
|
84
|
+
},
|
|
85
|
+
target: () => readJournalTarget(client),
|
|
86
|
+
onStep: options.onStep,
|
|
87
|
+
redact: options.redact,
|
|
88
|
+
});
|
|
89
|
+
const observed = await observeRelease(client, release);
|
|
90
|
+
if (!complete(observed)) {
|
|
91
|
+
throw new Error("Independent release page readback failed");
|
|
92
|
+
}
|
|
93
|
+
const assembled = {
|
|
94
|
+
...release,
|
|
95
|
+
status: "awaiting-copy",
|
|
96
|
+
target: {
|
|
97
|
+
...release.target,
|
|
98
|
+
pageId: observed.pageId,
|
|
99
|
+
overviewFrameId: observed.overviewFrameId,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
const journals = {};
|
|
103
|
+
for (const [index, contract] of contracts.entries()) {
|
|
104
|
+
const mapRunId = `map-${observationHash({
|
|
105
|
+
runId,
|
|
106
|
+
index,
|
|
107
|
+
}).slice(0, 48)}`;
|
|
108
|
+
let resume = false;
|
|
109
|
+
if (options.resume) {
|
|
110
|
+
try {
|
|
111
|
+
await readJournal(root, mapRunId);
|
|
112
|
+
resume = true;
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (error.code !== "ENOENT") {
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
journals[`${contract.kind}:${contract.id}`] = {
|
|
121
|
+
runId: mapRunId,
|
|
122
|
+
resume,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const maps = await assembleReleaseMaps(client, root, {
|
|
126
|
+
fileId: release.fileId,
|
|
127
|
+
pageId: observed.pageId,
|
|
128
|
+
}, contracts, {
|
|
129
|
+
...options.exportOptions,
|
|
130
|
+
apply: true,
|
|
131
|
+
journals,
|
|
132
|
+
redact: options.redact,
|
|
133
|
+
origin: {
|
|
134
|
+
x: 0,
|
|
135
|
+
y: 1000,
|
|
136
|
+
},
|
|
137
|
+
onStep: options.onStep,
|
|
138
|
+
});
|
|
139
|
+
for (const map of maps) {
|
|
140
|
+
const { projection, image, report } = map.publication;
|
|
141
|
+
if (!report.verified || !projection.boardId || !image?.stable) {
|
|
142
|
+
throw new Error("Native release map publication is incomplete");
|
|
143
|
+
}
|
|
144
|
+
const prefix = `design/releases/${release.productVersion}/evidence/maps/${map.mapKind}-${map.mapId}`;
|
|
145
|
+
assembled.maps = assembled.maps.filter((item) => item.kind !== map.mapKind || item.id !== map.mapId);
|
|
146
|
+
assembled.maps.push({
|
|
147
|
+
kind: map.mapKind,
|
|
148
|
+
id: map.mapId,
|
|
149
|
+
boardId: projection.boardId,
|
|
150
|
+
contractHash: map.contractHash,
|
|
151
|
+
projection: await saveArtifact(root, `${prefix}-${projection.fingerprint}.json`, `${JSON.stringify(projection, null, 2)}\n`, options.redact),
|
|
152
|
+
render: await saveArtifact(root, `${prefix}-${observationHash(image.capture)}.png`, image.png, options.redact),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const overview = await captureReleaseScreen(client, release.fileId, observed.pageId, observed.overviewFrameId);
|
|
156
|
+
const image = await exportShapePng(client, observed.overviewFrameId, {
|
|
157
|
+
...options.exportOptions,
|
|
158
|
+
stable: true,
|
|
159
|
+
});
|
|
160
|
+
if (!image.stable ||
|
|
161
|
+
image.capture.fileId !== release.fileId ||
|
|
162
|
+
image.capture.pageId !== observed.pageId) {
|
|
163
|
+
throw new Error("Release overview render did not stabilize");
|
|
164
|
+
}
|
|
165
|
+
const pixels = await decodedPixels(image.png);
|
|
166
|
+
assembled.overview = {
|
|
167
|
+
contentHash: inspectionContentHash(overview),
|
|
168
|
+
decodedPixelsHash: pixels.hash,
|
|
169
|
+
render: await saveArtifact(root, `design/releases/${release.productVersion}/evidence/overview-${pixels.hash}.png`, image.png, options.redact),
|
|
170
|
+
};
|
|
171
|
+
for (const source of release.sources) {
|
|
172
|
+
await assertSourceUnchanged(client, root, release, source);
|
|
173
|
+
}
|
|
174
|
+
return validateDesignRelease(assembled);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PenpotToolClient } from "../client.js";
|
|
2
|
+
import type { DesignRelease, ReleaseArtifactRef, ReleaseCheck, ReleaseOptions } from "./types.js";
|
|
3
|
+
export interface ReleaseBundle {
|
|
4
|
+
schemaVersion: 1;
|
|
5
|
+
kind: "approved-design-release";
|
|
6
|
+
designHash: string;
|
|
7
|
+
release: DesignRelease;
|
|
8
|
+
review: DesignRelease["reviews"][number];
|
|
9
|
+
artifacts: Array<{
|
|
10
|
+
original: ReleaseArtifactRef;
|
|
11
|
+
snapshot: ReleaseArtifactRef;
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
export declare function releaseReferences(release: DesignRelease): ReleaseArtifactRef[];
|
|
15
|
+
export declare function readReleaseBundle(root: string, release: DesignRelease): Promise<ReleaseBundle>;
|
|
16
|
+
export declare function checkRelease(client: PenpotToolClient | undefined, root: string, raw: DesignRelease, options?: ReleaseOptions): Promise<ReleaseCheck>;
|