@penpair/core 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/dist/adapters.d.ts +16 -1
  2. package/dist/adapters.js +62 -19
  3. package/dist/approvals.d.ts +32 -0
  4. package/dist/approvals.js +213 -0
  5. package/dist/atomic-output.d.ts +5 -0
  6. package/dist/atomic-output.js +33 -0
  7. package/dist/audit-rules.d.ts +60 -0
  8. package/dist/audit-rules.js +443 -0
  9. package/dist/backup-note.d.ts +4 -0
  10. package/dist/backup-note.js +49 -0
  11. package/dist/baseline-coverage.d.ts +25 -0
  12. package/dist/baseline-coverage.js +248 -0
  13. package/dist/baselines-config.d.ts +25 -0
  14. package/dist/baselines-config.js +150 -11
  15. package/dist/baselines.d.ts +37 -8
  16. package/dist/baselines.js +362 -59
  17. package/dist/cli-arguments.d.ts +7 -0
  18. package/dist/cli-arguments.js +47 -0
  19. package/dist/cli-main.d.ts +13 -1
  20. package/dist/cli-main.js +2107 -247
  21. package/dist/client.d.ts +30 -6
  22. package/dist/client.js +160 -35
  23. package/dist/command-result.d.ts +159 -0
  24. package/dist/command-result.js +468 -0
  25. package/dist/coordination.d.ts +15 -0
  26. package/dist/coordination.js +184 -0
  27. package/dist/credentials.d.ts +21 -0
  28. package/dist/credentials.js +105 -0
  29. package/dist/deep-check.js +57 -9
  30. package/dist/design-spec.d.ts +55 -0
  31. package/dist/design-spec.js +275 -0
  32. package/dist/endpoint.d.ts +8 -1
  33. package/dist/endpoint.js +133 -21
  34. package/dist/errors.d.ts +2 -1
  35. package/dist/errors.js +8 -4
  36. package/dist/export.d.ts +18 -0
  37. package/dist/export.js +113 -10
  38. package/dist/file-archive.d.ts +16 -0
  39. package/dist/file-archive.js +58 -0
  40. package/dist/file-guard.d.ts +8 -0
  41. package/dist/file-guard.js +58 -0
  42. package/dist/formats.d.ts +4 -0
  43. package/dist/formats.js +229 -0
  44. package/dist/index.d.ts +34 -1
  45. package/dist/index.js +34 -1
  46. package/dist/inspection.d.ts +106 -0
  47. package/dist/inspection.js +657 -0
  48. package/dist/instance/config-schema.d.ts +422 -0
  49. package/dist/instance/config-schema.js +435 -0
  50. package/dist/instance/config.d.ts +29 -0
  51. package/dist/instance/config.js +191 -29
  52. package/dist/instance/connect.d.ts +10 -1
  53. package/dist/instance/connect.js +178 -30
  54. package/dist/instance/connection.d.ts +39 -4
  55. package/dist/instance/connection.js +198 -14
  56. package/dist/instance/gitignore.js +5 -2
  57. package/dist/instance/init.js +32 -7
  58. package/dist/instance/runner.js +32 -10
  59. package/dist/instance/setup.js +46 -13
  60. package/dist/instance/tty.js +28 -8
  61. package/dist/journal.d.ts +87 -0
  62. package/dist/journal.js +423 -0
  63. package/dist/kit-smoke.d.ts +31 -0
  64. package/dist/kit-smoke.js +145 -0
  65. package/dist/kit-source.d.ts +2 -0
  66. package/dist/kit-source.js +279 -0
  67. package/dist/kit.d.ts +25 -0
  68. package/dist/kit.js +158 -0
  69. package/dist/layout-policy.d.ts +2 -0
  70. package/dist/layout-policy.js +86 -0
  71. package/dist/layout.d.ts +21 -0
  72. package/dist/layout.js +36 -0
  73. package/dist/live-smoke.d.ts +8 -0
  74. package/dist/live-smoke.js +23 -0
  75. package/dist/local-profile.js +2 -1
  76. package/dist/mcp-server.d.ts +12 -0
  77. package/dist/mcp-server.js +415 -0
  78. package/dist/native-api.d.ts +15 -0
  79. package/dist/native-api.js +78 -0
  80. package/dist/onboard.d.ts +10 -0
  81. package/dist/onboard.js +41 -0
  82. package/dist/operation.d.ts +22 -3
  83. package/dist/operation.js +341 -31
  84. package/dist/page-session.d.ts +23 -0
  85. package/dist/page-session.js +142 -0
  86. package/dist/png.d.ts +5 -0
  87. package/dist/png.js +22 -0
  88. package/dist/portable-tokens.d.ts +22 -0
  89. package/dist/portable-tokens.js +331 -0
  90. package/dist/probe.d.ts +6 -2
  91. package/dist/probe.js +64 -11
  92. package/dist/project-input.d.ts +6 -0
  93. package/dist/project-input.js +65 -0
  94. package/dist/project-snippets.js +29 -12
  95. package/dist/project-status.d.ts +24 -0
  96. package/dist/project-status.js +188 -0
  97. package/dist/registry.d.ts +2 -1
  98. package/dist/registry.js +224 -53
  99. package/dist/run-file.d.ts +26 -0
  100. package/dist/run-file.js +199 -0
  101. package/dist/shots.d.ts +43 -0
  102. package/dist/shots.js +172 -0
  103. package/dist/skill-instructions.d.ts +13 -0
  104. package/dist/skill-instructions.js +144 -0
  105. package/dist/skills.d.ts +7 -0
  106. package/dist/skills.js +163 -46
  107. package/dist/snippet.d.ts +3 -2
  108. package/dist/snippet.js +33 -9
  109. package/dist/snippets/archive.d.ts +9 -0
  110. package/dist/snippets/archive.js +10 -4
  111. package/dist/snippets/read.d.ts +1 -1
  112. package/dist/snippets/read.js +78 -41
  113. package/dist/structure-snapshot.d.ts +85 -0
  114. package/dist/structure-snapshot.js +525 -0
  115. package/dist/token-edit.d.ts +53 -0
  116. package/dist/token-edit.js +352 -0
  117. package/dist/token-projection.d.ts +56 -0
  118. package/dist/token-projection.js +286 -0
  119. package/dist/tokens-diff.d.ts +19 -6
  120. package/dist/tokens-diff.js +182 -18
  121. package/dist/version.d.ts +1 -1
  122. package/dist/version.js +1 -1
  123. package/package.json +1 -1
  124. package/skills/penpair-design/SKILL.md +20 -45
  125. package/skills/penpair-design/references/penpot-plugin-gotchas.md +58 -2
  126. package/skills/penpair-design/references/project-facts.md +3 -1
  127. package/skills/penpair-design/references/write-protocol.md +15 -3
  128. package/skills/penpair-handoff/SKILL.md +16 -0
  129. package/skills/penpair-handoff/references/comparison-companion.md +13 -0
  130. package/skills/penpair-handoff/references/handoff-flow.md +13 -0
  131. package/skills/penpair-recipes/SKILL.md +21 -0
  132. package/skills/penpair-recipes/references/build-flow.md +11 -0
  133. package/skills/penpair-setup/SKILL.md +8 -2
  134. package/dist/cli.d.ts +0 -2
  135. package/dist/cli.js +0 -3
  136. package/dist/docker/lifecycle.d.ts +0 -96
  137. package/dist/docker/lifecycle.js +0 -273
  138. package/dist/docker/secrets.d.ts +0 -11
  139. package/dist/docker/secrets.js +0 -38
  140. package/dist/docker/template.d.ts +0 -8
  141. package/dist/docker/template.js +0 -158
  142. package/dist/docker-profile.d.ts +0 -114
  143. package/dist/docker-profile.js +0 -18
  144. package/dist/instance/lifecycle.d.ts +0 -129
  145. package/dist/instance/lifecycle.js +0 -336
  146. package/dist/instance/secrets.d.ts +0 -11
  147. package/dist/instance/secrets.js +0 -38
  148. package/dist/instance/template.d.ts +0 -8
  149. package/dist/instance/template.js +0 -158
