@penpair/core 0.1.2 → 0.2.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 +8 -12
- package/dist/approvals.d.ts +32 -0
- package/dist/approvals.js +99 -0
- package/dist/atomic-output.d.ts +5 -0
- package/dist/atomic-output.js +28 -0
- package/dist/audit-rules.d.ts +60 -0
- package/dist/audit-rules.js +177 -0
- package/dist/backup-note.d.ts +4 -0
- package/dist/backup-note.js +28 -0
- package/dist/baseline-coverage.d.ts +25 -0
- package/dist/baseline-coverage.js +114 -0
- package/dist/baselines-config.d.ts +25 -0
- package/dist/baselines-config.js +52 -3
- package/dist/baselines.d.ts +37 -8
- package/dist/baselines.js +132 -52
- package/dist/cli-arguments.d.ts +7 -0
- package/dist/cli-arguments.js +35 -0
- package/dist/cli-main.d.ts +13 -1
- package/dist/cli-main.js +770 -139
- package/dist/client.d.ts +26 -5
- package/dist/client.js +46 -20
- package/dist/command-result.d.ts +159 -0
- package/dist/command-result.js +179 -0
- package/dist/coordination.d.ts +15 -0
- package/dist/coordination.js +122 -0
- package/dist/credentials.d.ts +21 -0
- package/dist/credentials.js +61 -0
- package/dist/deep-check.js +9 -5
- package/dist/design-spec.d.ts +55 -0
- package/dist/design-spec.js +135 -0
- package/dist/endpoint.d.ts +8 -1
- package/dist/endpoint.js +48 -4
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +8 -4
- package/dist/export.d.ts +18 -0
- package/dist/export.js +42 -7
- package/dist/file-archive.d.ts +16 -0
- package/dist/file-archive.js +32 -0
- package/dist/file-guard.d.ts +8 -0
- package/dist/file-guard.js +40 -0
- package/dist/formats.d.ts +4 -0
- package/dist/formats.js +28 -0
- package/dist/index.d.ts +33 -1
- package/dist/index.js +33 -1
- package/dist/inspection.d.ts +106 -0
- package/dist/inspection.js +344 -0
- package/dist/instance/config-schema.d.ts +422 -0
- package/dist/instance/config-schema.js +76 -0
- package/dist/instance/config.d.ts +29 -0
- package/dist/instance/config.js +102 -11
- package/dist/instance/connect.d.ts +10 -1
- package/dist/instance/connect.js +53 -12
- package/dist/instance/connection.d.ts +14 -0
- package/dist/instance/connection.js +13 -2
- package/dist/instance/init.js +1 -1
- package/dist/instance/setup.js +1 -1
- package/dist/instance/tty.js +3 -1
- package/dist/journal.d.ts +77 -0
- package/dist/journal.js +260 -0
- package/dist/kit-smoke.d.ts +31 -0
- package/dist/kit-smoke.js +83 -0
- package/dist/kit-source.d.ts +2 -0
- package/dist/kit-source.js +309 -0
- package/dist/kit.d.ts +25 -0
- package/dist/kit.js +90 -0
- package/dist/layout.d.ts +15 -0
- package/dist/layout.js +79 -0
- package/dist/live-smoke.d.ts +8 -0
- package/dist/live-smoke.js +14 -0
- package/dist/mcp-server.d.ts +12 -0
- package/dist/mcp-server.js +102 -0
- package/dist/native-api.d.ts +15 -0
- package/dist/native-api.js +42 -0
- package/dist/onboard.d.ts +10 -0
- package/dist/onboard.js +15 -0
- package/dist/operation.d.ts +20 -1
- package/dist/operation.js +113 -13
- package/dist/png.d.ts +5 -0
- package/dist/png.js +7 -0
- package/dist/portable-tokens.d.ts +22 -0
- package/dist/portable-tokens.js +134 -0
- package/dist/probe.d.ts +6 -2
- package/dist/probe.js +19 -3
- package/dist/project-input.d.ts +6 -0
- package/dist/project-input.js +51 -0
- package/dist/project-snippets.js +12 -7
- package/dist/project-status.d.ts +24 -0
- package/dist/project-status.js +79 -0
- package/dist/registry.d.ts +2 -1
- package/dist/registry.js +17 -3
- package/dist/run-file.d.ts +26 -0
- package/dist/run-file.js +100 -0
- package/dist/shots.d.ts +43 -0
- package/dist/shots.js +90 -0
- package/dist/skill-instructions.d.ts +13 -0
- package/dist/skill-instructions.js +75 -0
- package/dist/skills.d.ts +11 -0
- package/dist/skills.js +71 -33
- package/dist/snippet.d.ts +3 -2
- package/dist/snippet.js +12 -8
- 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 +71 -40
- package/dist/structure-snapshot.d.ts +73 -0
- package/dist/structure-snapshot.js +171 -0
- package/dist/token-edit.d.ts +53 -0
- package/dist/token-edit.js +197 -0
- package/dist/tokens-diff.d.ts +19 -6
- package/dist/tokens-diff.js +89 -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 +12 -40
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +56 -0
- package/skills/penpair-design/references/project-facts.md +1 -1
- package/skills/penpair-design/references/write-protocol.md +15 -3
- package/skills/penpair-handoff/SKILL.md +14 -0
- package/skills/penpair-handoff/references/handoff-flow.md +11 -0
- package/skills/penpair-recipes/SKILL.md +16 -0
- package/skills/penpair-recipes/references/build-flow.md +11 -0
- package/skills/penpair-setup/SKILL.md +6 -2
package/dist/instance/config.js
CHANGED
|
@@ -1,21 +1,53 @@
|
|
|
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
|
+
}
|
|
12
23
|
export function validateConfig(value) {
|
|
24
|
+
validateSchemaVersion(value);
|
|
13
25
|
const fail = (message) => {
|
|
14
26
|
throw new ConfigError(`${configFileName}: ${message}`);
|
|
15
27
|
};
|
|
16
|
-
if (typeof value !== "object" || value === null)
|
|
28
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
17
29
|
return fail("must be a JSON object");
|
|
18
30
|
const v = value;
|
|
31
|
+
validateFile(v.file);
|
|
32
|
+
validateBackup(v.backup);
|
|
33
|
+
if (v.connection !== undefined) {
|
|
34
|
+
if (!v.connection || typeof v.connection !== "object" || Array.isArray(v.connection) || Object.keys(v.connection).some(k => k !== "credential"))
|
|
35
|
+
throw new ConfigError("connection requires only a credential reference");
|
|
36
|
+
try {
|
|
37
|
+
validateCredentialReference(v.connection.credential);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
throw new ConfigError(String(error.message));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (v.audit !== undefined) {
|
|
44
|
+
try {
|
|
45
|
+
validateAuditRules(v.audit);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
throw new ConfigError(String(error.message));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
19
51
|
if (typeof v.instance !== "string" || !/^[a-z][a-z0-9-]{0,30}$/.test(v.instance))
|
|
20
52
|
fail("instance must be lowercase letters, digits and hyphens, starting with a letter (at most 31 characters)");
|
|
21
53
|
if (typeof v.penpotVersion !== "string" || !/^\d+\.\d+(\.\d+)?$/.test(v.penpotVersion))
|
|
@@ -46,10 +78,33 @@ export function validateConfig(value) {
|
|
|
46
78
|
const instanceNamePattern = /^[a-z][a-z0-9-]{0,30}$/;
|
|
47
79
|
/** Accepts either kind of project config: a local Docker instance or an external Penpot. */
|
|
48
80
|
export function validateProjectConfig(value) {
|
|
81
|
+
validateSchemaVersion(value);
|
|
49
82
|
const profile = typeof value === "object" && value !== null ? value.profile : undefined;
|
|
50
|
-
if (profile
|
|
83
|
+
if (profile === undefined)
|
|
51
84
|
return validateConfig(value);
|
|
85
|
+
if (typeof profile !== "object" || profile === null || Array.isArray(profile) || profile.type !== "external")
|
|
86
|
+
throw new ConfigError(`${configFileName}: profile.type must be external; omit profile for an instance on this computer`);
|
|
52
87
|
const v = value;
|
|
88
|
+
validateFile(v.file);
|
|
89
|
+
validateBackup(v.backup);
|
|
90
|
+
if (v.connection !== undefined) {
|
|
91
|
+
if (!v.connection || typeof v.connection !== "object" || Array.isArray(v.connection) || Object.keys(v.connection).some(k => k !== "credential"))
|
|
92
|
+
throw new ConfigError("connection requires only a credential reference");
|
|
93
|
+
try {
|
|
94
|
+
validateCredentialReference(v.connection.credential);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
throw new ConfigError(String(error.message));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (v.audit !== undefined) {
|
|
101
|
+
try {
|
|
102
|
+
validateAuditRules(v.audit);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
throw new ConfigError(String(error.message));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
53
108
|
const fail = (message) => {
|
|
54
109
|
throw new ConfigError(`${configFileName}: ${message}`);
|
|
55
110
|
};
|
|
@@ -73,28 +128,64 @@ export function validateProjectConfig(value) {
|
|
|
73
128
|
fail("adapters must be a list of package names or relative paths");
|
|
74
129
|
return v;
|
|
75
130
|
}
|
|
131
|
+
function validateFile(value) {
|
|
132
|
+
if (value === undefined)
|
|
133
|
+
return;
|
|
134
|
+
const file = value;
|
|
135
|
+
if (typeof file !== "object" || file === null || Array.isArray(file) || typeof file.id !== "string" || !file.id.trim() || (file.name !== undefined && typeof file.name !== "string"))
|
|
136
|
+
throw new ConfigError(`${configFileName}: file must have a nonempty id and an optional name`);
|
|
137
|
+
}
|
|
138
|
+
function validateBackup(value) {
|
|
139
|
+
if (value === undefined)
|
|
140
|
+
return;
|
|
141
|
+
const backup = value;
|
|
142
|
+
if (!backup || typeof backup !== "object" || Array.isArray(backup) || typeof backup.noteFile !== "string" || !backup.noteFile || path.isAbsolute(backup.noteFile) || backup.noteFile.split(/[\\/]/).includes("..") || !Number.isSafeInteger(backup.remindAfterDays) || backup.remindAfterDays <= 0)
|
|
143
|
+
throw new ConfigError(`${configFileName}: backup needs an in-project relative noteFile and positive integer remindAfterDays`);
|
|
144
|
+
}
|
|
76
145
|
/** The config of a project that runs its own Penpot; anything else cannot start, stop or back up containers. */
|
|
77
146
|
export function requireLocal(config) {
|
|
78
147
|
if (isExternal(config))
|
|
79
148
|
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.`);
|
|
80
149
|
return config;
|
|
81
150
|
}
|
|
82
|
-
|
|
151
|
+
/** Missing is optional; unreadable, malformed or invalid project files always stop the caller. */
|
|
152
|
+
export async function readProjectConfig(root) {
|
|
83
153
|
let text;
|
|
84
154
|
try {
|
|
85
155
|
text = await readFile(path.join(root, configFileName), "utf8");
|
|
86
156
|
}
|
|
87
|
-
catch {
|
|
88
|
-
|
|
157
|
+
catch (error) {
|
|
158
|
+
const code = error.code;
|
|
159
|
+
if (code === "ENOENT") {
|
|
160
|
+
try {
|
|
161
|
+
await lstat(path.join(root, configFileName));
|
|
162
|
+
}
|
|
163
|
+
catch (statError) {
|
|
164
|
+
if (statError.code === "ENOENT")
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
throw new ConfigError(`Cannot read ${configFileName}${code ? ` (${code})` : ""}. Check that it is a readable file and fix its permissions.`, "unreadable");
|
|
89
169
|
}
|
|
170
|
+
let parsed;
|
|
90
171
|
try {
|
|
91
|
-
|
|
172
|
+
parsed = JSON.parse(text);
|
|
92
173
|
}
|
|
93
|
-
catch
|
|
94
|
-
|
|
95
|
-
throw error;
|
|
96
|
-
throw new ConfigError(`${configFileName} is not valid JSON`);
|
|
174
|
+
catch {
|
|
175
|
+
throw new ConfigError(`${configFileName} is not valid JSON. Fix its JSON syntax, then run \`penpair config check\`.`, "invalid-json");
|
|
97
176
|
}
|
|
177
|
+
validateProjectConfig(parsed);
|
|
178
|
+
return parsed;
|
|
179
|
+
}
|
|
180
|
+
export async function loadOptionalConfig(root) {
|
|
181
|
+
const raw = await readProjectConfig(root);
|
|
182
|
+
return raw === undefined ? undefined : validateProjectConfig(raw);
|
|
183
|
+
}
|
|
184
|
+
export async function loadConfig(root) {
|
|
185
|
+
const config = await loadOptionalConfig(root);
|
|
186
|
+
if (config === undefined)
|
|
187
|
+
throw new ConfigError(`No ${configFileName} here. Run \`penpair init\` first. For config-free inspection, use a read command with --standalone.`, "missing");
|
|
188
|
+
return config;
|
|
98
189
|
}
|
|
99
190
|
export const instancePaths = (root, config) => {
|
|
100
191
|
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 {
|
|
@@ -78,18 +79,24 @@ export async function readKeyFile(root) {
|
|
|
78
79
|
}
|
|
79
80
|
/** Warns when .env.local exists but git would commit it. Asks git itself, so global ignore rules count. */
|
|
80
81
|
export async function keyFileCheck(root, runner) {
|
|
82
|
+
let info;
|
|
81
83
|
try {
|
|
82
|
-
await
|
|
84
|
+
info = await stat(path.join(root, ".env.local"));
|
|
83
85
|
}
|
|
84
86
|
catch {
|
|
85
87
|
return undefined;
|
|
86
88
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
return { check: "key file", ok: false, detail: ".env.local
|
|
92
|
-
|
|
89
|
+
if (process.platform !== "win32" && (info.mode & 0o077) !== 0)
|
|
90
|
+
return { check: "key file", ok: false, detail: ".env.local is group/world-readable", remedy: "Make the private connection file owner-readable only (0600) and verify repository exclusion." };
|
|
91
|
+
const ignored = await runner.run("git", ["-C", root, "check-ignore", "-q", ".env.local"]);
|
|
92
|
+
if (ignored.code === 1)
|
|
93
|
+
return { check: "key file", ok: false, detail: ".env.local is tracked or not ignored", remedy: "Keep .env.local out of Git with .gitignore; if already tracked, coordinate removal and key rotation with the owner." };
|
|
94
|
+
if (ignored.code !== 0)
|
|
95
|
+
return { check: "key file", ok: true, detail: ".env.local exists; this folder is not a git repository" };
|
|
96
|
+
const history = await runner.run("git", ["-C", root, "log", "--all", "-n", "1", "--format=%H", "--", ".env.local"]);
|
|
97
|
+
if (history.code === 0 && history.stdout.trim())
|
|
98
|
+
return { check: "key file", ok: true, warning: true, detail: ".env.local is ignored now but appears in Git history", remedy: "Rotate any exposed key through the provider UI; let the owner decide whether history remediation is needed. No historical file contents were read." };
|
|
99
|
+
return { check: "key file", ok: true, detail: ".env.local is private and kept out of git" };
|
|
93
100
|
}
|
|
94
101
|
/** Waits for the editor tab and the plugin, saying in plain words what to do, until `timeoutMs` has passed. */
|
|
95
102
|
export async function waitForEditor(url, io, options = {}) {
|
|
@@ -123,7 +130,26 @@ export async function waitForEditor(url, io, options = {}) {
|
|
|
123
130
|
* a git-ignored file, then wait for the editor and plugin and read the file's pages once. Safe to run again.
|
|
124
131
|
*/
|
|
125
132
|
export async function connectProject(root, config, io, options = {}) {
|
|
126
|
-
|
|
133
|
+
const environment = options.environment ?? process.env;
|
|
134
|
+
let reference = config.connection?.credential;
|
|
135
|
+
if (!reference) {
|
|
136
|
+
let text = "";
|
|
137
|
+
try {
|
|
138
|
+
text = await readFile(path.join(root, ".env.local"), "utf8");
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (error.code !== "ENOENT")
|
|
142
|
+
throw new ConnectError("Project connection file could not be read");
|
|
143
|
+
}
|
|
144
|
+
const file = environment.PENPAIR_MCP_URL_FILE?.trim() || parseEnvFileValue(text, "PENPAIR_MCP_URL_FILE");
|
|
145
|
+
if (file)
|
|
146
|
+
reference = { type: "file", path: file };
|
|
147
|
+
else if (environment.PENPAIR_MCP_URL_COMMAND || parseEnvFileValue(text, "PENPAIR_MCP_URL_COMMAND"))
|
|
148
|
+
throw new ConnectError("Arbitrary credential commands are not enabled; configure a file or OS reference");
|
|
149
|
+
}
|
|
150
|
+
if (reference && options.replace)
|
|
151
|
+
throw new ConnectError("Rotate the configured credential in its private storage, then reconnect; --replace is for the legacy pasted-URL flow");
|
|
152
|
+
let url = reference ? await readCredential(reference, { root }) : options.replace ? undefined : await readKeyFile(root);
|
|
127
153
|
let keyWritten = false;
|
|
128
154
|
if (url) {
|
|
129
155
|
let origin;
|
|
@@ -134,7 +160,9 @@ export async function connectProject(root, config, io, options = {}) {
|
|
|
134
160
|
origin = undefined;
|
|
135
161
|
}
|
|
136
162
|
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.)");
|
|
163
|
+
io.say(reference ? "Using the configured private credential reference; its output stays private." : "The key in .env.local belongs to this Penpot, so I will use it. (Run `penpair connect --replace` to paste a new one.)");
|
|
164
|
+
else if (reference)
|
|
165
|
+
throw new ConnectError("Configured credential reference belongs to another or invalid Penpot origin; no fallback was used");
|
|
138
166
|
else
|
|
139
167
|
url = undefined;
|
|
140
168
|
}
|
|
@@ -153,6 +181,19 @@ export async function connectProject(root, config, io, options = {}) {
|
|
|
153
181
|
const editor = await waitForEditor(url, io, options.wait);
|
|
154
182
|
if (!editor)
|
|
155
183
|
throw new ConnectError("I did not see the editor within five minutes. Open a design file in Penpot, choose File, MCP Server, Connect, keep the tab in front, then run `penpair connect` again.");
|
|
184
|
+
if (config.file && config.file.id !== editor.fileId && !options.anyFile)
|
|
185
|
+
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.`);
|
|
186
|
+
let filePinned = false;
|
|
187
|
+
if (!config.file && editor.fileId && await io.confirmFile?.({ id: editor.fileId, name: editor.file })) {
|
|
188
|
+
const raw = await readProjectConfig(root);
|
|
189
|
+
if (!raw || raw.file)
|
|
190
|
+
throw new ConnectError("The project configuration changed while connecting. Inspect it and connect again; no file pin was replaced.");
|
|
191
|
+
await writeFile(path.join(root, configFileName), `${JSON.stringify({ ...raw, file: { id: editor.fileId, name: editor.file } }, null, 2)}\n`);
|
|
192
|
+
filePinned = true;
|
|
193
|
+
io.say("Pinned the design file ID in penpair.config.json. Commit that file for your team.");
|
|
194
|
+
}
|
|
195
|
+
if (!config.file && !filePinned)
|
|
196
|
+
io.say("No file ID is pinned yet. Run `penpair connect --pin-file` to protect against a different file being open.");
|
|
156
197
|
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 };
|
|
198
|
+
return { keyWritten, file: editor.file, pages: editor.pages, fileId: editor.fileId, filePinned };
|
|
158
199
|
}
|
|
@@ -9,8 +9,21 @@ export interface Check {
|
|
|
9
9
|
}
|
|
10
10
|
export declare const frontendUrl: (config: InstanceConfig) => string;
|
|
11
11
|
export interface DoctorCheck extends Check {
|
|
12
|
+
warning?: boolean;
|
|
12
13
|
remedy?: string;
|
|
14
|
+
file?: {
|
|
15
|
+
expected: ProjectConfig["file"] | null;
|
|
16
|
+
actual: {
|
|
17
|
+
id?: string;
|
|
18
|
+
name: string;
|
|
19
|
+
};
|
|
20
|
+
ok: boolean;
|
|
21
|
+
};
|
|
13
22
|
}
|
|
23
|
+
export declare function fileIdentityCheck(config: ProjectConfig, actual: {
|
|
24
|
+
file: string;
|
|
25
|
+
fileId?: string;
|
|
26
|
+
}): DoctorCheck;
|
|
14
27
|
/**
|
|
15
28
|
* A command that acts on a Penpot file must act on this project's instance. A key left in the agent's global configuration
|
|
16
29
|
* (or any other key) can point at a different instance and silently drive the wrong file, so a mismatch is refused.
|
|
@@ -25,6 +38,7 @@ export interface DoctorOptions {
|
|
|
25
38
|
origin: string;
|
|
26
39
|
connect: () => Promise<{
|
|
27
40
|
file: string;
|
|
41
|
+
fileId?: string;
|
|
28
42
|
}>;
|
|
29
43
|
};
|
|
30
44
|
}
|
|
@@ -3,6 +3,14 @@ import { isExternal } from "./config.js";
|
|
|
3
3
|
export class LifecycleError extends Error {
|
|
4
4
|
}
|
|
5
5
|
export const frontendUrl = (config) => `http://127.0.0.1:${config.ports.frontend}`;
|
|
6
|
+
export function fileIdentityCheck(config, actual) {
|
|
7
|
+
const ok = !config.file || config.file.id === actual.fileId;
|
|
8
|
+
return { check: "file identity", ok,
|
|
9
|
+
detail: config.file ? `expected ${config.file.name ?? config.file.id} (${config.file.id}); connected ${actual.file} (${actual.fileId ?? "unknown id"})` : "No file ID pinned; run penpair connect --pin-file",
|
|
10
|
+
...(!ok ? { remedy: "Open the project's expected design file and reconnect its MCP plugin." } : {}),
|
|
11
|
+
file: { expected: config.file ?? null, actual: { id: actual.fileId, name: actual.file }, ok },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
6
14
|
/**
|
|
7
15
|
* A command that acts on a Penpot file must act on this project's instance. A key left in the agent's global configuration
|
|
8
16
|
* (or any other key) can point at a different instance and silently drive the wrong file, so a mismatch is refused.
|
|
@@ -56,11 +64,14 @@ export async function doctorExternal(config, options = {}) {
|
|
|
56
64
|
return out;
|
|
57
65
|
}
|
|
58
66
|
try {
|
|
59
|
-
const
|
|
67
|
+
const actual = await options.mcp.connect();
|
|
68
|
+
const { file } = actual;
|
|
60
69
|
add("editor and plugin", true, `connected to file "${file}"`);
|
|
70
|
+
if (config.file)
|
|
71
|
+
out.push(fileIdentityCheck(config, actual));
|
|
61
72
|
}
|
|
62
73
|
catch (error) {
|
|
63
|
-
add("editor and plugin", false, String(error.message).split("\n")[0] ?? "not connected", "Open a file in Penpot, choose File, MCP Server, Connect, and keep the tab visible.");
|
|
74
|
+
add("editor and plugin", false, String(error.message).split("\n")[0] ?? "not connected", "Open a file in Penpot, choose File, MCP Server, Connect, and keep the tab visible in a separate window if needed.");
|
|
64
75
|
}
|
|
65
76
|
return out;
|
|
66
77
|
}
|
package/dist/instance/init.js
CHANGED
|
@@ -18,7 +18,7 @@ export async function init(root, options = {}, deps = {}) {
|
|
|
18
18
|
existing = await loadConfig(root);
|
|
19
19
|
}
|
|
20
20
|
catch (error) {
|
|
21
|
-
if (!(error instanceof ConfigError) ||
|
|
21
|
+
if (!(error instanceof ConfigError) || error.code !== "missing")
|
|
22
22
|
throw error;
|
|
23
23
|
}
|
|
24
24
|
if (existing && isExternal(existing))
|
package/dist/instance/setup.js
CHANGED
|
@@ -25,7 +25,7 @@ export async function setup(root, io, options) {
|
|
|
25
25
|
io.say(`This folder is already set up for ${isExternal(config) ? `Penpot at ${config.profile.origin}` : `Penpot on this computer (port ${config.ports.frontend})`}. Continuing from there.`);
|
|
26
26
|
}
|
|
27
27
|
catch (error) {
|
|
28
|
-
if (!(error instanceof ConfigError) ||
|
|
28
|
+
if (!(error instanceof ConfigError) || error.code !== "missing")
|
|
29
29
|
throw error;
|
|
30
30
|
}
|
|
31
31
|
if (!config) {
|
package/dist/instance/tty.js
CHANGED
|
@@ -60,5 +60,7 @@ export async function askLine(question, fallback) {
|
|
|
60
60
|
export async function openInBrowser(url) {
|
|
61
61
|
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
62
62
|
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
63
|
-
await nodeRunner.run(command, args);
|
|
63
|
+
const result = await nodeRunner.run(command, args);
|
|
64
|
+
if (result.code !== 0)
|
|
65
|
+
throw new Error("Browser launch failed; open the configured public Penpot address manually.");
|
|
64
66
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { WriteLease } from "./coordination.js";
|
|
2
|
+
import type { PenpotToolClient } from "./client.js";
|
|
3
|
+
export interface JournalTarget {
|
|
4
|
+
fileId: string;
|
|
5
|
+
revision?: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function readJournalTarget(client: PenpotToolClient): Promise<JournalTarget>;
|
|
8
|
+
export interface JournalStep<T> {
|
|
9
|
+
key: string;
|
|
10
|
+
label: string;
|
|
11
|
+
/** Source/signature identifying the implementation of this step, not only its display name. */
|
|
12
|
+
signature: string;
|
|
13
|
+
initial?: T;
|
|
14
|
+
inspect(): Promise<T>;
|
|
15
|
+
apply(current: T, target: JournalTarget): Promise<unknown>;
|
|
16
|
+
verify(current: T, before: T, result?: unknown): boolean;
|
|
17
|
+
reconcile(before: T, current: T, result?: unknown): "complete" | "pending" | "conflict";
|
|
18
|
+
}
|
|
19
|
+
export interface OperationJournal {
|
|
20
|
+
schemaVersion: 1;
|
|
21
|
+
engineVersion: string;
|
|
22
|
+
runId: string;
|
|
23
|
+
operation: string;
|
|
24
|
+
inputHash: string;
|
|
25
|
+
planHash: string;
|
|
26
|
+
payload?: unknown;
|
|
27
|
+
target: JournalTarget;
|
|
28
|
+
state: "planned" | "running" | "uncertain" | "failed" | "conflict" | "verified";
|
|
29
|
+
createdAt: string;
|
|
30
|
+
updatedAt: string;
|
|
31
|
+
redacted: boolean;
|
|
32
|
+
checkpoint?: unknown;
|
|
33
|
+
error?: string;
|
|
34
|
+
steps: {
|
|
35
|
+
key: string;
|
|
36
|
+
label: string;
|
|
37
|
+
state: "pending" | "started" | "completed" | "uncertain" | "failed";
|
|
38
|
+
before: unknown;
|
|
39
|
+
after?: unknown;
|
|
40
|
+
targetBefore?: JournalTarget;
|
|
41
|
+
targetAfter?: JournalTarget;
|
|
42
|
+
result?: unknown;
|
|
43
|
+
error?: string;
|
|
44
|
+
}[];
|
|
45
|
+
}
|
|
46
|
+
export interface JournalOptions {
|
|
47
|
+
root: string;
|
|
48
|
+
forceLock?: boolean;
|
|
49
|
+
coordinate?(runId: string, target: JournalTarget): Promise<WriteLease>;
|
|
50
|
+
operation: string;
|
|
51
|
+
input: unknown;
|
|
52
|
+
payload?: unknown;
|
|
53
|
+
runId?: string;
|
|
54
|
+
resume?: boolean;
|
|
55
|
+
target(): Promise<JournalTarget>;
|
|
56
|
+
checkpoint?(runId: string): Promise<{
|
|
57
|
+
supported: boolean;
|
|
58
|
+
report?: {
|
|
59
|
+
verified: boolean;
|
|
60
|
+
};
|
|
61
|
+
}>;
|
|
62
|
+
requireCheckpoint?: boolean;
|
|
63
|
+
finalVerify?(): Promise<boolean>;
|
|
64
|
+
redact?(text: string): string;
|
|
65
|
+
onStep?(message: string): void;
|
|
66
|
+
}
|
|
67
|
+
/** Stable JSON hashing; observation comparisons may explicitly ignore transport execution metadata. */
|
|
68
|
+
export declare function observationHash(value: unknown, ignoreExecution?: boolean): string;
|
|
69
|
+
export declare function readJournal(root: string, runId: string): Promise<OperationJournal>;
|
|
70
|
+
export declare function listJournals(root: string): Promise<Pick<OperationJournal, "runId" | "operation" | "state" | "target" | "updatedAt">[]>;
|
|
71
|
+
export declare class JournalRunError extends Error {
|
|
72
|
+
readonly runId: string;
|
|
73
|
+
readonly outcome: OperationJournal["state"];
|
|
74
|
+
constructor(runId: string, outcome: OperationJournal["state"], message: string);
|
|
75
|
+
}
|
|
76
|
+
/** Each started phase is persisted before its write. Resume must inspect and reconcile actual state, never replay blindly. */
|
|
77
|
+
export declare function runJournaledSteps<T>(steps: JournalStep<T>[], options: JournalOptions): Promise<OperationJournal>;
|