@penpair/core 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/dist/adapters.js +58 -11
  2. package/dist/approvals.js +143 -29
  3. package/dist/atomic-output.js +9 -4
  4. package/dist/audit-rules.js +345 -79
  5. package/dist/backup-note.js +29 -8
  6. package/dist/baseline-coverage.js +169 -35
  7. package/dist/baselines-config.js +119 -29
  8. package/dist/baselines.js +280 -57
  9. package/dist/cli-arguments.js +18 -6
  10. package/dist/cli-main.js +1621 -392
  11. package/dist/client.d.ts +5 -2
  12. package/dist/client.js +127 -28
  13. package/dist/command-result.js +341 -52
  14. package/dist/coordination.js +91 -29
  15. package/dist/credentials.js +57 -13
  16. package/dist/deep-check.js +56 -12
  17. package/dist/design-spec.js +189 -49
  18. package/dist/endpoint.js +102 -34
  19. package/dist/export.js +86 -18
  20. package/dist/file-archive.js +33 -7
  21. package/dist/file-guard.js +29 -11
  22. package/dist/formats.js +215 -14
  23. package/dist/index.d.ts +8 -7
  24. package/dist/index.js +8 -7
  25. package/dist/inspection.d.ts +1 -1
  26. package/dist/inspection.js +378 -65
  27. package/dist/instance/config-schema.js +400 -41
  28. package/dist/instance/config.js +101 -30
  29. package/dist/instance/connect.js +151 -44
  30. package/dist/instance/connection.d.ts +26 -5
  31. package/dist/instance/connection.js +194 -21
  32. package/dist/instance/gitignore.js +5 -2
  33. package/dist/instance/init.js +32 -7
  34. package/dist/instance/runner.js +32 -10
  35. package/dist/instance/setup.js +46 -13
  36. package/dist/instance/tty.js +26 -8
  37. package/dist/journal.d.ts +10 -0
  38. package/dist/journal.js +212 -49
  39. package/dist/kit-smoke.js +82 -20
  40. package/dist/kit-source.js +10 -40
  41. package/dist/kit.js +102 -34
  42. package/dist/layout-policy.d.ts +2 -0
  43. package/dist/layout-policy.js +86 -0
  44. package/dist/layout.d.ts +7 -1
  45. package/dist/layout.js +27 -70
  46. package/dist/live-smoke.js +13 -4
  47. package/dist/local-profile.js +2 -1
  48. package/dist/mcp-server.js +360 -47
  49. package/dist/native-api.js +47 -11
  50. package/dist/onboard.js +33 -7
  51. package/dist/operation.d.ts +2 -2
  52. package/dist/operation.js +279 -69
  53. package/dist/page-session.d.ts +23 -0
  54. package/dist/page-session.js +142 -0
  55. package/dist/png.js +18 -3
  56. package/dist/portable-tokens.js +254 -57
  57. package/dist/probe.d.ts +1 -1
  58. package/dist/probe.js +48 -11
  59. package/dist/project-input.js +24 -10
  60. package/dist/project-snippets.js +18 -6
  61. package/dist/project-status.js +133 -24
  62. package/dist/registry.js +217 -60
  63. package/dist/run-file.js +129 -30
  64. package/dist/shots.js +108 -26
  65. package/dist/skill-instructions.js +91 -22
  66. package/dist/skills.js +129 -39
  67. package/dist/snippet.js +27 -7
  68. package/dist/snippets/read.js +8 -2
  69. package/dist/structure-snapshot.d.ts +12 -0
  70. package/dist/structure-snapshot.js +439 -85
  71. package/dist/token-edit.js +219 -64
  72. package/dist/token-projection.d.ts +56 -0
  73. package/dist/token-projection.js +286 -0
  74. package/dist/tokens-diff.js +125 -32
  75. package/dist/version.d.ts +1 -1
  76. package/dist/version.js +1 -1
  77. package/package.json +1 -1
  78. package/skills/penpair-design/SKILL.md +3 -0
  79. package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
  80. package/skills/penpair-design/references/project-facts.md +2 -0
  81. package/skills/penpair-handoff/SKILL.md +2 -0
  82. package/skills/penpair-handoff/references/comparison-companion.md +13 -0
  83. package/skills/penpair-handoff/references/handoff-flow.md +2 -0
  84. package/skills/penpair-recipes/SKILL.md +5 -0
  85. package/skills/penpair-setup/SKILL.md +3 -1
  86. package/dist/cli.d.ts +0 -2
  87. package/dist/cli.js +0 -3
  88. package/dist/docker/lifecycle.d.ts +0 -96
  89. package/dist/docker/lifecycle.js +0 -273
  90. package/dist/docker/secrets.d.ts +0 -11
  91. package/dist/docker/secrets.js +0 -38
  92. package/dist/docker/template.d.ts +0 -8
  93. package/dist/docker/template.js +0 -158
  94. package/dist/docker-profile.d.ts +0 -114
  95. package/dist/docker-profile.js +0 -18
  96. package/dist/instance/lifecycle.d.ts +0 -129
  97. package/dist/instance/lifecycle.js +0 -336
  98. package/dist/instance/secrets.d.ts +0 -11
  99. package/dist/instance/secrets.js +0 -38
  100. package/dist/instance/template.d.ts +0 -8
  101. package/dist/instance/template.js +0 -158
