@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.
- package/dist/adapters.js +58 -11
- package/dist/approvals.js +143 -29
- package/dist/atomic-output.js +9 -4
- package/dist/audit-rules.js +345 -79
- package/dist/backup-note.js +29 -8
- package/dist/baseline-coverage.js +169 -35
- package/dist/baselines-config.js +119 -29
- package/dist/baselines.js +280 -57
- package/dist/cli-arguments.js +18 -6
- package/dist/cli-main.js +1645 -392
- package/dist/client.d.ts +5 -2
- package/dist/client.js +127 -28
- package/dist/command-result.js +341 -52
- package/dist/coordination.js +91 -29
- package/dist/credentials.js +57 -13
- package/dist/deep-check.js +56 -12
- package/dist/design-spec.js +189 -49
- package/dist/endpoint.js +102 -34
- package/dist/export.js +86 -18
- package/dist/file-archive.js +33 -7
- package/dist/file-guard.js +29 -11
- package/dist/formats.js +215 -14
- package/dist/index.d.ts +8 -7
- package/dist/index.js +8 -7
- package/dist/inspection.d.ts +1 -1
- package/dist/inspection.js +378 -65
- package/dist/instance/config-schema.js +400 -41
- package/dist/instance/config.js +101 -30
- package/dist/instance/connect.js +151 -44
- package/dist/instance/connection.d.ts +26 -5
- package/dist/instance/connection.js +194 -21
- 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 +26 -8
- package/dist/journal.d.ts +10 -0
- package/dist/journal.js +212 -49
- package/dist/kit-smoke.js +82 -20
- package/dist/kit-source.js +10 -40
- package/dist/kit.js +102 -34
- package/dist/layout-policy.d.ts +2 -0
- package/dist/layout-policy.js +86 -0
- package/dist/layout.d.ts +7 -1
- package/dist/layout.js +27 -70
- package/dist/live-smoke.js +13 -4
- package/dist/local-profile.js +2 -1
- package/dist/mcp-server.js +360 -47
- package/dist/native-api.js +47 -11
- package/dist/onboard.js +33 -7
- package/dist/operation.d.ts +2 -2
- package/dist/operation.js +279 -69
- package/dist/page-session.d.ts +23 -0
- package/dist/page-session.js +142 -0
- package/dist/png.js +18 -3
- package/dist/portable-tokens.js +254 -57
- package/dist/probe.d.ts +1 -1
- package/dist/probe.js +48 -11
- package/dist/project-input.js +24 -10
- package/dist/project-snippets.js +18 -6
- package/dist/project-status.js +133 -24
- package/dist/registry.js +217 -60
- package/dist/run-file.js +129 -30
- package/dist/shots.js +108 -26
- package/dist/skill-check-entry.d.ts +17 -0
- package/dist/skill-check-entry.js +89 -0
- package/dist/skill-check-instructions.d.ts +7 -0
- package/dist/skill-check-instructions.js +39 -0
- package/dist/skill-check.d.ts +15 -0
- package/dist/skill-check.js +36 -0
- package/dist/skill-file.d.ts +2 -0
- package/dist/skill-file.js +31 -0
- package/dist/skill-instructions.d.ts +4 -0
- package/dist/skill-instructions.js +97 -22
- package/dist/skill-metadata.d.ts +8 -0
- package/dist/skill-metadata.js +52 -0
- package/dist/skill-routing.d.ts +2 -0
- package/dist/skill-routing.js +36 -0
- package/dist/skills.js +131 -41
- package/dist/snippet.js +27 -7
- package/dist/snippets/read.js +8 -2
- package/dist/structure-snapshot.d.ts +12 -0
- package/dist/structure-snapshot.js +439 -85
- package/dist/token-edit.js +219 -64
- package/dist/token-projection.d.ts +56 -0
- package/dist/token-projection.js +286 -0
- package/dist/tokens-diff.js +125 -32
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
- package/skills/design-judgment/SKILL.md +1 -1
- package/skills/design-judgment/agents/openai.yaml +6 -0
- package/skills/penpair-design/SKILL.md +14 -1
- package/skills/penpair-design/agents/openai.yaml +6 -0
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
- package/skills/penpair-design/references/project-facts.md +2 -0
- package/skills/penpair-handoff/SKILL.md +14 -1
- package/skills/penpair-handoff/agents/openai.yaml +6 -0
- package/skills/penpair-handoff/references/comparison-companion.md +16 -0
- package/skills/penpair-handoff/references/handoff-flow.md +2 -0
- package/skills/penpair-recipes/SKILL.md +13 -1
- package/skills/penpair-recipes/agents/openai.yaml +6 -0
- package/skills/penpair-recipes/assets/visual-checkpoint.md +48 -0
- package/skills/penpair-recipes/references/visual-checkpoint.md +37 -0
- package/skills/penpair-setup/SKILL.md +9 -3
- package/skills/penpair-setup/agents/openai.yaml +6 -0
- package/skills/penpair-verify/SKILL.md +49 -0
- package/skills/penpair-verify/agents/openai.yaml +6 -0
- package/skills/penpair-verify/references/browser-evidence.md +33 -0
- 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
|
@@ -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 ||
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
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) &&
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
83
|
-
|
|
106
|
+
const profile = typeof value === "object" && value !== null
|
|
107
|
+
? value.profile
|
|
108
|
+
: undefined;
|
|
109
|
+
if (profile === undefined) {
|
|
84
110
|
return validateConfig(value);
|
|
85
|
-
|
|
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 ||
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
172
|
+
}
|
|
173
|
+
if (v.baselines !== undefined) {
|
|
126
174
|
validateBaselines(v.baselines, fail);
|
|
127
|
-
|
|
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" ||
|
|
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 ||
|
|
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) => {
|
package/dist/instance/connect.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
63
73
|
lines.push(line);
|
|
64
|
-
|
|
74
|
+
}
|
|
75
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
65
76
|
lines.pop();
|
|
66
|
-
|
|
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 {
|
|
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 {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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)
|
|
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 = {
|
|
147
|
-
|
|
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
|
-
|
|
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
|
|
164
|
-
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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. */
|