@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.
Files changed (101) hide show
  1. package/dist/adapters.js +58 -11
  2. package/dist/approvals.js +143 -29
  3. package/dist/atomic-output.js +9 -4
  4. package/dist/audit-rules.js +345 -79
  5. package/dist/backup-note.js +29 -8
  6. package/dist/baseline-coverage.js +169 -35
  7. package/dist/baselines-config.js +119 -29
  8. package/dist/baselines.js +280 -57
  9. package/dist/cli-arguments.js +18 -6
  10. package/dist/cli-main.js +1621 -392
  11. package/dist/client.d.ts +5 -2
  12. package/dist/client.js +127 -28
  13. package/dist/command-result.js +341 -52
  14. package/dist/coordination.js +91 -29
  15. package/dist/credentials.js +57 -13
  16. package/dist/deep-check.js +56 -12
  17. package/dist/design-spec.js +189 -49
  18. package/dist/endpoint.js +102 -34
  19. package/dist/export.js +86 -18
  20. package/dist/file-archive.js +33 -7
  21. package/dist/file-guard.js +29 -11
  22. package/dist/formats.js +215 -14
  23. package/dist/index.d.ts +8 -7
  24. package/dist/index.js +8 -7
  25. package/dist/inspection.d.ts +1 -1
  26. package/dist/inspection.js +378 -65
  27. package/dist/instance/config-schema.js +400 -41
  28. package/dist/instance/config.js +101 -30
  29. package/dist/instance/connect.js +151 -44
  30. package/dist/instance/connection.d.ts +26 -5
  31. package/dist/instance/connection.js +194 -21
  32. package/dist/instance/gitignore.js +5 -2
  33. package/dist/instance/init.js +32 -7
  34. package/dist/instance/runner.js +32 -10
  35. package/dist/instance/setup.js +46 -13
  36. package/dist/instance/tty.js +26 -8
  37. package/dist/journal.d.ts +10 -0
  38. package/dist/journal.js +212 -49
  39. package/dist/kit-smoke.js +82 -20
  40. package/dist/kit-source.js +10 -40
  41. package/dist/kit.js +102 -34
  42. package/dist/layout-policy.d.ts +2 -0
  43. package/dist/layout-policy.js +86 -0
  44. package/dist/layout.d.ts +7 -1
  45. package/dist/layout.js +27 -70
  46. package/dist/live-smoke.js +13 -4
  47. package/dist/local-profile.js +2 -1
  48. package/dist/mcp-server.js +360 -47
  49. package/dist/native-api.js +47 -11
  50. package/dist/onboard.js +33 -7
  51. package/dist/operation.d.ts +2 -2
  52. package/dist/operation.js +279 -69
  53. package/dist/page-session.d.ts +23 -0
  54. package/dist/page-session.js +142 -0
  55. package/dist/png.js +18 -3
  56. package/dist/portable-tokens.js +254 -57
  57. package/dist/probe.d.ts +1 -1
  58. package/dist/probe.js +48 -11
  59. package/dist/project-input.js +24 -10
  60. package/dist/project-snippets.js +18 -6
  61. package/dist/project-status.js +133 -24
  62. package/dist/registry.js +217 -60
  63. package/dist/run-file.js +129 -30
  64. package/dist/shots.js +108 -26
  65. package/dist/skill-instructions.js +91 -22
  66. package/dist/skills.js +129 -39
  67. package/dist/snippet.js +27 -7
  68. package/dist/snippets/read.js +8 -2
  69. package/dist/structure-snapshot.d.ts +12 -0
  70. package/dist/structure-snapshot.js +439 -85
  71. package/dist/token-edit.js +219 -64
  72. package/dist/token-projection.d.ts +56 -0
  73. package/dist/token-projection.js +286 -0
  74. package/dist/tokens-diff.js +125 -32
  75. package/dist/version.d.ts +1 -1
  76. package/dist/version.js +1 -1
  77. package/package.json +1 -1
  78. package/skills/penpair-design/SKILL.md +3 -0
  79. package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
  80. package/skills/penpair-design/references/project-facts.md +2 -0
  81. package/skills/penpair-handoff/SKILL.md +2 -0
  82. package/skills/penpair-handoff/references/comparison-companion.md +13 -0
  83. package/skills/penpair-handoff/references/handoff-flow.md +2 -0
  84. package/skills/penpair-recipes/SKILL.md +5 -0
  85. package/skills/penpair-setup/SKILL.md +3 -1
  86. package/dist/cli.d.ts +0 -2
  87. package/dist/cli.js +0 -3
  88. package/dist/docker/lifecycle.d.ts +0 -96
  89. package/dist/docker/lifecycle.js +0 -273
  90. package/dist/docker/secrets.d.ts +0 -11
  91. package/dist/docker/secrets.js +0 -38
  92. package/dist/docker/template.d.ts +0 -8
  93. package/dist/docker/template.js +0 -158
  94. package/dist/docker-profile.d.ts +0 -114
  95. package/dist/docker-profile.js +0 -18
  96. package/dist/instance/lifecycle.d.ts +0 -129
  97. package/dist/instance/lifecycle.js +0 -336
  98. package/dist/instance/secrets.d.ts +0 -11
  99. package/dist/instance/secrets.js +0 -38
  100. package/dist/instance/template.d.ts +0 -8
  101. package/dist/instance/template.js +0 -158
