@penpair/core 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters.d.ts +16 -1
- package/dist/adapters.js +62 -19
- package/dist/approvals.d.ts +32 -0
- package/dist/approvals.js +213 -0
- package/dist/atomic-output.d.ts +5 -0
- package/dist/atomic-output.js +33 -0
- package/dist/audit-rules.d.ts +60 -0
- package/dist/audit-rules.js +443 -0
- package/dist/backup-note.d.ts +4 -0
- package/dist/backup-note.js +49 -0
- package/dist/baseline-coverage.d.ts +25 -0
- package/dist/baseline-coverage.js +248 -0
- package/dist/baselines-config.d.ts +25 -0
- package/dist/baselines-config.js +150 -11
- package/dist/baselines.d.ts +37 -8
- package/dist/baselines.js +362 -59
- package/dist/cli-arguments.d.ts +7 -0
- package/dist/cli-arguments.js +47 -0
- package/dist/cli-main.d.ts +13 -1
- package/dist/cli-main.js +2107 -247
- package/dist/client.d.ts +30 -6
- package/dist/client.js +160 -35
- package/dist/command-result.d.ts +159 -0
- package/dist/command-result.js +468 -0
- package/dist/coordination.d.ts +15 -0
- package/dist/coordination.js +184 -0
- package/dist/credentials.d.ts +21 -0
- package/dist/credentials.js +105 -0
- package/dist/deep-check.js +57 -9
- package/dist/design-spec.d.ts +55 -0
- package/dist/design-spec.js +275 -0
- package/dist/endpoint.d.ts +8 -1
- package/dist/endpoint.js +133 -21
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +8 -4
- package/dist/export.d.ts +18 -0
- package/dist/export.js +113 -10
- package/dist/file-archive.d.ts +16 -0
- package/dist/file-archive.js +58 -0
- package/dist/file-guard.d.ts +8 -0
- package/dist/file-guard.js +58 -0
- package/dist/formats.d.ts +4 -0
- package/dist/formats.js +229 -0
- package/dist/index.d.ts +34 -1
- package/dist/index.js +34 -1
- package/dist/inspection.d.ts +106 -0
- package/dist/inspection.js +657 -0
- package/dist/instance/config-schema.d.ts +422 -0
- package/dist/instance/config-schema.js +435 -0
- package/dist/instance/config.d.ts +29 -0
- package/dist/instance/config.js +191 -29
- package/dist/instance/connect.d.ts +10 -1
- package/dist/instance/connect.js +178 -30
- package/dist/instance/connection.d.ts +39 -4
- package/dist/instance/connection.js +198 -14
- package/dist/instance/gitignore.js +5 -2
- package/dist/instance/init.js +32 -7
- package/dist/instance/runner.js +32 -10
- package/dist/instance/setup.js +46 -13
- package/dist/instance/tty.js +28 -8
- package/dist/journal.d.ts +87 -0
- package/dist/journal.js +423 -0
- package/dist/kit-smoke.d.ts +31 -0
- package/dist/kit-smoke.js +145 -0
- package/dist/kit-source.d.ts +2 -0
- package/dist/kit-source.js +279 -0
- package/dist/kit.d.ts +25 -0
- package/dist/kit.js +158 -0
- package/dist/layout-policy.d.ts +2 -0
- package/dist/layout-policy.js +86 -0
- package/dist/layout.d.ts +21 -0
- package/dist/layout.js +36 -0
- package/dist/live-smoke.d.ts +8 -0
- package/dist/live-smoke.js +23 -0
- package/dist/local-profile.js +2 -1
- package/dist/mcp-server.d.ts +12 -0
- package/dist/mcp-server.js +415 -0
- package/dist/native-api.d.ts +15 -0
- package/dist/native-api.js +78 -0
- package/dist/onboard.d.ts +10 -0
- package/dist/onboard.js +41 -0
- package/dist/operation.d.ts +22 -3
- package/dist/operation.js +341 -31
- package/dist/page-session.d.ts +23 -0
- package/dist/page-session.js +142 -0
- package/dist/png.d.ts +5 -0
- package/dist/png.js +22 -0
- package/dist/portable-tokens.d.ts +22 -0
- package/dist/portable-tokens.js +331 -0
- package/dist/probe.d.ts +6 -2
- package/dist/probe.js +64 -11
- package/dist/project-input.d.ts +6 -0
- package/dist/project-input.js +65 -0
- package/dist/project-snippets.js +29 -12
- package/dist/project-status.d.ts +24 -0
- package/dist/project-status.js +188 -0
- package/dist/registry.d.ts +2 -1
- package/dist/registry.js +224 -53
- package/dist/run-file.d.ts +26 -0
- package/dist/run-file.js +199 -0
- package/dist/shots.d.ts +43 -0
- package/dist/shots.js +172 -0
- package/dist/skill-instructions.d.ts +13 -0
- package/dist/skill-instructions.js +144 -0
- package/dist/skills.d.ts +7 -0
- package/dist/skills.js +163 -46
- package/dist/snippet.d.ts +3 -2
- package/dist/snippet.js +33 -9
- package/dist/snippets/archive.d.ts +9 -0
- package/dist/snippets/archive.js +10 -4
- package/dist/snippets/read.d.ts +1 -1
- package/dist/snippets/read.js +78 -41
- package/dist/structure-snapshot.d.ts +85 -0
- package/dist/structure-snapshot.js +525 -0
- package/dist/token-edit.d.ts +53 -0
- package/dist/token-edit.js +352 -0
- package/dist/token-projection.d.ts +56 -0
- package/dist/token-projection.js +286 -0
- package/dist/tokens-diff.d.ts +19 -6
- package/dist/tokens-diff.js +182 -18
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/penpair-design/SKILL.md +20 -45
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +58 -2
- package/skills/penpair-design/references/project-facts.md +3 -1
- package/skills/penpair-design/references/write-protocol.md +15 -3
- package/skills/penpair-handoff/SKILL.md +16 -0
- package/skills/penpair-handoff/references/comparison-companion.md +13 -0
- package/skills/penpair-handoff/references/handoff-flow.md +13 -0
- package/skills/penpair-recipes/SKILL.md +21 -0
- package/skills/penpair-recipes/references/build-flow.md +11 -0
- package/skills/penpair-setup/SKILL.md +8 -2
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -3
- package/dist/docker/lifecycle.d.ts +0 -96
- package/dist/docker/lifecycle.js +0 -273
- package/dist/docker/secrets.d.ts +0 -11
- package/dist/docker/secrets.js +0 -38
- package/dist/docker/template.d.ts +0 -8
- package/dist/docker/template.js +0 -158
- package/dist/docker-profile.d.ts +0 -114
- package/dist/docker-profile.js +0 -18
- package/dist/instance/lifecycle.d.ts +0 -129
- package/dist/instance/lifecycle.js +0 -336
- package/dist/instance/secrets.d.ts +0 -11
- package/dist/instance/secrets.js +0 -38
- package/dist/instance/template.d.ts +0 -8
- package/dist/instance/template.js +0 -158
package/dist/instance/config.js
CHANGED
|
@@ -1,60 +1,151 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
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) &&
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
172
|
+
}
|
|
173
|
+
if (v.baselines !== undefined) {
|
|
71
174
|
validateBaselines(v.baselines, fail);
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
+
parsed = JSON.parse(text);
|
|
92
243
|
}
|
|
93
|
-
catch
|
|
94
|
-
|
|
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>;
|
package/dist/instance/connect.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
62
73
|
lines.push(line);
|
|
63
|
-
|
|
74
|
+
}
|
|
75
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
64
76
|
lines.pop();
|
|
65
|
-
|
|
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 {
|
|
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
|
|
101
|
+
info = await stat(path.join(root, ".env.local"));
|
|
83
102
|
}
|
|
84
103
|
catch {
|
|
85
104
|
return undefined;
|
|
86
105
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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)
|
|
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
|
-
|
|
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(
|
|
138
|
-
|
|
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 ??
|
|
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 {
|
|
299
|
+
return {
|
|
300
|
+
keyWritten,
|
|
301
|
+
file: editor.file,
|
|
302
|
+
pages: editor.pages,
|
|
303
|
+
fileId: editor.fileId,
|
|
304
|
+
filePinned,
|
|
305
|
+
};
|
|
158
306
|
}
|