@@ -1,60 +1,151 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { validateCredentialReference } from "../credentials.js";
2
+ import { validateAuditRules } from "../audit-rules.js";
3
+ import { lstat, readFile } from "node:fs/promises";
2
4
  import path from "node:path";
3
5
  import { validateBaselines } from "../baselines-config.js";
4
6
  export const isExternal = (config) => "profile" in config && config.profile?.type === "external";
5
7
  export const configFileName = "penpair.config.json";
6
8
  export const testedPenpotVersion = "2.18.3";
7
9
  export class ConfigError extends Error {
10
+ code;
11
+ constructor(message, code = "invalid-config") {
12
+ super(message);
13
+ this.code = code;
14
+ }
8
15
  }
9
16
  export function composeProjectOf(config) {
10
17
  return config.composeProject ?? `penpair-${config.instance}`;
11
18
  }
19
+ function validateSchemaVersion(value) {
20
+ if (value && typeof value === "object" && "schemaVersion" in value && value.schemaVersion !== 1) {
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
+ }
23
+ }
12
24
  export function validateConfig(value) {
25
+ validateSchemaVersion(value);
13
26
  const fail = (message) => {
14
27
  throw new ConfigError(`${configFileName}: ${message}`);
15
28
  };
16
- if (typeof value !== "object" || value === null)
29
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
17
30
  return fail("must be a JSON object");
31
+ }
18
32
  const v = value;