@@ -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 = { schemaVersion: 1, components: [] };
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 || typeof c.libraryId !== "string" || !c.libraryId || typeof c.componentId !== "string" || !c.componentId || !/^[A-Z][A-Za-z0-9]*(?:\.[A-Z][A-Za-z0-9]*)*$/.test(c.codeComponent))
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
- const id = JSON.stringify([c.libraryId, c.componentId]);
23
- if (seen.has(id))
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) || !["string", "number", "boolean"].includes(typeof value) && value !== null || typeof value === "number" && !Number.isFinite(value))
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
- for (const [key, value] of Object.entries(c.textProps ?? {}))
30
- if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(key) || typeof value !== "string" || !value)
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 || v.schemaVersion !== 1 || v.kind !== "shape-inspection" || v.complete !== true || v.redacted === true || typeof v.fileId !== "string" || !v.fileId || typeof v.pageId !== "string" || !v.pageId || !Number.isInteger(v.revision) || !Array.isArray(v.nodes) || !v.nodes.length || v.nodes.length > 50000 || !Array.isArray(v.reasons) || v.reasons.length)
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 => [n.id, n]));
42
- if (byId.size !== nodes.length)
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" || !n.id || !Array.isArray(n.childIds) || n.childIds.length !== n.childrenTotal || !Number.isInteger(n.index) || Number(n.index) < 0)
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
- if (n.parentId === null)
98
+ }
99
+ if (n.parentId === null) {
49
100
  roots++;
50
- else if (typeof n.parentId !== "string" || !byId.has(n.parentId))
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 && (byId.get(n.parentId)?.childIds)[Number(n.index)] !== n.id)
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
- const seen = new Set(), stack = [nodes.find(n => n.parentId === null)];
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 = [...v.nodes].sort((a, b) => String(a.id).localeCompare(String(b.id), "en"));
77
- return { fileId: v.fileId, pageId: v.pageId, revision: v.revision, frameId: String(nodes.find(n => n.parentId === null).id), sha256: observationHash({ fileId: v.fileId, pageId: v.pageId, revision: v.revision, scope: v.scope ?? null, nodes }) };
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), map = validateComponentMap(mapping);
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 => [row.id, row]));
83
- const nodes = capture.nodes.map(n => {
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) ? n.unknowns.map(String) : ["inspection-field-availability"];
88
- for (const field of ["geometry", "text", "layout", "component", "tokens", "style", "metadata"])
89
- if (!(field in n))
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], props = { ...match.props };
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(), pending = [...n.childIds];
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
- const id = pending.pop(), row = byId.get(id);
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
- else
208
+ }
209
+ else {
107
210
  props[prop] = text.characters;
211
+ }
108
212
  }
109
- hint = { codeComponent: match.codeComponent, props };
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
- return { id: String(n.id), evidence: structuredClone(n), hint, unknowns };
220
+ }
221
+ return {
222
+ id: String(n.id),
223
+ evidence: structuredClone(n),
224
+ hint,
225
+ unknowns,
226
+ };
114
227
  });
115
- return { schemaVersion: 1, kind: "design-spec", source: designSource(capture), mappingHash: observationHash(map), nodes,
116
- unknowns: ["Interaction behavior and application state require an explicit implementation contract.", "Accessibility roles, focus order, keyboard behavior and responsive rules are not inferred from appearance.", "Pixel dimensions describe this captured state only; implementation component hints are owner-declared data, not executable JSX."] };
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" + designDataBlock(spec);
121
- const record = { schemaVersion: 1, kind: "design-spec-record", source: spec.source, mappingHash: spec.mappingHash, bodyHash: sha(body) };
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 { current, reason: current ? "Matches supplied inspection and explicit mappings; live freshness is not assessed offline" : "Source, mapping or generated document changed" };
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), { recursive: true });
134
- await atomicWriteFile(target, markdown, { replace: true });
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 = ["PENPAIR_MCP_URL"];
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
- return { url, description: { source, origin: parsed.origin, pathname: parsed.pathname } };
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" || server === null || !("url" in server) || typeof server.url !== "string")
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 ?? (environment === process.env ? [path.join(process.cwd(), ".env.local")] : []);
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, { root: options.credentialRoot ?? process.cwd() }), options.credential.type === "file" ? "credential-file" : "keychain");
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({ type: "file", path: providerFile }, { root: options.credentialRoot ?? process.cwd() }), "credential-file");
104
- if (environment.PENPAIR_MCP_URL_COMMAND?.trim())
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({ type: "file", path: provider }, { root: path.dirname(file) }), "credential-file");
114
- if (parseEnvFileValue(text, "PENPAIR_MCP_URL_COMMAND"))
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 [endpoint.url, parsed.search, parsed.search.slice(1), key, encodeURIComponent(key)].filter((value) => value.length > 1))
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 = [...new Set([key, encodeURIComponent(key), JSON.stringify(key).slice(1, -1)])].filter(value => value.length > 1);
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), suffix = value.slice(-size);
166
- if (clean.endsWith(prefix))
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
- if (clean.startsWith(suffix))
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
  }