@@ -1,14 +1,34 @@
1
- import { isExternal } from "./config.js";
1
+ import { randomUUID } from "node:crypto";
2
+ import { commandSignal, withCommandSignal } from "../command-result.js";
3
+ import { redactEndpoint } from "../endpoint.js";
4
+ import { isExternal, } from "./config.js";
2
5
  /** What Penpot itself, the connection and the checks need, whichever way Penpot is run. Nothing here knows about Docker. */
3
6
  export class LifecycleError extends Error {
4
7
  }
5
8
  export const frontendUrl = (config) => `http://127.0.0.1:${config.ports.frontend}`;
6
9
  export function fileIdentityCheck(config, actual) {
7
10
  const ok = !config.file || config.file.id === actual.fileId;
8
- return { check: "file identity", ok,
9
- detail: config.file ? `expected ${config.file.name ?? config.file.id} (${config.file.id}); connected ${actual.file} (${actual.fileId ?? "unknown id"})` : "No file ID pinned; run penpair connect --pin-file",
10
- ...(!ok ? { remedy: "Open the project's expected design file and reconnect its MCP plugin." } : {}),
11
- file: { expected: config.file ?? null, actual: { id: actual.fileId, name: actual.file }, ok },
11
+ return {
12
+ check: "file identity",
13
+ ok,
14
+ layer: "file-pin",
15
+ observedAt: new Date().toISOString(),
16
+ detail: config.file
17
+ ? `expected ${config.file.name ?? config.file.id} (${config.file.id}); connected ${actual.file} (${actual.fileId ?? "unknown id"})`
18
+ : "No file ID pinned; run penpair connect --pin-file",
19
+ ...(!ok
20
+ ? {
21
+ remedy: "Open the project's expected design file and reconnect its MCP plugin.",
22
+ }
23
+ : {}),
24
+ file: {
25
+ expected: config.file ?? null,
26
+ actual: {
27
+ id: actual.fileId,
28
+ name: actual.file,
29
+ },
30
+ ok,
31
+ },
12
32
  };
13
33
  }
14
34
  /**
@@ -17,13 +37,15 @@ export function fileIdentityCheck(config, actual) {
17
37
  */
18
38
  export function assertEndpointIsInstance(endpoint, config) {
19
39
  if (isExternal(config)) {
20
- if (sameOrigin(endpoint.origin, config.profile.origin))
40
+ if (sameOrigin(endpoint.origin, config.profile.origin)) {
21
41
  return;
42
+ }
22
43
  throw new LifecycleError(`The MCP endpoint (from ${endpoint.source}) points at ${endpoint.origin}, but this project uses ${config.profile.origin}. ` +
23
44
  "Put that Penpot's MCP URL (Account, Integrations, MCP Server) in this project's .env.local as PENPAIR_MCP_URL, or run again with --any-instance if the other one is what you mean.");
24
45
  }
25
- if (endpointIsInstance(endpoint.origin, config))
46
+ if (endpointIsInstance(endpoint.origin, config)) {
26
47
  return;
48
+ }
27
49
  throw new LifecycleError(`The MCP endpoint (from ${endpoint.source}) points at ${endpoint.origin}, but this project's instance is ${frontendUrl(config)}. ` +
28
50
  "Put this instance's MCP URL (Account, Integrations, MCP Server) in this project's .env.local as PENPAIR_MCP_URL, or run again with --any-instance if the other instance is what you mean.");
29
51
  }
@@ -35,11 +57,139 @@ const sameOrigin = (a, b) => {
35
57
  return false;
36
58
  }
37
59
  };
