@penpair/core 0.2.0 → 0.3.1

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 (125) 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 +1645 -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-check-entry.d.ts +17 -0
  66. package/dist/skill-check-entry.js +89 -0
  67. package/dist/skill-check-instructions.d.ts +7 -0
  68. package/dist/skill-check-instructions.js +39 -0
  69. package/dist/skill-check.d.ts +15 -0
  70. package/dist/skill-check.js +36 -0
  71. package/dist/skill-file.d.ts +2 -0
  72. package/dist/skill-file.js +31 -0
  73. package/dist/skill-instructions.d.ts +4 -0
  74. package/dist/skill-instructions.js +97 -22
  75. package/dist/skill-metadata.d.ts +8 -0
  76. package/dist/skill-metadata.js +52 -0
  77. package/dist/skill-routing.d.ts +2 -0
  78. package/dist/skill-routing.js +36 -0
  79. package/dist/skills.js +131 -41
  80. package/dist/snippet.js +27 -7
  81. package/dist/snippets/read.js +8 -2
  82. package/dist/structure-snapshot.d.ts +12 -0
  83. package/dist/structure-snapshot.js +439 -85
  84. package/dist/token-edit.js +219 -64
  85. package/dist/token-projection.d.ts +56 -0
  86. package/dist/token-projection.js +286 -0
  87. package/dist/tokens-diff.js +125 -32
  88. package/dist/version.d.ts +1 -1
  89. package/dist/version.js +1 -1
  90. package/package.json +3 -2
  91. package/skills/design-judgment/SKILL.md +1 -1
  92. package/skills/design-judgment/agents/openai.yaml +6 -0
  93. package/skills/penpair-design/SKILL.md +14 -1
  94. package/skills/penpair-design/agents/openai.yaml +6 -0
  95. package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
  96. package/skills/penpair-design/references/project-facts.md +2 -0
  97. package/skills/penpair-handoff/SKILL.md +14 -1
  98. package/skills/penpair-handoff/agents/openai.yaml +6 -0
  99. package/skills/penpair-handoff/references/comparison-companion.md +16 -0
  100. package/skills/penpair-handoff/references/handoff-flow.md +2 -0
  101. package/skills/penpair-recipes/SKILL.md +13 -1
  102. package/skills/penpair-recipes/agents/openai.yaml +6 -0
  103. package/skills/penpair-recipes/assets/visual-checkpoint.md +48 -0
  104. package/skills/penpair-recipes/references/visual-checkpoint.md +37 -0
  105. package/skills/penpair-setup/SKILL.md +9 -3
  106. package/skills/penpair-setup/agents/openai.yaml +6 -0
  107. package/skills/penpair-verify/SKILL.md +49 -0
  108. package/skills/penpair-verify/agents/openai.yaml +6 -0
  109. package/skills/penpair-verify/references/browser-evidence.md +33 -0
  110. package/dist/cli.d.ts +0 -2
  111. package/dist/cli.js +0 -3
  112. package/dist/docker/lifecycle.d.ts +0 -96
  113. package/dist/docker/lifecycle.js +0 -273
  114. package/dist/docker/secrets.d.ts +0 -11
  115. package/dist/docker/secrets.js +0 -38
  116. package/dist/docker/template.d.ts +0 -8
  117. package/dist/docker/template.js +0 -158
  118. package/dist/docker-profile.d.ts +0 -114
  119. package/dist/docker-profile.js +0 -18
  120. package/dist/instance/lifecycle.d.ts +0 -129
  121. package/dist/instance/lifecycle.js +0 -336
  122. package/dist/instance/secrets.d.ts +0 -11
  123. package/dist/instance/secrets.js +0 -38
  124. package/dist/instance/template.d.ts +0 -8
  125. package/dist/instance/template.js +0 -158
@@ -17,22 +17,28 @@ export function composeProjectOf(config) {
17
17
  return config.composeProject ?? `penpair-${config.instance}`;
18
18
  }
19
19
  function validateSchemaVersion(value) {
20
- if (value && typeof value === "object" && "schemaVersion" in value && value.schemaVersion !== 1)
20
+ if (value && typeof value === "object" && "schemaVersion" in value && value.schemaVersion !== 1) {
21
21
  throw new ConfigError("Unsupported project config schemaVersion; legacy/unversioned and v1 are supported. Use the producer's documented migration for another major version.");
22
+ }
22
23
  }