19
- if (typeof v.instance !== "string" || !/^[a-z][a-z0-9-]{0,30}$/.test(v.instance))
33
+ validateFile(v.file);
34
+ validateBackup(v.backup);
35
+ if (v.connection !== undefined) {
36
+ if (!v.connection ||
37
+ typeof v.connection !== "object" ||
38
+ Array.isArray(v.connection) ||
39
+ Object.keys(v.connection).some((k) => k !== "credential")) {
40
+ throw new ConfigError("connection requires only a credential reference");
41
+ }
42
+ try {
43
+ validateCredentialReference(v.connection.credential);
44
+ }
45
+ catch (error) {
46
+ throw new ConfigError(String(error.message));
47
+ }
48
+ }
49
+ if (v.audit !== undefined) {
50
+ try {
51
+ validateAuditRules(v.audit);
52
+ }
53
+ catch (error) {
54
+ throw new ConfigError(String(error.message));
55
+ }
56
+ }
57
+ if (typeof v.instance !== "string" || !/^[a-z][a-z0-9-]{0,30}$/.test(v.instance)) {
20
58
  fail("instance must be lowercase letters, digits and hyphens, starting with a letter (at most 31 characters)");
21
- if (typeof v.penpotVersion !== "string" || !/^\d+\.\d+(\.\d+)?$/.test(v.penpotVersion))
59
+ }
60
+ if (typeof v.penpotVersion !== "string" || !/^\d+\.\d+(\.\d+)?$/.test(v.penpotVersion)) {
22
61
  fail("penpotVersion must look like 2.18.3");
62
+ }
23
63
  const ports = v.ports;
24
- for (const key of ["frontend", "mail"]) {
64
+ for (const key of [
65
+ "frontend",
66
+ "mail",
67
+ ]) {
25
68
  const port = ports?.[key];
26
- if (typeof port !== "number" || !Number.isInteger(port) || port < 1024 || port > 65535)
69
+ if (typeof port !== "number" || !Number.isInteger(port) || port < 1024 || port > 65535) {
27
70
  fail(`ports.${key} must be a port number from 1024 to 65535`);
71
+ }
28
72
  }
29
- if (ports.frontend === ports.mail)
73
+ if (ports.frontend === ports.mail) {
30
74
  fail("ports.frontend and ports.mail must differ");
31
- 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)) {
32
77
  fail("directory must be a relative path");
33
- 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))) {
34
81
  fail("composeProject must be a valid compose project name");
82
+ }
35
83
  if (v.snippets !== undefined) {
36
- const ok = Array.isArray(v.snippets) && v.snippets.every((entry) => typeof entry === "string" && entry.length > 0 && !path.isAbsolute(entry) && !entry.split(/[\\/]/).includes(".."));
37
- 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) {
38
90
  fail("snippets must be a list of relative paths that stay inside the project");
91
+ }
39
92
  }