60
+ /** Readiness is a bounded fresh execution, separate from a reachable origin and connected transport. */
61
+ export async function doctorEditor(config, options) {
62
+ if (!options.mcp) {
63
+ return [];
64
+ }
65
+ const started = Date.now();
66
+ const out = [];
67
+ const challenge = randomUUID();
68
+ const timeoutMs = options.timeoutMs ?? 5000;
69
+ let connected = false;
70
+ let finished = false;
71
+ const transport = () => {
72
+ if (!finished && !connected) {
73
+ connected = true;
74
+ out.push({
75
+ check: "MCP transport",
76
+ layer: "transport",
77
+ ok: true,
78
+ detail: "Connected to MCP transport",
79
+ observedAt: new Date().toISOString(),
80
+ durationMs: Date.now() - started,
81
+ });
82
+ }
83
+ };
84
+ try {
85
+ const actual = await doctorDeadline(timeoutMs, (signal) => options.mcp.connect({
86
+ challenge,
87
+ signal,
88
+ timeoutMs,
89
+ onTransport: transport,
90
+ }));
91
+ if (!connected) {
92
+ throw new Error("MCP transport readiness was not independently observed");
93
+ }
94
+ if (actual.challenge !== challenge ||
95
+ typeof actual.fileId !== "string" ||
96
+ !actual.fileId ||
97
+ typeof actual.pageId !== "string" ||
98
+ !actual.pageId ||
99
+ typeof actual.file !== "string" ||
100
+ !actual.file ||
101
+ actual.file === "?") {
102
+ throw new Error("Fresh execution challenge or file/page identity did not match; editor readiness is unproven");
103
+ }
104
+ out.push({
105
+ check: "editor and plugin",
106
+ layer: "execution",
107
+ ok: true,
108
+ detail: `Fresh read from file "${actual.file}"`,
109
+ observedAt: new Date().toISOString(),
110
+ durationMs: Date.now() - started,
111
+ evidence: {
112
+ challenge,
113
+ pageId: actual.pageId,
114
+ ...(typeof actual.apiVersion === "string" && actual.apiVersion
115
+ ? {
116
+ apiVersion: actual.apiVersion,
117
+ }
118
+ : {}),
119
+ heartbeat: "unknown",
120
+ },
121
+ });
122
+ out.push(fileIdentityCheck(config, actual));
123
+ }
124
+ catch (error) {
125
+ if (!connected) {
126
+ out.push({
127
+ check: "MCP transport",
128
+ layer: "transport",
129
+ ok: false,
130
+ detail: "Transport connection not confirmed",
131
+ observedAt: new Date().toISOString(),
132
+ durationMs: Date.now() - started,
133
+ });
134
+ }
135
+ out.push({
136
+ check: "editor and plugin",
137
+ layer: "execution",
138
+ ok: false,
139
+ detail: redactEndpoint(error instanceof Error ? error.message : String(error)),
140
+ observedAt: new Date().toISOString(),
141
+ durationMs: Date.now() - started,
142
+ remedy: "Open the intended file, connect the MCP plugin and keep the editor responsive; rerun doctor for a fresh read.",
143
+ });
144
+ }
145
+ finished = true;
146
+ return out;
147
+ }
148
+ async function doctorDeadline(timeoutMs, work) {
149
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30000) {
150
+ throw new Error("Invalid doctor budget");
151
+ }
152
+ const controller = new AbortController();
153
+ const signal = AbortSignal.any([
154
+ controller.signal,
155
+ ...(commandSignal()
156
+ ? [
157
+ commandSignal(),
158
+ ]
159
+ : []),
160
+ ]);
161
+ let timer;
162
+ let abort = () => undefined;
163
+ const interrupted = new Promise((_resolve, reject) => {
164
+ abort = () => reject(signal.reason);
165
+ signal.addEventListener("abort", abort, {
166
+ once: true,
167
+ });
168
+ timer = setTimeout(() => controller.abort(new Error("Doctor deadline exceeded; no fresh editor execution was confirmed")), timeoutMs);
169
+ });
170
+ try {
171
+ signal.throwIfAborted();
172
+ return await withCommandSignal(signal, () => Promise.race([
173
+ work(signal),
174
+ interrupted,
175
+ ]));
176
+ }
177
+ finally {
178
+ if (timer) {
179
+ clearTimeout(timer);
180
+ }
181
+ signal.removeEventListener("abort", abort);
182
+ }
183
+ }
38
184
  /** Whether a configured MCP origin is this instance's frontend. */
