@penpair/core 0.2.0 → 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.js +58 -11
- package/dist/approvals.js +143 -29
- package/dist/atomic-output.js +9 -4
- package/dist/audit-rules.js +345 -79
- package/dist/backup-note.js +29 -8
- package/dist/baseline-coverage.js +169 -35
- package/dist/baselines-config.js +119 -29
- package/dist/baselines.js +280 -57
- package/dist/cli-arguments.js +18 -6
- package/dist/cli-main.js +1621 -392
- package/dist/client.d.ts +5 -2
- package/dist/client.js +127 -28
- package/dist/command-result.js +341 -52
- package/dist/coordination.js +91 -29
- package/dist/credentials.js +57 -13
- package/dist/deep-check.js +56 -12
- package/dist/design-spec.js +189 -49
- package/dist/endpoint.js +102 -34
- package/dist/export.js +86 -18
- package/dist/file-archive.js +33 -7
- package/dist/file-guard.js +29 -11
- package/dist/formats.js +215 -14
- package/dist/index.d.ts +8 -7
- package/dist/index.js +8 -7
- package/dist/inspection.d.ts +1 -1
- package/dist/inspection.js +378 -65
- package/dist/instance/config-schema.js +400 -41
- package/dist/instance/config.js +101 -30
- package/dist/instance/connect.js +151 -44
- package/dist/instance/connection.d.ts +26 -5
- package/dist/instance/connection.js +194 -21
- 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 +26 -8
- package/dist/journal.d.ts +10 -0
- package/dist/journal.js +212 -49
- package/dist/kit-smoke.js +82 -20
- package/dist/kit-source.js +10 -40
- package/dist/kit.js +102 -34
- package/dist/layout-policy.d.ts +2 -0
- package/dist/layout-policy.js +86 -0
- package/dist/layout.d.ts +7 -1
- package/dist/layout.js +27 -70
- package/dist/live-smoke.js +13 -4
- package/dist/local-profile.js +2 -1
- package/dist/mcp-server.js +360 -47
- package/dist/native-api.js +47 -11
- package/dist/onboard.js +33 -7
- package/dist/operation.d.ts +2 -2
- package/dist/operation.js +279 -69
- package/dist/page-session.d.ts +23 -0
- package/dist/page-session.js +142 -0
- package/dist/png.js +18 -3
- package/dist/portable-tokens.js +254 -57
- package/dist/probe.d.ts +1 -1
- package/dist/probe.js +48 -11
- package/dist/project-input.js +24 -10
- package/dist/project-snippets.js +18 -6
- package/dist/project-status.js +133 -24
- package/dist/registry.js +217 -60
- package/dist/run-file.js +129 -30
- package/dist/shots.js +108 -26
- package/dist/skill-instructions.js +91 -22
- package/dist/skills.js +129 -39
- package/dist/snippet.js +27 -7
- package/dist/snippets/read.js +8 -2
- package/dist/structure-snapshot.d.ts +12 -0
- package/dist/structure-snapshot.js +439 -85
- package/dist/token-edit.js +219 -64
- package/dist/token-projection.d.ts +56 -0
- package/dist/token-projection.js +286 -0
- package/dist/tokens-diff.js +125 -32
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/penpair-design/SKILL.md +3 -0
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
- package/skills/penpair-design/references/project-facts.md +2 -0
- package/skills/penpair-handoff/SKILL.md +2 -0
- package/skills/penpair-handoff/references/comparison-companion.md +13 -0
- package/skills/penpair-handoff/references/handoff-flow.md +2 -0
- package/skills/penpair-recipes/SKILL.md +5 -0
- package/skills/penpair-setup/SKILL.md +3 -1
- 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/design-spec.js
CHANGED
|
@@ -4,132 +4,272 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import { designDataBlock, resolveProjectInput, resolveProjectOutput } from "./project-input.js";
|
|
5
5
|
import { atomicWriteFile } from "./atomic-output.js";
|
|
6
6
|
import { observationHash } from "./journal.js";
|
|
7
|
-
export const emptyComponentMap = {
|
|
7
|
+
export const emptyComponentMap = {
|
|
8
|
+
schemaVersion: 1,
|
|
9
|
+
components: [],
|
|
10
|
+
};
|
|
8
11
|
export async function readDesignJson(root, file, maxBytes = 16 * 1024 * 1024) {
|
|
9
12
|
const input = await resolveProjectInput(root, file);
|
|
10
|
-
if ((await stat(input)).size > maxBytes)
|
|
13
|
+
if ((await stat(input)).size > maxBytes) {
|
|
11
14
|
throw new Error("Design artifact exceeds the input byte budget");
|
|
15
|
+
}
|
|
12
16
|
return JSON.parse(await readFile(input, "utf8"));
|
|
13
17
|
}
|
|
14
18
|
export function validateComponentMap(raw) {
|
|
15
19
|
const v = raw;
|
|
16
|
-
if (!v || v.schemaVersion !== 1 || !Array.isArray(v.components) || v.components.length > 1000)
|
|
20
|
+
if (!v || v.schemaVersion !== 1 || !Array.isArray(v.components) || v.components.length > 1000) {
|
|
17
21
|
throw new Error("Unsupported component-map schema");
|
|
22
|
+
}
|
|
18
23
|
const seen = new Set();
|
|
19
24
|
for (const c of v.components) {
|
|
20
|
-
if (!c ||
|
|
25
|
+
if (!c ||
|
|
26
|
+
typeof c.libraryId !== "string" ||
|
|
27
|
+
!c.libraryId ||
|
|
28
|
+
typeof c.componentId !== "string" ||
|
|
29
|
+
!c.componentId ||
|
|
30
|
+
!/^[A-Z][A-Za-z0-9]*(?:\.[A-Z][A-Za-z0-9]*)*$/.test(c.codeComponent)) {
|
|
21
31
|
throw new Error("Mappings require explicit library/component IDs and an identifier codeComponent");
|
|
22
|
-
|
|
23
|
-
|
|
32
|
+
}
|
|
33
|
+
const id = JSON.stringify([
|
|
34
|
+
c.libraryId,
|
|
35
|
+
c.componentId,
|
|
36
|
+
]);
|
|
37
|
+
if (seen.has(id)) {
|
|
24
38
|
throw new Error("Ambiguous component mapping");
|
|
39
|
+
}
|
|
25
40
|
seen.add(id);
|
|
26
|
-
for (const [key, value] of Object.entries(c.props ?? {}))
|
|
27
|
-
if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(key) ||
|
|
41
|
+
for (const [key, value] of Object.entries(c.props ?? {})) {
|
|
42
|
+
if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(key) ||
|
|
43
|
+
(![
|
|
44
|
+
"string",
|
|
45
|
+
"number",
|
|
46
|
+
"boolean",
|
|
47
|
+
].includes(typeof value) &&
|
|
48
|
+
value !== null) ||
|
|
49
|
+
(typeof value === "number" && !Number.isFinite(value))) {
|
|
28
50
|
throw new Error("Mapping props must be literal scalar data");
|
|
29
|
-
|
|
30
|
-
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const [key, value] of Object.entries(c.textProps ?? {})) {
|
|
54
|
+
if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(key) || typeof value !== "string" || !value) {
|
|
31
55
|
throw new Error("textProps map prop names to explicit descendant node IDs");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
32
58
|
}
|
|
33
59
|
return structuredClone(v);
|
|
34
60
|
}
|
|
35
61
|
/** A complete single-root tree is required; no missing descendant may quietly disappear from the handoff. */
|
|
36
62
|
export function validateDesignInspection(raw) {
|
|
37
63
|
const v = raw;
|
|
38
|
-
if (!v ||
|
|
64
|
+
if (!v ||
|
|
65
|
+
v.schemaVersion !== 1 ||
|
|
66
|
+
v.kind !== "shape-inspection" ||
|
|
67
|
+
v.complete !== true ||
|
|
68
|
+
v.redacted === true ||
|
|
69
|
+
typeof v.fileId !== "string" ||
|
|
70
|
+
!v.fileId ||
|
|
71
|
+
typeof v.pageId !== "string" ||
|
|
72
|
+
!v.pageId ||
|
|
73
|
+
!Number.isInteger(v.revision) ||
|
|
74
|
+
!Array.isArray(v.nodes) ||
|
|
75
|
+
!v.nodes.length ||
|
|
76
|
+
v.nodes.length > 50000 ||
|
|
77
|
+
!Array.isArray(v.reasons) ||
|
|
78
|
+
v.reasons.length) {
|
|
39
79
|
throw new Error("A complete unredacted schema-1 shape inspection is required");
|
|
80
|
+
}
|
|
40
81
|
const nodes = v.nodes;
|
|
41
|
-
const byId = new Map(nodes.map(n => [
|
|
42
|
-
|
|
82
|
+
const byId = new Map(nodes.map((n) => [
|
|
83
|
+
n.id,
|
|
84
|
+
n,
|
|
85
|
+
]));
|
|
86
|
+
if (byId.size !== nodes.length) {
|
|
43
87
|
throw new Error("Duplicate design node identity");
|
|
88
|
+
}
|
|
44
89
|
let roots = 0;
|
|
45
90
|
for (const n of nodes) {
|
|
46
|
-
if (typeof n.id !== "string" ||
|
|
91
|
+
if (typeof n.id !== "string" ||
|
|
92
|
+
!n.id ||
|
|
93
|
+
!Array.isArray(n.childIds) ||
|
|
94
|
+
n.childIds.length !== n.childrenTotal ||
|
|
95
|
+
!Number.isInteger(n.index) ||
|
|
96
|
+
Number(n.index) < 0) {
|
|
47
97
|
throw new Error("Invalid design node identity/hierarchy");
|
|
48
|
-
|
|
98
|
+
}
|
|
99
|
+
if (n.parentId === null) {
|
|
49
100
|
roots++;
|
|
50
|
-
|
|
101
|
+
}
|
|
102
|
+
else if (typeof n.parentId !== "string" || !byId.has(n.parentId)) {
|
|
51
103
|
throw new Error("Missing design parent");
|
|
104
|
+
}
|
|
52
105
|
for (const [index, childId] of n.childIds.entries()) {
|
|
53
106
|
const child = byId.get(childId);
|
|
54
|
-
if (!child || child.parentId !== n.id || child.index !== index)
|
|
107
|
+
if (!child || child.parentId !== n.id || child.index !== index) {
|
|
55
108
|
throw new Error("Incomplete or inconsistent design hierarchy");
|
|
109
|
+
}
|
|
56
110
|
}
|
|
57
|
-
if (n.parentId !== null &&
|
|
111
|
+
if (n.parentId !== null &&
|
|
112
|
+
(byId.get(n.parentId)?.childIds)[Number(n.index)] !== n.id) {
|
|
58
113
|
throw new Error("Unlinked design node");
|
|
114
|
+
}
|
|
59
115
|
}
|
|
60
|
-
if (roots !== 1)
|
|
116
|
+
if (roots !== 1) {
|
|
61
117
|
throw new Error("Design capture must have one root");
|
|
62
|
-
|
|
118
|
+
}
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
const stack = [
|
|
121
|
+
nodes.find((n) => n.parentId === null),
|
|
122
|
+
];
|
|
63
123
|
while (stack.length) {
|
|
64
124
|
const n = stack.pop();
|
|
65
|
-
if (seen.has(n.id))
|
|
125
|
+
if (seen.has(n.id)) {
|
|
66
126
|
throw new Error("Design hierarchy cycle");
|
|
127
|
+
}
|
|
67
128
|
seen.add(n.id);
|
|
68
|
-
stack.push(...n.childIds.map(id => byId.get(id)));
|
|
129
|
+
stack.push(...n.childIds.map((id) => byId.get(id)));
|
|
69
130
|
}
|
|
70
|
-
if (seen.size !== nodes.length)
|
|
131
|
+
if (seen.size !== nodes.length) {
|
|
71
132
|
throw new Error("Disconnected design nodes");
|
|
133
|
+
}
|
|
72
134
|
return v;
|
|
73
135
|
}
|
|
74
136
|
export function designSource(inspection) {
|
|
75
137
|
const v = validateDesignInspection(inspection);
|
|
76
|
-
const nodes = [
|
|
77
|
-
|
|
138
|
+
const nodes = [
|
|
139
|
+
...v.nodes,
|
|
140
|
+
].sort((a, b) => String(a.id).localeCompare(String(b.id), "en"));
|
|
141
|
+
return {
|
|
142
|
+
fileId: v.fileId,
|
|
143
|
+
pageId: v.pageId,
|
|
144
|
+
revision: v.revision,
|
|
145
|
+
frameId: String(nodes.find((n) => n.parentId === null).id),
|
|
146
|
+
sha256: observationHash({
|
|
147
|
+
fileId: v.fileId,
|
|
148
|
+
pageId: v.pageId,
|
|
149
|
+
revision: v.revision,
|
|
150
|
+
scope: v.scope ?? null,
|
|
151
|
+
nodes,
|
|
152
|
+
}),
|
|
153
|
+
};
|
|
78
154
|
}
|
|
79
155
|
export function buildDesignSpec(raw, mapping = emptyComponentMap) {
|
|
80
|
-
const capture = validateDesignInspection(raw)
|
|
156
|
+
const capture = validateDesignInspection(raw);
|
|
157
|
+
const map = validateComponentMap(mapping);
|
|
81
158
|
let traversalBudget = 1_000_000;
|
|
82
|
-
const byId = new Map(capture.nodes.map(row => [
|
|
83
|
-
|
|
159
|
+
const byId = new Map(capture.nodes.map((row) => [
|
|
160
|
+
row.id,
|
|
161
|
+
row,
|
|
162
|
+
]));
|
|
163
|
+
const nodes = capture.nodes.map((n) => {
|
|
84
164
|
const source = n.component?.source;
|
|
85
|
-
const matches = map.components.filter(c => c.libraryId === source?.libraryId && c.componentId === source?.id);
|
|
165
|
+
const matches = map.components.filter((c) => c.libraryId === source?.libraryId && c.componentId === source?.id);
|
|
86
166
|
let hint = null;
|
|
87
|
-
const unknowns = Array.isArray(n.unknowns)
|
|
88
|
-
|
|
89
|
-
|
|
167
|
+
const unknowns = Array.isArray(n.unknowns)
|
|
168
|
+
? n.unknowns.map(String)
|
|
169
|
+
: [
|
|
170
|
+
"inspection-field-availability",
|
|
171
|
+
];
|
|
172
|
+
for (const field of [
|
|
173
|
+
"geometry",
|
|
174
|
+
"text",
|
|
175
|
+
"layout",
|
|
176
|
+
"component",
|
|
177
|
+
"tokens",
|
|
178
|
+
"style",
|
|
179
|
+
"metadata",
|
|
180
|
+
]) {
|
|
181
|
+
if (!(field in n)) {
|
|
90
182
|
unknowns.push(`uninspected:${field}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
91
185
|
if (matches.length === 1) {
|
|
92
|
-
const match = matches[0]
|
|
186
|
+
const match = matches[0];
|
|
187
|
+
const props = {
|
|
188
|
+
...match.props,
|
|
189
|
+
};
|
|
93
190
|
// Restrict overrides to descendants of this instance, not similarly named text elsewhere in the page.
|
|
94
|
-
const descendants = new Set()
|
|
191
|
+
const descendants = new Set();
|
|
192
|
+
const pending = [
|
|
193
|
+
...n.childIds,
|
|
194
|
+
];
|
|
95
195
|
while (pending.length) {
|
|
96
|
-
if (--traversalBudget < 0)
|
|
196
|
+
if (--traversalBudget < 0) {
|
|
97
197
|
throw new Error("Component override traversal budget exceeded");
|
|
98
|
-
|
|
198
|
+
}
|
|
199
|
+
const id = pending.pop();
|
|
200
|
+
const row = byId.get(id);
|
|
99
201
|
descendants.add(id);
|
|
100
202
|
pending.push(...row.childIds);
|
|
101
203
|
}
|
|
102
204
|
for (const [prop, id] of Object.entries(match.textProps ?? {})) {
|
|
103
205
|
const text = byId.get(id)?.text;
|
|
104
|
-
if (!descendants.has(id) || typeof text?.characters !== "string")
|
|
206
|
+
if (!descendants.has(id) || typeof text?.characters !== "string") {
|
|
105
207
|
unknowns.push(`unresolved-text-prop:${prop}`);
|
|
106
|
-
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
107
210
|
props[prop] = text.characters;
|
|
211
|
+
}
|
|
108
212
|
}
|
|
109
|
-
hint = {
|
|
213
|
+
hint = {
|
|
214
|
+
codeComponent: match.codeComponent,
|
|
215
|
+
props,
|
|
216
|
+
};
|
|
110
217
|
}
|
|
111
|
-
else if (source)
|
|
218
|
+
else if (source) {
|
|
112
219
|
unknowns.push("unmapped-component");
|
|
113
|
-
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
id: String(n.id),
|
|
223
|
+
evidence: structuredClone(n),
|
|
224
|
+
hint,
|
|
225
|
+
unknowns,
|
|
226
|
+
};
|
|
114
227
|
});
|
|
115
|
-
return {
|
|
116
|
-
|
|
228
|
+
return {
|
|
229
|
+
schemaVersion: 1,
|
|
230
|
+
kind: "design-spec",
|
|
231
|
+
source: designSource(capture),
|
|
232
|
+
mappingHash: observationHash(map),
|
|
233
|
+
nodes,
|
|
234
|
+
unknowns: [
|
|
235
|
+
"Interaction behavior and application state require an explicit implementation contract.",
|
|
236
|
+
"Accessibility roles, focus order, keyboard behavior and responsive rules are not inferred from appearance.",
|
|
237
|
+
"Pixel dimensions describe this captured state only; implementation component hints are owner-declared data, not executable JSX.",
|
|
238
|
+
],
|
|
239
|
+
};
|
|
117
240
|
}
|
|
118
241
|
const sha = (text) => createHash("sha256").update(text).digest("hex");
|
|
119
242
|
export function renderDesignSpec(spec) {
|
|
120
|
-
const body = "# Derived design handoff\n\nGenerated evidence; review unknowns with the design owner before implementation.\n\n" +
|
|
121
|
-
|
|
243
|
+
const body = "# Derived design handoff\n\nGenerated evidence; review unknowns with the design owner before implementation.\n\n" +
|
|
244
|
+
designDataBlock(spec);
|
|
245
|
+
const record = {
|
|
246
|
+
schemaVersion: 1,
|
|
247
|
+
kind: "design-spec-record",
|
|
248
|
+
source: spec.source,
|
|
249
|
+
mappingHash: spec.mappingHash,
|
|
250
|
+
bodyHash: sha(body),
|
|
251
|
+
};
|
|
122
252
|
return `<!-- penpair-spec:${JSON.stringify(record)} -->\n${body}`;
|
|
123
253
|
}
|
|
124
254
|
export function checkDesignSpec(markdown, inspection, mapping = emptyComponentMap) {
|
|
125
255
|
// Exact regeneration detects both changed provenance and manual edits, without executing any embedded design content.
|
|
126
256
|
const current = markdown === renderDesignSpec(buildDesignSpec(inspection, mapping));
|
|
127
|
-
return {
|
|
257
|
+
return {
|
|
258
|
+
current,
|
|
259
|
+
reason: current
|
|
260
|
+
? "Matches supplied inspection and explicit mappings; live freshness is not assessed offline"
|
|
261
|
+
: "Source, mapping or generated document changed",
|
|
262
|
+
};
|
|
128
263
|
}
|
|
129
264
|
export async function writeDesignSpec(root, out, markdown, redact) {
|
|
130
|
-
if (redact && redact(markdown) !== markdown)
|
|
265
|
+
if (redact && redact(markdown) !== markdown) {
|
|
131
266
|
throw new Error("Design handoff requires redaction; prior output preserved");
|
|
267
|
+
}
|
|
132
268
|
const target = await resolveProjectOutput(root, out);
|
|
133
|
-
await mkdir(path.dirname(target), {
|
|
134
|
-
|
|
269
|
+
await mkdir(path.dirname(target), {
|
|
270
|
+
recursive: true,
|
|
271
|
+
});
|
|
272
|
+
await atomicWriteFile(target, markdown, {
|
|
273
|
+
replace: true,
|
|
274
|
+
});
|
|
135
275
|
}
|
package/dist/endpoint.js
CHANGED
|
@@ -3,7 +3,9 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { PenpairError } from "./errors.js";
|
|
6
|
-
export const defaultVariableNames = [
|
|
6
|
+
export const defaultVariableNames = [
|
|
7
|
+
"PENPAIR_MCP_URL",
|
|
8
|
+
];
|
|
7
9
|
function describe(url, source) {
|
|
8
10
|
let parsed;
|
|
9
11
|
try {
|
|
@@ -12,17 +14,26 @@ function describe(url, source) {
|
|
|
12
14
|
catch {
|
|
13
15
|
throw new PenpairError("endpoint-invalid", `The ${source} Penpot endpoint is not a valid URL`);
|
|
14
16
|
}
|
|
15
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
17
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
16
18
|
throw new PenpairError("endpoint-invalid", `The ${source} Penpot endpoint must use http or https`);
|
|
17
|
-
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
url,
|
|
22
|
+
description: {
|
|
23
|
+
source,
|
|
24
|
+
origin: parsed.origin,
|
|
25
|
+
pathname: parsed.pathname,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
18
28
|
}
|
|
19
29
|
async function readOptional(file, strict = false) {
|
|
20
30
|
try {
|
|
21
31
|
return await readFile(file, "utf8");
|
|
22
32
|
}
|
|
23
33
|
catch (error) {
|
|
24
|
-
if (strict && error.code !== "ENOENT")
|
|
34
|
+
if (strict && error.code !== "ENOENT") {
|
|
25
35
|
throw new PenpairError("endpoint-unconfigured", "Project connection source cannot be read; repair its storage before resolving credentials");
|
|
36
|
+
}
|
|
26
37
|
// An unreadable file is treated as absent; the error would otherwise expose a local path layout.
|
|
27
38
|
return undefined;
|
|
28
39
|
}
|
|
@@ -31,19 +42,22 @@ async function readOptional(file, strict = false) {
|
|
|
31
42
|
export function parseEnvFileValue(text, name) {
|
|
32
43
|
for (const rawLine of text.split(/\r?\n/)) {
|
|
33
44
|
const line = rawLine.trim();
|
|
34
|
-
if (line === "" || line.startsWith("#"))
|
|
45
|
+
if (line === "" || line.startsWith("#")) {
|
|
35
46
|
continue;
|
|
47
|
+
}
|
|
36
48
|
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
37
|
-
if (!match || match[1] !== name)
|
|
49
|
+
if (!match || match[1] !== name) {
|
|
38
50
|
continue;
|
|
51
|
+
}
|
|
39
52
|
let value = (match[2] ?? "").trim();
|
|
40
53
|
const quote = value[0];
|
|
41
54
|
if ((quote === '"' || quote === "'") && value.length > 1) {
|
|
42
55
|
const end = value.indexOf(quote, 1);
|
|
43
56
|
value = end === -1 ? value.slice(1) : value.slice(1, end);
|
|
44
57
|
}
|
|
45
|
-
else
|
|
58
|
+
else {
|
|
46
59
|
value = value.replace(/\s+#.*$/, "");
|
|
60
|
+
}
|
|
47
61
|
return value.length > 0 ? value : undefined;
|
|
48
62
|
}
|
|
49
63
|
return undefined;
|
|
@@ -59,11 +73,13 @@ export function parseCodexServerUrl(toml, serverName = "penpot") {
|
|
|
59
73
|
inServer = header.test(line);
|
|
60
74
|
continue;
|
|
61
75
|
}
|
|
62
|
-
if (!inServer)
|
|
76
|
+
if (!inServer) {
|
|
63
77
|
continue;
|
|
78
|
+
}
|
|
64
79
|
const match = /^url\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\s*(?:#.*)?$/.exec(line);
|
|
65
|
-
if (match)
|
|
80
|
+
if (match) {
|
|
66
81
|
return (match[1] ?? match[2])?.replace(/\\(["\\])/g, "$1");
|
|
82
|
+
}
|
|
67
83
|
}
|
|
68
84
|
return undefined;
|
|
69
85
|
}
|
|
@@ -71,14 +87,20 @@ export function parseCodexServerUrl(toml, serverName = "penpot") {
|
|
|
71
87
|
export function parseClaudeServerUrl(json, serverName = "penpot") {
|
|
72
88
|
try {
|
|
73
89
|
const value = JSON.parse(json);
|
|
74
|
-
if (typeof value !== "object" || value === null || !("mcpServers" in value))
|
|
90
|
+
if (typeof value !== "object" || value === null || !("mcpServers" in value)) {
|
|
75
91
|
return undefined;
|
|
92
|
+
}
|
|
76
93
|
const servers = value.mcpServers;
|
|
77
|
-
if (typeof servers !== "object" || servers === null || !(serverName in servers))
|
|
94
|
+
if (typeof servers !== "object" || servers === null || !(serverName in servers)) {
|
|
78
95
|
return undefined;
|
|
96
|
+
}
|
|
79
97
|
const server = servers[serverName];
|
|
80
|
-
if (typeof server !== "object" ||
|
|
98
|
+
if (typeof server !== "object" ||
|
|
99
|
+
server === null ||
|
|
100
|
+
!("url" in server) ||
|
|
101
|
+
typeof server.url !== "string") {
|
|
81
102
|
return undefined;
|
|
103
|
+
}
|
|
82
104
|
return server.url;
|
|
83
105
|
}
|
|
84
106
|
catch {
|
|
@@ -94,51 +116,80 @@ export async function resolveEndpoint(options = {}) {
|
|
|
94
116
|
const environment = options.environment ?? process.env;
|
|
95
117
|
const home = options.home ?? os.homedir();
|
|
96
118
|
const names = options.variableNames ?? defaultVariableNames;
|
|
97
|
-
const envFiles = options.envFiles ??
|
|
119
|
+
const envFiles = options.envFiles ??
|
|
120
|
+
(environment === process.env
|
|
121
|
+
? [
|
|
122
|
+
path.join(process.cwd(), ".env.local"),
|
|
123
|
+
]
|
|
124
|
+
: []);
|
|
98
125
|
const serverName = options.serverName ?? "penpot";
|
|
99
|
-
if (options.credential)
|
|
100
|
-
return describe(await readCredential(options.credential, {
|
|
126
|
+
if (options.credential) {
|
|
127
|
+
return describe(await readCredential(options.credential, {
|
|
128
|
+
root: options.credentialRoot ?? process.cwd(),
|
|
129
|
+
}), options.credential.type === "file" ? "credential-file" : "keychain");
|
|
130
|
+
}
|
|
101
131
|
const providerFile = environment.PENPAIR_MCP_URL_FILE?.trim();
|
|
102
|
-
if (providerFile)
|
|
103
|
-
return describe(await readCredential({
|
|
104
|
-
|
|
132
|
+
if (providerFile) {
|
|
133
|
+
return describe(await readCredential({
|
|
134
|
+
type: "file",
|
|
135
|
+
path: providerFile,
|
|
136
|
+
}, {
|
|
137
|
+
root: options.credentialRoot ?? process.cwd(),
|
|
138
|
+
}), "credential-file");
|
|
139
|
+
}
|
|
140
|
+
if (environment.PENPAIR_MCP_URL_COMMAND?.trim()) {
|
|
105
141
|
throw new PenpairError("endpoint-invalid", "Arbitrary credential commands are not enabled; use a private file or OS credential reference");
|
|
142
|
+
}
|
|
106
143
|
// Explicit file references outrank legacy URL/global sources and fail closed when unreadable.
|
|
107
144
|
for (const file of envFiles) {
|
|
108
145
|
const text = await readOptional(file, true);
|
|
109
|
-
if (text === undefined)
|
|
146
|
+
if (text === undefined) {
|
|
110
147
|
continue;
|
|
148
|
+
}
|
|
111
149
|
const provider = parseEnvFileValue(text, "PENPAIR_MCP_URL_FILE");
|
|
112
|
-
if (provider)
|
|
113
|
-
return describe(await readCredential({
|
|
114
|
-
|
|
150
|
+
if (provider) {
|
|
151
|
+
return describe(await readCredential({
|
|
152
|
+
type: "file",
|
|
153
|
+
path: provider,
|
|
154
|
+
}, {
|
|
155
|
+
root: path.dirname(file),
|
|
156
|
+
}), "credential-file");
|
|
157
|
+
}
|
|
158
|
+
if (parseEnvFileValue(text, "PENPAIR_MCP_URL_COMMAND")) {
|
|
115
159
|
throw new PenpairError("endpoint-invalid", "Arbitrary credential commands are not enabled; use a private file or OS credential reference");
|
|
160
|
+
}
|
|
116
161
|
}
|
|
117
162
|
for (const name of names) {
|
|
118
163
|
const explicit = environment[name]?.trim();
|
|
119
|
-
if (explicit)
|
|
164
|
+
if (explicit) {
|
|
120
165
|
return describe(explicit, "env");
|
|
166
|
+
}
|
|
121
167
|
}
|
|
122
168
|
for (const file of envFiles) {
|
|
123
169
|
const text = await readOptional(file, true);
|
|
124
|
-
if (text === undefined)
|
|
170
|
+
if (text === undefined) {
|
|
125
171
|
continue;
|
|
172
|
+
}
|
|
126
173
|
for (const name of names) {
|
|
127
174
|
const fromFile = parseEnvFileValue(text, name);
|
|
128
|
-
if (fromFile)
|
|
175
|
+
if (fromFile) {
|
|
129
176
|
return describe(fromFile, "env-file");
|
|
177
|
+
}
|
|
130
178
|
}
|
|
131
179
|
}
|
|
132
|
-
if (options.globalConfig === false)
|
|
180
|
+
if (options.globalConfig === false) {
|
|
133
181
|
throw new PenpairError("endpoint-unconfigured", "No project Penpot endpoint in the environment or configured project env file; global client fallback is disabled");
|
|
182
|
+
}
|
|
134
183
|
const codex = await readOptional(path.join(home, ".codex/config.toml"));
|
|
135
184
|
const fromCodex = codex === undefined ? undefined : parseCodexServerUrl(codex, serverName);
|
|
136
|
-
if (fromCodex)
|
|
185
|
+
if (fromCodex) {
|
|
137
186
|
return describe(fromCodex, "codex-config");
|
|
187
|
+
}
|
|
138
188
|
const claude = await readOptional(path.join(home, ".claude.json"));
|
|
139
189
|
const fromClaude = claude === undefined ? undefined : parseClaudeServerUrl(claude, serverName);
|
|
140
|
-
if (fromClaude)
|
|
190
|
+
if (fromClaude) {
|
|
141
191
|
return describe(fromClaude, "claude-config");
|
|
192
|
+
}
|
|
142
193
|
throw new PenpairError("endpoint-unconfigured", `No Penpot MCP endpoint: ${names.join(" or ")} is unset (environment and .env.local) and no '${serverName}' MCP server is configured for Codex or Claude Code`);
|
|
143
194
|
}
|
|
144
195
|
/** Removes the endpoint URL, its query string and any userToken parameter from text that may be persisted. */
|
|
@@ -147,28 +198,45 @@ export function redactEndpoint(text, endpoint) {
|
|
|
147
198
|
if (endpoint) {
|
|
148
199
|
const parsed = new URL(endpoint.url);
|
|
149
200
|
const key = parsed.searchParams.get("userToken") ?? "";
|
|
150
|
-
for (const secret of [
|
|
201
|
+
for (const secret of [
|
|
202
|
+
endpoint.url,
|
|
203
|
+
parsed.search,
|
|
204
|
+
parsed.search.slice(1),
|
|
205
|
+
key,
|
|
206
|
+
encodeURIComponent(key),
|
|
207
|
+
].filter((value) => value.length > 1)) {
|
|
151
208
|
redacted = redacted.split(secret).join("[redacted]");
|
|
209
|
+
}
|
|
152
210
|
}
|
|
153
211
|
return redacted.replace(/userToken=[^\s&"'<>]+/gi, "userToken=[redacted]");
|
|
154
212
|
}
|
|
155
213
|
/** Known endpoint redaction with optional boundary fragments for serialized inspection chunks. */
|
|
156
214
|
export function endpointRedactor(endpoint, fragments = false) {
|
|
157
215
|
const key = new URL(endpoint.url).searchParams.get("userToken") ?? "";
|
|
158
|
-
const variants = [
|
|
216
|
+
const variants = [
|
|
217
|
+
...new Set([
|
|
218
|
+
key,
|
|
219
|
+
encodeURIComponent(key),
|
|
220
|
+
JSON.stringify(key).slice(1, -1),
|
|
221
|
+
]),
|
|
222
|
+
].filter((value) => value.length > 1);
|
|
159
223
|
return (text) => {
|
|
160
224
|
let clean = redactEndpoint(text, endpoint);
|
|
161
|
-
if (fragments)
|
|
225
|
+
if (fragments) {
|
|
162
226
|
for (const value of variants) {
|
|
163
227
|
const minimum = Math.min(8, Math.ceil(value.length / 2));
|
|
164
228
|
for (let size = value.length - 1; size >= minimum; size--) {
|
|
165
|
-
const prefix = value.slice(0, size)
|
|
166
|
-
|
|
229
|
+
const prefix = value.slice(0, size);
|
|
230
|
+
const suffix = value.slice(-size);
|
|
231
|
+
if (clean.endsWith(prefix)) {
|
|
167
232
|
clean = clean.slice(0, -prefix.length) + "[redacted]";
|
|
168
|
-
|
|
233
|
+
}
|
|
234
|
+
if (clean.startsWith(suffix)) {
|
|
169
235
|
clean = "[redacted]" + clean.slice(suffix.length);
|
|
236
|
+
}
|
|
170
237
|
}
|
|
171
238
|
}
|
|
239
|
+
}
|
|
172
240
|
return clean;
|
|
173
241
|
};
|
|
174
242
|
}
|