@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.
Files changed (123) hide show
  1. package/dist/adapters.d.ts +16 -1
  2. package/dist/adapters.js +8 -12
  3. package/dist/approvals.d.ts +32 -0
  4. package/dist/approvals.js +99 -0
  5. package/dist/atomic-output.d.ts +5 -0
  6. package/dist/atomic-output.js +28 -0
  7. package/dist/audit-rules.d.ts +60 -0
  8. package/dist/audit-rules.js +177 -0
  9. package/dist/backup-note.d.ts +4 -0
  10. package/dist/backup-note.js +28 -0
  11. package/dist/baseline-coverage.d.ts +25 -0
  12. package/dist/baseline-coverage.js +114 -0
  13. package/dist/baselines-config.d.ts +25 -0
  14. package/dist/baselines-config.js +52 -3
  15. package/dist/baselines.d.ts +37 -8
  16. package/dist/baselines.js +132 -52
  17. package/dist/cli-arguments.d.ts +7 -0
  18. package/dist/cli-arguments.js +35 -0
  19. package/dist/cli-main.d.ts +13 -1
  20. package/dist/cli-main.js +770 -139
  21. package/dist/client.d.ts +26 -5
  22. package/dist/client.js +46 -20
  23. package/dist/command-result.d.ts +159 -0
  24. package/dist/command-result.js +179 -0
  25. package/dist/coordination.d.ts +15 -0
  26. package/dist/coordination.js +122 -0
  27. package/dist/credentials.d.ts +21 -0
  28. package/dist/credentials.js +61 -0
  29. package/dist/deep-check.js +9 -5
  30. package/dist/design-spec.d.ts +55 -0
  31. package/dist/design-spec.js +135 -0
  32. package/dist/endpoint.d.ts +8 -1
  33. package/dist/endpoint.js +48 -4
  34. package/dist/errors.d.ts +2 -1
  35. package/dist/errors.js +8 -4
  36. package/dist/export.d.ts +18 -0
  37. package/dist/export.js +42 -7
  38. package/dist/file-archive.d.ts +16 -0
  39. package/dist/file-archive.js +32 -0
  40. package/dist/file-guard.d.ts +8 -0
  41. package/dist/file-guard.js +40 -0
  42. package/dist/formats.d.ts +4 -0
  43. package/dist/formats.js +28 -0
  44. package/dist/index.d.ts +33 -1
  45. package/dist/index.js +33 -1
  46. package/dist/inspection.d.ts +106 -0
  47. package/dist/inspection.js +344 -0
  48. package/dist/instance/config-schema.d.ts +422 -0
  49. package/dist/instance/config-schema.js +76 -0
  50. package/dist/instance/config.d.ts +29 -0
  51. package/dist/instance/config.js +102 -11
  52. package/dist/instance/connect.d.ts +10 -1
  53. package/dist/instance/connect.js +53 -12
  54. package/dist/instance/connection.d.ts +14 -0
  55. package/dist/instance/connection.js +13 -2
  56. package/dist/instance/init.js +1 -1
  57. package/dist/instance/setup.js +1 -1
  58. package/dist/instance/tty.js +3 -1
  59. package/dist/journal.d.ts +77 -0
  60. package/dist/journal.js +260 -0
  61. package/dist/kit-smoke.d.ts +31 -0
  62. package/dist/kit-smoke.js +83 -0
  63. package/dist/kit-source.d.ts +2 -0
  64. package/dist/kit-source.js +309 -0
  65. package/dist/kit.d.ts +25 -0
  66. package/dist/kit.js +90 -0
  67. package/dist/layout.d.ts +15 -0
  68. package/dist/layout.js +79 -0
  69. package/dist/live-smoke.d.ts +8 -0
  70. package/dist/live-smoke.js +14 -0
  71. package/dist/mcp-server.d.ts +12 -0
  72. package/dist/mcp-server.js +102 -0
  73. package/dist/native-api.d.ts +15 -0
  74. package/dist/native-api.js +42 -0
  75. package/dist/onboard.d.ts +10 -0
  76. package/dist/onboard.js +15 -0
  77. package/dist/operation.d.ts +20 -1
  78. package/dist/operation.js +113 -13
  79. package/dist/png.d.ts +5 -0
  80. package/dist/png.js +7 -0
  81. package/dist/portable-tokens.d.ts +22 -0
  82. package/dist/portable-tokens.js +134 -0
  83. package/dist/probe.d.ts +6 -2
  84. package/dist/probe.js +19 -3
  85. package/dist/project-input.d.ts +6 -0
  86. package/dist/project-input.js +51 -0
  87. package/dist/project-snippets.js +12 -7
  88. package/dist/project-status.d.ts +24 -0
  89. package/dist/project-status.js +79 -0
  90. package/dist/registry.d.ts +2 -1
  91. package/dist/registry.js +17 -3
  92. package/dist/run-file.d.ts +26 -0
  93. package/dist/run-file.js +100 -0
  94. package/dist/shots.d.ts +43 -0
  95. package/dist/shots.js +90 -0
  96. package/dist/skill-instructions.d.ts +13 -0
  97. package/dist/skill-instructions.js +75 -0
  98. package/dist/skills.d.ts +7 -0
  99. package/dist/skills.js +59 -32
  100. package/dist/snippet.d.ts +3 -2
  101. package/dist/snippet.js +12 -8
  102. package/dist/snippets/archive.d.ts +9 -0
  103. package/dist/snippets/archive.js +10 -4
  104. package/dist/snippets/read.d.ts +1 -1
  105. package/dist/snippets/read.js +71 -40
  106. package/dist/structure-snapshot.d.ts +73 -0
  107. package/dist/structure-snapshot.js +171 -0
  108. package/dist/token-edit.d.ts +53 -0
  109. package/dist/token-edit.js +197 -0
  110. package/dist/tokens-diff.d.ts +19 -6
  111. package/dist/tokens-diff.js +89 -18
  112. package/dist/version.d.ts +1 -1
  113. package/dist/version.js +1 -1
  114. package/package.json +1 -1
  115. package/skills/penpair-design/SKILL.md +12 -40
  116. package/skills/penpair-design/references/penpot-plugin-gotchas.md +56 -0
  117. package/skills/penpair-design/references/project-facts.md +1 -1
  118. package/skills/penpair-design/references/write-protocol.md +15 -3
  119. package/skills/penpair-handoff/SKILL.md +14 -0
  120. package/skills/penpair-handoff/references/handoff-flow.md +11 -0
  121. package/skills/penpair-recipes/SKILL.md +16 -0
  122. package/skills/penpair-recipes/references/build-flow.md +11 -0
  123. package/skills/penpair-setup/SKILL.md +6 -2