39
185
  export function endpointIsInstance(origin, config) {
40
186
  try {
41
187
  const url = new URL(origin);
42
- return ["localhost", "127.0.0.1"].includes(url.hostname) && Number(url.port || (url.protocol === "https:" ? 443 : 80)) === config.ports.frontend;
188
+ return ([
189
+ "localhost",
190
+ "127.0.0.1",
191
+ ].includes(url.hostname) &&
192
+ Number(url.port || (url.protocol === "https:" ? 443 : 80)) === config.ports.frontend);
43
193
  }
44
194
  catch {
45
195
  return false;
@@ -48,30 +198,53 @@ export function endpointIsInstance(origin, config) {
48
198
  /** The checks that make sense for a Penpot someone else runs: it answers, the key is for it, the editor and plugin are connected. */
49
199
  export async function doctorExternal(config, options = {}) {
50
200
  const out = [];
51
- const add = (check, ok, detail, remedy) => void out.push({ check, ok, detail, ...(remedy && !ok ? { remedy } : {}) });
201
+ const add = (check, ok, detail, remedy) => void out.push({
202
+ check,
203
+ ok,
204
+ detail,
205
+ ...(remedy && !ok
206
+ ? {
207
+ remedy,
208
+ }
209
+ : {}),
210
+ });
211
+ const started = Date.now();
212
+ const timeoutMs = options.timeoutMs ?? 5000;
213
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 30000) {
214
+ throw new Error("doctor --timeout-ms must be an integer from 1000 to 30000");
215
+ }
52
216
  let reachable = false;
53
217
  try {
54
- reachable = (await (options.fetch ?? fetch)(config.profile.origin, { redirect: "manual" })).status < 500;
218
+ reachable =
219
+ (await doctorDeadline(timeoutMs, (signal) => (options.fetch ?? fetch)(config.profile.origin, {
220
+ redirect: "manual",
221
+ signal,
222
+ }))).status < 500;
55
223
  }
56
224
  catch {
57
225
  reachable = false;
58
226
  }
59
227
  add("penpot", reachable, reachable ? `${config.profile.origin} answers` : `${config.profile.origin} does not answer`, "Check the address and your network, then try again.");
60
- if (!options.mcp)
228
+ Object.assign(out[0], {
229
+ layer: "origin",
230
+ observedAt: new Date().toISOString(),
231
+ durationMs: Date.now() - started,
232
+ });
233
+ if (!options.mcp) {
61
234
  return out;
235
+ }
62
236
  if (!sameOrigin(options.mcp.origin, config.profile.origin)) {
63
- add("editor and plugin", false, `the MCP endpoint points at ${options.mcp.origin}, which is not ${config.profile.origin}`, "Put this Penpot's MCP URL (Account, Integrations, MCP Server) in this project's .env.local as PENPAIR_MCP_URL.");
237
+ add("editor and plugin", false, `the MCP endpoint points at ${options.mcp.origin}, which is not ${config.profile.origin}`, "Configure this project's intended Penpot endpoint.");
64
238
  return out;
65
239
  }
66
- try {
67
- const actual = await options.mcp.connect();
68
- const { file } = actual;
69
- add("editor and plugin", true, `connected to file "${file}"`);
70
- if (config.file)
71
- out.push(fileIdentityCheck(config, actual));
72
- }
73
- catch (error) {
74
- add("editor and plugin", false, String(error.message).split("\n")[0] ?? "not connected", "Open a file in Penpot, choose File, MCP Server, Connect, and keep the tab visible in a separate window if needed.");
240
+ const remaining = timeoutMs - (Date.now() - started);
241
+ if (remaining <= 0) {
242
+ add("editor and plugin", false, "Doctor deadline exhausted during origin check; execution unproven");
243
+ return out;
75
244
  }
245
+ out.push(...(await doctorEditor(config, {
246
+ ...options,
247
+ timeoutMs: remaining,
248
+ })));
76
249
  return out;
77
250
  }
@@ -10,10 +10,13 @@ export async function ensureGitignoreEntry(root, entry, options) {
10
10
  catch {
11
11
  // no .gitignore yet
12
12
  }
13
- if (text.split(/\r?\n/).some((line) => line.trim() === entry || line.trim() === `${entry}/`))
13
+ if (text.split(/\r?\n/).some((line) => line.trim() === entry || line.trim() === `${entry}/`)) {
14
14
  return false;
15
+ }
15
16
  await appendFile(file, `${text.length > 0 && !text.endsWith("\n") ? "\n" : ""}# ${options.comment}\n${entry}${options.directory ? "/" : ""}\n`);
16
17
  return true;
17
18
  }
18
19
  /** The MCP key goes in .env.local; whoever writes it makes sure git never sees it. */
19
- export const ensureKeyFileIgnored = (root) => ensureGitignoreEntry(root, ".env.local", { comment: "penpair: your personal Penpot MCP key" });
20
+ export const ensureKeyFileIgnored = (root) => ensureGitignoreEntry(root, ".env.local", {
21
+ comment: "penpair: your personal Penpot MCP key",
22
+ });
@@ -1,11 +1,16 @@
1
1
  import { writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { requireLocalProfile } from "../local-profile.js";
4
- import { configFileName, ConfigError, isExternal, loadConfig, validateProjectConfig } from "./config.js";
4
+ import { configFileName, ConfigError, isExternal, loadConfig, validateProjectConfig, } from "./config.js";
5
5
  import { ensureKeyFileIgnored } from "./gitignore.js";
6
6
  /** A short, valid instance name from the folder's name; "design" when nothing usable is left. */
7
7
  export function defaultInstanceName(root) {
8
- const name = path.basename(path.resolve(root)).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^[^a-z]+/, "").slice(0, 31);
8
+ const name = path
9
+ .basename(path.resolve(root))
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9-]+/g, "-")
12
+ .replace(/^[^a-z]+/, "")
13
+ .slice(0, 31);
9
14
  return name.length > 0 ? name : "design";
10
15
  }
11
16
  /**
@@ -18,15 +23,35 @@ export async function init(root, options = {}, deps = {}) {
18
23
  existing = await loadConfig(root);
19
24
  }
20
25
  catch (error) {
21
- if (!(error instanceof ConfigError) || error.code !== "missing")
26
+ if (!(error instanceof ConfigError) || error.code !== "missing") {
22
27
  throw error;
28
+ }
29
+ }
30
+ if (existing && isExternal(existing)) {
31
+ return {
32
+ config: existing,
33
+ createdConfig: false,
34
+ createdSecrets: false,
35
+ files: [],
36
+ gitignoreAdded: options.gitignore ? await ensureKeyFileIgnored(root) : false,
37
+ };
23
38
  }
24
- if (existing && isExternal(existing))
25
- return { config: existing, createdConfig: false, createdSecrets: false, files: [], gitignoreAdded: options.gitignore ? await ensureKeyFileIgnored(root) : false };
26
39
  if (!existing && options.external) {
27
- const config = validateProjectConfig({ instance: options.instance ?? defaultInstanceName(root), profile: { type: "external", origin: options.external } });
40
+ const config = validateProjectConfig({
41
+ instance: options.instance ?? defaultInstanceName(root),
42
+ profile: {
43
+ type: "external",
44
+ origin: options.external,
45
+ },
46
+ });
28
47
  await writeFile(path.join(root, configFileName), `${JSON.stringify(config, null, 2)}\n`);
29
- return { config, createdConfig: true, createdSecrets: false, files: [], gitignoreAdded: options.gitignore ? await ensureKeyFileIgnored(root) : false };
48
+ return {
49
+ config,
50
+ createdConfig: true,
51
+ createdSecrets: false,
52
+ files: [],
53
+ gitignoreAdded: options.gitignore ? await ensureKeyFileIgnored(root) : false,
54
+ };
30
55
  }
31
56
  return requireLocalProfile(deps.local).init(root, options);
32
57
  }
@@ -3,34 +3,56 @@ import { createReadStream, createWriteStream } from "node:fs";
3
3
  import { once } from "node:events";
4
4
  export const nodeRunner = {
5
5
  async run(command, args, options = {}) {
6
- const child = spawn(command, args, { stdio: [options.input !== undefined || options.inFile ? "pipe" : "ignore", "pipe", "pipe"] });
6
+ const child = spawn(command, args, {
7
+ stdio: [
8
+ options.input !== undefined || options.inFile ? "pipe" : "ignore",
9
+ "pipe",
10
+ "pipe",
11
+ ],
12
+ });
7
13
  const { stdout: out, stderr: err } = child;
8
- if (!out || !err)
14
+ if (!out || !err) {
9
15
  throw new Error("the command has no output streams");
16
+ }
10
17
  let stdout = "";
11
18
  let stderr = "";
12
19
  const writing = options.outFile ? createWriteStream(options.outFile) : undefined;
13
20
  // Registered at once: the file can finish closing before the child does, and a missed event would hang forever.
14
- const written = writing ? new Promise((resolve) => writing.once("close", () => resolve())) : undefined;
15
- if (writing)
21
+ const written = writing
22
+ ? new Promise((resolve) => writing.once("close", () => resolve()))
23
+ : undefined;
24
+ if (writing) {
16
25
  out.pipe(writing);
17
- else
26
+ }
27
+ else {
18
28
  out.on("data", (chunk) => (stdout += chunk.toString("utf8")));
29
+ }
19
30
  err.on("data", (chunk) => (stderr += chunk.toString("utf8")));
20
- if (options.inFile && child.stdin)
31
+ if (options.inFile && child.stdin) {
21
32
  createReadStream(options.inFile).pipe(child.stdin);
22
- else if (options.input !== undefined && child.stdin)
33
+ }
34
+ else if (options.input !== undefined && child.stdin) {
23
35
  child.stdin.end(options.input);
36
+ }
24
37
  try {
25
38
  const [code] = (await once(child, "close"));
26
- if (written)
39
+ if (written) {
27
40
  await written;
28
- return { code: code ?? 1, stdout, stderr };
41
+ }
42
+ return {
43
+ code: code ?? 1,
44
+ stdout,
45
+ stderr,
46
+ };
29
47
  }
30
48
  catch (error) {
31
49
  // The command could not start at all (not installed, not executable).
32
50
  writing?.destroy();
33
- return { code: 127, stdout, stderr: String(error.message) };
51
+ return {
52
+ code: 127,
53
+ stdout,
54
+ stderr: String(error.message),
55
+ };
34
56
  }
35
57
  },
36
58
  };
@@ -1,17 +1,25 @@
1
1
  import { ConfigError, isExternal, loadConfig, requireLocal } from "./config.js";
2
- import { connectProject, penpotAddress } from "./connect.js";
2
+ import { connectProject, penpotAddress, } from "./connect.js";
3
3
  import { init } from "./init.js";
4
4
  import { requireLocalProfile } from "../local-profile.js";
5
5
  export const hostedOrigin = "https://design.penpot.app";
6
6
  async function chooseWhere(io, hasLocal) {
7
7
  io.say("Where is your Penpot?");
8
8
  io.say(" 1 On the web: design.penpot.app (free account) or your team's own Penpot. Nothing to install. (recommended)");
9
- io.say(hasLocal ? " 2 On this computer, in Docker. Works offline; needs Docker Desktop and a 2 GB download." : " 2 On this computer, in Docker. Needs the @penpair/docker package, which this install does not include.");
9
+ io.say(hasLocal
10
+ ? " 2 On this computer, in Docker. Works offline; needs Docker Desktop and a 2 GB download."
11
+ : " 2 On this computer, in Docker. Needs the @penpair/docker package, which this install does not include.");
10
12
  const answer = (await io.ask("Press Enter for 1, or type 2: ", "1")).trim();
11
- if (answer === "2")
12
- return { type: "docker" };
13
+ if (answer === "2") {
14
+ return {
15
+ type: "docker",
16
+ };
17
+ }
13
18
  const origin = (await io.ask(`Penpot address (Enter for ${hostedOrigin}): `, hostedOrigin)).trim();
14
- return { type: "web", origin };
19
+ return {
20
+ type: "web",
21
+ origin,
22
+ };
15
23
  }
16
24
  /**
17
25
  * One guided path from nothing to "the assistant can read my Penpot file": choose where Penpot is, write the config, start
@@ -25,32 +33,57 @@ export async function setup(root, io, options) {
25
33
  io.say(`This folder is already set up for ${isExternal(config) ? `Penpot at ${config.profile.origin}` : `Penpot on this computer (port ${config.ports.frontend})`}. Continuing from there.`);
26
34
  }
27
35
  catch (error) {
28
- if (!(error instanceof ConfigError) || error.code !== "missing")
36
+ if (!(error instanceof ConfigError) || error.code !== "missing") {
29
37
  throw error;
38
+ }
30
39
  }
31
40
  if (!config) {
32
41
  const where = options.where ?? (await chooseWhere(io, Boolean(options.local)));
33
- const made = await init(root, { gitignore: true, instance: options.instance, port: options.port, ...(where.type === "web" ? { external: where.origin } : {}) }, { local: options.local });
42
+ const made = await init(root, {
43
+ gitignore: true,
44
+ instance: options.instance,
45
+ port: options.port,
46
+ ...(where.type === "web"
47
+ ? {
48
+ external: where.origin,
49
+ }
50
+ : {}),
51
+ }, {
52
+ local: options.local,
53
+ });
34
54
  config = made.config;
35
55
  io.say(`Wrote penpair.config.json (${where.type === "web" ? `Penpot at ${where.origin}` : "Penpot on this computer"}). Commit that file; it holds no secrets.`);
36
56
  }
37
57
  if (!isExternal(config)) {
38
58
  const local = requireLocalProfile(options.local);
39
59
  await local.preflight(options.runner);
40
- const c = { root, config: requireLocal(config), runner: options.runner, log: options.log ?? (() => undefined) };
60
+ const c = {
61
+ root,
62
+ config: requireLocal(config),
63
+ runner: options.runner,
64
+ log: options.log ?? (() => undefined),
65
+ };
41
66
  io.say("Starting Penpot. The first time it downloads about 2 GB and can take several minutes.");
42
67
  const started = await local.up(c);
43
- if (!started.ready)
68
+ if (!started.ready) {
44
69
  throw new ConfigError(`Penpot started but ${started.url} does not answer yet. Wait a minute and run \`penpair setup\` again.`);
70
+ }
45
71
  io.say("Penpot is running. Create your account on its start page (no email confirmation is needed), then create a design file.");
46
72
  }
47
- else
73
+ else {
48
74
  io.say("In Penpot: log in, then create a project and a design file for this work if you have none.");
75
+ }
49
76
  await io.openBrowser(penpotAddress(config)).catch(() => undefined);
50
- const connect = await connectProject(root, config, io, { wait: options.wait });
77
+ const connect = await connectProject(root, config, io, {
78
+ wait: options.wait,
79
+ });
51
80
  io.say("Everything answers. Last: the rules your assistant follows.");
52
- if (options.installSkills)
81
+ if (options.installSkills) {
53
82
  await options.installSkills();
83
+ }
54
84
  io.say("Done. Try `penpair pages`, or tell your assistant to read the penpair-design skill and start.");
55
- return { config, connect };
85
+ return {
86
+ config,
87
+ connect,
88
+ };
56
89
  }
@@ -3,14 +3,16 @@ import { nodeRunner } from "./runner.js";
3
3
  /** Reads everything piped into standard input (for `pbpaste | penpair connect`, where a key never appears in a command line). */
4
4
  async function readPiped() {
5
5
  let text = "";
6
- for await (const chunk of process.stdin)
6
+ for await (const chunk of process.stdin) {
7
7
  text += String(chunk);
8
+ }
8
9
  return text.trim();
9
10
  }
10
11
  /** Asks for a secret without echoing it. With no terminal (a pipe), the whole input is the answer. */
11
12
  export async function askHidden(question) {
12
- if (!process.stdin.isTTY)
13
+ if (!process.stdin.isTTY) {
13
14
  return readPiped();
15
+ }
14
16
  process.stderr.write(question);
15
17
  const stdin = process.stdin;
16
18
  stdin.setRawMode(true);
@@ -31,10 +33,12 @@ export async function askHidden(question) {
31
33
  resolve(value.trim());
32
34
  return;
33
35
  }
34
- if (char === "\u007f" || char === "\b")
36
+ if (char === "\u007f" || char === "\b") {
35
37
  value = value.slice(0, -1);
36
- else
38
+ }
39
+ else {
37
40
  value += char;
41
+ }
38
42
  }
39
43
  };
