@penpair/core 0.4.0 → 0.5.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 (38) hide show
  1. package/dist/adapters.d.ts +1 -1
  2. package/dist/adapters.js +1 -0
  3. package/dist/browser-wake.d.ts +22 -0
  4. package/dist/browser-wake.js +241 -0
  5. package/dist/cli-main.js +140 -15
  6. package/dist/clipboard-payload.d.ts +2 -0
  7. package/dist/clipboard-payload.js +89 -0
  8. package/dist/design-commands/context.d.ts +5 -0
  9. package/dist/design-commands/copy-command.d.ts +3 -0
  10. package/dist/design-commands/copy-command.js +101 -0
  11. package/dist/design-commands/release-command.js +6 -1
  12. package/dist/design-releases/copy-evidence.d.ts +33 -0
  13. package/dist/design-releases/copy-evidence.js +278 -0
  14. package/dist/design-releases/copy-plan.d.ts +49 -0
  15. package/dist/design-releases/copy-plan.js +55 -0
  16. package/dist/design-releases/copy-snippets.d.ts +11 -0
  17. package/dist/design-releases/copy-snippets.js +293 -0
  18. package/dist/design-releases/copy.d.ts +28 -0
  19. package/dist/design-releases/copy.js +466 -0
  20. package/dist/design-releases/index.d.ts +1 -0
  21. package/dist/design-releases/index.js +1 -0
  22. package/dist/errors.js +1 -1
  23. package/dist/instance/connection.d.ts +2 -0
  24. package/dist/instance/connection.js +15 -3
  25. package/dist/kit-source.js +1 -1
  26. package/dist/layout-policy.js +21 -11
  27. package/dist/layout-settling.d.ts +1 -0
  28. package/dist/layout-settling.js +51 -17
  29. package/dist/layout.d.ts +7 -1
  30. package/dist/layout.js +19 -3
  31. package/dist/mcp-server.js +99 -3
  32. package/dist/native-editor-clipboard.d.ts +17 -0
  33. package/dist/native-editor-clipboard.js +470 -0
  34. package/dist/plugin-health.d.ts +13 -0
  35. package/dist/plugin-health.js +39 -0
  36. package/dist/version.d.ts +1 -1
  37. package/dist/version.js +1 -1
  38. package/package.json +1 -1
@@ -1,11 +1,14 @@
1
1
  /** Self-contained policy: its JavaScript source also runs inside the Penpot sandbox. */
