@penpair/core 0.1.3 → 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 (149) hide show
  1. package/dist/adapters.d.ts +16 -1
  2. package/dist/adapters.js +62 -19
  3. package/dist/approvals.d.ts +32 -0
  4. package/dist/approvals.js +213 -0
  5. package/dist/atomic-output.d.ts +5 -0
  6. package/dist/atomic-output.js +33 -0
  7. package/dist/audit-rules.d.ts +60 -0
  8. package/dist/audit-rules.js +443 -0
  9. package/dist/backup-note.d.ts +4 -0
  10. package/dist/backup-note.js +49 -0
  11. package/dist/baseline-coverage.d.ts +25 -0
  12. package/dist/baseline-coverage.js +248 -0
  13. package/dist/baselines-config.d.ts +25 -0
  14. package/dist/baselines-config.js +150 -11
  15. package/dist/baselines.d.ts +37 -8
  16. package/dist/baselines.js +362 -59
  17. package/dist/cli-arguments.d.ts +7 -0
  18. package/dist/cli-arguments.js +47 -0
  19. package/dist/cli-main.d.ts +13 -1
  20. package/dist/cli-main.js +2107 -247
  21. package/dist/client.d.ts +30 -6
  22. package/dist/client.js +160 -35
  23. package/dist/command-result.d.ts +159 -0
  24. package/dist/command-result.js +468 -0
  25. package/dist/coordination.d.ts +15 -0
  26. package/dist/coordination.js +184 -0
  27. package/dist/credentials.d.ts +21 -0
  28. package/dist/credentials.js +105 -0
  29. package/dist/deep-check.js +57 -9
  30. package/dist/design-spec.d.ts +55 -0
  31. package/dist/design-spec.js +275 -0
  32. package/dist/endpoint.d.ts +8 -1
  33. package/dist/endpoint.js +133 -21
  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 +113 -10
  38. package/dist/file-archive.d.ts +16 -0
  39. package/dist/file-archive.js +58 -0
  40. package/dist/file-guard.d.ts +8 -0
  41. package/dist/file-guard.js +58 -0
  42. package/dist/formats.d.ts +4 -0
  43. package/dist/formats.js +229 -0
  44. package/dist/index.d.ts +34 -1
  45. package/dist/index.js +34 -1
  46. package/dist/inspection.d.ts +106 -0
  47. package/dist/inspection.js +657 -0
  48. package/dist/instance/config-schema.d.ts +422 -0
  49. package/dist/instance/config-schema.js +435 -0
  50. package/dist/instance/config.d.ts +29 -0
  51. package/dist/instance/config.js +191 -29
  52. package/dist/instance/connect.d.ts +10 -1
  53. package/dist/instance/connect.js +178 -30
  54. package/dist/instance/connection.d.ts +39 -4
  55. package/dist/instance/connection.js +198 -14
  56. package/dist/instance/gitignore.js +5 -2
  57. package/dist/instance/init.js +32 -7
  58. package/dist/instance/runner.js +32 -10
  59. package/dist/instance/setup.js +46 -13
  60. package/dist/instance/tty.js +28 -8
  61. package/dist/journal.d.ts +87 -0
  62. package/dist/journal.js +423 -0
  63. package/dist/kit-smoke.d.ts +31 -0
  64. package/dist/kit-smoke.js +145 -0
  65. package/dist/kit-source.d.ts +2 -0
  66. package/dist/kit-source.js +279 -0
  67. package/dist/kit.d.ts +25 -0
  68. package/dist/kit.js +158 -0
  69. package/dist/layout-policy.d.ts +2 -0
  70. package/dist/layout-policy.js +86 -0
  71. package/dist/layout.d.ts +21 -0
  72. package/dist/layout.js +36 -0
  73. package/dist/live-smoke.d.ts +8 -0
  74. package/dist/live-smoke.js +23 -0
  75. package/dist/local-profile.js +2 -1
  76. package/dist/mcp-server.d.ts +12 -0
  77. package/dist/mcp-server.js +415 -0
  78. package/dist/native-api.d.ts +15 -0
  79. package/dist/native-api.js +78 -0
  80. package/dist/onboard.d.ts +10 -0
  81. package/dist/onboard.js +41 -0
  82. package/dist/operation.d.ts +22 -3
  83. package/dist/operation.js +341 -31
  84. package/dist/page-session.d.ts +23 -0
  85. package/dist/page-session.js +142 -0
  86. package/dist/png.d.ts +5 -0
  87. package/dist/png.js +22 -0
  88. package/dist/portable-tokens.d.ts +22 -0
  89. package/dist/portable-tokens.js +331 -0
  90. package/dist/probe.d.ts +6 -2
  91. package/dist/probe.js +64 -11
  92. package/dist/project-input.d.ts +6 -0
  93. package/dist/project-input.js +65 -0
  94. package/dist/project-snippets.js +29 -12
  95. package/dist/project-status.d.ts +24 -0
  96. package/dist/project-status.js +188 -0
  97. package/dist/registry.d.ts +2 -1
  98. package/dist/registry.js +224 -53
  99. package/dist/run-file.d.ts +26 -0
  100. package/dist/run-file.js +199 -0
  101. package/dist/shots.d.ts +43 -0
  102. package/dist/shots.js +172 -0
  103. package/dist/skill-instructions.d.ts +13 -0
  104. package/dist/skill-instructions.js +144 -0
  105. package/dist/skills.d.ts +7 -0
  106. package/dist/skills.js +163 -46
  107. package/dist/snippet.d.ts +3 -2
  108. package/dist/snippet.js +33 -9
  109. package/dist/snippets/archive.d.ts +9 -0
  110. package/dist/snippets/archive.js +10 -4
  111. package/dist/snippets/read.d.ts +1 -1
  112. package/dist/snippets/read.js +78 -41
  113. package/dist/structure-snapshot.d.ts +85 -0
  114. package/dist/structure-snapshot.js +525 -0
  115. package/dist/token-edit.d.ts +53 -0
  116. package/dist/token-edit.js +352 -0
  117. package/dist/token-projection.d.ts +56 -0
  118. package/dist/token-projection.js +286 -0
  119. package/dist/tokens-diff.d.ts +19 -6
  120. package/dist/tokens-diff.js +182 -18
  121. package/dist/version.d.ts +1 -1
  122. package/dist/version.js +1 -1
  123. package/package.json +1 -1
  124. package/skills/penpair-design/SKILL.md +20 -45
  125. package/skills/penpair-design/references/penpot-plugin-gotchas.md +58 -2
  126. package/skills/penpair-design/references/project-facts.md +3 -1
  127. package/skills/penpair-design/references/write-protocol.md +15 -3
  128. package/skills/penpair-handoff/SKILL.md +16 -0
  129. package/skills/penpair-handoff/references/comparison-companion.md +13 -0
  130. package/skills/penpair-handoff/references/handoff-flow.md +13 -0
  131. package/skills/penpair-recipes/SKILL.md +21 -0
  132. package/skills/penpair-recipes/references/build-flow.md +11 -0
  133. package/skills/penpair-setup/SKILL.md +8 -2
  134. package/dist/cli.d.ts +0 -2
  135. package/dist/cli.js +0 -3
  136. package/dist/docker/lifecycle.d.ts +0 -96
  137. package/dist/docker/lifecycle.js +0 -273
  138. package/dist/docker/secrets.d.ts +0 -11
  139. package/dist/docker/secrets.js +0 -38
  140. package/dist/docker/template.d.ts +0 -8
  141. package/dist/docker/template.js +0 -158
  142. package/dist/docker-profile.d.ts +0 -114
  143. package/dist/docker-profile.js +0 -18
  144. package/dist/instance/lifecycle.d.ts +0 -129
  145. package/dist/instance/lifecycle.js +0 -336
  146. package/dist/instance/secrets.d.ts +0 -11
  147. package/dist/instance/secrets.js +0 -38
  148. package/dist/instance/template.d.ts +0 -8
  149. package/dist/instance/template.js +0 -158