40
- if (v.baselines !== undefined)
93
+ if (v.baselines !== undefined) {
41
94
  validateBaselines(v.baselines, fail);
42
- 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))) {
43
98
  fail("adapters must be a list of package names or relative paths");
99
+ }
44
100
  return v;
45
101
  }
46
102
  const instanceNamePattern = /^[a-z][a-z0-9-]{0,30}$/;
47
103
  /** Accepts either kind of project config: a local Docker instance or an external Penpot. */
48
104
  export function validateProjectConfig(value) {
49
- const profile = typeof value === "object" && value !== null ? value.profile : undefined;
50
- if (profile?.type !== "external")
105
+ validateSchemaVersion(value);
106
+ const profile = typeof value === "object" && value !== null
107
+ ? value.profile
108
+ : undefined;
109
+ if (profile === undefined) {
51
110
  return validateConfig(value);
111
+ }
112
+ if (typeof profile !== "object" ||
113
+ profile === null ||
114
+ Array.isArray(profile) ||
115
+ profile.type !== "external") {
116
+ throw new ConfigError(`${configFileName}: profile.type must be external; omit profile for an instance on this computer`);
117
+ }
52
118
  const v = value;
119
+ validateFile(v.file);
120
+ validateBackup(v.backup);
121
+ if (v.connection !== undefined) {
122
+ if (!v.connection ||
123
+ typeof v.connection !== "object" ||
124
+ Array.isArray(v.connection) ||
125
+ Object.keys(v.connection).some((k) => k !== "credential")) {
126
+ throw new ConfigError("connection requires only a credential reference");
127
+ }
128
+ try {
129
+ validateCredentialReference(v.connection.credential);
130
+ }
131
+ catch (error) {
132
+ throw new ConfigError(String(error.message));
133
+ }
134
+ }
135
+ if (v.audit !== undefined) {
136
+ try {
137
+ validateAuditRules(v.audit);
138
+ }
139
+ catch (error) {
140
+ throw new ConfigError(String(error.message));
141
+ }
142
+ }
53
143
  const fail = (message) => {
54
144
  throw new ConfigError(`${configFileName}: ${message}`);
55
145
  };
56
- if (typeof v.instance !== "string" || !instanceNamePattern.test(v.instance))
146
+ if (typeof v.instance !== "string" || !instanceNamePattern.test(v.instance)) {
57
147
  fail("instance must be lowercase letters, digits and hyphens, starting with a letter");
148
+ }
58
149
  const origin = v.profile.origin;
59
150
  let parsed;
60
151
  try {
@@ -63,38 +154,109 @@ export function validateProjectConfig(value) {
63
154
  catch {
64
155
  parsed = undefined;
65
156
  }
66
- 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) {
67
163
  fail("profile.origin must be an http or https origin such as https://design.penpot.app (no path)");
68
- 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("..")))) {
69
171
  fail("snippets must be a list of relative paths that stay inside the project");
70
- if (v.baselines !== undefined)
172
+ }
173
+ if (v.baselines !== undefined) {
71
174
  validateBaselines(v.baselines, fail);
72
- 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))) {
73
178
  fail("adapters must be a list of package names or relative paths");
179
+ }
74
180
  return v;
75
181
  }
