@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.
- package/dist/adapters.d.ts +16 -1
- package/dist/adapters.js +62 -19
- package/dist/approvals.d.ts +32 -0
- package/dist/approvals.js +213 -0
- package/dist/atomic-output.d.ts +5 -0
- package/dist/atomic-output.js +33 -0
- package/dist/audit-rules.d.ts +60 -0
- package/dist/audit-rules.js +443 -0
- package/dist/backup-note.d.ts +4 -0
- package/dist/backup-note.js +49 -0
- package/dist/baseline-coverage.d.ts +25 -0
- package/dist/baseline-coverage.js +248 -0
- package/dist/baselines-config.d.ts +25 -0
- package/dist/baselines-config.js +150 -11
- package/dist/baselines.d.ts +37 -8
- package/dist/baselines.js +362 -59
- package/dist/cli-arguments.d.ts +7 -0
- package/dist/cli-arguments.js +47 -0
- package/dist/cli-main.d.ts +13 -1
- package/dist/cli-main.js +2107 -247
- package/dist/client.d.ts +30 -6
- package/dist/client.js +160 -35
- package/dist/command-result.d.ts +159 -0
- package/dist/command-result.js +468 -0
- package/dist/coordination.d.ts +15 -0
- package/dist/coordination.js +184 -0
- package/dist/credentials.d.ts +21 -0
- package/dist/credentials.js +105 -0
- package/dist/deep-check.js +57 -9
- package/dist/design-spec.d.ts +55 -0
- package/dist/design-spec.js +275 -0
- package/dist/endpoint.d.ts +8 -1
- package/dist/endpoint.js +133 -21
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +8 -4
- package/dist/export.d.ts +18 -0
- package/dist/export.js +113 -10
- package/dist/file-archive.d.ts +16 -0
- package/dist/file-archive.js +58 -0
- package/dist/file-guard.d.ts +8 -0
- package/dist/file-guard.js +58 -0
- package/dist/formats.d.ts +4 -0
- package/dist/formats.js +229 -0
- package/dist/index.d.ts +34 -1
- package/dist/index.js +34 -1
- package/dist/inspection.d.ts +106 -0
- package/dist/inspection.js +657 -0
- package/dist/instance/config-schema.d.ts +422 -0
- package/dist/instance/config-schema.js +435 -0
- package/dist/instance/config.d.ts +29 -0
- package/dist/instance/config.js +191 -29
- package/dist/instance/connect.d.ts +10 -1
- package/dist/instance/connect.js +178 -30
- package/dist/instance/connection.d.ts +39 -4
- package/dist/instance/connection.js +198 -14
- package/dist/instance/gitignore.js +5 -2
- package/dist/instance/init.js +32 -7
- package/dist/instance/runner.js +32 -10
- package/dist/instance/setup.js +46 -13
- package/dist/instance/tty.js +28 -8
- package/dist/journal.d.ts +87 -0
- package/dist/journal.js +423 -0
- package/dist/kit-smoke.d.ts +31 -0
- package/dist/kit-smoke.js +145 -0
- package/dist/kit-source.d.ts +2 -0
- package/dist/kit-source.js +279 -0
- package/dist/kit.d.ts +25 -0
- package/dist/kit.js +158 -0
- package/dist/layout-policy.d.ts +2 -0
- package/dist/layout-policy.js +86 -0
- package/dist/layout.d.ts +21 -0
- package/dist/layout.js +36 -0
- package/dist/live-smoke.d.ts +8 -0
- package/dist/live-smoke.js +23 -0
- package/dist/local-profile.js +2 -1
- package/dist/mcp-server.d.ts +12 -0
- package/dist/mcp-server.js +415 -0
- package/dist/native-api.d.ts +15 -0
- package/dist/native-api.js +78 -0
- package/dist/onboard.d.ts +10 -0
- package/dist/onboard.js +41 -0
- package/dist/operation.d.ts +22 -3
- package/dist/operation.js +341 -31
- package/dist/page-session.d.ts +23 -0
- package/dist/page-session.js +142 -0
- package/dist/png.d.ts +5 -0
- package/dist/png.js +22 -0
- package/dist/portable-tokens.d.ts +22 -0
- package/dist/portable-tokens.js +331 -0
- package/dist/probe.d.ts +6 -2
- package/dist/probe.js +64 -11
- package/dist/project-input.d.ts +6 -0
- package/dist/project-input.js +65 -0
- package/dist/project-snippets.js +29 -12
- package/dist/project-status.d.ts +24 -0
- package/dist/project-status.js +188 -0
- package/dist/registry.d.ts +2 -1
- package/dist/registry.js +224 -53
- package/dist/run-file.d.ts +26 -0
- package/dist/run-file.js +199 -0
- package/dist/shots.d.ts +43 -0
- package/dist/shots.js +172 -0
- package/dist/skill-instructions.d.ts +13 -0
- package/dist/skill-instructions.js +144 -0
- package/dist/skills.d.ts +7 -0
- package/dist/skills.js +163 -46
- package/dist/snippet.d.ts +3 -2
- package/dist/snippet.js +33 -9
- package/dist/snippets/archive.d.ts +9 -0
- package/dist/snippets/archive.js +10 -4
- package/dist/snippets/read.d.ts +1 -1
- package/dist/snippets/read.js +78 -41
- package/dist/structure-snapshot.d.ts +85 -0
- package/dist/structure-snapshot.js +525 -0
- package/dist/token-edit.d.ts +53 -0
- package/dist/token-edit.js +352 -0
- package/dist/token-projection.d.ts +56 -0
- package/dist/token-projection.js +286 -0
- package/dist/tokens-diff.d.ts +19 -6
- package/dist/tokens-diff.js +182 -18
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/penpair-design/SKILL.md +20 -45
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +58 -2
- package/skills/penpair-design/references/project-facts.md +3 -1
- package/skills/penpair-design/references/write-protocol.md +15 -3
- package/skills/penpair-handoff/SKILL.md +16 -0
- package/skills/penpair-handoff/references/comparison-companion.md +13 -0
- package/skills/penpair-handoff/references/handoff-flow.md +13 -0
- package/skills/penpair-recipes/SKILL.md +21 -0
- package/skills/penpair-recipes/references/build-flow.md +11 -0
- package/skills/penpair-setup/SKILL.md +8 -2
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -3
- package/dist/docker/lifecycle.d.ts +0 -96
- package/dist/docker/lifecycle.js +0 -273
- package/dist/docker/secrets.d.ts +0 -11
- package/dist/docker/secrets.js +0 -38
- package/dist/docker/template.d.ts +0 -8
- package/dist/docker/template.js +0 -158
- package/dist/docker-profile.d.ts +0 -114
- package/dist/docker-profile.js +0 -18
- package/dist/instance/lifecycle.d.ts +0 -129
- package/dist/instance/lifecycle.js +0 -336
- package/dist/instance/secrets.d.ts +0 -11
- package/dist/instance/secrets.js +0 -38
- package/dist/instance/template.d.ts +0 -8
- package/dist/instance/template.js +0 -158
package/dist/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 = [
|
|
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
|
-
|
|
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" ||
|
|
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 ??
|
|
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
|
-
|
|
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
|
|
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→ ${
|
|
20
|
+
super(`${message}\n→ ${errorRemedies[code]}`);
|
|
17
21
|
this.name = "PenpairError";
|
|
18
22
|
this.code = code;
|
|
19
|
-
this.remedy =
|
|
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 };`, {
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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 {
|
|
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>;
|