package/dist/endpoint.js CHANGED
@@ -1,8 +1,11 @@
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";
4
5
  import { PenpairError } from "./errors.js";
5
- export const defaultVariableNames = ["PENPAIR_MCP_URL"];
6
+ export const defaultVariableNames = [
7
+ "PENPAIR_MCP_URL",
8
+ ];
6
9
  function describe(url, source) {
7
10
  let parsed;
8
11
  try {
@@ -11,15 +14,26 @@ function describe(url, source) {
11
14
  catch {
12
15
  throw new PenpairError("endpoint-invalid", `The ${source} Penpot endpoint is not a valid URL`);
13
16
  }
14
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
17
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
15
18
  throw new PenpairError("endpoint-invalid", `The ${source} Penpot endpoint must use http or https`);
16
- 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
+ };
17
28
  }
18
- async function readOptional(file) {
29
+ async function readOptional(file, strict = false) {
19
30
  try {
20
31
  return await readFile(file, "utf8");
21
32
  }
22
- catch {
33
+ catch (error) {
34
+ if (strict && error.code !== "ENOENT") {
35
+ throw new PenpairError("endpoint-unconfigured", "Project connection source cannot be read; repair its storage before resolving credentials");
36
+ }
23
37
  // An unreadable file is treated as absent; the error would otherwise expose a local path layout.
24
38
  return undefined;
25
39
  }
@@ -28,19 +42,22 @@ async function readOptional(file) {
28
42
  export function parseEnvFileValue(text, name) {
29
43
  for (const rawLine of text.split(/\r?\n/)) {
30
44
  const line = rawLine.trim();
31
- if (line === "" || line.startsWith("#"))
45
+ if (line === "" || line.startsWith("#")) {
32
46
  continue;
47
+ }
33
48
  const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
34
- if (!match || match[1] !== name)
49
+ if (!match || match[1] !== name) {
35
50
  continue;
51
+ }
36
52
  let value = (match[2] ?? "").trim();
37
53
  const quote = value[0];
38
54
  if ((quote === '"' || quote === "'") && value.length > 1) {
39
55
  const end = value.indexOf(quote, 1);
40
56
  value = end === -1 ? value.slice(1) : value.slice(1, end);
41
57
  }
42
- else
58
+ else {
43
59
  value = value.replace(/\s+#.*$/, "");
60
+ }
44
61
  return value.length > 0 ? value : undefined;
45
62
  }
46
63
  return undefined;
@@ -56,11 +73,13 @@ export function parseCodexServerUrl(toml, serverName = "penpot") {
56
73
  inServer = header.test(line);
57
74
  continue;
58
75
  }
59
- if (!inServer)
76
+ if (!inServer) {
60
77
  continue;
78
+ }
61
79
  const match = /^url\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\s*(?:#.*)?$/.exec(line);
62
- if (match)
80
+ if (match) {
63
81
  return (match[1] ?? match[2])?.replace(/\\(["\\])/g, "$1");
82
+ }
64
83
  }
65
84
  return undefined;
66
85
  }
@@ -68,14 +87,20 @@ export function parseCodexServerUrl(toml, serverName = "penpot") {
68
87
  export function parseClaudeServerUrl(json, serverName = "penpot") {
69
88
  try {
70
89
  const value = JSON.parse(json);
71
- if (typeof value !== "object" || value === null || !("mcpServers" in value))
90
+ if (typeof value !== "object" || value === null || !("mcpServers" in value)) {
72
91
  return undefined;
92
+ }
73
93
  const servers = value.mcpServers;
74
- if (typeof servers !== "object" || servers === null || !(serverName in servers))
94
+ if (typeof servers !== "object" || servers === null || !(serverName in servers)) {
75
95
  return undefined;
96
+ }
76
97
  const server = servers[serverName];
77
- 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") {
78
102
  return undefined;
103
+ }
79
104
  return server.url;
80
105
  }
81
106
  catch {
@@ -91,31 +116,80 @@ export async function resolveEndpoint(options = {}) {
91
116
  const environment = options.environment ?? process.env;
92
117
  const home = options.home ?? os.homedir();
93
118
  const names = options.variableNames ?? defaultVariableNames;
94
- 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
+ : []);
95
125
  const serverName = options.serverName ?? "penpot";
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
+ }
131
+ const providerFile = environment.PENPAIR_MCP_URL_FILE?.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()) {
141
+ throw new PenpairError("endpoint-invalid", "Arbitrary credential commands are not enabled; use a private file or OS credential reference");
142
+ }
143
+ // Explicit file references outrank legacy URL/global sources and fail closed when unreadable.
144
+ for (const file of envFiles) {
145
+ const text = await readOptional(file, true);
146
+ if (text === undefined) {
147
+ continue;
148
+ }
149
+ const provider = parseEnvFileValue(text, "PENPAIR_MCP_URL_FILE");
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")) {
159
+ throw new PenpairError("endpoint-invalid", "Arbitrary credential commands are not enabled; use a private file or OS credential reference");
160
+ }
161
+ }
96
162
  for (const name of names) {
97
163
  const explicit = environment[name]?.trim();
98
- if (explicit)
164
+ if (explicit) {
99
165
  return describe(explicit, "env");
166
+ }
100
167
  }
101
168
  for (const file of envFiles) {
102
- const text = await readOptional(file);
103
- if (text === undefined)
169
+ const text = await readOptional(file, true);
170
+ if (text === undefined) {
104
171
  continue;
172
+ }
105
173
  for (const name of names) {
106
174
  const fromFile = parseEnvFileValue(text, name);
107
- if (fromFile)
175
+ if (fromFile) {
108
176
  return describe(fromFile, "env-file");
177
+ }
109
178
  }
110
179
  }
180
+ if (options.globalConfig === false) {
181
+ throw new PenpairError("endpoint-unconfigured", "No project Penpot endpoint in the environment or configured project env file; global client fallback is disabled");
182
+ }
111
183
  const codex = await readOptional(path.join(home, ".codex/config.toml"));
112
184
  const fromCodex = codex === undefined ? undefined : parseCodexServerUrl(codex, serverName);
113
- if (fromCodex)
185
+ if (fromCodex) {
114
186
  return describe(fromCodex, "codex-config");
187
+ }
115
188
  const claude = await readOptional(path.join(home, ".claude.json"));
116
189
  const fromClaude = claude === undefined ? undefined : parseClaudeServerUrl(claude, serverName);
117
- if (fromClaude)
190
+ if (fromClaude) {
118
191
  return describe(fromClaude, "claude-config");
192
+ }
119
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`);
120
194
  }
121
195
  /** Removes the endpoint URL, its query string and any userToken parameter from text that may be persisted. */
@@ -123,8 +197,46 @@ export function redactEndpoint(text, endpoint) {
123
197
  let redacted = text;
124
198
  if (endpoint) {
125
199
  const parsed = new URL(endpoint.url);
126
- for (const secret of [endpoint.url, parsed.search, parsed.search.slice(1)].filter((value) => value.length > 1))
200
+ const key = parsed.searchParams.get("userToken") ?? "";
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)) {
127
208
  redacted = redacted.split(secret).join("[redacted]");
209
+ }
128
210
  }
129
211
  return redacted.replace(/userToken=[^\s&"'<>]+/gi, "userToken=[redacted]");
130
212
  }
213
+ /** Known endpoint redaction with optional boundary fragments for serialized inspection chunks. */
214
+ export function endpointRedactor(endpoint, fragments = false) {
215
+ const key = new URL(endpoint.url).searchParams.get("userToken") ?? "";
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);
223
+ return (text) => {
224
+ let clean = redactEndpoint(text, endpoint);
225
+ if (fragments) {
226
+ for (const value of variants) {
227
+ const minimum = Math.min(8, Math.ceil(value.length / 2));
228
+ for (let size = value.length - 1; size >= minimum; size--) {
229
+ const prefix = value.slice(0, size);
230
+ const suffix = value.slice(-size);
231
+ if (clean.endsWith(prefix)) {
232
+ clean = clean.slice(0, -prefix.length) + "[redacted]";
233
+ }
234
+ if (clean.startsWith(suffix)) {
235
+ clean = "[redacted]" + clean.slice(suffix.length);
236
+ }
237
+ }
238
+ }
239
+ }
240
+ return clean;
241
+ };
242
+ }
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;
package/dist/export.js CHANGED
@@ -1,3 +1,7 @@
1
+ import { withPageSession } from "./page-session.js";
2
+ import { waitForStableLayout } from "./layout.js";
3
+ import { PenpairError } from "./errors.js";
4
+ import { commandSleep } from "./command-result.js";
1
5
  import { createHash } from "node:crypto";
2
6
  import { Buffer } from "node:buffer";
3
7
  import { snippet, requirePage } from "./snippet.js";
@@ -5,36 +9,135 @@ import { runOnPage } from "./operation.js";
5
9
  const exportSnippet = (shapeId, scale, page) => snippet("export", `${requirePage}
6
10
  const s = penpot.currentPage.getShapeById(CFG.id);
7
11
  if (!s) return { ok: false, message: "no shape " + CFG.id + " on " + penpot.currentPage.name };
12
+ const active = (penpot.library?.local?.tokens?.themes || []).filter(t=>t.active);
13
+ const capture = {designSize:{width:s.width,height:s.height},activeThemeIds:active.every(t=>typeof t.id==="string"&&t.id)?active.map(t=>t.id).sort():null,fileId:penpot.currentFile.id, pageId:penpot.currentPage.id, frameId:s.id, revision:penpot.currentFile.revn, scale:CFG.scale,
14
+ penpotApiVersion:typeof penpot.version === "string" ? penpot.version : null,
15
+ renderEnvironment:typeof navigator !== "undefined" ? navigator.userAgent : null,
16
+ activeThemes:(penpot.library?.local?.tokens?.themes || []).filter(t=>t.active).map(t=>t.group+"/"+t.name).sort()};
17
+ const geometry = JSON.stringify([s.x,s.y,s.width,s.height]);
8
18
  const bytes = await s.export({ type: "png", scale: CFG.scale });
19
+ const afterShape = penpot.currentPage.getShapeById(CFG.id);
20
+ if (penpot.currentFile.id !== capture.fileId || penpot.currentPage.id !== capture.pageId || penpot.currentFile.revn !== capture.revision || !afterShape || afterShape.id !== capture.frameId || JSON.stringify([afterShape.x,afterShape.y,afterShape.width,afterShape.height]) !== geometry)
21
+ return {ok:false, message:"Export source changed during capture"};
9
22
  let bin = ""; for (let i = 0; i < bytes.length; i += 8192) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192));
10
- return { ok: true, base64: btoa(bin), length: bytes.length };`, { id: shapeId, scale, page });
11
- const defaultSleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
23
+ return { ok: true, base64: btoa(bin), length: bytes.length, capture };`, {
24
+ id: shapeId,
25
+ scale,
26
+ page,
27
+ }, "read");
28
+ const defaultSleep = commandSleep;
12
29
  /** Exports one shape as PNG. A first call can fail once and then work; this retries, and by default waits for a stable render. */
13
30
  export async function exportShapePng(client, shapeId, options = {}) {
14
- const { page, scale = 1, attempts = 3, stable = true, maxExports = 4, sleep = defaultSleep } = options;
31
+ return withPageSession(client, () => exportInSession(client, shapeId, options));
32
+ }
33
+ async function exportInSession(client, shapeId, options) {
34
+ const { page, scale = 1, attempts = 3, stable = true, maxExports = 4, sleep = defaultSleep, } = options;
35
+ if (!Number.isFinite(scale) ||
36
+ scale <= 0 ||
37
+ scale > 8 ||
38
+ !Number.isInteger(attempts) ||
39
+ attempts < 1 ||
40
+ attempts > 10 ||
41
+ !Number.isInteger(maxExports) ||
42
+ maxExports < (stable ? 2 : 1) ||
43
+ maxExports > 20) {
44
+ throw new Error("Invalid export scale/attempt limits");
45
+ }
15
46
  const once = async () => {
16
47
  for (let attempt = 1;; attempt++) {
17
48
  try {
18
49
  const record = await runOnPage(client, exportSnippet(shapeId, scale, page), page, options);
19
- return Buffer.from(String(record.base64), "base64");
50
+ const capture = record.capture;
51
+ if (!capture ||
52
+ typeof capture.fileId !== "string" ||
53
+ !capture.fileId ||
54
+ typeof capture.pageId !== "string" ||
55
+ !capture.pageId ||
56
+ capture.frameId !== shapeId ||
57
+ !Number.isInteger(capture.revision) ||
58
+ capture.scale !== scale ||
59
+ !Array.isArray(capture.activeThemes)) {
60
+ throw new Error("Export did not return target provenance");
61
+ }
62
+ if (capture.designSize &&
63
+ ![
64
+ capture.designSize.width,
65
+ capture.designSize.height,
66
+ ].every((value) => Number.isFinite(value) && value >= 0)) {
67
+ throw new Error("Export has malformed design dimensions");
68
+ }
69
+ if (capture.activeThemeIds !== undefined &&
70
+ capture.activeThemeIds !== null &&
71
+ (!Array.isArray(capture.activeThemeIds) ||
72
+ capture.activeThemeIds.some((id) => typeof id !== "string" || !id) ||
73
+ new Set(capture.activeThemeIds).size !== capture.activeThemeIds.length)) {
74
+ throw new Error("Export has malformed active theme identity");
75
+ }
76
+ if (typeof record.base64 !== "string" ||
77
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(record.base64)) {
78
+ throw new Error("Invalid export byte encoding");
79
+ }
80
+ const png = Buffer.from(record.base64, "base64");
81
+ if (!png.length || png.length !== record.length) {
82
+ throw new Error("Export byte length does not match its evidence");
83
+ }
84
+ return {
85
+ png,
86
+ capture,
87
+ };
20
88
  }
21
89
  catch (error) {
22
- if (attempt >= attempts)
90
+ if (attempt >= attempts) {
23
91
  throw error;
92
+ }
24
93
  await sleep(5_000);
25
94
  }
26
95
  }
27
96
  };
28
97
  let previous = await once();
29
- if (!stable)
30
- return { png: previous, stable: false, exports: 1 };
98
+ let layout;
99
+ if (options.layout !== false) {
100
+ layout = await waitForStableLayout(client, {
101
+ shapeId,
102
+ sleep,
103
+ ...options.layout,
104
+ });
105
+ if (!layout.stable) {
106
+ throw new PenpairError("incomplete-output", `Layout did not settle before export: ${layout.reason ?? "unknown reason"}`);
107
+ }
108
+ }
109
+ if (!stable) {
110
+ return {
111
+ layout,
112
+ ...previous,
113
+ stable: false,
114
+ exports: 1,
115
+ };
116
+ }
31
117
  const hash = (buffer) => createHash("sha256").update(buffer).digest("hex");
32
118
  for (let count = 2; count <= maxExports; count++) {
33
119
  await sleep(2_000);
34
120
  const next = await once();
35
- if (hash(next) === hash(previous))
36
- return { png: next, stable: true, exports: count };
121
+ const identity = ({ designSize: _size, ...value }) => value;
122
+ if (JSON.stringify(identity(next.capture)) !== JSON.stringify(identity(previous.capture))) {
123
+ throw new Error("Export identity, revision or render environment changed between captures");
124
+ }
125
+ if (JSON.stringify(next.capture.designSize ?? null) ===
126
+ JSON.stringify(previous.capture.designSize ?? null) &&
127
+ hash(next.png) === hash(previous.png)) {
128
+ return {
129
+ layout,
130
+ ...next,
131
+ stable: true,
132
+ exports: count,
133
+ };
134
+ }
37
135
  previous = next;
38
136
  }
39
- return { png: previous, stable: false, exports: maxExports };
137
+ return {
138
+ layout,
139
+ ...previous,
140
+ stable: false,
141
+ exports: maxExports,
142
+ };
40
143
  }
@@ -0,0 +1,16 @@
1
+ import type { PenpotToolClient } from "./client.js";
2
+ export type FileArchive = {
3
+ status: "exported";
4
+ bytes: Buffer;
5
+ fileId: string;
6
+ revn: number;
7
+ sha256: string;
8
+ restoreVerified: false;
9
+ } | {
10
+ status: "unsupported" | "failed" | "too-large" | "changed";
11
+ message: string;
12
+ };
13
+ /** Full-fidelity recovery is established by import/readback, not by receiving archive bytes. */
14
+ export declare function exportFileArchive(client: PenpotToolClient, options?: {
15
+ maxBytes?: number;
16
+ }): Promise<FileArchive>;
@@ -0,0 +1,58 @@
1
+ import { createHash } from "node:crypto";
2
+ import { runSnippet, snippet } from "./snippet.js";
3
+ import { redactEndpoint } from "./endpoint.js";
4
+ /** Full-fidelity recovery is established by import/readback, not by receiving archive bytes. */
5
+ export async function exportFileArchive(client, options = {}) {
6
+ const maxBytes = options.maxBytes ?? 512 * 1024;
7
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0 || maxBytes > 16 * 1024 * 1024) {
8
+ throw new Error("Archive budget must be positive and at most 16 MiB");
9
+ }
10
+ const result = await runSnippet(client, snippet("file-archive", `
11
+ const file = penpot.currentFile, fileId = file.id, revn = file.revn;
12
+ if (typeof file.export !== "function") return { ok: true, status: "unsupported", message: "File export is unavailable; use Penpot's File export menu" };
13
+ let bytes;
14
+ try { bytes = await file.export("penpot", "all"); }
15
+ catch (error) { return { ok: true, status: "failed", message: "Archive export failed: " + String(error && error.message || error) + ". Use the manual export route." }; }
16
+ if (penpot.currentFile.id !== fileId || penpot.currentFile.revn !== revn) return { ok: true, status: "changed", message: "The file changed during export; archive was not accepted" };
17
+ if (!bytes || !Number.isSafeInteger(bytes.length) || bytes.length === 0) return { ok: true, status: "failed", message: "File export returned no archive bytes" };
18
+ if (bytes.length > CFG.maxBytes) return { ok: true, status: "too-large", message: "Archive exceeds the configured response budget; export it manually" };
19
+ let binary = "";
20
+ for (let i = 0; i < bytes.length; i += 8192) binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192));
21
+ return { ok: true, status: "exported", fileId, revn, length: bytes.length, base64: btoa(binary) };`, {
22
+ maxBytes,
23
+ }, "read"));
24
+ if (result.status !== "exported") {
25
+ if (![
26
+ "unsupported",
27
+ "failed",
28
+ "too-large",
29
+ "changed",
30
+ ].includes(String(result.status)) ||
31
+ typeof result.message !== "string") {
32
+ throw new Error("Archive response contained no valid outcome");
33
+ }
34
+ return {
35
+ status: result.status,
36
+ message: redactEndpoint(result.message),
37
+ };
38
+ }
39
+ if (result.ok !== true ||
40
+ typeof result.fileId !== "string" ||
41
+ !Number.isInteger(result.revn) ||
42
+ typeof result.base64 !== "string" ||
43
+ !/^[A-Za-z0-9+/]*={0,2}$/.test(result.base64)) {
44
+ throw new Error("Archive response contained incomplete metadata");
45
+ }
46
+ const bytes = Buffer.from(result.base64, "base64");
47
+ if (bytes.length === 0 || bytes.length > maxBytes || bytes.length !== result.length) {
48
+ throw new Error("Archive response was empty, truncated or over budget");
49
+ }
50
+ return {
51
+ status: "exported",
52
+ bytes,
53
+ fileId: result.fileId,
54
+ revn: Number(result.revn),
55
+ sha256: createHash("sha256").update(bytes).digest("hex"),
56
+ restoreVerified: false,
57
+ };
58
+ }
@@ -0,0 +1,8 @@
1
+ import { type PenpotToolClient } from "./client.js";
2
+ import type { ProjectFile } from "./instance/config.js";
3
+ /** A guard in the delivered execution closes the gap between a connection check and a later batch. */
4
+ export declare function guardFile(client: PenpotToolClient, expected: ProjectFile | undefined, options?: {
5
+ mode?: "strict" | "warn";
6
+ anyFile?: boolean;
7
+ warn?: (message: string) => void;
8
+ }): PenpotToolClient;
@@ -0,0 +1,58 @@
1
+ import { inheritPageSession } from "./page-session.js";
2
+ import { executionEnvelope, maxSnippetBytes } from "./client.js";
3
+ import { PenpairError } from "./errors.js";
4
+ /** A guard in the delivered execution closes the gap between a connection check and a later batch. */
5
+ export function guardFile(client, expected, options = {}) {
6
+ if (!expected || options.anyFile) {
7
+ options.warn?.(options.anyFile
8
+ ? "--any-file overrides the project's file guard for this run."
9
+ : "No file ID is pinned. Run `penpair connect --pin-file` to protect this project's file.");
10
+ return client;
11
+ }
12
+ const warningPrefix = "penpair-file-warning: ";
13
+ return inheritPageSession({
14
+ ...client,
15
+ close: () => client.close(),
16
+ ...(client.listTools
17
+ ? {
18
+ listTools: () => client.listTools(),
19
+ }
20
+ : {}),
21
+ async callTool(name, args, callOptions) {
22
+ if (name !== "execute_code" || typeof args.code !== "string") {
23
+ throw new PenpairError("file-mismatch", "The file guard only permits guarded execute_code calls.");
24
+ }
25
+ const guard = `{ const __penpairExpectedFile = ${JSON.stringify(expected)};
26
+ const __penpairActualFile = penpot.currentFile;
27
+ if (!__penpairActualFile || __penpairActualFile.id !== __penpairExpectedFile.id) {
28
+ const message = "Expected file " + (__penpairExpectedFile.name || __penpairExpectedFile.id) + " (" + __penpairExpectedFile.id + "), connected to " + (__penpairActualFile ? __penpairActualFile.name + " (" + __penpairActualFile.id + ")" : "no file") + ". Open the expected file and reconnect, or explicitly use --any-file.";
29
+ ${options.mode === "warn" ? `console.warn(${JSON.stringify(warningPrefix)} + message);` : "return { ok: false, fileMismatch: true, message };"}
30
+ } }
31
+ `;
32
+ const code = guard + args.code;
33
+ if (Buffer.byteLength(code, "utf8") > maxSnippetBytes) {
34
+ throw new PenpairError("snippet-too-large", "The snippet including its file guard exceeds 90 KB");
35
+ }
36
+ const outcome = await client.callTool(name, {
37
+ ...args,
38
+ code,
39
+ }, callOptions);
40
+ const envelope = executionEnvelope(outcome);
41
+ const result = envelope?.result;
42
+ if (typeof result === "object" &&
43
+ result !== null &&
44
+ "fileMismatch" in result &&
45
+ result.fileMismatch === true) {
46
+ throw new PenpairError("file-mismatch", String("message" in result ? result.message : "The connected file changed"));
47
+ }
48
+ if (typeof envelope?.log === "string") {
49
+ for (const line of envelope.log.split("\n")) {
50
+ if (line.includes(warningPrefix)) {
51
+ options.warn?.(line.slice(line.indexOf(warningPrefix) + warningPrefix.length));
52
+ }
53
+ }
54
+ }
55
+ return outcome;
56
+ },
57
+ }, client);
58
+ }
@@ -0,0 +1,4 @@
1
+ /** Public envelope schemas; runtime validators additionally enforce hashes, identities, hierarchy and budgets. */
2
+ export declare const artifactSchemas: Record<string, unknown>;
3
+ /** Legacy unversioned config is v1; preserve every extension block and require explicit new output for persistence. */
4
+ export declare function migrateProjectConfig(raw: unknown): Record<string, unknown>;