182
+ function validateFile(value) {
183
+ if (value === undefined) {
184
+ return;
185
+ }
186
+ const file = value;
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")) {
193
+ throw new ConfigError(`${configFileName}: file must have a nonempty id and an optional name`);
194
+ }
195
+ }
196
+ function validateBackup(value) {
197
+ if (value === undefined) {
198
+ return;
199
+ }
200
+ const backup = value;
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) {
210
+ throw new ConfigError(`${configFileName}: backup needs an in-project relative noteFile and positive integer remindAfterDays`);
211
+ }
212
+ }
76
213
  /** The config of a project that runs its own Penpot; anything else cannot start, stop or back up containers. */
77
214
  export function requireLocal(config) {
78
- if (isExternal(config))
215
+ if (isExternal(config)) {
79
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
+ }
80
218
  return config;
81
219
  }
82
- export async function loadConfig(root) {
220
+ /** Missing is optional; unreadable, malformed or invalid project files always stop the caller. */
221
+ export async function readProjectConfig(root) {
83
222
  let text;
84
223
  try {
85
224
  text = await readFile(path.join(root, configFileName), "utf8");
86
225
  }
87
- catch {
88
- throw new ConfigError(`No ${configFileName} here. Run \`penpair init\` first.`);
226
+ catch (error) {
227
+ const code = error.code;
228
+ if (code === "ENOENT") {
229
+ try {
230
+ await lstat(path.join(root, configFileName));
231
+ }
232
+ catch (statError) {
233
+ if (statError.code === "ENOENT") {
234
+ return undefined;
235
+ }
236
+ }
237
+ }
238
+ throw new ConfigError(`Cannot read ${configFileName}${code ? ` (${code})` : ""}. Check that it is a readable file and fix its permissions.`, "unreadable");
89
239
  }
240
+ let parsed;
90
241
  try {
91
- return validateProjectConfig(JSON.parse(text));
242
+ parsed = JSON.parse(text);
92
243
  }
93
- catch (error) {
94
- if (error instanceof ConfigError)
95
- throw error;
96
- throw new ConfigError(`${configFileName} is not valid JSON`);
244
+ catch {
245
+ throw new ConfigError(`${configFileName} is not valid JSON. Fix its JSON syntax, then run \`penpair config check\`.`, "invalid-json");
97
246
  }
247
+ validateProjectConfig(parsed);
248
+ return parsed;
249
+ }
250
+ export async function loadOptionalConfig(root) {
251
+ const raw = await readProjectConfig(root);
252
+ return raw === undefined ? undefined : validateProjectConfig(raw);
253
+ }
254
+ export async function loadConfig(root) {
255
+ const config = await loadOptionalConfig(root);
256
+ if (config === undefined) {
257
+ throw new ConfigError(`No ${configFileName} here. Run \`penpair init\` first. For config-free inspection, use a read command with --standalone.`, "missing");
258
+ }
259
+ return config;
98
260
  }
99
261
  export const instancePaths = (root, config) => {
100
262
  const directory = path.join(root, config.directory);
@@ -1,4 +1,4 @@
1
- import { type ProjectConfig } from "./config.js";
1
+ import { type ProjectConfig, type ProjectFile } from "./config.js";
2
2
  import type { Runner } from "./runner.js";
3
3
  export declare class ConnectError extends Error {
4
4
  }
@@ -11,8 +11,11 @@ export interface ConnectIo {
11
11
  /** Looks for the editor tab and the plugin; throws until both are there. */
12
12
  openFile(url: string): Promise<{
13
13
  file: string;
14
+ fileId?: string;
14
15
  pages: string[];
15
16
  }>;
17
+ /** Optional for noninteractive hosts; a positive answer pins local config, never changes the design. */
18
+ confirmFile?(file: ProjectFile): Promise<boolean>;
16
19
  }
17
20
  export declare const keyVariable = "PENPAIR_MCP_URL";
18
21
  /** What was pasted, cleaned: surrounding quotes, a `PENPAIR_MCP_URL=` prefix and whitespace are tolerated. Never echoes the text in an error. */
@@ -33,6 +36,7 @@ export declare function readKeyFile(root: string): Promise<string | undefined>;
33
36
  export declare function keyFileCheck(root: string, runner: Runner): Promise<{
34
37
  check: string;
35
38
  ok: boolean;
39
+ warning?: boolean;
36
40
  detail: string;
37
41
  remedy?: string;
38
42
  } | undefined>;
@@ -45,12 +49,15 @@ export interface WaitOptions {
45
49
  /** Waits for the editor tab and the plugin, saying in plain words what to do, until `timeoutMs` has passed. */
46
50
  export declare function waitForEditor(url: string, io: ConnectIo, options?: WaitOptions): Promise<{
47
51
  file: string;
52
+ fileId?: string;
48
53
  pages: string[];
49
54
  } | undefined>;
50
55
  export interface ConnectResult {
51
56
  keyWritten: boolean;
52
57
  file?: string;
53
58
  pages?: string[];
59
+ fileId?: string;
60
+ filePinned?: boolean;
54
61
  }
55
62
  /**
56
63
  * The connection step: take the personal key without showing it, check that it belongs to this project's Penpot, store it in
@@ -60,4 +67,6 @@ export declare function connectProject(root: string, config: ProjectConfig, io:
60
67
  replace?: boolean;
61
68
  pasted?: string;
62
69
  wait?: WaitOptions;
70
+ anyFile?: boolean;
71
+ environment?: NodeJS.ProcessEnv;
63
72
  }): Promise<ConnectResult>;
@@ -1,7 +1,8 @@
1
- import { chmod, readFile, writeFile } from "node:fs/promises";
1
+ import { readCredential } from "../credentials.js";
2
+ import { chmod, readFile, stat, writeFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { parseEnvFileValue } from "../endpoint.js";
4
- import { isExternal } from "./config.js";
5
+ import { configFileName, isExternal, readProjectConfig, } from "./config.js";
5
6
  import { ensureKeyFileIgnored } from "./gitignore.js";
6
7
  import { endpointIsInstance } from "./connection.js";
7
8
  export class ConnectError extends Error {
@@ -11,10 +12,13 @@ export const keyVariable = "PENPAIR_MCP_URL";
11
12
  export function parseMcpAddress(pasted) {
12
13
  let text = pasted.trim();
13
14
  const assignment = /^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.*)$/.exec(text);
14
- if (assignment)
15
+ if (assignment) {
15
16
  text = (assignment[1] ?? "").trim();
16
- if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'")))
17
+ }
18
+ if ((text.startsWith('"') && text.endsWith('"')) ||
19
+ (text.startsWith("'") && text.endsWith("'"))) {
17
20
  text = text.slice(1, -1).trim();
21
+ }
18
22
  let parsed;
19
23
  try {
20
24
  parsed = new URL(text);
@@ -22,13 +26,19 @@ export function parseMcpAddress(pasted) {
22
26
  catch {
23
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).");
24
28
  }
25
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
29
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
26
30
  throw new ConnectError("The address must start with https:// or http://.");
27
- if (!parsed.pathname.includes("/mcp"))
31
+ }
32
+ if (!parsed.pathname.includes("/mcp")) {
28
33
  throw new ConnectError("That address has no /mcp part. Copy the MCP Server address, not the address of the Penpot page.");
29
- if (!parsed.searchParams.get("userToken"))
34
+ }
35
+ if (!parsed.searchParams.get("userToken")) {
30
36
  throw new ConnectError("That address has no key (userToken=...). Copy the full address from Account, Integrations, MCP Server.");
31
- return { url: text, origin: parsed.origin };
37
+ }
38
+ return {
39
+ url: text,
40
+ origin: parsed.origin,
41
+ };
32
42
  }
33
43
  /** Whether an MCP address belongs to the Penpot this project is configured for. */
34
44
  export function belongsToProject(origin, config) {
@@ -42,7 +52,7 @@ export function belongsToProject(origin, config) {
42
52
  }
43
53
  return endpointIsInstance(origin, config);
44
54
  }
45
- 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}`;
46
56
  /** Writes (or replaces) the key line in .env.local, readable by the owner only, and makes sure git ignores the file. */
47
57
  export async function writeKeyFile(root, url) {
48
58
  const file = path.join(root, ".env.local");
@@ -56,17 +66,25 @@ export async function writeKeyFile(root, url) {
56
66
  const line = `${keyVariable}=${url}`;
57
67
  const lines = (text ?? "").split(/\r?\n/);
58
68
  const at = lines.findIndex((l) => new RegExp(`^\\s*(?:export\\s+)?${keyVariable}\\s*=`).test(l));
59
- if (at >= 0)
69
+ if (at >= 0) {
60
70
  lines[at] = line;
61
- else
71
+ }
72
+ else {
62
73
  lines.push(line);
63
- while (lines.length > 0 && lines[lines.length - 1] === "")
74
+ }
75
+ while (lines.length > 0 && lines[lines.length - 1] === "") {
64
76
  lines.pop();
65
- await writeFile(file, `${lines.join("\n")}\n`, { mode: 0o600 });
77
+ }
78
+ await writeFile(file, `${lines.join("\n")}\n`, {
79
+ mode: 0o600,
80
+ });
66
81
  await chmod(file, 0o600);
67
82
  // Ignored before anything else can happen: a key in an unignored file is one `git add .` from public.
68
83
  const ignoreAdded = await ensureKeyFileIgnored(root);
69
- return { created: text === undefined, ignoreAdded };
84
+ return {
85
+ created: text === undefined,
86
+ ignoreAdded,
87
+ };
70
88
  }
71
89
  export async function readKeyFile(root) {
72
90
  try {
@@ -78,18 +96,68 @@ export async function readKeyFile(root) {
78
96
  }
79
97
  /** Warns when .env.local exists but git would commit it. Asks git itself, so global ignore rules count. */
80
98
  export async function keyFileCheck(root, runner) {
99
+ let info;
81
100
  try {
82
- await readFile(path.join(root, ".env.local"), "utf8");
101
+ info = await stat(path.join(root, ".env.local"));
83
102
  }
84
103
  catch {
85
104
  return undefined;
86
105
  }
87
- const result = await runner.run("git", ["-C", root, "check-ignore", "-q", ".env.local"]);
88
- if (result.code === 0)
89
- return { check: "key file", ok: true, detail: ".env.local is kept out of git" };
90
- if (result.code === 1)
91
- return { check: "key file", ok: false, detail: ".env.local holds your personal key and git would commit it", remedy: "Add .env.local to .gitignore (penpair connect does it for you)." };
92
- return { check: "key file", ok: true, detail: ".env.local exists; this folder is not a git repository" };
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
+ };
93
161
  }
94
162
  /** Waits for the editor tab and the plugin, saying in plain words what to do, until `timeoutMs` has passed. */
95
163
  export async function waitForEditor(url, io, options = {}) {
@@ -105,13 +173,18 @@ export async function waitForEditor(url, io, options = {}) {
105
173
  catch (error) {
106
174
  if (sinceReminder >= remind) {
107
175
  const message = String(error.message ?? error);
108
- 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";
109
181
  io.say(`Waiting for Penpot (${reason}).`);
110
182
  io.say(" In your browser: open a design file, choose File, then MCP Server, then Connect, and keep that tab in front.");
111
183
  sinceReminder = 0;
112
184
  }
113
- if (waited >= timeout)
185
+ if (waited >= timeout) {
114
186
  return undefined;
187
+ }
115
188
  await io.sleep(interval);
116
189
  waited += interval;
117
190
  sinceReminder += interval;
@@ -123,7 +196,40 @@ export async function waitForEditor(url, io, options = {}) {
123
196
  * a git-ignored file, then wait for the editor and plugin and read the file's pages once. Safe to run again.
124
197
  */
125
198
  export async function connectProject(root, config, io, options = {}) {
126
- let url = options.replace ? undefined : await readKeyFile(root);
199
+ const environment = options.environment ?? process.env;
200
+ let reference = config.connection?.credential;
201
+ if (!reference) {
202
+ let text = "";
203
+ try {
204
+ text = await readFile(path.join(root, ".env.local"), "utf8");
205
+ }
206
+ catch (error) {
207
+ if (error.code !== "ENOENT") {
208
+ throw new ConnectError("Project connection file could not be read");
209
+ }
210
+ }
211
+ const file = environment.PENPAIR_MCP_URL_FILE?.trim() || parseEnvFileValue(text, "PENPAIR_MCP_URL_FILE");
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")) {
220
+ throw new ConnectError("Arbitrary credential commands are not enabled; configure a file or OS reference");
221
+ }
222
+ }
223
+ if (reference && options.replace) {
224
+ throw new ConnectError("Rotate the configured credential in its private storage, then reconnect; --replace is for the legacy pasted-URL flow");
225
+ }
226
+ let url = reference
227
+ ? await readCredential(reference, {
228
+ root,
229
+ })
230
+ : options.replace
231
+ ? undefined
232
+ : await readKeyFile(root);
127
233
  let keyWritten = false;
128
234
  if (url) {
129
235
  let origin;
@@ -133,14 +239,22 @@ export async function connectProject(root, config, io, options = {}) {
133
239
  catch {
134
240
  origin = undefined;
135
241
  }
136
- if (origin && belongsToProject(origin, config))
137
- io.say("The key in .env.local belongs to this Penpot, so I will use it. (Run `penpair connect --replace` to paste a new one.)");
138
- else
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) {
248
+ throw new ConnectError("Configured credential reference belongs to another or invalid Penpot origin; no fallback was used");
249
+ }
250
+ else {
139
251
  url = undefined;
252
+ }
140
253
  }
141
254
  if (!url) {
142
255
  io.say(`Open ${penpotAddress(config)}, log in, then go to Account, Integrations, MCP Server and switch it on. Copy the address it shows.`);
143
- 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): "));
144
258
  const parsed = parseMcpAddress(pasted);
145
259
  if (!belongsToProject(parsed.origin, config)) {
146
260
  throw new ConnectError(`That address belongs to ${parsed.origin}, but this project uses ${penpotAddress(config)}. Copy the address from that Penpot instead.`);
@@ -151,8 +265,42 @@ export async function connectProject(root, config, io, options = {}) {
151
265
  io.say(`Saved to .env.local${written.ignoreAdded ? " and added it to .gitignore" : ""}. It is only readable by you; never share it.`);
152
266
  }
153
267
  const editor = await waitForEditor(url, io, options.wait);
154
- if (!editor)
268
+ if (!editor) {
155
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.");
270
+ }
271
+ if (config.file && config.file.id !== editor.fileId && !options.anyFile) {
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
+ }
274
+ let filePinned = false;
275
+ if (!config.file &&
276
+ editor.fileId &&
277
+ (await io.confirmFile?.({
278
+ id: editor.fileId,
279
+ name: editor.file,
280
+ }))) {
281
+ const raw = await readProjectConfig(root);
282
+ if (!raw || raw.file) {
283
+ throw new ConnectError("The project configuration changed while connecting. Inspect it and connect again; no file pin was replaced.");
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`);
292
+ filePinned = true;
293
+ io.say("Pinned the design file ID in penpair.config.json. Commit that file for your team.");
294
+ }
295
+ if (!config.file && !filePinned) {
296
+ io.say("No file ID is pinned yet. Run `penpair connect --pin-file` to protect against a different file being open.");
297
+ }
156
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 ? ", ..." : ""}).`);
157
- return { keyWritten, file: editor.file, pages: editor.pages };
299
+ return {
300
+ keyWritten,
301
+ file: editor.file,
302
+ pages: editor.pages,
303
+ fileId: editor.fileId,
304
+ filePinned,
305
+ };
158
306
  }