40
44
  const cleanup = () => {
@@ -47,9 +51,13 @@ export async function askHidden(question) {
47
51
  }
48
52
  /** One visible line of input; the fallback is taken on an empty answer or when there is no terminal. */
49
53
  export async function askLine(question, fallback) {
50
- if (!process.stdin.isTTY)
54
+ if (!process.stdin.isTTY) {
51
55
  return fallback;
52
- const rl = createInterface({ input: process.stdin, output: process.stderr });
56
+ }
57
+ const rl = createInterface({
58
+ input: process.stdin,
59
+ output: process.stderr,
60
+ });
53
61
  try {
54
62
  return await new Promise((resolve) => rl.question(question, (answer) => resolve(answer.trim() || fallback)));
55
63
  }
@@ -59,8 +67,18 @@ export async function askLine(question, fallback) {
59
67
  }
60
68
  export async function openInBrowser(url) {
61
69
  const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
62
- const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
70
+ const args = process.platform === "win32"
71
+ ? [
72
+ "/c",
73
+ "start",
74
+ "",
75
+ url,
76
+ ]
77
+ : [
78
+ url,
79
+ ];
63
80
  const result = await nodeRunner.run(command, args);
64
- if (result.code !== 0)
81
+ if (result.code !== 0) {
65
82
  throw new Error("Browser launch failed; open the configured public Penpot address manually.");
83
+ }
66
84
  }
package/dist/journal.d.ts CHANGED
@@ -16,6 +16,13 @@ export interface JournalStep<T> {
16
16
  verify(current: T, before: T, result?: unknown): boolean;
17
17
  reconcile(before: T, current: T, result?: unknown): "complete" | "pending" | "conflict";
18
18
  }
19
+ export interface JournalTraceEvent {
20
+ event: "lease-acquire-start" | "lease-acquired" | "lease-check" | "inspection-start" | "inspection-end" | "revision-observed" | "lease-release-start" | "lease-released" | "lease-release-failed";
21
+ at: string;
22
+ elapsedMs: number;
23
+ step?: number;
24
+ target?: JournalTarget;
25
+ }
19
26
  export interface OperationJournal {
20
27
  schemaVersion: 1;
21
28
  engineVersion: string;
@@ -29,6 +36,7 @@ export interface OperationJournal {
29
36
  createdAt: string;
30
37
  updatedAt: string;
31
38
  redacted: boolean;
39
+ trace?: JournalTraceEvent[];
32
40
  checkpoint?: unknown;
33
41
  error?: string;
34
42
  steps: {
@@ -62,6 +70,8 @@ export interface JournalOptions {
62
70
  requireCheckpoint?: boolean;
63
71
  finalVerify?(): Promise<boolean>;
64
72
  redact?(text: string): string;
73
+ trace?: boolean;
74
+ onTrace?(event: JournalTraceEvent): void;
65
75
  onStep?(message: string): void;
66
76
  }
67
77
  /** Stable JSON hashing; observation comparisons may explicitly ignore transport execution metadata. */