23
24
  export function validateConfig(value) {
24
25
  validateSchemaVersion(value);
25
26
  const fail = (message) => {
26
27
  throw new ConfigError(`${configFileName}: ${message}`);
27
28
  };
28
- if (typeof value !== "object" || value === null || Array.isArray(value))
29
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
29
30
  return fail("must be a JSON object");
31
+ }
30
32
  const v = value;
31
33
  validateFile(v.file);
32
34
  validateBackup(v.backup);
33
35
  if (v.connection !== undefined) {
34
- if (!v.connection || typeof v.connection !== "object" || Array.isArray(v.connection) || Object.keys(v.connection).some(k => k !== "credential"))
36
+ if (!v.connection ||
37
+ typeof v.connection !== "object" ||
38
+ Array.isArray(v.connection) ||
39
+ Object.keys(v.connection).some((k) => k !== "credential")) {
35
40
  throw new ConfigError("connection requires only a credential reference");
41
+ }
36
42
  try {
37
43
  validateCredentialReference(v.connection.credential);
38
44
  }
@@ -48,48 +54,77 @@ export function validateConfig(value) {
48
54
  throw new ConfigError(String(error.message));
49
55
  }
50
56
  }
51
- if (typeof v.instance !== "string" || !/^[a-z][a-z0-9-]{0,30}$/.test(v.instance))
57
+ if (typeof v.instance !== "string" || !/^[a-z][a-z0-9-]{0,30}$/.test(v.instance)) {
52
58
  fail("instance must be lowercase letters, digits and hyphens, starting with a letter (at most 31 characters)");
53
- if (typeof v.penpotVersion !== "string" || !/^\d+\.\d+(\.\d+)?$/.test(v.penpotVersion))
59
+ }
60
+ if (typeof v.penpotVersion !== "string" || !/^\d+\.\d+(\.\d+)?$/.test(v.penpotVersion)) {
54
61
  fail("penpotVersion must look like 2.18.3");
62
+ }
55
63
  const ports = v.ports;
56
- for (const key of ["frontend", "mail"]) {
64
+ for (const key of [
65
+ "frontend",
66
+ "mail",
67
+ ]) {
57
68
  const port = ports?.[key];
58
- if (typeof port !== "number" || !Number.isInteger(port) || port < 1024 || port > 65535)
69
+ if (typeof port !== "number" || !Number.isInteger(port) || port < 1024 || port > 65535) {
59
70
  fail(`ports.${key} must be a port number from 1024 to 65535`);
71
+ }
60
72
  }
61
- if (ports.frontend === ports.mail)
73
+ if (ports.frontend === ports.mail) {
62
74
  fail("ports.frontend and ports.mail must differ");
63
- if (typeof v.directory !== "string" || v.directory.length === 0 || path.isAbsolute(v.directory))
75
+ }
76
+ if (typeof v.directory !== "string" || v.directory.length === 0 || path.isAbsolute(v.directory)) {
64
77
  fail("directory must be a relative path");
65
- if (v.composeProject !== undefined && (typeof v.composeProject !== "string" || !/^[a-z0-9][a-z0-9_-]*$/.test(v.composeProject)))
78
+ }
79
+ if (v.composeProject !== undefined &&
80
+ (typeof v.composeProject !== "string" || !/^[a-z0-9][a-z0-9_-]*$/.test(v.composeProject))) {
66
81
  fail("composeProject must be a valid compose project name");
82
+ }
67
83
  if (v.snippets !== undefined) {
68
- const ok = Array.isArray(v.snippets) && v.snippets.every((entry) => typeof entry === "string" && entry.length > 0 && !path.isAbsolute(entry) && !entry.split(/[\\/]/).includes(".."));
69
- if (!ok)
84
+ const ok = Array.isArray(v.snippets) &&
85
+ v.snippets.every((entry) => typeof entry === "string" &&
86
+ entry.length > 0 &&
87
+ !path.isAbsolute(entry) &&
88
+ !entry.split(/[\\/]/).includes(".."));
89
+ if (!ok) {
70
90
  fail("snippets must be a list of relative paths that stay inside the project");
91
+ }
71
92
  }
72
- if (v.baselines !== undefined)
93
+ if (v.baselines !== undefined) {
73
94
  validateBaselines(v.baselines, fail);
74
- if (v.adapters !== undefined && !(Array.isArray(v.adapters) && v.adapters.every((e) => typeof e === "string" && e.length > 0)))
95
+ }
96
+ if (v.adapters !== undefined &&
97
+ !(Array.isArray(v.adapters) && v.adapters.every((e) => typeof e === "string" && e.length > 0))) {
75
98
  fail("adapters must be a list of package names or relative paths");
99
+ }
76
100
  return v;
77
101
  }