2
2
  export async function settleLayout(observe, options) {
3
- const { timeoutMs, intervalMs, strategy = "auto", sleep } = options;
4
- if (![
5
- "auto",
6
- "native",
7
- "geometry",
8
- ].includes(strategy) ||
3
+ const { timeoutMs, intervalMs, strategy = "auto", maxNodes = 10000, sleep } = options;
4
+ if (!Number.isInteger(maxNodes) ||
5
+ maxNodes < 1 ||
6
+ maxNodes > 100000 ||
7
+ ![
8
+ "auto",
9
+ "native",
10
+ "geometry",
11
+ ].includes(strategy) ||
9
12
  !Number.isFinite(timeoutMs) ||
10
13
  timeoutMs < 1 ||
11
14
  timeoutMs > 30000 ||
@@ -17,6 +20,9 @@ export async function settleLayout(observe, options) {
17
20
  const started = Date.now();
18
21
  const deadline = started + timeoutMs;
19
22
  let samples = 0;
23
+ let nodeCount = 0;
24
+ let rootIds = [];
25
+ let truncated = false;
20
26
  let nativeOutcome = "skipped";
21
27
  let fallbackReason;
22
28
  let targetIdentity;
@@ -24,8 +30,12 @@ export async function settleLayout(observe, options) {
24
30
  let matchingSamples = 0;
25
31
  const createResult = (stable, method, reason) => ({
26
32
  stable,
27
- method,
33
+ method: truncated ? "partial" : method,
28
34
  samples,
35
+ rootIds,
36
+ nodeCount,
37
+ maxNodes,
38
+ truncated,
29
39
  strategy,
30
40
  nativeOutcome,
31
41
  ...(fallbackReason
@@ -34,9 +44,10 @@ export async function settleLayout(observe, options) {
34
44
  }
35
45
  : {}),
36
46
  elapsedMs: Date.now() - started,
37
- ...(reason
47
+ ...(reason || truncated
38
48
  ? {
39
- reason,
49
+ reason: reason ??
50
+ "Only the bounded layout sample was observed; independent readback is required",
40
51
  }
41
52
  : {}),
42
53
  });
@@ -74,20 +85,34 @@ export async function settleLayout(observe, options) {
74
85
  const nextTargetIdentity = JSON.stringify([
75
86
  observation.fileId,
76
87
  observation.pageId,
77
- observation.geometry?.[0]?.[0],
88
+ observation.rootIds ?? [
89
+ observation.geometry?.[0]?.[0],
90
+ ],
78
91
  ]);
79
92
  if (targetIdentity && targetIdentity !== nextTargetIdentity) {
80
93
  throw new Error("Layout target changed between samples");
81
94
  }
82
95
  targetIdentity = nextTargetIdentity;
83
- if (observation.truncated ||
84
- !Array.isArray(observation.geometry) ||
96
+ if (!Array.isArray(observation.geometry) ||
85
97
  !observation.geometry.length ||
86
- observation.geometry.length > 10000) {
98
+ observation.geometry.length > maxNodes) {
87
99
  throw new Error("Incomplete layout geometry evidence");
88
100
  }
101
+ const observedRoots = observation.rootIds ?? [
102
+ observation.geometry[0][0],
103
+ ];
104
+ if (!Array.isArray(observedRoots) ||
105
+ !observedRoots.length ||
106
+ observedRoots.some((id) => typeof id !== "string" || !id) ||
107
+ new Set(observedRoots).size !== observedRoots.length) {
108
+ throw new Error("Malformed layout root evidence");
109
+ }
110
+ rootIds = observedRoots;
111
+ nodeCount = observation.geometry.length;
112
+ truncated = observation.truncated;
89
113
  const observedShapeIds = new Set();
90
114
  const siblingCountByParent = new Map();
115
+ let rootIndex = 0;
91
116
  for (const geometryRow of observation.geometry) {
92
117
  if (!Array.isArray(geometryRow) ||
93
118
  geometryRow.length !== 7 ||
@@ -102,9 +127,12 @@ export async function settleLayout(observe, options) {
102
127
  geometryRow[6] < 0) {
103
128
  throw new Error("Layout API returned no geometry evidence or malformed hierarchy");
104
129
  }
105
- if (!observedShapeIds.size
106
- ? geometryRow[5] !== null || geometryRow[6] !== 0
107
- : geometryRow[5] === null || typeof geometryRow[5] !== "string") {
130
+ if (geometryRow[5] === null) {
131
+ if (geometryRow[0] !== rootIds[rootIndex++] || geometryRow[6] !== 0) {
132
+ throw new Error("Malformed layout root hierarchy");
133
+ }
134
+ }
135
+ else if (typeof geometryRow[5] !== "string") {
108
136
  throw new Error("Malformed layout root hierarchy");
109
137
  }
110
138
  if (geometryRow[5] !== null) {
@@ -116,6 +144,9 @@ export async function settleLayout(observe, options) {
116
144
  }
117
145
  observedShapeIds.add(geometryRow[0]);
118
146
  }
147
+ if (!truncated && rootIndex !== rootIds.length) {
148
+ throw new Error("Incomplete layout root evidence");
149
+ }
119
150
  if (sampleIndex === 0) {
120
151
  nativeOutcome =
121
152
  observation.nativeOutcome ||
@@ -143,7 +174,10 @@ export async function settleLayout(observe, options) {
143
174
  if (Date.now() >= deadline) {
144
175
  return createResult(false, "geometry", "Layout observation exceeded its deadline");
145
176
  }
146
- const currentGeometry = JSON.stringify(observation.geometry);
177
+ const currentGeometry = JSON.stringify([
178
+ observation.geometry,
179
+ observation.truncated,
180
+ ]);
147
181
  matchingSamples = currentGeometry === previousGeometry ? matchingSamples + 1 : 1;
148
182
  if (matchingSamples >= 3) {
149
183
  return createResult(true, "geometry");
package/dist/layout.d.ts CHANGED
@@ -2,6 +2,8 @@ import type { PenpotToolClient } from "./client.js";
2
2
  export type LayoutStrategy = "auto" | "native" | "geometry";
3
3
  export interface LayoutOptions {
4
4
  shapeId?: string;
5
+ shapeIds?: string[];
6
+ maxNodes?: number;
5
7
  strategy?: LayoutStrategy;
6
8
  timeoutMs?: number;
7
9
  intervalMs?: number;
@@ -9,7 +11,11 @@ export interface LayoutOptions {
9
11
  }
10
12
  export interface LayoutResult {
11
13
  stable: boolean;
12
- method: "native" | "geometry" | "unavailable";
14
+ method: "native" | "geometry" | "partial" | "unavailable";
15
+ rootIds?: string[];
16
+ nodeCount?: number;
17
+ maxNodes?: number;
18
+ truncated?: boolean;
13
19
  samples: number;
14
20
  reason?: string;
15
21
  strategy?: LayoutStrategy;
package/dist/layout.js CHANGED
@@ -5,10 +5,25 @@ import { layoutPolicySource } from "./layout-policy.js";
5
5
  import { settleLayout } from "./layout-settling.js";
6
6
  /** Layout evidence is independent of render equality and visual approval. Transport failures propagate without fallback. */
7
7
  export async function waitForStableLayout(client, options = {}) {
8
- const { timeoutMs = 30000, intervalMs = 200, strategy = "auto", sleep = commandSleep, shapeId, } = options;
8
+ const { timeoutMs = 30000, intervalMs = 200, strategy = "auto", sleep = commandSleep, shapeId, shapeIds, maxNodes = 10000, } = options;
9
+ const roots = shapeIds ??
10
+ (shapeId
11
+ ? [
12
+ shapeId,
13
+ ]
14
+ : []);
15
+ if (!Array.isArray(roots) ||
16
+ roots.some((id) => typeof id !== "string" || !id.trim()) ||
17
+ new Set(roots).size !== roots.length) {
18
+ throw new Error("Layout roots must be distinct nonempty shape IDs");
19
+ }
20
+ if (!Number.isInteger(maxNodes) || maxNodes < 1 || maxNodes > 100000) {
21
+ throw new Error("Layout node budget must be an integer from 1 to 100000");
22
+ }
9
23
  try {
10
- return await settleLayout(async (nativeMs, remaining) => runSnippet(client, snippet("layout-state", `${layoutPolicySource}\nconst shape = CFG.shapeId ? penpot.currentPage.getShapeById(CFG.shapeId) : penpot.currentPage.root;\nreturn await observeLayout(penpot, shape, CFG);`, {
11
- shapeId,
24
+ return await settleLayout(async (nativeMs, remaining) => runSnippet(client, snippet("layout-state", `${layoutPolicySource}\nconst roots = CFG.shapeIds.length ? CFG.shapeIds.map(id => penpot.currentPage.getShapeById(id)) : [penpot.currentPage.root];\nreturn await observeLayout(penpot, roots, CFG);`, {
25
+ shapeIds: roots,
26
+ maxNodes,
12
27
  nativeMs,
13
28
  }, "read"), {
14
29
  retries: 0,
@@ -17,6 +32,7 @@ export async function waitForStableLayout(client, options = {}) {
17
32
  timeoutMs,
18
33
  intervalMs,
19
34
  strategy,
35
+ maxNodes,
20
36
  sleep,
21
37
  });
22
38
  }
@@ -13,9 +13,9 @@ const string = {
13
13
  minLength: 1,
14
14
  maxLength: 1000,
15
15
  };
16
- const integer = (max) => ({
16
+ const integer = (max, min = 0) => ({
17
17
  type: "integer",
18
- minimum: 0,
18
+ minimum: min,
19
19
  maximum: max,
20
20
  });
21
21
  const definitions = [
@@ -247,6 +247,83 @@ const definitions = [
247
247
  ],
248
248
  localWrite: true,
249
249
  },
250
+ {
251
+ name: "design_release_copy",
252
+ annotations: {
253
+ readOnlyHint: false,
254
+ destructiveHint: false,
255
+ idempotentHint: false,
256
+ openWorldHint: true,
257
+ },
258
+ description: "Experimental same-file native board copy to release/<version>, preview by default. Requires a configured project, pinned file and connected Penpot plugin; advertised only with --allow-writes. Applying also requires an already enabled loopback browser debugging port, existing clipboard read/write access and focused editor canvas. Does not start a browser or grant permissions. Host-independent CLI/MCP operation; no automatic fallback to the assistant's computer-control tools. The successful native-UI trial used an injected test driver; bundled CDP/Penpot qualification remains pending. Preserves originals, changes focus/clipboard, never blindly replays an uncertain paste and does not approve a release.",
259
+ properties: {
260
+ sourcePage: {
261
+ ...string,
262
+ description: "Exact source page name or ID within the project's pinned Penpot file.",
263
+ },
264
+ frameIds: {
265
+ type: "array",
266
+ items: string,
267
+ minItems: 1,
268
+ maxItems: 100,
269
+ uniqueItems: true,
270
+ description: "Observed native board IDs on the source page; selections must not overlap.",
271
+ },
272
+ releaseVersion: {
273
+ ...string,
274
+ pattern: "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$",
275
+ description: "Version naming the new or existing destination page: release/<version>.",
276
+ },
277
+ debugPort: {
278
+ ...integer(65535, 1),
279
+ description: "Required for apply: an existing loopback CDP port. This does not enable debugging or grant clipboard access.",
280
+ },
281
+ apply: {
282
+ type: "boolean",
283
+ default: false,
284
+ description: "False previews only. True explicitly authorizes native copying and requires debugPort plus existing browser clipboard read/write access.",
285
+ },
286
+ resume: {
287
+ ...string,
288
+ description: "Original run ID for explicit reconciliation with apply=true and the same inputs; never blindly repeats a paste.",
289
+ },
290
+ },
291
+ required: [
292
+ "sourcePage",
293
+ "frameIds",
294
+ "releaseVersion",
295
+ ],
296
+ write: true,
297
+ command: (a) => [
298
+ "design-release",
299
+ "copy",
300
+ "--source-page",
301
+ String(a.sourcePage),
302
+ ...a.frameIds.flatMap((frameId) => [
303
+ "--frame",
304
+ frameId,
305
+ ]),
306
+ "--release",
307
+ String(a.releaseVersion),
308
+ ...(a.debugPort !== undefined
309
+ ? [
310
+ "--debug-port",
311
+ String(a.debugPort),
312
+ ]
313
+ : []),
314
+ ...(a.apply
315
+ ? [
316
+ "--apply",
317
+ ]
318
+ : []),
319
+ ...(a.resume
320
+ ? [
321
+ "--resume",
322
+ String(a.resume),
323
+ ]
324
+ : []),
325
+ ],
326
+ },
250
327
  {
251
328
  name: "run_registered",
252
329
  annotations: {
@@ -329,6 +406,7 @@ function validate(def, raw) {
329
406
  !value ||
330
407
  value.startsWith("-") ||
331
408
  value.length > (schema.maxLength ?? 1000) ||
409
+ (schema.pattern !== undefined && !new RegExp(schema.pattern).test(value)) ||
332
410
  /[\x00-\x1f]/.test(value))) {
333
411
  throw new Error(`Invalid ${key}`);
334
412
  }
@@ -336,7 +414,22 @@ function validate(def, raw) {
336
414
  throw new Error(`Invalid ${key}`);
337
415
  }
338
416
  if (schema.type === "integer" &&
339
- (!Number.isInteger(value) || Number(value) < 0 || Number(value) > schema.maximum)) {
417
+ (!Number.isInteger(value) ||
418
+ Number(value) < (schema.minimum ?? 0) ||
419
+ Number(value) > schema.maximum)) {
420
+ throw new Error(`Invalid ${key}`);
421
+ }
422
+ if (schema.type === "array" &&
423
+ (!Array.isArray(value) ||
424
+ value.length < (schema.minItems ?? 0) ||
425
+ value.length > (schema.maxItems ?? 100) ||
426
+ (schema.uniqueItems && new Set(value).size !== value.length) ||
427
+ value.some((item) => schema.items?.type !== "string" ||
428
+ typeof item !== "string" ||
429
+ !item.trim() ||
430
+ item.startsWith("-") ||
431
+ item.length > (schema.items.maxLength ?? 1000) ||
432
+ /[\x00-\x1f]/.test(item)))) {
340
433
  throw new Error(`Invalid ${key}`);
341
434
  }
342
435
  if (key === "params" &&
@@ -351,6 +444,9 @@ function validate(def, raw) {
351
444
  if ((a.resume || a.checkpoint) && !a.apply) {
352
445
  throw new Error("Explicit resume requires apply");
353
446
  }
447
+ if (def.name === "design_release_copy" && a.apply && a.debugPort === undefined) {
448
+ throw new Error("Native copy apply requires debugPort");
449
+ }
354
450
  return a;
355
451
  }
356
452
  /** Long-lived server with an immutable explicit root and no process.chdir or raw-code tool. */
@@ -0,0 +1,17 @@
1
+ import { type BrowserTargetOptions } from "./browser-wake.js";
2
+ export interface NativeClipboard {
3
+ copy(expectedPageId: string, expectedFrameIds: string[]): Promise<void>;
4
+ paste(expectedPageId: string): Promise<void>;
5
+ close(): Promise<void>;
6
+ }
7
+ export interface ClipboardSocket extends EventTarget {
8
+ readyState: number;
9
+ send(data: string): void;
10
+ close(): void;
11
+ }
12
+ export interface NativeClipboardOptions extends BrowserTargetOptions {
13
+ /** Tests may substitute the protocol transport; ordinary commands use Node's WebSocket. */
14
+ createSocket?: (url: string) => ClipboardSocket;
15
+ }
16
+ /** Holds a short exclusive clipboard session; closing releases control, never the browser. */
17
+ export declare function connectNativeClipboard(options: NativeClipboardOptions): Promise<NativeClipboard>;