@@ -0,0 +1,122 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { hostname, userInfo } from "node:os";
3
+ import { lstat, mkdir, open, readFile, unlink } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { ensureGitignoreEntry } from "./instance/gitignore.js";
6
+ import { runSnippet, snippet } from "./snippet.js";
7
+ import { maxSnippetBytes } from "./client.js";
8
+ import { PenpairError } from "./errors.js";
9
+ /** Exclusive only within this checkout. Shared file markers below are advisory, not compare-and-swap. */
10
+ async function localLease(root, fileId, runId, force) {
11
+ await ensureGitignoreEntry(root, ".penpair", { directory: true, comment: "penpair: private local operation evidence" });
12
+ const parent = path.join(root, ".penpair");
13
+ await mkdir(parent, { mode: 0o700 }).catch((error) => { if (error.code !== "EEXIST")
14
+ throw error; });
15
+ const parentInfo = await lstat(parent);
16
+ if (!parentInfo.isDirectory() || parentInfo.isSymbolicLink())
17
+ throw new Error("Write locks require a real local directory");
18
+ const directory = path.join(parent, "locks");
19
+ await mkdir(directory, { mode: 0o700 }).catch((error) => { if (error.code !== "EEXIST")
20
+ throw error; });
21
+ const info = await lstat(directory);
22
+ if (!info.isDirectory() || info.isSymbolicLink())
23
+ throw new Error("Write locks require a real local directory");
24
+ const destination = path.join(directory, `${createHash("sha256").update(fileId).digest("hex")}.json`);
25
+ const token = randomUUID();
26
+ const entry = { token, runId, pid: process.pid, host: hostname(), createdAt: new Date().toISOString() };
27
+ const create = async () => {
28
+ const handle = await open(destination, "wx", 0o600);
29
+ try {
30
+ await handle.writeFile(JSON.stringify(entry));
31
+ }
32
+ finally {
33
+ await handle.close();
34
+ }
35
+ };
36
+ try {
37
+ await create();
38
+ }
39
+ catch (error) {
40
+ if (error.code !== "EEXIST")
41
+ throw error;
42
+ const previous = JSON.parse(await readFile(destination, "utf8"));
43
+ let running = false;
44
+ if (previous.host === hostname() && Number.isInteger(previous.pid) && previous.pid > 0) {
45
+ try {
46
+ process.kill(previous.pid, 0);
47
+ running = true;
48
+ }
49
+ catch (failure) {
50
+ if (failure.code !== "ESRCH")
51
+ running = true;
52
+ }
53
+ }
54
+ if (!force || running)
55
+ throw new Error(`Local writer ${previous.runId} holds this file. ${running ? "Its process is still running; wait for it to finish." : "Inspect its journal, then use --force-lock to remove the abandoned local lock."}`);
56
+ // Explicit owner override only. Simultaneous force operations are not an exclusion guarantee.
57
+ await unlink(destination);
58
+ await create();
59
+ }
60
+ return async () => {
61
+ const current = JSON.parse(await readFile(destination, "utf8"));
62
+ if (current.token === token)
63
+ await unlink(destination);
64
+ };
65
+ }
66
+ const readMarker = `const f = penpot.currentFile;
67
+ if (typeof f.getSharedPluginData !== "function" || typeof f.setSharedPluginData !== "function") return { ok: true, supported: false };
68
+ const raw = f.getSharedPluginData("penpair", "writer");
69
+ let marker = null;
70
+ if (raw) { try { marker = JSON.parse(raw); } catch { return { ok: false, message: "Malformed shared writer marker; inspect before overriding" }; } }
71
+ `;
72
+ export function writeCoordinator(client, options) {
73
+ return async (runId, target) => {
74
+ const releaseLocal = await localLease(options.root, target.fileId, runId, options.forceLock === true);
75
+ const marker = { schemaVersion: 1, token: randomUUID(), runId, operation: options.operation, owner: userInfo().username, createdAt: Date.now(), expiresAt: Date.now() + 15 * 60_000 };
76
+ const params = { marker, fileId: target.fileId, force: options.forceLock === true };
77
+ const targetGuard = 'if (penpot.currentFile.id !== CFG.fileId) return { ok:false, message:"Writer marker target changed" };';
78
+ try {
79
+ const before = await runSnippet(client, snippet("writer-inspect", targetGuard + readMarker + 'return { ok:true, supported:true, marker };', params, "read"));
80
+ if (before.supported === false) {
81
+ options.warn?.("Shared writer markers unavailable; local checkout lock only. Coordinate with colleagues before writing.");
82
+ return { check: async () => undefined, release: releaseLocal };
83
+ }
84
+ const acquired = await runSnippet(client, snippet("writer-acquire", targetGuard + readMarker + `
85
+ if (marker && marker.token !== CFG.marker.token && (!Number.isFinite(marker.expiresAt) || marker.expiresAt > Date.now()) && !CFG.force)
86
+ return { ok:false, message:"Another writer is running " + String(marker.operation) + " (run " + String(marker.runId) + "). Coordinate before --force-lock." };
87
+ f.setSharedPluginData("penpair", "writer", JSON.stringify(CFG.marker));
88
+ return { ok:true, supported:true };`, params, "write"));
89
+ if (acquired.supported !== true)
90
+ throw new Error("Writer marker capability changed during acquisition");
91
+ const check = async () => {
92
+ const observed = await runSnippet(client, snippet("writer-check", targetGuard + readMarker + 'return { ok:true, supported:true, owned: !!marker && marker.token === CFG.marker.token && marker.expiresAt > Date.now() };', params, "read"));
93
+ if (observed.supported !== true || observed.owned !== true)
94
+ throw new Error("Shared writer marker was lost or expired; stop and coordinate before resuming");
95
+ };
96
+ await check();
97
+ return { check, release: async () => {
98
+ try {
99
+ await runSnippet(client, snippet("writer-release", targetGuard + readMarker + 'if (marker && marker.token === CFG.marker.token) f.setSharedPluginData("penpair", "writer", ""); return { ok:true };', params, "write"));
100
+ }
101
+ finally {
102
+ await releaseLocal();
103
+ }
104
+ } };
105
+ }
106
+ catch (error) {
107
+ await releaseLocal();
108
+ throw error;
109
+ }
110
+ };
111
+ }
112
+ /** Revision precondition is checked inside the delivered mutation, after all guard calls. It is not a server CAS. */
113
+ export function guardRevision(client, target) {
114
+ return { ...client, close: () => client.close(), async callTool(name, args, options) {
115
+ if (name !== "execute_code" || typeof args.code !== "string")
116
+ throw new Error("Revision guards require execute_code");
117
+ const code = `{ if (penpot.currentFile.id !== ${JSON.stringify(target.fileId)} || penpot.currentFile.revn !== ${JSON.stringify(target.revision)}) return {ok:false, message:"File revision changed before mutation; inspect again"}; }\n` + args.code;
118
+ if (Buffer.byteLength(code) > maxSnippetBytes)
119
+ throw new PenpairError("snippet-too-large", "Snippet including revision guard exceeds 90 KB");
120
+ return client.callTool(name, { ...args, code }, options);
121
+ } };
122
+ }
@@ -0,0 +1,21 @@
1
+ export type CredentialReference = {
2
+ type: "file";
3
+ path: string;
4
+ } | {
5
+ type: "keychain";
6
+ service: string;
7
+ account: string;
8
+ };
9
+ export declare function validateCredentialReference(raw: unknown): CredentialReference;
10
+ type KeychainRead = (command: string, args: string[], options: {
11
+ timeout: number;
12
+ maxBuffer: number;
13
+ encoding: "utf8";
14
+ }) => Promise<string>;
15
+ /** Resolve only at connection time. No URL, key, child stderr or command error object enters diagnostics. */
16
+ export declare function readCredential(reference: CredentialReference, options: {
17
+ root: string;
18
+ platform?: string;
19
+ keychainRead?: KeychainRead;
20
+ }): Promise<string>;
21
+ export {};
@@ -0,0 +1,61 @@
1
+ import { execFile } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { open } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { PenpairError } from "./errors.js";
6
+ export function validateCredentialReference(raw) {
7
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
8
+ throw new Error("Credential reference must be a file or keychain object");
9
+ const v = raw;
10
+ if (v.type === "file") {
11
+ if (Object.keys(v).some(k => !["type", "path"].includes(k)) || typeof v.path !== "string" || !v.path.trim() || v.path.length > 4096 || /[\x00-\x1f]/.test(v.path))
12
+ throw new Error("Invalid credential file reference");
13
+ }
14
+ else if (v.type === "keychain") {
15
+ if (Object.keys(v).some(k => !["type", "service", "account"].includes(k)) || [v.service, v.account].some(s => typeof s !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._ /:@-]{0,199}$/.test(s)))
16
+ throw new Error("Invalid keychain service/account reference");
17
+ }
18
+ else
19
+ throw new Error("Only file and macOS keychain credential references are supported; arbitrary command providers are deferred");
20
+ return structuredClone(v);
21
+ }
22
+ const keychainRead = (command, args, options) => new Promise((resolve, reject) => execFile(command, args, { ...options, killSignal: "SIGKILL" }, (error, stdout) => error ? reject(new Error("Credential provider unavailable or access denied")) : resolve(stdout)));
23
+ const normalize = (raw) => {
24
+ const value = raw.trim();
25
+ if (!value || Buffer.byteLength(value) > 8192 || /[\r\n\x00]/.test(value))
26
+ throw new PenpairError("endpoint-invalid", "Credential provider must return one bounded MCP URL line");
27
+ return value;
28
+ };
29
+ /** Resolve only at connection time. No URL, key, child stderr or command error object enters diagnostics. */
30
+ export async function readCredential(reference, options) {
31
+ const ref = validateCredentialReference(reference), platform = options.platform ?? process.platform;
32
+ if (ref.type === "keychain") {
33
+ if (platform !== "darwin")
34
+ throw new PenpairError("endpoint-unconfigured", "macOS Keychain references require macOS; configure a private credential file on this platform");
35
+ try {
36
+ return normalize(await (options.keychainRead ?? keychainRead)("/usr/bin/security", ["find-generic-password", "-s", ref.service, "-a", ref.account, "-w"], { timeout: 5000, maxBuffer: 16384, encoding: "utf8" }));
37
+ }
38
+ catch {
39
+ throw new PenpairError("endpoint-unconfigured", "OS credential lookup failed, timed out, or returned invalid output; inspect access in the OS credential UI");
40
+ }
41
+ }
42
+ let handle;
43
+ try {
44
+ handle = await open(path.resolve(options.root, ref.path), constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
45
+ const info = await handle.stat();
46
+ if (!info.isFile() || info.size > 16384)
47
+ throw new Error("not a bounded regular file");
48
+ if (platform !== "win32" && ((info.mode & 0o077) !== 0 || typeof process.getuid === "function" && info.uid !== process.getuid()))
49
+ throw new Error("not private to current owner");
50
+ const buffer = Buffer.alloc(16385), read = await handle.read(buffer, 0, buffer.length, 0);
51
+ if (read.bytesRead > 16384)
52
+ throw new Error("credential file byte budget exceeded");
53
+ return normalize(buffer.subarray(0, read.bytesRead).toString("utf8"));
54
+ }
55
+ catch {
56
+ throw new PenpairError("endpoint-unconfigured", "Credential file must be a readable owner-private regular file (0600 on POSIX), not a symlink, with one bounded MCP URL line");
57
+ }
58
+ finally {
59
+ await handle?.close();
60
+ }
61
+ }
@@ -68,20 +68,24 @@ for (const c of penpot.library.local.components) if (c.name.startsWith(tag)) lef
68
68
  return { ok: true, readOnly: true, left };`;
69
69
  export async function runDeepCheck(client, options = {}) {
70
70
  const tag = `penpair-deep-${randomBytes(3).toString("hex")}`;
71
- const first = (await executeCode(client, wrap(deepCheckBody, { tag }), options)).result;
71
+ const first = (await executeCode(client, wrap(deepCheckBody, { tag }), { ...options, effect: "write" })).result;
72
72
  if (first.ok === false)
73
73
  throw new Error(`the deep check could not run: ${first.message ?? "unknown error"}`);
74
74
  await new Promise((resolve) => setTimeout(resolve, options.settleMs ?? 3_000));
75
- const second = (await executeCode(client, wrap(deepLeftoversBody, { tag }), options)).result;
76
- const steps = first.steps ?? [];
77
- const leftovers = second.left ?? [];
75
+ const second = (await executeCode(client, wrap(deepLeftoversBody, { tag }), { ...options, effect: "read" })).result;
76
+ const steps = Array.isArray(first.steps) ? [...first.steps] : [];
77
+ const required = ["create board", "flex layout and children", "text content", "token set, token and binding", "component and swap", "clone", "export png"];
78
+ const complete = required.every((name) => steps.some((step) => step.step === name && step.ok === true));
79
+ if (!complete)
80
+ steps.push({ step: "complete evidence", ok: false, ms: 0, detail: "Required deep-check observations are missing or failed" });
81
+ const leftovers = Array.isArray(second.left) ? second.left : ["cleanup readback was missing; inspect the scratch file"];
78
82
  return {
79
83
  tag,
80
84
  ...(first.penpotVersion ? { penpotVersion: first.penpotVersion } : {}),
81
85
  steps,
82
86
  cleanup: first.cleanup ?? [],
83
87
  leftovers,
84
- ok: steps.every((s) => s.ok) && leftovers.length === 0,
88
+ ok: first.ok === true && second.ok === true && complete && steps.every((s) => s.ok === true) && leftovers.length === 0,
85
89
  };
86
90
  }
87
91
  export function formatDeepReport(report) {
@@ -0,0 +1,55 @@
1
+ import type { InspectionDocument } from "./inspection.js";
2
+ export interface ComponentMapEntry {
3
+ libraryId: string;
4
+ componentId: string;
5
+ codeComponent: string;
6
+ props?: Record<string, string | number | boolean | null>;
7
+ textProps?: Record<string, string>;
8
+ }
9
+ export interface ComponentMap {
10
+ schemaVersion: 1;
11
+ components: ComponentMapEntry[];
12
+ }
13
+ export interface DesignSpec {
14
+ schemaVersion: 1;
15
+ kind: "design-spec";
16
+ source: {
17
+ fileId: string;
18
+ pageId: string;
19
+ revision: number;
20
+ frameId: string;
21
+ sha256: string;
22
+ };
23
+ mappingHash: string;
24
+ nodes: Array<{
25
+ id: string;
26
+ evidence: Record<string, unknown>;
27
+ hint: {
28
+ codeComponent: string;
29
+ props: Record<string, unknown>;
30
+ } | null;
31
+ unknowns: string[];
32
+ }>;
33
+ unknowns: string[];
34
+ }
35
+ export declare const emptyComponentMap: ComponentMap;
36
+ export declare function readDesignJson(root: string, file: string, maxBytes?: number): Promise<unknown>;
37
+ export declare function validateComponentMap(raw: unknown): ComponentMap;
38
+ /** A complete single-root tree is required; no missing descendant may quietly disappear from the handoff. */
39
+ export declare function validateDesignInspection(raw: unknown): InspectionDocument & {
40
+ nodes: Record<string, unknown>[];
41
+ };
42
+ export declare function designSource(inspection: InspectionDocument): {
43
+ fileId: string;
44
+ pageId: string;
45
+ revision: number;
46
+ frameId: string;
47
+ sha256: string;
48
+ };
49
+ export declare function buildDesignSpec(raw: InspectionDocument, mapping?: ComponentMap): DesignSpec;
50
+ export declare function renderDesignSpec(spec: DesignSpec): string;
51
+ export declare function checkDesignSpec(markdown: string, inspection: InspectionDocument, mapping?: ComponentMap): {
52
+ current: boolean;
53
+ reason: string;
54
+ };
55
+ export declare function writeDesignSpec(root: string, out: string, markdown: string, redact?: (text: string) => string): Promise<void>;
@@ -0,0 +1,135 @@
1
+ import { mkdir, readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { designDataBlock, resolveProjectInput, resolveProjectOutput } from "./project-input.js";
5
+ import { atomicWriteFile } from "./atomic-output.js";
6
+ import { observationHash } from "./journal.js";
7
+ export const emptyComponentMap = { schemaVersion: 1, components: [] };
8
+ export async function readDesignJson(root, file, maxBytes = 16 * 1024 * 1024) {
9
+ const input = await resolveProjectInput(root, file);
10
+ if ((await stat(input)).size > maxBytes)
11
+ throw new Error("Design artifact exceeds the input byte budget");
12
+ return JSON.parse(await readFile(input, "utf8"));
13
+ }
14
+ export function validateComponentMap(raw) {
15
+ const v = raw;
16
+ if (!v || v.schemaVersion !== 1 || !Array.isArray(v.components) || v.components.length > 1000)
17
+ throw new Error("Unsupported component-map schema");
18
+ const seen = new Set();
19
+ 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))
21
+ 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))
24
+ throw new Error("Ambiguous component mapping");
25
+ 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))
28
+ 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)
31
+ throw new Error("textProps map prop names to explicit descendant node IDs");
32
+ }
33
+ return structuredClone(v);
34
+ }
35
+ /** A complete single-root tree is required; no missing descendant may quietly disappear from the handoff. */
36
+ export function validateDesignInspection(raw) {
37
+ 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)
39
+ throw new Error("A complete unredacted schema-1 shape inspection is required");
40
+ const nodes = v.nodes;
41
+ const byId = new Map(nodes.map(n => [n.id, n]));
42
+ if (byId.size !== nodes.length)
43
+ throw new Error("Duplicate design node identity");
44
+ let roots = 0;
45
+ 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)
47
+ throw new Error("Invalid design node identity/hierarchy");
48
+ if (n.parentId === null)
49
+ roots++;
50
+ else if (typeof n.parentId !== "string" || !byId.has(n.parentId))
51
+ throw new Error("Missing design parent");
52
+ for (const [index, childId] of n.childIds.entries()) {
53
+ const child = byId.get(childId);
54
+ if (!child || child.parentId !== n.id || child.index !== index)
55
+ throw new Error("Incomplete or inconsistent design hierarchy");
56
+ }
57
+ if (n.parentId !== null && (byId.get(n.parentId)?.childIds)[Number(n.index)] !== n.id)
58
+ throw new Error("Unlinked design node");
59
+ }
60
+ if (roots !== 1)
61
+ throw new Error("Design capture must have one root");
62
+ const seen = new Set(), stack = [nodes.find(n => n.parentId === null)];
63
+ while (stack.length) {
64
+ const n = stack.pop();
65
+ if (seen.has(n.id))
66
+ throw new Error("Design hierarchy cycle");
67
+ seen.add(n.id);
68
+ stack.push(...n.childIds.map(id => byId.get(id)));
69
+ }
70
+ if (seen.size !== nodes.length)
71
+ throw new Error("Disconnected design nodes");
72
+ return v;
73
+ }
74
+ export function designSource(inspection) {
75
+ 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 }) };
78
+ }
79
+ export function buildDesignSpec(raw, mapping = emptyComponentMap) {
80
+ const capture = validateDesignInspection(raw), map = validateComponentMap(mapping);
81
+ let traversalBudget = 1_000_000;
82
+ const byId = new Map(capture.nodes.map(row => [row.id, row]));
83
+ const nodes = capture.nodes.map(n => {
84
+ const source = n.component?.source;
85
+ const matches = map.components.filter(c => c.libraryId === source?.libraryId && c.componentId === source?.id);
86
+ 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))
90
+ unknowns.push(`uninspected:${field}`);
91
+ if (matches.length === 1) {
92
+ const match = matches[0], props = { ...match.props };
93
+ // Restrict overrides to descendants of this instance, not similarly named text elsewhere in the page.
94
+ const descendants = new Set(), pending = [...n.childIds];
95
+ while (pending.length) {
96
+ if (--traversalBudget < 0)
97
+ throw new Error("Component override traversal budget exceeded");
98
+ const id = pending.pop(), row = byId.get(id);
99
+ descendants.add(id);
100
+ pending.push(...row.childIds);
101
+ }
102
+ for (const [prop, id] of Object.entries(match.textProps ?? {})) {
103
+ const text = byId.get(id)?.text;
104
+ if (!descendants.has(id) || typeof text?.characters !== "string")
105
+ unknowns.push(`unresolved-text-prop:${prop}`);
106
+ else
107
+ props[prop] = text.characters;
108
+ }
109
+ hint = { codeComponent: match.codeComponent, props };
110
+ }
111
+ else if (source)
112
+ unknowns.push("unmapped-component");
113
+ return { id: String(n.id), evidence: structuredClone(n), hint, unknowns };
114
+ });
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."] };
117
+ }
118
+ const sha = (text) => createHash("sha256").update(text).digest("hex");
119
+ 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) };
122
+ return `<!-- penpair-spec:${JSON.stringify(record)} -->\n${body}`;
123
+ }
124
+ export function checkDesignSpec(markdown, inspection, mapping = emptyComponentMap) {
125
+ // Exact regeneration detects both changed provenance and manual edits, without executing any embedded design content.
126
+ 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" };
128
+ }
129
+ export async function writeDesignSpec(root, out, markdown, redact) {
130
+ if (redact && redact(markdown) !== markdown)
131
+ throw new Error("Design handoff requires redaction; prior output preserved");
132
+ const target = await resolveProjectOutput(root, out);
133
+ await mkdir(path.dirname(target), { recursive: true });
134
+ await atomicWriteFile(target, markdown, { replace: true });
135
+ }
@@ -1,4 +1,5 @@
1
- export type EndpointSource = "env" | "env-file" | "codex-config" | "claude-config";
1
+ import { type CredentialReference } from "./credentials.js";
2
+ export type EndpointSource = "credential-file" | "keychain" | "env" | "env-file" | "codex-config" | "claude-config";
2
3
  export interface EndpointDescription {
3
4
  source: EndpointSource;
4
5
  origin: string;
@@ -19,6 +20,10 @@ export interface ResolveOptions {
19
20
  variableNames?: string[];
20
21
  /** The MCP server name looked up in the Codex and Claude Code configuration. */
21
22
  serverName?: string;
23
+ /** Long-lived project tools may disable unrelated global client configuration fallback. */
24
+ globalConfig?: boolean;
25
+ credential?: CredentialReference;
26
+ credentialRoot?: string;
22
27
  }
23
28
  /** Reads one `NAME=value` line from env-file text: optional `export`, optional quotes, `#` comments; an empty value counts as unset. */
24
29
  export declare function parseEnvFileValue(text: string, name: string): string | undefined;
@@ -34,3 +39,5 @@ export declare function parseClaudeServerUrl(json: string, serverName?: string):
34
39
  export declare function resolveEndpoint(options?: ResolveOptions): Promise<Endpoint>;
35
40
  /** Removes the endpoint URL, its query string and any userToken parameter from text that may be persisted. */
36
41
  export declare function redactEndpoint(text: string, endpoint?: Endpoint): string;
42
+ /** Known endpoint redaction with optional boundary fragments for serialized inspection chunks. */
43
+ export declare function endpointRedactor(endpoint: Endpoint, fragments?: boolean): (text: string) => string;
package/dist/endpoint.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { readCredential } from "./credentials.js";
1
2
  import { readFile } from "node:fs/promises";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
@@ -15,11 +16,13 @@ function describe(url, source) {
15
16
  throw new PenpairError("endpoint-invalid", `The ${source} Penpot endpoint must use http or https`);
16
17
  return { url, description: { source, origin: parsed.origin, pathname: parsed.pathname } };
17
18
  }
18
- async function readOptional(file) {
19
+ async function readOptional(file, strict = false) {
19
20
  try {
20
21
  return await readFile(file, "utf8");
21
22
  }
22
- catch {
23
+ catch (error) {
24
+ if (strict && error.code !== "ENOENT")
25
+ throw new PenpairError("endpoint-unconfigured", "Project connection source cannot be read; repair its storage before resolving credentials");
23
26
  // An unreadable file is treated as absent; the error would otherwise expose a local path layout.
24
27
  return undefined;
25
28
  }
@@ -93,13 +96,31 @@ export async function resolveEndpoint(options = {}) {
93
96
  const names = options.variableNames ?? defaultVariableNames;
94
97
  const envFiles = options.envFiles ?? (environment === process.env ? [path.join(process.cwd(), ".env.local")] : []);
95
98
  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");
101
+ 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())
105
+ throw new PenpairError("endpoint-invalid", "Arbitrary credential commands are not enabled; use a private file or OS credential reference");
106
+ // Explicit file references outrank legacy URL/global sources and fail closed when unreadable.
107
+ for (const file of envFiles) {
108
+ const text = await readOptional(file, true);
109
+ if (text === undefined)
110
+ continue;
111
+ 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"))
115
+ throw new PenpairError("endpoint-invalid", "Arbitrary credential commands are not enabled; use a private file or OS credential reference");
116
+ }
96
117
  for (const name of names) {
97
118
  const explicit = environment[name]?.trim();
98
119
  if (explicit)
99
120
  return describe(explicit, "env");
100
121
  }
101
122
  for (const file of envFiles) {
102
- const text = await readOptional(file);
123
+ const text = await readOptional(file, true);
103
124
  if (text === undefined)
104
125
  continue;
105
126
  for (const name of names) {
@@ -108,6 +129,8 @@ export async function resolveEndpoint(options = {}) {
108
129
  return describe(fromFile, "env-file");
109
130
  }
110
131
  }
132
+ if (options.globalConfig === false)
133
+ throw new PenpairError("endpoint-unconfigured", "No project Penpot endpoint in the environment or configured project env file; global client fallback is disabled");
111
134
  const codex = await readOptional(path.join(home, ".codex/config.toml"));
112
135
  const fromCodex = codex === undefined ? undefined : parseCodexServerUrl(codex, serverName);
113
136
  if (fromCodex)
@@ -123,8 +146,29 @@ export function redactEndpoint(text, endpoint) {
123
146
  let redacted = text;
124
147
  if (endpoint) {
125
148
  const parsed = new URL(endpoint.url);
126
- for (const secret of [endpoint.url, parsed.search, parsed.search.slice(1)].filter((value) => value.length > 1))
149
+ 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))
127
151
  redacted = redacted.split(secret).join("[redacted]");
128
152
  }
129
153
  return redacted.replace(/userToken=[^\s&"'<>]+/gi, "userToken=[redacted]");
130
154
  }
155
+ /** Known endpoint redaction with optional boundary fragments for serialized inspection chunks. */
156
+ export function endpointRedactor(endpoint, fragments = false) {
157
+ 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);
159
+ return (text) => {
160
+ let clean = redactEndpoint(text, endpoint);
161
+ if (fragments)
162
+ for (const value of variants) {
163
+ const minimum = Math.min(8, Math.ceil(value.length / 2));
164
+ 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))
167
+ clean = clean.slice(0, -prefix.length) + "[redacted]";
168
+ if (clean.startsWith(suffix))
169
+ clean = "[redacted]" + clean.slice(suffix.length);
170
+ }
171
+ }
172
+ return clean;
173
+ };
174
+ }
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export type PenpairErrorCode = "endpoint-unconfigured" | "endpoint-invalid" | "mcp-unavailable" | "auth-rejected" | "plugin-disconnected" | "plugin-suspended" | "tool-failed" | "snippet-too-large";
1
+ export type PenpairErrorCode = "stale-inspection" | "incomplete-output" | "write-uncertain" | "file-mismatch" | "endpoint-unconfigured" | "endpoint-invalid" | "mcp-unavailable" | "auth-rejected" | "plugin-disconnected" | "plugin-suspended" | "tool-failed" | "snippet-too-large";
2
+ export declare const errorRemedies: Readonly<Record<PenpairErrorCode, string>>;
2
3
  export declare class PenpairError extends Error {
3
4
  readonly code: PenpairErrorCode;
4
5
  readonly remedy: string;
package/dist/errors.js CHANGED
@@ -1,5 +1,9 @@
1
1
  // Every failure tells the person running the command what to do next. Remedies never contain credentials.
2
- const remedies = {
2
+ export const errorRemedies = Object.freeze({
3
+ "stale-inspection": "The file, revision, query or inspected content changed. Discard the old cursor/chunks and start a fresh capture.",
4
+ "incomplete-output": "Required output is missing, truncated or unstable. Narrow the scope or resolve the capture problem before accepting it as complete.",
5
+ "write-uncertain": "Do not replay this write blindly. Inspect the intended file independently and reconcile what already changed before explicitly resuming.",
6
+ "file-mismatch": "Open the project's pinned design file and reconnect its MCP plugin. Use --any-file only when you intend to act on another file.",
3
7
  "endpoint-unconfigured": "Set PENPAIR_MCP_URL in the shell or in a git-ignored .env.local to the MCP URL from Penpot (Account, Integrations, MCP Server; do not regenerate the key), or configure an MCP server named 'penpot' for Codex or Claude Code.",
4
8
  "endpoint-invalid": "The configured MCP URL must be a complete http or https URL.",
5
9
  "mcp-unavailable": "Start the Penpot instance and check the MCP URL. Run `penpair probe` to see what answers.",
@@ -8,14 +12,14 @@ const remedies = {
8
12
  "plugin-suspended": "The browser put the Penpot tab to sleep. Bring it to the foreground and keep it visible while a command runs. A suspended-tab error does not mean nothing was written: read the page before running a write again.",
9
13
  "tool-failed": "Penpot's tool reported a failure; the message above says what.",
10
14
  "snippet-too-large": "Split the snippet. Penpot's proxy refuses request bodies of about 110 KB, so snippets stay under 90 KB.",
11
- };
15
+ });
12
16
  export class PenpairError extends Error {
13
17
  code;
14
18
  remedy;
15
19
  constructor(code, message) {
16
- super(`${message}\n→ ${remedies[code]}`);
20
+ super(`${message}\n→ ${errorRemedies[code]}`);
17
21
  this.name = "PenpairError";
18
22
  this.code = code;
19
- this.remedy = remedies[code];
23
+ this.remedy = errorRemedies[code];
20
24
  }
21
25
  }
package/dist/export.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type LayoutResult } from "./layout.js";
1
2
  import { Buffer } from "node:buffer";
2
3
  import type { PenpotToolClient } from "./client.js";
3
4
  import { type OperationOptions } from "./operation.js";
@@ -11,7 +12,24 @@ export interface ExportOptions extends OperationOptions {
11
12
  stable?: boolean;
12
13
  maxExports?: number;
13
14
  }
15
+ export interface CaptureIdentity {
16
+ fileId: string;
17
+ pageId: string;
18
+ frameId: string;
19
+ revision: number;
20
+ designSize?: {
21
+ width: number;
22
+ height: number;
23
+ };
24
+ activeThemeIds?: string[] | null;
25
+ scale: number;
26
+ penpotApiVersion: string | null;
27
+ renderEnvironment: string | null;
28
+ activeThemes: string[];
29
+ }
14
30
  export interface ExportResult {
31
+ layout?: LayoutResult;
32
+ capture: CaptureIdentity;
15
33
  png: Buffer;
16
34
  /** Whether two consecutive exports were byte-identical. Always false when `stable` is off. */
17
35
  stable: boolean;