78
102
  const instanceNamePattern = /^[a-z][a-z0-9-]{0,30}$/;
79
103
  /** Accepts either kind of project config: a local Docker instance or an external Penpot. */
80
104
  export function validateProjectConfig(value) {
81
105
  validateSchemaVersion(value);
82
- const profile = typeof value === "object" && value !== null ? value.profile : undefined;
83
- if (profile === undefined)
106
+ const profile = typeof value === "object" && value !== null
107
+ ? value.profile
108
+ : undefined;
109
+ if (profile === undefined) {
84
110
  return validateConfig(value);
85
- if (typeof profile !== "object" || profile === null || Array.isArray(profile) || profile.type !== "external")
111
+ }
112
+ if (typeof profile !== "object" ||
113
+ profile === null ||
114
+ Array.isArray(profile) ||
115
+ profile.type !== "external") {
86
116
  throw new ConfigError(`${configFileName}: profile.type must be external; omit profile for an instance on this computer`);
117
+ }
87
118
  const v = value;
88
119
  validateFile(v.file);
89
120
  validateBackup(v.backup);
90
121
  if (v.connection !== undefined) {
91
- if (!v.connection || typeof v.connection !== "object" || Array.isArray(v.connection) || Object.keys(v.connection).some(k => k !== "credential"))
122
+ if (!v.connection ||
123
+ typeof v.connection !== "object" ||
124
+ Array.isArray(v.connection) ||
125
+ Object.keys(v.connection).some((k) => k !== "credential")) {
92
126
  throw new ConfigError("connection requires only a credential reference");
127
+ }
93
128
  try {
94
129
  validateCredentialReference(v.connection.credential);
95
130
  }
@@ -108,8 +143,9 @@ export function validateProjectConfig(value) {
108
143
  const fail = (message) => {
109
144
  throw new ConfigError(`${configFileName}: ${message}`);
110
145
  };
111
- if (typeof v.instance !== "string" || !instanceNamePattern.test(v.instance))
146
+ if (typeof v.instance !== "string" || !instanceNamePattern.test(v.instance)) {
112
147
  fail("instance must be lowercase letters, digits and hyphens, starting with a letter");
148
+ }
113
149
  const origin = v.profile.origin;
114
150
  let parsed;
115
151
  try {
@@ -118,34 +154,67 @@ export function validateProjectConfig(value) {
118
154
  catch {
119
155
  parsed = undefined;
120
156
  }
121
- if (!parsed || !["http:", "https:"].includes(parsed.protocol) || parsed.origin !== origin)
157
+ if (!parsed ||
158
+ ![
159
+ "http:",
160
+ "https:",
161
+ ].includes(parsed.protocol) ||
162
+ parsed.origin !== origin) {
122
163
  fail("profile.origin must be an http or https origin such as https://design.penpot.app (no path)");
123
- if (v.snippets !== undefined && !(Array.isArray(v.snippets) && v.snippets.every((entry) => typeof entry === "string" && entry.length > 0 && !path.isAbsolute(entry) && !entry.split(/[\\/]/).includes(".."))))
164
+ }
165
+ if (v.snippets !== undefined &&
166
+ !(Array.isArray(v.snippets) &&
167
+ v.snippets.every((entry) => typeof entry === "string" &&
168
+ entry.length > 0 &&
169
+ !path.isAbsolute(entry) &&
170
+ !entry.split(/[\\/]/).includes("..")))) {
124
171
  fail("snippets must be a list of relative paths that stay inside the project");
125
- if (v.baselines !== undefined)
172
+ }
173
+ if (v.baselines !== undefined) {
126
174
  validateBaselines(v.baselines, fail);
127
- if (v.adapters !== undefined && !(Array.isArray(v.adapters) && v.adapters.every((e) => typeof e === "string" && e.length > 0)))
175
+ }
176
+ if (v.adapters !== undefined &&
177
+ !(Array.isArray(v.adapters) && v.adapters.every((e) => typeof e === "string" && e.length > 0))) {
128
178
  fail("adapters must be a list of package names or relative paths");
179
+ }
129
180
  return v;
130
181
  }
131
182
  function validateFile(value) {
132
- if (value === undefined)
183
+ if (value === undefined) {
133
184
  return;
185
+ }
134
186
  const file = value;
135
- if (typeof file !== "object" || file === null || Array.isArray(file) || typeof file.id !== "string" || !file.id.trim() || (file.name !== undefined && typeof file.name !== "string"))
187
+ if (typeof file !== "object" ||
188
+ file === null ||
189
+ Array.isArray(file) ||
190
+ typeof file.id !== "string" ||
191
+ !file.id.trim() ||
192
+ (file.name !== undefined && typeof file.name !== "string")) {
136
193
  throw new ConfigError(`${configFileName}: file must have a nonempty id and an optional name`);
194
+ }
137
195
  }
138
196
  function validateBackup(value) {
139
- if (value === undefined)
197
+ if (value === undefined) {
140
198
  return;
199
+ }
141
200
  const backup = value;
142
- if (!backup || typeof backup !== "object" || Array.isArray(backup) || typeof backup.noteFile !== "string" || !backup.noteFile || path.isAbsolute(backup.noteFile) || backup.noteFile.split(/[\\/]/).includes("..") || !Number.isSafeInteger(backup.remindAfterDays) || backup.remindAfterDays <= 0)
201
+ if (!backup ||
202
+ typeof backup !== "object" ||
203
+ Array.isArray(backup) ||
204
+ typeof backup.noteFile !== "string" ||
205
+ !backup.noteFile ||
206
+ path.isAbsolute(backup.noteFile) ||
207
+ backup.noteFile.split(/[\\/]/).includes("..") ||
208
+ !Number.isSafeInteger(backup.remindAfterDays) ||
209
+ backup.remindAfterDays <= 0) {
143
210
  throw new ConfigError(`${configFileName}: backup needs an in-project relative noteFile and positive integer remindAfterDays`);
211
+ }
144
212
  }
145
213
  /** The config of a project that runs its own Penpot; anything else cannot start, stop or back up containers. */
146
214
  export function requireLocal(config) {
147
- if (isExternal(config))
215
+ if (isExternal(config)) {
148
216
  throw new ConfigError(`This project uses a Penpot that someone else runs (${config.profile.origin}). \`up\`, \`down\`, \`status\`, \`backup\` and \`restore\` only apply to a Penpot that penpair starts for you; use Penpot's own version history and export for this one.`);
217
+ }
149
218
  return config;
150
219
  }
151
220
  /** Missing is optional; unreadable, malformed or invalid project files always stop the caller. */
@@ -161,8 +230,9 @@ export async function readProjectConfig(root) {
161
230
  await lstat(path.join(root, configFileName));
162
231
  }
163
232
  catch (statError) {
164
- if (statError.code === "ENOENT")
233
+ if (statError.code === "ENOENT") {
165
234
  return undefined;
235
+ }
166
236
  }
167
237
  }
168
238
  throw new ConfigError(`Cannot read ${configFileName}${code ? ` (${code})` : ""}. Check that it is a readable file and fix its permissions.`, "unreadable");
@@ -183,8 +253,9 @@ export async function loadOptionalConfig(root) {
183
253
  }
184
254
  export async function loadConfig(root) {
185
255
  const config = await loadOptionalConfig(root);
186
- if (config === undefined)
256
+ if (config === undefined) {
187
257
  throw new ConfigError(`No ${configFileName} here. Run \`penpair init\` first. For config-free inspection, use a read command with --standalone.`, "missing");
258
+ }
188
259
  return config;
189
260
  }
190
261
  export const instancePaths = (root, config) => {
@@ -2,7 +2,7 @@ import { readCredential } from "../credentials.js";
2
2
  import { chmod, readFile, stat, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { parseEnvFileValue } from "../endpoint.js";
5
- import { configFileName, isExternal, readProjectConfig } from "./config.js";
5
+ import { configFileName, isExternal, readProjectConfig, } from "./config.js";
6
6
  import { ensureKeyFileIgnored } from "./gitignore.js";
7
7
  import { endpointIsInstance } from "./connection.js";
8
8
  export class ConnectError extends Error {
@@ -12,10 +12,13 @@ export const keyVariable = "PENPAIR_MCP_URL";
12
12
  export function parseMcpAddress(pasted) {
13
13
  let text = pasted.trim();
14
14
  const assignment = /^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.*)$/.exec(text);
15
- if (assignment)
15
+ if (assignment) {
16
16
  text = (assignment[1] ?? "").trim();
17
- if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'")))
17
+ }
18
+ if ((text.startsWith('"') && text.endsWith('"')) ||
19
+ (text.startsWith("'") && text.endsWith("'"))) {
18
20
  text = text.slice(1, -1).trim();
21
+ }
19
22
  let parsed;
20
23
  try {
21
24
  parsed = new URL(text);
@@ -23,13 +26,19 @@ export function parseMcpAddress(pasted) {
23
26
  catch {
24
27
  throw new ConnectError("That does not look like an address. Copy the whole line Penpot shows under Account, Integrations, MCP Server; it starts with https:// (or http:// for Penpot on your computer).");
25
28
  }
26
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
29
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
27
30
  throw new ConnectError("The address must start with https:// or http://.");
28
- if (!parsed.pathname.includes("/mcp"))
31
+ }
32
+ if (!parsed.pathname.includes("/mcp")) {
29
33
  throw new ConnectError("That address has no /mcp part. Copy the MCP Server address, not the address of the Penpot page.");
30
- if (!parsed.searchParams.get("userToken"))
34
+ }
35
+ if (!parsed.searchParams.get("userToken")) {
31
36
  throw new ConnectError("That address has no key (userToken=...). Copy the full address from Account, Integrations, MCP Server.");
32
- return { url: text, origin: parsed.origin };
37
+ }
38
+ return {
39
+ url: text,
40
+ origin: parsed.origin,
41
+ };
33
42
  }
34
43
  /** Whether an MCP address belongs to the Penpot this project is configured for. */
35
44
  export function belongsToProject(origin, config) {
@@ -43,7 +52,7 @@ export function belongsToProject(origin, config) {
43
52
  }
44
53
  return endpointIsInstance(origin, config);
45
54
  }
46
- export const penpotAddress = (config) => (isExternal(config) ? config.profile.origin : `http://127.0.0.1:${config.ports.frontend}`);
55
+ export const penpotAddress = (config) => isExternal(config) ? config.profile.origin : `http://127.0.0.1:${config.ports.frontend}`;
47
56
  /** Writes (or replaces) the key line in .env.local, readable by the owner only, and makes sure git ignores the file. */
48
57
  export async function writeKeyFile(root, url) {
49
58
  const file = path.join(root, ".env.local");
@@ -57,17 +66,25 @@ export async function writeKeyFile(root, url) {
57
66
  const line = `${keyVariable}=${url}`;
58
67
  const lines = (text ?? "").split(/\r?\n/);
59
68
  const at = lines.findIndex((l) => new RegExp(`^\\s*(?:export\\s+)?${keyVariable}\\s*=`).test(l));
60
- if (at >= 0)
69
+ if (at >= 0) {
61
70
  lines[at] = line;
62
- else
71
+ }
72
+ else {
63
73
  lines.push(line);
64
- while (lines.length > 0 && lines[lines.length - 1] === "")
74
+ }
75
+ while (lines.length > 0 && lines[lines.length - 1] === "") {
65
76
  lines.pop();
66
- await writeFile(file, `${lines.join("\n")}\n`, { mode: 0o600 });
77
+ }
78
+ await writeFile(file, `${lines.join("\n")}\n`, {
79
+ mode: 0o600,
80
+ });
67
81
  await chmod(file, 0o600);
68
82
  // Ignored before anything else can happen: a key in an unignored file is one `git add .` from public.
69
83
  const ignoreAdded = await ensureKeyFileIgnored(root);
70
- return { created: text === undefined, ignoreAdded };
84
+ return {
85
+ created: text === undefined,
86
+ ignoreAdded,
87
+ };
71
88
  }
72
89
  export async function readKeyFile(root) {
73
90
  try {
@@ -86,17 +103,61 @@ export async function keyFileCheck(root, runner) {
86
103
  catch {
87
104
  return undefined;
88
105
  }
89
- if (process.platform !== "win32" && (info.mode & 0o077) !== 0)
90
- return { check: "key file", ok: false, detail: ".env.local is group/world-readable", remedy: "Make the private connection file owner-readable only (0600) and verify repository exclusion." };
91
- const ignored = await runner.run("git", ["-C", root, "check-ignore", "-q", ".env.local"]);
92
- if (ignored.code === 1)
93
- return { check: "key file", ok: false, detail: ".env.local is tracked or not ignored", remedy: "Keep .env.local out of Git with .gitignore; if already tracked, coordinate removal and key rotation with the owner." };
94
- if (ignored.code !== 0)
95
- return { check: "key file", ok: true, detail: ".env.local exists; this folder is not a git repository" };
96
- const history = await runner.run("git", ["-C", root, "log", "--all", "-n", "1", "--format=%H", "--", ".env.local"]);
97
- if (history.code === 0 && history.stdout.trim())
98
- return { check: "key file", ok: true, warning: true, detail: ".env.local is ignored now but appears in Git history", remedy: "Rotate any exposed key through the provider UI; let the owner decide whether history remediation is needed. No historical file contents were read." };
99
- return { check: "key file", ok: true, detail: ".env.local is private and kept out of git" };
106
+ if (process.platform !== "win32" && (info.mode & 0o077) !== 0) {
107
+ return {
108
+ check: "key file",
109
+ ok: false,
110
+ detail: ".env.local is group/world-readable",
111
+ remedy: "Make the private connection file owner-readable only (0600) and verify repository exclusion.",
112
+ };
113
+ }
114
+ const ignored = await runner.run("git", [
115
+ "-C",
116
+ root,
117
+ "check-ignore",
118
+ "-q",
119
+ ".env.local",
120
+ ]);
121
+ if (ignored.code === 1) {
122
+ return {
123
+ check: "key file",
124
+ ok: false,
125
+ detail: ".env.local is tracked or not ignored",
126
+ remedy: "Keep .env.local out of Git with .gitignore; if already tracked, coordinate removal and key rotation with the owner.",
127
+ };
128
+ }
129
+ if (ignored.code !== 0) {
130
+ return {
131
+ check: "key file",
132
+ ok: true,
133
+ detail: ".env.local exists; this folder is not a git repository",
134
+ };
135
+ }
136
+ const history = await runner.run("git", [
137
+ "-C",
138
+ root,
139
+ "log",
140
+ "--all",
141
+ "-n",
142
+ "1",
143
+ "--format=%H",
144
+ "--",
145
+ ".env.local",
146
+ ]);
147
+ if (history.code === 0 && history.stdout.trim()) {
148
+ return {
149
+ check: "key file",
150
+ ok: true,
151
+ warning: true,
152
+ detail: ".env.local is ignored now but appears in Git history",
153
+ remedy: "Rotate any exposed key through the provider UI; let the owner decide whether history remediation is needed. No historical file contents were read.",
154
+ };
155
+ }
156
+ return {
157
+ check: "key file",
158
+ ok: true,
159
+ detail: ".env.local is private and kept out of git",
160
+ };
100
161
  }
101
162
  /** Waits for the editor tab and the plugin, saying in plain words what to do, until `timeoutMs` has passed. */
102
163
  export async function waitForEditor(url, io, options = {}) {
@@ -112,13 +173,18 @@ export async function waitForEditor(url, io, options = {}) {
112
173
  catch (error) {
113
174
  if (sinceReminder >= remind) {
114
175
  const message = String(error.message ?? error);
115
- const reason = /suspended|heartbeat/i.test(message) ? "the Penpot tab is asleep or the plugin is not connected" : /refused|ECONN|fetch failed|not found/i.test(message) ? "Penpot does not answer yet" : "the editor is not connected yet";
176
+ const reason = /suspended|heartbeat/i.test(message)
177
+ ? "the Penpot tab is asleep or the plugin is not connected"
178
+ : /refused|ECONN|fetch failed|not found/i.test(message)
179
+ ? "Penpot does not answer yet"
180
+ : "the editor is not connected yet";
116
181
  io.say(`Waiting for Penpot (${reason}).`);
117
182
  io.say(" In your browser: open a design file, choose File, then MCP Server, then Connect, and keep that tab in front.");
118
183
  sinceReminder = 0;
119
184
  }
120
- if (waited >= timeout)
185
+ if (waited >= timeout) {
121
186
  return undefined;
187
+ }
122
188
  await io.sleep(interval);
123
189
  waited += interval;
124
190
  sinceReminder += interval;
@@ -138,18 +204,32 @@ export async function connectProject(root, config, io, options = {}) {
138
204
  text = await readFile(path.join(root, ".env.local"), "utf8");
139
205
  }
140
206
  catch (error) {
141
- if (error.code !== "ENOENT")
207
+ if (error.code !== "ENOENT") {
142
208
  throw new ConnectError("Project connection file could not be read");
209
+ }
143
210
  }
144
211
  const file = environment.PENPAIR_MCP_URL_FILE?.trim() || parseEnvFileValue(text, "PENPAIR_MCP_URL_FILE");
145
- if (file)
146
- reference = { type: "file", path: file };
147
- else if (environment.PENPAIR_MCP_URL_COMMAND || parseEnvFileValue(text, "PENPAIR_MCP_URL_COMMAND"))
212
+ if (file) {
213
+ reference = {
214
+ type: "file",
215
+ path: file,
216
+ };
217
+ }
218
+ else if (environment.PENPAIR_MCP_URL_COMMAND ||
219
+ parseEnvFileValue(text, "PENPAIR_MCP_URL_COMMAND")) {
148
220
  throw new ConnectError("Arbitrary credential commands are not enabled; configure a file or OS reference");
221
+ }
149
222
  }
150
- if (reference && options.replace)
223
+ if (reference && options.replace) {
151
224
  throw new ConnectError("Rotate the configured credential in its private storage, then reconnect; --replace is for the legacy pasted-URL flow");
152
- let url = reference ? await readCredential(reference, { root }) : options.replace ? undefined : await readKeyFile(root);
225
+ }
226
+ let url = reference
227
+ ? await readCredential(reference, {
228
+ root,
229
+ })
230
+ : options.replace
231
+ ? undefined
232
+ : await readKeyFile(root);
153
233
  let keyWritten = false;
154
234
  if (url) {
155
235
  let origin;
@@ -159,16 +239,22 @@ export async function connectProject(root, config, io, options = {}) {
159
239
  catch {
160
240
  origin = undefined;
161
241
  }
162
- if (origin && belongsToProject(origin, config))
163
- io.say(reference ? "Using the configured private credential reference; its output stays private." : "The key in .env.local belongs to this Penpot, so I will use it. (Run `penpair connect --replace` to paste a new one.)");
164
- else if (reference)
242
+ if (origin && belongsToProject(origin, config)) {
243
+ io.say(reference
244
+ ? "Using the configured private credential reference; its output stays private."
245
+ : "The key in .env.local belongs to this Penpot, so I will use it. (Run `penpair connect --replace` to paste a new one.)");
246
+ }
247
+ else if (reference) {
165
248
  throw new ConnectError("Configured credential reference belongs to another or invalid Penpot origin; no fallback was used");
166
- else
249
+ }
250
+ else {
167
251
  url = undefined;
252
+ }
168
253
  }
169
254
  if (!url) {
170
255
  io.say(`Open ${penpotAddress(config)}, log in, then go to Account, Integrations, MCP Server and switch it on. Copy the address it shows.`);
171
- const pasted = options.pasted ?? (await io.askHidden("Paste the address here and press Enter (nothing is shown as you paste): "));
256
+ const pasted = options.pasted ??
257
+ (await io.askHidden("Paste the address here and press Enter (nothing is shown as you paste): "));
172
258
  const parsed = parseMcpAddress(pasted);
173
259
  if (!belongsToProject(parsed.origin, config)) {
174
260
  throw new ConnectError(`That address belongs to ${parsed.origin}, but this project uses ${penpotAddress(config)}. Copy the address from that Penpot instead.`);
@@ -179,21 +265,42 @@ export async function connectProject(root, config, io, options = {}) {
179
265
  io.say(`Saved to .env.local${written.ignoreAdded ? " and added it to .gitignore" : ""}. It is only readable by you; never share it.`);
180
266
  }
181
267
  const editor = await waitForEditor(url, io, options.wait);
182
- if (!editor)
268
+ if (!editor) {
183
269
  throw new ConnectError("I did not see the editor within five minutes. Open a design file in Penpot, choose File, MCP Server, Connect, keep the tab in front, then run `penpair connect` again.");
184
- if (config.file && config.file.id !== editor.fileId && !options.anyFile)
270
+ }
271
+ if (config.file && config.file.id !== editor.fileId && !options.anyFile) {
185
272
  throw new ConnectError(`Expected file "${config.file.name ?? config.file.id}" (${config.file.id}), connected to "${editor.file}" (${editor.fileId ?? "unknown id"}). Open the expected file and reconnect.`);
273
+ }
186
274
  let filePinned = false;
187
- if (!config.file && editor.fileId && await io.confirmFile?.({ id: editor.fileId, name: editor.file })) {
275
+ if (!config.file &&
276
+ editor.fileId &&
277
+ (await io.confirmFile?.({
278
+ id: editor.fileId,
279
+ name: editor.file,
280
+ }))) {
188
281
  const raw = await readProjectConfig(root);
189
- if (!raw || raw.file)
282
+ if (!raw || raw.file) {
190
283
  throw new ConnectError("The project configuration changed while connecting. Inspect it and connect again; no file pin was replaced.");
191
- await writeFile(path.join(root, configFileName), `${JSON.stringify({ ...raw, file: { id: editor.fileId, name: editor.file } }, null, 2)}\n`);
284
+ }
285
+ await writeFile(path.join(root, configFileName), `${JSON.stringify({
286
+ ...raw,
287
+ file: {
288
+ id: editor.fileId,
289
+ name: editor.file,
290
+ },
291
+ }, null, 2)}\n`);
192
292
  filePinned = true;
193
293
  io.say("Pinned the design file ID in penpair.config.json. Commit that file for your team.");
194
294
  }
195
- if (!config.file && !filePinned)
295
+ if (!config.file && !filePinned) {
196
296
  io.say("No file ID is pinned yet. Run `penpair connect --pin-file` to protect against a different file being open.");
297
+ }
197
298
  io.say(`Connected to the file "${editor.file}" (${editor.pages.length} page${editor.pages.length === 1 ? "" : "s"}: ${editor.pages.slice(0, 6).join(", ")}${editor.pages.length > 6 ? ", ..." : ""}).`);
198
- return { keyWritten, file: editor.file, pages: editor.pages, fileId: editor.fileId, filePinned };
299
+ return {
300
+ keyWritten,
301
+ file: editor.file,
302
+ pages: editor.pages,
303
+ fileId: editor.fileId,
304
+ filePinned,
305
+ };
199
306
  }
@@ -10,6 +10,15 @@ export interface Check {
10
10
  export declare const frontendUrl: (config: InstanceConfig) => string;
11
11
  export interface DoctorCheck extends Check {
12
12
  warning?: boolean;
13
+ layer?: "origin" | "transport" | "execution" | "file-pin";
14
+ observedAt?: string;
15
+ durationMs?: number;
16
+ evidence?: {
17
+ challenge?: string;
18
+ pageId?: string;
19
+ apiVersion?: string;
20
+ heartbeat?: "unknown";
21
+ };
13
22
  remedy?: string;
14
23
  file?: {
15
24
  expected: ProjectConfig["file"] | null;
@@ -32,16 +41,28 @@ export declare function assertEndpointIsInstance(endpoint: {
32
41
  source: string;
33
42
  origin: string;
34
43
  }, config: ProjectConfig): void;
44
+ export interface DoctorObservation {
45
+ file: string;
46
+ fileId?: string;
47
+ pageId?: string;
48
+ challenge?: string;
49
+ apiVersion?: string;
50
+ }
51
+ export interface DoctorProbeRequest {
52
+ challenge: string;
53
+ signal: AbortSignal;
54
+ timeoutMs: number;
55
+ onTransport: () => void;
56
+ }
35
57
  export interface DoctorOptions {
36
- /** Looks for the editor tab and the plugin through the MCP. `origin` is where the endpoint points, so a key for another instance is noticed. */
58
+ timeoutMs?: number;
37
59
  mcp?: {
38
60
  origin: string;
39
- connect: () => Promise<{
40
- file: string;
41
- fileId?: string;
42
- }>;
61
+ connect: (request?: DoctorProbeRequest) => Promise<DoctorObservation>;
43
62
  };
44
63
  }
64
+ /** Readiness is a bounded fresh execution, separate from a reachable origin and connected transport. */
65
+ export declare function doctorEditor(config: ProjectConfig, options: DoctorOptions): Promise<DoctorCheck[]>;
45
66
  /** Whether a configured MCP origin is this instance's frontend. */
46
67
  export declare function endpointIsInstance(origin: string, config: InstanceConfig): boolean;
47
68
  /** The checks that make sense for a Penpot someone else runs: it answers, the key is for it, the editor and plugin are connected. */