@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/cli-main.js
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { auditDesignRules } from "./audit-rules.js";
|
|
2
|
+
import { projectStatus, projectFileUrl, openProject } from "./project-status.js";
|
|
3
|
+
import { artifactSchemas, migrateProjectConfig } from "./formats.js";
|
|
4
|
+
import { importDtcg, exportDtcg } from "./portable-tokens.js";
|
|
5
|
+
import { captureStructuralSnapshot, writeStructuralSnapshot, readStructuralSnapshot, diffStructuralSnapshots, renderStructuralDiff } from "./structure-snapshot.js";
|
|
6
|
+
import { recordApproval, approvalFingerprint, artifactSha } from "./approvals.js";
|
|
7
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
8
|
+
import { buildDesignSpec, renderDesignSpec, checkDesignSpec, readDesignJson, validateComponentMap, emptyComponentMap, writeDesignSpec } from "./design-spec.js";
|
|
9
|
+
import { readBaselineCoverage } from "./baseline-coverage.js";
|
|
10
|
+
import { runKitSmoke } from "./kit-smoke.js";
|
|
11
|
+
import { checkKits, loadKits } from "./kit.js";
|
|
12
|
+
import { prepareFileScript, runFileScript } from "./run-file.js";
|
|
13
|
+
import { tokenEditOperation, validateTokenEdit } from "./token-edit.js";
|
|
14
|
+
import { withCommandSignal, commandSignal, commandOutput as output, commandFailure, commandRedactor, collectCommand, CommandInterruptedError } from "./command-result.js";
|
|
15
|
+
import { readFile } from "node:fs/promises";
|
|
2
16
|
import path from "node:path";
|
|
3
|
-
import {
|
|
17
|
+
import { CommandArgumentError, parseCommandArgs } from "./cli-arguments.js";
|
|
4
18
|
import { connect, executeCode } from "./client.js";
|
|
5
|
-
import { redactEndpoint, resolveEndpoint } from "./endpoint.js";
|
|
19
|
+
import { endpointRedactor, redactEndpoint, resolveEndpoint } from "./endpoint.js";
|
|
6
20
|
import { PenpairError } from "./errors.js";
|
|
21
|
+
import { captureInspection, inspectChunk, validateCaptureBudget, validateInspectionQuery } from "./inspection.js";
|
|
22
|
+
import { resolveProjectOutput } from "./project-input.js";
|
|
23
|
+
import { mkdir } from "node:fs/promises";
|
|
24
|
+
import { captureShots, exportShapeByName, findShapesByName } from "./shots.js";
|
|
7
25
|
import { exportShapePng } from "./export.js";
|
|
8
26
|
import { exportBaselines } from "./baselines.js";
|
|
9
27
|
import { archiveOperation, runOnPage, runOperation } from "./operation.js";
|
|
@@ -13,9 +31,10 @@ import { installSkills, listBundledSkills } from "./skills.js";
|
|
|
13
31
|
import { checkProjectSnippets, loadProjectSnippets } from "./project-snippets.js";
|
|
14
32
|
import { builtInRegistry, coerceParams, RegistryError } from "./registry.js";
|
|
15
33
|
import { runSnippet } from "./snippet.js";
|
|
16
|
-
import { audit, pages,
|
|
17
|
-
import { diffEffective, effectiveValues } from "./tokens-diff.js";
|
|
18
|
-
import { ConfigError, isExternal, loadConfig, requireLocal } from "./instance/config.js";
|
|
34
|
+
import { audit, pages, tree } from "./snippets/read.js";
|
|
35
|
+
import { diffEffective, effectiveValues, parseTokenDump } from "./tokens-diff.js";
|
|
36
|
+
import { ConfigError, isExternal, loadConfig, loadOptionalConfig, requireLocal } from "./instance/config.js";
|
|
37
|
+
import { projectConfigSchema } from "./instance/config-schema.js";
|
|
19
38
|
import { init } from "./instance/init.js";
|
|
20
39
|
import { ConnectError, connectProject, keyFileCheck } from "./instance/connect.js";
|
|
21
40
|
import { hostedOrigin, setup } from "./instance/setup.js";
|
|
@@ -25,26 +44,46 @@ import { requireLocalProfile } from "./local-profile.js";
|
|
|
25
44
|
import { assertEndpointIsInstance, doctorExternal, LifecycleError } from "./instance/connection.js";
|
|
26
45
|
import { nodeRunner } from "./instance/runner.js";
|
|
27
46
|
import { version } from "./version.js";
|
|
47
|
+
import { guardFile } from "./file-guard.js";
|
|
48
|
+
import { atomicWriteFile } from "./atomic-output.js";
|
|
49
|
+
import { onboardingChecklist } from "./onboard.js";
|
|
50
|
+
import { exportFileArchive } from "./file-archive.js";
|
|
51
|
+
import { backupNoteCheck } from "./backup-note.js";
|
|
52
|
+
import { createCheckpoint } from "./native-api.js";
|
|
53
|
+
import { listJournals, readJournal } from "./journal.js";
|
|
28
54
|
const usageHead = `penpair ${version}
|
|
29
55
|
Start here (guided, plain language; safe to run again):
|
|
30
56
|
penpair setup asks where your Penpot is (web is the default), sets everything up and checks it
|
|
31
57
|
penpair setup --web [--external <address>] Penpot on the web: design.penpot.app, or your team's address; no questions about it
|
|
32
58
|
@@SETUP@@ penpair connect [--replace] only the connection: takes your personal key without showing it, waits for the editor
|
|
33
59
|
(a pipe works too: pbpaste | penpair connect)
|
|
60
|
+
penpair connect --pin-file pin the connected design file ID in project configuration
|
|
61
|
+
(--any-file explicitly overrides a pinned file; instance guarding still applies)
|
|
34
62
|
Read-only:
|
|
35
|
-
penpair
|
|
63
|
+
penpair config check [--json] validate project configuration offline, without loading extensions
|
|
64
|
+
penpair config schema print its JSON Schema (also works while fixing invalid config)
|
|
65
|
+
(without a project config, built-in inspection requires --standalone; writes require a project)
|
|
66
|
+
penpair probe [--integrity] APIs and limits; optionally validate file integrity when supported
|
|
36
67
|
penpair probe --deep --write also tries layout, tokens, components, swap, clone and export on a temporary
|
|
37
68
|
board it removes again (it touches the open file, so --write is required)
|
|
38
69
|
(in a project with an instance every command below acts on that instance only; --any-instance overrides)
|
|
39
70
|
penpair pages the file, its revision, pages and their sizes
|
|
71
|
+
penpair inspect <shape-id> --page <name> [--all] [--fields geometry,text,tokens] [--cursor <cursor>]
|
|
40
72
|
penpair tree --page <name> [--depth 3] [--prefix <text>]
|
|
41
73
|
penpair audit --page <name> --prefix <frame name start> [--steps 0,2,4,8] [--ignore <name starts>]
|
|
42
|
-
penpair
|
|
74
|
+
penpair baselines coverage [--json] offline case matrix: approved intent, missing, unsupported and unknown
|
|
75
|
+
penpair kit check / recipes [--include @kit] offline compilation and recipe metadata
|
|
76
|
+
penpair run-file <script.js> [--include helpers.js] trusted plugin body; read declaration or reviewed inspect/apply/readback
|
|
77
|
+
penpair tokens dump typed token sets and themes as JSON
|
|
78
|
+
penpair tokens add-set|set|remove|promote inspect token edits; --apply writes once
|
|
43
79
|
penpair tokens diff <before.json> <after.json> --theme <name>
|
|
44
80
|
penpair export <shapeId> <out.png> [--page <name>] [--scale 1]
|
|
81
|
+
penpair export --name <exact-name> --page <name> <out.png>
|
|
82
|
+
penpair shots --page <name> [--prefix <prefix>] [--out design/shots]
|
|
83
|
+
penpair backup file <new.penpot> try a bounded full-file export, without replacing an existing archive
|
|
45
84
|
@@INSTANCE@@Skills (the rules an AI agent follows, shipped with penpair):
|
|
46
85
|
penpair skills list them
|
|
47
|
-
penpair skills install [--to
|
|
86
|
+
penpair skills install [--to <folder>] [--only <name>] [--force] [--agents AGENTS.md] [--dry-run] copy them into the project and make them discoverable
|
|
48
87
|
Snippets (the built-in ones and the project's own, listed under "snippets" in penpair.config.json):
|
|
49
88
|
penpair snippets what can be run, with its parameters
|
|
50
89
|
penpair snippets check validate the project's snippets without a connection
|
|
@@ -54,6 +93,9 @@ Hand a page's design to a check (Reflection or any image comparison):
|
|
|
54
93
|
a manifest and ready-made Reflection cases. Looks first; --apply writes new images;
|
|
55
94
|
--update also replaces images whose design changed (approving the new design)
|
|
56
95
|
Changes in a Penpot file (inspect first; nothing is written without --apply):
|
|
96
|
+
penpair onboard [--json] colleague setup checklist, offline
|
|
97
|
+
penpair runs [run-id] list recovery runs or inspect one, offline
|
|
98
|
+
(write commands support --resume <run-id> --apply and optional --checkpoint)
|
|
57
99
|
penpair archive --page <name> --name <archive board> --keep <prefixes> --parts <prefixes> [--x 9000] [--apply]
|
|
58
100
|
penpair --version`;
|
|
59
101
|
const usageLocalInstance = `An instance on this computer (one Penpot per project, configured in penpair.config.json):
|
|
@@ -69,21 +111,46 @@ const usageConnection = `Connecting to a Penpot (one project, configured in penp
|
|
|
69
111
|
penpair init --external <origin> use a Penpot someone else runs (a team server or https://design.penpot.app)
|
|
70
112
|
penpair doctor [--json] what is up, what is not, and what to do about it (--json for scripts and agents)
|
|
71
113
|
`;
|
|
72
|
-
const usage = (hasLocal, adapters = []) => usageHead.replace("@@INSTANCE@@", usageConnection + (hasLocal ? usageLocalInstance : usageNoLocalInstance)).replace("@@SETUP@@", hasLocal ? " penpair setup --docker Penpot on this computer in Docker\n" : "") + adapterUsage(adapters);
|
|
114
|
+
const usage = (hasLocal, adapters = [], configured = []) => usageHead.replace("@@INSTANCE@@", usageConnection + (hasLocal ? usageLocalInstance : usageNoLocalInstance)).replace("@@SETUP@@", hasLocal ? " penpair setup --docker Penpot on this computer in Docker\n" : "") + adapterUsage(adapters) + (configured.length ? `\nConfigured executable extensions (not loaded for built-in help):\n${configured.map((name) => ` ${JSON.stringify(name)}`).join("\n")}\nUse the extension command to load its trusted project code.\n` : "");
|
|
73
115
|
/** The commands that adapters from penpair.config.json add, after the core's own. */
|
|
74
116
|
const adapterUsage = (adapters) => adapters.length === 0 ? "" : `\nAdapters (listed under "adapters" in penpair.config.json):\n${adapters.flatMap((a) => a.commands.flatMap((c) => c.usage.map((line) => ` ${line}`))).join("\n")}\n`;
|
|
75
117
|
const list = (value) => (value ?? "").split(",").map((v) => v.trim()).filter(Boolean);
|
|
118
|
+
const commandProjects = new AsyncLocalStorage();
|
|
119
|
+
const targetSignature = (config) => JSON.stringify({ instance: config.instance, file: config.file, profile: "profile" in config ? config.profile : null, ports: "ports" in config ? config.ports : null });
|
|
120
|
+
async function loadCommandConfig(root) {
|
|
121
|
+
const config = await loadConfig(root), pinned = commandProjects.getStore()?.pinnedConfig;
|
|
122
|
+
if (pinned && targetSignature(config) !== targetSignature(pinned))
|
|
123
|
+
throw new Error("Pinned project target changed; restart and review the server configuration");
|
|
124
|
+
return config;
|
|
125
|
+
}
|
|
126
|
+
const projectRoot = () => commandProjects.getStore()?.root ?? process.cwd();
|
|
127
|
+
const projectEndpoint = async (root) => {
|
|
128
|
+
const config = await loadOptionalConfig(root);
|
|
129
|
+
return resolveEndpoint({ envFiles: [path.join(root, ".env.local")], credentialRoot: root, credential: config?.connection?.credential, globalConfig: !commandProjects.getStore()?.projectEndpointOnly });
|
|
130
|
+
};
|
|
76
131
|
async function main(argv, cli) {
|
|
77
|
-
const { values, positionals } =
|
|
132
|
+
const { values, positionals } = parseCommandArgs({
|
|
78
133
|
args: argv,
|
|
79
134
|
allowPositionals: true,
|
|
80
135
|
options: {
|
|
136
|
+
include: { type: "string", multiple: true },
|
|
137
|
+
rules: { type: "boolean" }, "token-source": { type: "string" },
|
|
138
|
+
adapters: { type: "boolean" }, print: { type: "boolean" },
|
|
139
|
+
note: { type: "string" }, by: { type: "string" }, "case": { type: "string" }, "force-reason": { type: "string" },
|
|
140
|
+
from: { type: "string" }, map: { type: "string" }, frame: { type: "string" },
|
|
141
|
+
inspect: { type: "string" }, readback: { type: "string" },
|
|
81
142
|
page: { type: "string" },
|
|
143
|
+
out: { type: "string" },
|
|
144
|
+
fields: { type: "string" }, cursor: { type: "string" }, all: { type: "boolean" },
|
|
145
|
+
"max-nodes": { type: "string" }, "response-bytes": { type: "string" }, "time-budget-ms": { type: "string" }, "document-bytes": { type: "string" },
|
|
146
|
+
"max-calls": { type: "string" }, "max-total-bytes": { type: "string" }, "capture-timeout-ms": { type: "string" },
|
|
82
147
|
depth: { type: "string" },
|
|
148
|
+
limit: { type: "string" },
|
|
83
149
|
prefix: { type: "string" },
|
|
84
150
|
steps: { type: "string" },
|
|
85
151
|
ignore: { type: "string" },
|
|
86
152
|
theme: { type: "string" },
|
|
153
|
+
type: { type: "string" }, value: { type: "string" }, description: { type: "string" },
|
|
87
154
|
scale: { type: "string" },
|
|
88
155
|
name: { type: "string" },
|
|
89
156
|
keep: { type: "string" },
|
|
@@ -96,12 +163,23 @@ async function main(argv, cli) {
|
|
|
96
163
|
gitignore: { type: "boolean" },
|
|
97
164
|
yes: { type: "boolean" },
|
|
98
165
|
"any-instance": { type: "boolean" },
|
|
166
|
+
"any-file": { type: "boolean" },
|
|
167
|
+
"pin-file": { type: "boolean" },
|
|
168
|
+
standalone: { type: "boolean" },
|
|
169
|
+
"builtins-only": { type: "boolean" },
|
|
170
|
+
agents: { type: "string" },
|
|
171
|
+
"dry-run": { type: "boolean" },
|
|
99
172
|
web: { type: "boolean" },
|
|
100
173
|
docker: { type: "boolean" },
|
|
101
174
|
replace: { type: "boolean" },
|
|
102
175
|
update: { type: "boolean" },
|
|
103
176
|
json: { type: "boolean" },
|
|
104
177
|
deep: { type: "boolean" },
|
|
178
|
+
integrity: { type: "boolean" },
|
|
179
|
+
kit: { type: "boolean" },
|
|
180
|
+
resume: { type: "string" },
|
|
181
|
+
checkpoint: { type: "boolean" },
|
|
182
|
+
"force-lock": { type: "boolean" },
|
|
105
183
|
force: { type: "boolean" },
|
|
106
184
|
to: { type: "string" },
|
|
107
185
|
only: { type: "string", multiple: true },
|
|
@@ -112,36 +190,210 @@ async function main(argv, cli) {
|
|
|
112
190
|
},
|
|
113
191
|
});
|
|
114
192
|
const [command, ...rest] = positionals;
|
|
193
|
+
const inspectionFlags = ["cursor", "all", "out", "max-nodes", "response-bytes", "time-budget-ms", "document-bytes", "max-calls", "max-total-bytes", "capture-timeout-ms"];
|
|
194
|
+
const flags = {
|
|
195
|
+
formats: ["out"],
|
|
196
|
+
snapshot: ["write", "out", "from"],
|
|
197
|
+
approve: ["page", "frame", "note", "by", "case", "write"],
|
|
198
|
+
spec: ["from", "map", "out", "page", "frame"],
|
|
199
|
+
pages: [], tree: ["page", "depth", "prefix", "limit"], audit: ["page", "prefix", "steps", "ignore", "rules", "from", "token-source"], inspect: ["page", "depth", "fields", ...inspectionFlags], tokens: rest[0] === "dump" ? inspectionFlags : rest[0] === "diff" ? ["theme"] : ["type", "value", "description", "apply", "resume", "checkpoint", "force-lock"], export: ["page", "scale", "name"], shots: ["page", "prefix", "out", "scale"],
|
|
200
|
+
archive: ["page", "name", "keep", "parts", "x", "apply", "resume", "checkpoint", "force-lock"],
|
|
201
|
+
kit: ["include"], recipes: ["include"],
|
|
202
|
+
"run-file": ["include", "inspect", "readback", "page", "param", "apply", "resume", "checkpoint", "force-lock"],
|
|
203
|
+
run: ["builtins-only", "param", "apply", "resume", "checkpoint", "force-lock"], baselines: ["apply", "update", "force-reason"], probe: ["deep", "write", "integrity", "kit"],
|
|
204
|
+
skills: ["to", "only", "force", "agents", "dry-run"], snippets: ["builtins-only"], init: ["instance", "port", "gitignore", "external"], setup: ["agents", "web", "docker", "external", "instance", "port"],
|
|
205
|
+
connect: ["replace", "pin-file", "any-file"], doctor: [], up: [], down: [], open: ["print"], status: ["adapters"], backup: [], restore: ["yes"],
|
|
206
|
+
};
|
|
207
|
+
const network = ["snapshot", "approve", "spec", "inspect", "pages", "tree", "audit", "tokens", "export", "shots", "archive", "run", "run-file", "baselines", "probe", "backup", "doctor"];
|
|
208
|
+
const allowed = new Set(["help", "version", "json", ...(flags[command ?? ""] ?? []), ...(network.includes(command ?? "") ? ["any-instance", "any-file", "standalone"] : [])]);
|
|
209
|
+
for (const key of Object.keys(values))
|
|
210
|
+
if (!allowed.has(key))
|
|
211
|
+
throw new Error(`--${key} is not supported by ${command || "this command"}`);
|
|
212
|
+
for (const [key, min, max, integer] of [["depth", 0, command === "inspect" ? 10000 : 100, true], ["limit", 1, 1000, true], ["scale", 0.01, 8, false], ["x", -10000000, 10000000, false], ["port", 1024, 65535, true]]) {
|
|
213
|
+
const raw = values[key];
|
|
214
|
+
if (raw === undefined)
|
|
215
|
+
continue;
|
|
216
|
+
const number = Number(raw);
|
|
217
|
+
if (!raw.trim() || !Number.isFinite(number) || number < min || number > max || integer && !Number.isInteger(number))
|
|
218
|
+
throw new Error(`--${key} must be ${integer ? "an integer" : "a number"} from ${min} to ${max}`);
|
|
219
|
+
}
|
|
220
|
+
if (command === "export" && (rest.length !== (values.name ? 1 : 2) || values.name && !values.page))
|
|
221
|
+
throw new CommandArgumentError("Export requires an ID and output path, or --name with an explicit --page and one output path");
|
|
222
|
+
if (command === "shots" && (!values.page || rest.length))
|
|
223
|
+
throw new CommandArgumentError("Shots requires --page and supports optional --prefix/--out");
|
|
224
|
+
let tokenEdit;
|
|
225
|
+
if (command === "tokens" && !["dump", "diff"].includes(rest[0] ?? "")) {
|
|
226
|
+
const [kind, set, name] = rest;
|
|
227
|
+
if (kind === "add-set" && rest.length === 2)
|
|
228
|
+
tokenEdit = { kind, set: set };
|
|
229
|
+
else if (kind === "remove" && rest.length === 3)
|
|
230
|
+
tokenEdit = { kind, set: set, name: name };
|
|
231
|
+
else if (kind === "promote" && rest.length === 3)
|
|
232
|
+
tokenEdit = { kind, set: set, to: name };
|
|
233
|
+
else if (kind === "set" && rest.length === 3 && values.type && values.value) {
|
|
234
|
+
let value;
|
|
235
|
+
try {
|
|
236
|
+
value = JSON.parse(values.value);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
throw new CommandArgumentError("--value must be JSON: quote strings or supply a composite object");
|
|
240
|
+
}
|
|
241
|
+
tokenEdit = { kind, set: set, token: { name: name, type: values.type, value: value, description: values.description ?? "" } };
|
|
242
|
+
}
|
|
243
|
+
else
|
|
244
|
+
throw new CommandArgumentError("Usage: tokens add-set SET; tokens set SET NAME --type TYPE --value JSON; tokens remove SET NAME; tokens promote SOURCE DESTINATION");
|
|
245
|
+
if (kind !== "set" && [values.type, values.value, values.description].some(v => v !== undefined))
|
|
246
|
+
throw new CommandArgumentError("Token value options apply only to tokens set");
|
|
247
|
+
tokenEdit = validateTokenEdit(tokenEdit);
|
|
248
|
+
}
|
|
249
|
+
let inspectionRequest;
|
|
250
|
+
if (command === "inspect" || command === "tokens" && rest[0] === "dump") {
|
|
251
|
+
if (rest.length !== 1 || command === "inspect" && !values.page)
|
|
252
|
+
throw new CommandArgumentError("Inspect requires one shape ID and --page; tokens dump accepts no extra positionals");
|
|
253
|
+
if (values.cursor && values.all || values.cursor && values.out || command === "inspect" && values.out && !values.all)
|
|
254
|
+
throw new CommandArgumentError("Use --all for a complete output file; --cursor selects one continuation chunk");
|
|
255
|
+
if ((values.cursor || command === "inspect" && !values.all) && [values["max-calls"], values["max-total-bytes"], values["capture-timeout-ms"]].some(value => value !== undefined))
|
|
256
|
+
throw new CommandArgumentError("Collection budgets require a full capture rather than one cursor chunk");
|
|
257
|
+
const numeric = (key) => values[key] === undefined ? undefined : Number(values[key]);
|
|
258
|
+
const query = { kind: command === "inspect" ? "shape" : "tokens", shapeId: command === "inspect" ? rest[0] : undefined, page: values.page,
|
|
259
|
+
fields: values.fields?.split(",").map(field => field.trim()), depth: numeric("depth"), maxNodes: numeric("max-nodes"), responseBytes: numeric("response-bytes"), timeBudgetMs: numeric("time-budget-ms"), documentBytes: numeric("document-bytes"), cursor: values.cursor };
|
|
260
|
+
validateInspectionQuery(query);
|
|
261
|
+
inspectionRequest = { query, budget: validateCaptureBudget({ maxCalls: numeric("max-calls"), maxTotalBytes: numeric("max-total-bytes"), timeoutMs: numeric("capture-timeout-ms") }) };
|
|
262
|
+
}
|
|
263
|
+
if (values.kit && (!values.deep || !values.write))
|
|
264
|
+
throw new CommandArgumentError("--kit requires the explicit scratch probe --deep --write");
|
|
265
|
+
if (values.steps && values.steps.split(",").some((step) => !step.trim() || !Number.isFinite(Number(step)) || Number(step) < 0))
|
|
266
|
+
throw new Error("--steps requires finite nonnegative numbers");
|
|
267
|
+
if (command === "skills" && rest[0] !== "install" && [values.agents, values["dry-run"], values.to, values.only, values.force].some((value) => value !== undefined))
|
|
268
|
+
throw new Error("Installation flags require skills install");
|
|
269
|
+
if ((values.resume || values.checkpoint || values["force-lock"]) && (!values.apply || !(["archive", "run", "run-file"].includes(command ?? "") || command === "tokens" && tokenEdit)))
|
|
270
|
+
throw new Error("--resume and --checkpoint require an operation command (archive/run/run-file/tokens) with --apply");
|
|
115
271
|
if (values.version) {
|
|
116
|
-
|
|
272
|
+
output.log(version);
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
275
|
+
if (!command || command === "help" || values.help) {
|
|
276
|
+
output.log(usage(Boolean(cli.local), cli.adapters, cli.configuredAdapters));
|
|
117
277
|
return 0;
|
|
118
278
|
}
|
|
119
|
-
if (
|
|
120
|
-
|
|
279
|
+
if (command === "audit" && values.from) {
|
|
280
|
+
if (rest.length || values.page || values.prefix || values.steps || values.ignore || values["any-file"] || values["any-instance"] || values.standalone)
|
|
281
|
+
throw new CommandArgumentError("audit --from INSPECTION [--token-source TOKENS] uses configured advisory rules offline");
|
|
282
|
+
const config = await loadCommandConfig(projectRoot());
|
|
283
|
+
if (!config.audit)
|
|
284
|
+
throw new Error("Configure explicit audit rules first");
|
|
285
|
+
const report = auditDesignRules(await readDesignJson(projectRoot(), values.from), config.audit, values["token-source"] ? await readDesignJson(projectRoot(), values["token-source"]) : undefined);
|
|
286
|
+
output.result(report);
|
|
287
|
+
output.log(JSON.stringify(report, null, 2));
|
|
288
|
+
return report.complete ? 0 : 3;
|
|
289
|
+
}
|
|
290
|
+
if (command === "formats") {
|
|
291
|
+
const [action, input] = rest;
|
|
292
|
+
if (action === "schema" && rest.length === 2 && !values.out && artifactSchemas[input]) {
|
|
293
|
+
output.result(artifactSchemas[input]);
|
|
294
|
+
output.log(JSON.stringify(artifactSchemas[input], null, 2));
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
if (!["import-tokens", "export-tokens", "migrate-config"].includes(action ?? "") || rest.length !== 2)
|
|
298
|
+
throw new CommandArgumentError("formats schema NAME | import-tokens FILE | export-tokens FILE | migrate-config FILE [--out NEW_FILE]");
|
|
299
|
+
const raw = await readDesignJson(projectRoot(), input, 8 * 1024 * 1024);
|
|
300
|
+
const result = action === "import-tokens" ? importDtcg(raw) : action === "export-tokens" ? exportDtcg(raw) : migrateProjectConfig(raw);
|
|
301
|
+
if (values.out) {
|
|
302
|
+
const target = await resolveProjectOutput(projectRoot(), values.out);
|
|
303
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
304
|
+
await atomicWriteFile(target, JSON.stringify(result, null, 2) + "\n");
|
|
305
|
+
}
|
|
306
|
+
output.result(result);
|
|
307
|
+
output.log(JSON.stringify(result, null, 2));
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
if (command === "snapshot" && rest[0] === "diff") {
|
|
311
|
+
if (rest.length !== 2 || !values.from || values.write || values["any-file"] || values["any-instance"] || values.standalone)
|
|
312
|
+
throw new CommandArgumentError("snapshot diff BEFORE_FOLDER --from AFTER_FOLDER [--out FILE]");
|
|
313
|
+
const diff = diffStructuralSnapshots(await readStructuralSnapshot(projectRoot(), rest[1]), await readStructuralSnapshot(projectRoot(), values.from));
|
|
314
|
+
if (values.out)
|
|
315
|
+
await writeDesignSpec(projectRoot(), values.out, renderStructuralDiff(diff));
|
|
316
|
+
output.result(diff);
|
|
317
|
+
if (!values.json)
|
|
318
|
+
output.log(renderStructuralDiff(diff));
|
|
319
|
+
return !diff.complete ? 3 : diff.totalChanges ? 2 : 0;
|
|
320
|
+
}
|
|
321
|
+
if (command === "snapshot" && (rest.length || values.from))
|
|
322
|
+
throw new CommandArgumentError("snapshot [--write] [--out FOLDER], or snapshot diff BEFORE --from AFTER");
|
|
323
|
+
if (command === "spec" && values.from) {
|
|
324
|
+
if (rest.length > 1 || rest.length === 1 && rest[0] !== "check" || values.page || values.frame || values.standalone || values["any-file"] || values["any-instance"])
|
|
325
|
+
throw new CommandArgumentError("Offline spec requires --from INSPECTION [--map MAPPING] [--out FILE]; spec check also requires --out");
|
|
326
|
+
const inspection = await readDesignJson(projectRoot(), values.from);
|
|
327
|
+
const mapping = values.map ? validateComponentMap(await readDesignJson(projectRoot(), values.map, 1024 * 1024)) : emptyComponentMap;
|
|
328
|
+
if (rest[0] === "check") {
|
|
329
|
+
if (!values.out)
|
|
330
|
+
throw new CommandArgumentError("spec check requires --out and --from");
|
|
331
|
+
const file = await import("./project-input.js").then(m => m.resolveProjectInput(projectRoot(), values.out));
|
|
332
|
+
const report = checkDesignSpec(await readFile(file, "utf8"), inspection, mapping);
|
|
333
|
+
output.result(report);
|
|
334
|
+
output.log(JSON.stringify(report));
|
|
335
|
+
return report.current ? 0 : 2;
|
|
336
|
+
}
|
|
337
|
+
const spec = buildDesignSpec(inspection, mapping), markdown = renderDesignSpec(spec);
|
|
338
|
+
if (values.out)
|
|
339
|
+
await writeDesignSpec(projectRoot(), values.out, markdown);
|
|
340
|
+
output.result(spec);
|
|
341
|
+
if (!values.json)
|
|
342
|
+
output.log(markdown);
|
|
343
|
+
return 0;
|
|
344
|
+
}
|
|
345
|
+
if (command === "spec" && (rest.length || !values.page || !values.frame))
|
|
346
|
+
throw new CommandArgumentError("Live spec requires --page and --frame ID (or --from for offline generation)");
|
|
347
|
+
if (command === "baselines" && rest[0] === "coverage") {
|
|
348
|
+
if (rest.length !== 1 || values.apply || values.update || values["any-file"] || values["any-instance"] || values.standalone)
|
|
349
|
+
throw new CommandArgumentError("baselines coverage is an offline report with optional --json");
|
|
350
|
+
const config = await loadCommandConfig(projectRoot());
|
|
351
|
+
if (!config.baselines)
|
|
352
|
+
throw new Error("Configure baselines before reporting coverage");
|
|
353
|
+
const report = await readBaselineCoverage(projectRoot(), config.baselines, config.file?.id);
|
|
354
|
+
output.result(report);
|
|
355
|
+
if (!values.json)
|
|
356
|
+
output.log(JSON.stringify(report, null, 2));
|
|
357
|
+
return report.designCasesComplete ? 0 : 3;
|
|
358
|
+
}
|
|
359
|
+
if (command === "kit" || command === "recipes") {
|
|
360
|
+
if (command === "kit" ? rest.length !== 1 || rest[0] !== "check" : rest.length)
|
|
361
|
+
throw new CommandArgumentError("Use kit check, or recipes, with optional ordered --include aliases");
|
|
362
|
+
const kits = await loadKits(projectRoot(), values.include ?? ["@kit"]);
|
|
363
|
+
output.result(command === "kit" ? checkKits(kits) : { recipes: kits.flatMap(value => value.recipes.map(recipe => ({ ...recipe, include: value.include }))), includes: kits.map(value => value.include) });
|
|
364
|
+
if (!values.json)
|
|
365
|
+
output.log(command === "kit" ? JSON.stringify(checkKits(kits), null, 2) : kits.flatMap(value => value.recipes.map(recipe => `${value.include} ${recipe.name}: ${recipe.description}`)).join("\n"));
|
|
121
366
|
return 0;
|
|
122
367
|
}
|
|
123
368
|
if (command === "tokens" && rest[0] === "diff") {
|
|
124
369
|
const [, beforeFile, afterFile] = rest;
|
|
125
370
|
if (!beforeFile || !afterFile || !values.theme)
|
|
126
371
|
throw new Error("usage: penpair tokens diff <before.json> <after.json> --theme <name>");
|
|
127
|
-
const load = async (file) => JSON.parse(await readFile(file, "utf8"));
|
|
372
|
+
const load = async (file) => parseTokenDump(JSON.parse(await readFile(file, "utf8")));
|
|
128
373
|
const diff = diffEffective(effectiveValues(await load(beforeFile), values.theme), effectiveValues(await load(afterFile), values.theme));
|
|
129
|
-
|
|
130
|
-
return diff.added.length + diff.removed.length + diff.changed.length === 0 ? 0 : 2;
|
|
374
|
+
output.log(JSON.stringify(diff, null, 2));
|
|
375
|
+
return !diff.complete ? 3 : diff.added.length + diff.removed.length + diff.changed.length === 0 ? 0 : 2;
|
|
376
|
+
}
|
|
377
|
+
const root = projectRoot();
|
|
378
|
+
const log = (message) => output.error(`… ${message}`);
|
|
379
|
+
if (values.standalone) {
|
|
380
|
+
const inspection = ["inspect", "pages", "tree", "audit", "export", "shots"].includes(command) || (command === "probe" && !values.deep) || (command === "tokens" && rest[0] === "dump");
|
|
381
|
+
if (await loadOptionalConfig(root) || !inspection || values.apply || values.write)
|
|
382
|
+
throw new ConfigError("--standalone is only for built-in inspection commands in a folder without penpair.config.json. It cannot bypass a project or run writes/snippets.");
|
|
131
383
|
}
|
|
132
|
-
const root = process.cwd();
|
|
133
|
-
const log = (message) => console.error(`… ${message}`);
|
|
134
384
|
/** What `doctor` needs to look for the editor and plugin, or nothing when no endpoint is set yet. */
|
|
135
385
|
async function mcpProbeFor() {
|
|
136
386
|
try {
|
|
137
|
-
const endpoint = await
|
|
387
|
+
const endpoint = await projectEndpoint(root);
|
|
388
|
+
commandRedactor(endpointRedactor(endpoint));
|
|
138
389
|
return {
|
|
139
390
|
origin: endpoint.description.origin,
|
|
140
391
|
connect: async () => {
|
|
141
392
|
const client = await connect(endpoint);
|
|
142
393
|
try {
|
|
143
|
-
const { result } = await executeCode(client, "return { file: penpot.currentFile.name };", { retries: 0 });
|
|
144
|
-
|
|
394
|
+
const { result } = await executeCode(client, "return { file: penpot.currentFile.name, fileId: penpot.currentFile.id };", { retries: 0, effect: "read" });
|
|
395
|
+
const actual = result;
|
|
396
|
+
return { file: String(actual.file ?? "?"), fileId: actual.fileId };
|
|
145
397
|
}
|
|
146
398
|
finally {
|
|
147
399
|
await client.close();
|
|
@@ -153,30 +405,28 @@ async function main(argv, cli) {
|
|
|
153
405
|
return undefined;
|
|
154
406
|
}
|
|
155
407
|
}
|
|
156
|
-
async function registryFor() {
|
|
408
|
+
async function registryFor(requested) {
|
|
157
409
|
const registry = builtInRegistry();
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
catch {
|
|
163
|
-
// not a penpair project: only the built-in snippets
|
|
164
|
-
}
|
|
410
|
+
if (values["builtins-only"] || requested && registry.list().some((entry) => entry.name === requested))
|
|
411
|
+
return { registry, problems: [], files: [] };
|
|
412
|
+
const snippets = (await loadOptionalConfig(root))?.snippets;
|
|
165
413
|
const report = await loadProjectSnippets(root, snippets, registry);
|
|
166
414
|
return { registry, problems: report.problems, files: report.files };
|
|
167
415
|
}
|
|
168
416
|
const connectIo = () => ({
|
|
169
|
-
say: (line) =>
|
|
417
|
+
say: (line) => output.log(line),
|
|
170
418
|
askHidden,
|
|
171
419
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
420
|
+
confirmFile: async (file) => values["pin-file"] === true || /^(y|yes)$/i.test(await askLine(`Pin this project's file to "${file.name}" (${file.id})? [y/N] `, "n")),
|
|
172
421
|
openFile: async (url) => {
|
|
173
422
|
const endpoint = await resolveEndpoint({ environment: { PENPAIR_MCP_URL: url }, envFiles: [] });
|
|
423
|
+
commandRedactor(endpointRedactor(endpoint));
|
|
174
424
|
try {
|
|
175
425
|
const client = await connect(endpoint);
|
|
176
426
|
try {
|
|
177
|
-
const { result } = await executeCode(client, "return { file: penpot.currentFile.name, pages: penpot.currentFile.pages.map((p) => p.name) };", { retries: 0 });
|
|
427
|
+
const { result } = await executeCode(client, "return { file: penpot.currentFile.name, fileId: penpot.currentFile.id, pages: penpot.currentFile.pages.map((p) => p.name) };", { retries: 0, effect: "read" });
|
|
178
428
|
const read = result;
|
|
179
|
-
return { file: String(read.file ?? "?"), pages: read.pages ?? [] };
|
|
429
|
+
return { file: String(read.file ?? "?"), fileId: read.fileId, pages: read.pages ?? [] };
|
|
180
430
|
}
|
|
181
431
|
finally {
|
|
182
432
|
await client.close();
|
|
@@ -189,9 +439,9 @@ async function main(argv, cli) {
|
|
|
189
439
|
});
|
|
190
440
|
if (command === "setup" || command === "connect") {
|
|
191
441
|
if (command === "connect") {
|
|
192
|
-
const project = await
|
|
193
|
-
await connectProject(root, project, connectIo(), { replace: values.replace === true });
|
|
194
|
-
|
|
442
|
+
const project = await loadCommandConfig(root);
|
|
443
|
+
await connectProject(root, project, connectIo(), { replace: values.replace === true, anyFile: values["any-file"] === true });
|
|
444
|
+
output.log("Next: `penpair doctor`, then `penpair skills install` if you have not yet.");
|
|
195
445
|
return 0;
|
|
196
446
|
}
|
|
197
447
|
let where;
|
|
@@ -207,85 +457,160 @@ async function main(argv, cli) {
|
|
|
207
457
|
log,
|
|
208
458
|
local: cli.local,
|
|
209
459
|
installSkills: async () => {
|
|
210
|
-
const report = await installSkills({ root });
|
|
211
|
-
|
|
460
|
+
const report = await installSkills({ root, agents: values.agents });
|
|
461
|
+
output.log(`Installed ${report.installed.length} rule file(s) in ${report.to}/ (${report.unchanged.length} already there).`);
|
|
462
|
+
if (report.instructions?.path)
|
|
463
|
+
output.log(`Instructions: ${report.instructions.path} (${report.instructions.status})`);
|
|
464
|
+
else
|
|
465
|
+
output.log(`Choose an instruction destination with skills install --agents <path>. Rows:\n${report.agentsRows.join("\n")}`);
|
|
212
466
|
},
|
|
213
467
|
});
|
|
214
468
|
return 0;
|
|
215
469
|
}
|
|
216
470
|
if (command === "skills") {
|
|
217
471
|
if (rest[0] === "install") {
|
|
218
|
-
const report = await installSkills({ root, to: values.to, only: values.only, force: values.force });
|
|
472
|
+
const report = await installSkills({ root, to: values.to, only: values.only, force: values.force, agents: values.agents, dryRun: values["dry-run"] });
|
|
473
|
+
output.result(report);
|
|
474
|
+
if (report.instructions?.path)
|
|
475
|
+
output.log(`instructions: ${report.instructions.path} (${report.instructions.status})`);
|
|
476
|
+
if (report.dryRun) {
|
|
477
|
+
for (const file of report.wouldInstall ?? [])
|
|
478
|
+
output.log(`would write ${file}`);
|
|
479
|
+
for (const file of report.wouldLink ?? [])
|
|
480
|
+
output.log(`would link ${file}`);
|
|
481
|
+
if (report.instructions?.diff)
|
|
482
|
+
output.log(report.instructions.diff);
|
|
483
|
+
}
|
|
484
|
+
if (report.instructions?.status === "choice-required")
|
|
485
|
+
output.error("Both AGENTS.md and CLAUDE.md exist; choose --agents <path> explicitly. Instruction files were not changed.");
|
|
219
486
|
for (const file of report.installed)
|
|
220
|
-
|
|
487
|
+
output.log(`wrote ${file}`);
|
|
221
488
|
for (const file of report.kept)
|
|
222
|
-
|
|
489
|
+
output.log(`kept ${file} (differs from the shipped one; --force replaces it, except the project-owned files)`);
|
|
223
490
|
for (const link of report.linked)
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
491
|
+
output.log(`linked ${link}`);
|
|
492
|
+
output.log(`${report.unchanged.length} file(s) already up to date`);
|
|
493
|
+
if (!report.instructions?.path)
|
|
494
|
+
output.log("\nChoose --agents <path> to integrate these rows:\n" + report.agentsRows.join("\n"));
|
|
227
495
|
for (const old of report.legacy)
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return 0;
|
|
496
|
+
output.log(`\nnote ${old.folder} is the old name of ${old.renamedTo}. ${old.carriedFacts ? "Your project-facts.md was copied to the new folder. " : ""}Delete the old folder and its links (.agents/skills/${path.posix.basename(old.folder)}, .claude/skills/${path.posix.basename(old.folder)}) and update its row in your AGENTS.md.`);
|
|
497
|
+
output.log("\nThen fill in references/project-facts.md in penpair-design: it is yours and is never replaced.");
|
|
498
|
+
return report.instructions?.status === "choice-required" ? 3 : 0;
|
|
231
499
|
}
|
|
232
|
-
|
|
233
|
-
|
|
500
|
+
const bundled = await listBundledSkills();
|
|
501
|
+
output.result({ skills: bundled });
|
|
502
|
+
for (const name of bundled)
|
|
503
|
+
output.log(name);
|
|
234
504
|
return 0;
|
|
235
505
|
}
|
|
236
506
|
if (command === "snippets") {
|
|
237
507
|
const { registry, problems, files } = await registryFor();
|
|
238
508
|
if (rest[0] === "check") {
|
|
239
509
|
const found = [...problems, ...checkProjectSnippets(registry)];
|
|
510
|
+
output.result({ files, problems: found, valid: found.length === 0 });
|
|
240
511
|
const project = registry.list().filter((entry) => entry.source === "project");
|
|
241
|
-
|
|
512
|
+
output.log(`${project.length} project snippet(s) from ${files.length} file(s)`);
|
|
242
513
|
for (const problem of found)
|
|
243
|
-
|
|
514
|
+
output.log(`FAIL ${problem}`);
|
|
244
515
|
if (found.length === 0)
|
|
245
|
-
|
|
516
|
+
output.log("ok every snippet is valid plugin code and small enough");
|
|
246
517
|
return found.length === 0 ? 0 : 1;
|
|
247
518
|
}
|
|
519
|
+
output.result({ snippets: registry.list().map(({ name, description, kind, params, source }) => ({ name, description, kind, params, source })), problems, complete: problems.length === 0 });
|
|
248
520
|
for (const entry of registry.list()) {
|
|
249
|
-
|
|
521
|
+
output.log(`${entry.name.padEnd(14)} ${entry.kind === "write" ? "changes " : "reads "} ${entry.source === "project" ? "[project] " : ""}${entry.description}`);
|
|
250
522
|
for (const p of entry.params)
|
|
251
|
-
|
|
523
|
+
output.log(`${"".padEnd(14)} --param ${p.name}=<${p.type}>${p.required ? " (required)" : p.default !== undefined ? ` (default ${JSON.stringify(p.default)})` : ""}${p.description ? ` ${p.description}` : ""}`);
|
|
252
524
|
}
|
|
253
525
|
for (const problem of problems)
|
|
254
|
-
|
|
526
|
+
output.error(`warning: ${problem}`);
|
|
255
527
|
return 0;
|
|
256
528
|
}
|
|
257
529
|
if (command === "init") {
|
|
258
530
|
const result = await init(root, { instance: values.instance, port: values.port ? Number(values.port) : undefined, gitignore: values.gitignore, external: values.external }, { local: cli.local });
|
|
531
|
+
output.result({ createdConfig: result.createdConfig, createdSecrets: result.createdSecrets, instance: result.config.instance, files: result.files, gitignoreAdded: result.gitignoreAdded });
|
|
259
532
|
if (isExternal(result.config)) {
|
|
260
|
-
|
|
261
|
-
|
|
533
|
+
output.log(`${result.createdConfig ? "created" : "kept"} penpair.config.json (external Penpot at ${result.config.profile.origin})`);
|
|
534
|
+
output.log("next: in Penpot switch on Account, Integrations, MCP Server and copy the address; open a design file and choose File, MCP Server, Connect; then run penpair connect (takes the address without showing it)");
|
|
262
535
|
return 0;
|
|
263
536
|
}
|
|
264
|
-
|
|
265
|
-
|
|
537
|
+
output.log(`${result.createdConfig ? "created" : "kept"} penpair.config.json (instance ${result.config.instance}, Penpot ${result.config.penpotVersion}, port ${result.config.ports.frontend})`);
|
|
538
|
+
output.log(`wrote ${result.config.directory}/docker-compose.yaml; secrets ${result.createdSecrets ? "generated" : "kept"} in ${result.config.directory}/.env`);
|
|
266
539
|
if (!result.gitignoreAdded)
|
|
267
|
-
|
|
268
|
-
|
|
540
|
+
output.log(`keep ${result.config.directory}/ out of git (add it to .gitignore, or run init with --gitignore)`);
|
|
541
|
+
output.log("next: penpair up, then penpair connect");
|
|
269
542
|
return 0;
|
|
270
543
|
}
|
|
271
544
|
/** Prints the checks one per line, or as JSON for scripts and agents. The key file check is added to both kinds of project. */
|
|
272
545
|
async function report(checks, mcp) {
|
|
273
546
|
const keyCheck = await keyFileCheck(root, nodeRunner);
|
|
274
|
-
const
|
|
547
|
+
const backupCheck = await backupNoteCheck(root, await loadCommandConfig(root));
|
|
548
|
+
const all = [...checks, ...(!mcp ? [{ check: "editor and plugin", ok: false, detail: "No resolved project credential/connection; editor identity was not observed", remedy: "Configure the private credential source, connect the intended editor, then rerun doctor." }] : []), ...(keyCheck ? [keyCheck] : []), ...(backupCheck ? [backupCheck] : [])];
|
|
275
549
|
const ok = all.every((check) => check.ok);
|
|
276
550
|
if (values.json) {
|
|
277
|
-
|
|
551
|
+
output.log(JSON.stringify({ ok, mcpConfigured: Boolean(mcp), file: all.find((check) => check.file)?.file, checks: all }, null, 2));
|
|
278
552
|
return ok ? 0 : 1;
|
|
279
553
|
}
|
|
280
554
|
for (const check of all)
|
|
281
|
-
|
|
555
|
+
output.log(`${check.warning ? "WARN" : check.ok ? "ok " : "FAIL"} ${check.check}: ${check.detail}${"remedy" in check && check.remedy ? `\n → ${check.remedy}` : ""}`);
|
|
282
556
|
if (!mcp)
|
|
283
|
-
|
|
557
|
+
output.log("skip editor and plugin: no key yet. Run `penpair connect`.");
|
|
284
558
|
return ok ? 0 : 1;
|
|
285
559
|
}
|
|
560
|
+
if (command === "open") {
|
|
561
|
+
if (rest.length)
|
|
562
|
+
throw new CommandArgumentError("open accepts only --print for a URL preview");
|
|
563
|
+
const config = await loadCommandConfig(root), url = values.print ? projectFileUrl(config) : await openProject(config, openInBrowser);
|
|
564
|
+
output.result({ url, opened: !values.print });
|
|
565
|
+
if (!values.json)
|
|
566
|
+
output.log(url);
|
|
567
|
+
return 0;
|
|
568
|
+
}
|
|
569
|
+
if (command === "status") {
|
|
570
|
+
if (rest.length)
|
|
571
|
+
throw new CommandArgumentError("status accepts optional --adapters");
|
|
572
|
+
const config = await loadCommandConfig(root);
|
|
573
|
+
let endpoint;
|
|
574
|
+
try {
|
|
575
|
+
endpoint = await projectEndpoint(root);
|
|
576
|
+
commandRedactor(endpointRedactor(endpoint));
|
|
577
|
+
assertEndpointIsInstance(endpoint.description, config);
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
if (endpoint)
|
|
581
|
+
throw error;
|
|
582
|
+
}
|
|
583
|
+
const report = await projectStatus(root, config, {
|
|
584
|
+
...(endpoint ? { connection: async () => guardFile(await connect(endpoint), config.file, { mode: "strict" }), redact: endpointRedactor(endpoint) } : {}),
|
|
585
|
+
...(values.adapters ? { adapterStatus: async () => {
|
|
586
|
+
const loaded = await loadAdapters(root, config.adapters ?? []);
|
|
587
|
+
if (loaded.problems.length)
|
|
588
|
+
throw new Error(loaded.problems.join("; "));
|
|
589
|
+
const context = await adapterContext(root, []);
|
|
590
|
+
return (await Promise.all(loaded.adapters.map(async (a) => a.status ? a.status(context) : [{ id: a.name, label: a.name, state: "unknown", detail: "No status hook" }]))).flat();
|
|
591
|
+
} } : {}),
|
|
592
|
+
});
|
|
593
|
+
let localState = null;
|
|
594
|
+
if (!isExternal(config)) {
|
|
595
|
+
if (cli.local) {
|
|
596
|
+
const state = await cli.local.status({ root, config, runner: nodeRunner, log });
|
|
597
|
+
localState = state;
|
|
598
|
+
report.lines.push({ id: "local-runtime", label: "Local containers", state: state.frontendReachable && state.backendReady ? "ok" : "failed", detail: `${state.url}; backend ${state.backendReady ? "ready" : "not ready"}` });
|
|
599
|
+
}
|
|
600
|
+
else
|
|
601
|
+
report.lines.push({ id: "local-runtime", label: "Local containers", state: "unknown", detail: "Install the local-instance command package for container diagnostics" });
|
|
602
|
+
report.complete = report.lines.every(line => line.state !== "unknown");
|
|
603
|
+
report.ok = report.complete && report.lines.every(line => line.state === "ok");
|
|
604
|
+
}
|
|
605
|
+
output.result({ ...report, localState });
|
|
606
|
+
if (!values.json)
|
|
607
|
+
for (const line of report.lines)
|
|
608
|
+
output.log(`${line.state.padEnd(7)} ${line.label}: ${line.detail}`);
|
|
609
|
+
return report.lines.some(l => l.state === "failed") ? 1 : report.ok ? 0 : 3;
|
|
610
|
+
}
|
|
286
611
|
const lifecycle = new Set(["up", "down", "status", "doctor", "backup", "restore"]);
|
|
287
|
-
if (lifecycle.has(command)) {
|
|
288
|
-
const project = await
|
|
612
|
+
if (lifecycle.has(command) && !(command === "backup" && rest[0] === "file")) {
|
|
613
|
+
const project = await loadCommandConfig(root);
|
|
289
614
|
if (isExternal(project) && command !== "doctor")
|
|
290
615
|
requireLocal(project);
|
|
291
616
|
if (isExternal(project)) {
|
|
@@ -296,19 +621,19 @@ async function main(argv, cli) {
|
|
|
296
621
|
const c = { root, config: requireLocal(project), runner: nodeRunner, log };
|
|
297
622
|
if (command === "up") {
|
|
298
623
|
const result = await local.up(c);
|
|
299
|
-
|
|
624
|
+
output.log(result.ready ? `Penpot is up at ${result.url}. Create an account and open a file, then run \`penpair connect\`.` : `The containers started but ${result.url} does not answer yet. Run \`penpair status\`.`);
|
|
300
625
|
return result.ready ? 0 : 1;
|
|
301
626
|
}
|
|
302
627
|
if (command === "down") {
|
|
303
628
|
await local.down(c);
|
|
304
|
-
|
|
629
|
+
output.log("stopped; the data volumes are kept");
|
|
305
630
|
return 0;
|
|
306
631
|
}
|
|
307
632
|
if (command === "status") {
|
|
308
633
|
const state = await local.status(c);
|
|
309
634
|
for (const s of state.services)
|
|
310
|
-
|
|
311
|
-
|
|
635
|
+
output.log(`${s.state.padEnd(10)} ${s.service}${s.health ? ` (${s.health})` : ""}`);
|
|
636
|
+
output.log(`${state.frontendReachable ? "answers" : "does not answer"} at ${state.url}; backend ${state.backendReady ? "ready" : "not ready"}`);
|
|
312
637
|
return state.frontendReachable && state.backendReady ? 0 : 1;
|
|
313
638
|
}
|
|
314
639
|
if (command === "doctor") {
|
|
@@ -321,11 +646,11 @@ async function main(argv, cli) {
|
|
|
321
646
|
throw new Error("usage: penpair backup verify <backup directory>");
|
|
322
647
|
const result = await local.verifyBackup(c, rest[1]);
|
|
323
648
|
for (const check of result.checks)
|
|
324
|
-
|
|
649
|
+
output.log(`${check.ok ? "ok " : "FAIL"} ${check.check}: ${check.detail}`);
|
|
325
650
|
return result.ok ? 0 : 1;
|
|
326
651
|
}
|
|
327
652
|
const result = await local.backup(c);
|
|
328
|
-
|
|
653
|
+
output.log(`backup in ${result.directory} (${result.manifest.counts.files} files, ${result.manifest.counts.storageObjects} assets). It holds account data: never commit it.`);
|
|
329
654
|
return 0;
|
|
330
655
|
}
|
|
331
656
|
if (command === "restore") {
|
|
@@ -333,35 +658,60 @@ async function main(argv, cli) {
|
|
|
333
658
|
throw new Error("usage: penpair restore <backup directory> --yes");
|
|
334
659
|
const result = await local.restore(c, rest[0], { yes: values.yes === true });
|
|
335
660
|
for (const check of result.checks)
|
|
336
|
-
|
|
661
|
+
output.log(`${check.ok ? "ok " : "FAIL"} ${check.check}: ${check.detail}`);
|
|
337
662
|
if (result.safetyBackup)
|
|
338
|
-
|
|
663
|
+
output.log(`safety backup of the previous data: ${result.safetyBackup}`);
|
|
339
664
|
return result.ok ? 0 : 1;
|
|
340
665
|
}
|
|
341
666
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
667
|
+
if (command === "run-file" && rest.length !== 1)
|
|
668
|
+
throw new CommandArgumentError("run-file requires exactly one plugin body path");
|
|
669
|
+
const projectConfig = values.standalone ? await loadOptionalConfig(root) : await loadCommandConfig(root);
|
|
670
|
+
if (values.standalone && projectConfig)
|
|
671
|
+
throw new ConfigError("--standalone cannot bypass a project configuration.");
|
|
672
|
+
let preparedFile;
|
|
673
|
+
if (command === "run-file") {
|
|
674
|
+
const params = Object.create(null);
|
|
675
|
+
for (const pair of values.param ?? []) {
|
|
676
|
+
const at = pair.indexOf("=");
|
|
677
|
+
if (at < 1)
|
|
678
|
+
throw new CommandArgumentError("--param needs key=value");
|
|
679
|
+
const key = pair.slice(0, at);
|
|
680
|
+
if (Object.hasOwn(params, key))
|
|
681
|
+
throw new CommandArgumentError("Duplicate script parameter: " + key);
|
|
682
|
+
params[key] = pair.slice(at + 1);
|
|
351
683
|
}
|
|
352
|
-
|
|
353
|
-
|
|
684
|
+
const aliases = (values.include ?? []).filter(include => include.startsWith("@"));
|
|
685
|
+
const kits = aliases.length ? await loadKits(root, aliases) : [];
|
|
686
|
+
const virtualIncludes = Object.fromEntries(kits.map(value => [value.include, value.source]));
|
|
687
|
+
preparedFile = await prepareFileScript(root, rest[0], { virtualIncludes, include: values.include, inspect: values.inspect, readback: values.readback, page: values.page, params });
|
|
688
|
+
if (preparedFile.kind === "read" && values.apply)
|
|
689
|
+
throw new CommandArgumentError("Read files do not require or accept --apply");
|
|
354
690
|
}
|
|
691
|
+
const endpoint = await projectEndpoint(root);
|
|
692
|
+
commandRedactor(endpointRedactor(endpoint));
|
|
693
|
+
if (projectConfig && !values["any-instance"])
|
|
694
|
+
assertEndpointIsInstance(endpoint.description, projectConfig);
|
|
695
|
+
const guarded = async () => guardFile(await connect(endpoint), projectConfig?.file, {
|
|
696
|
+
anyFile: values["any-file"] === true,
|
|
697
|
+
mode: !commandProjects.getStore()?.strictFileGuard && (["pages", "tree", "audit", "tokens"].includes(command) || command === "probe" && !values.deep && !values.integrity) ? "warn" : "strict",
|
|
698
|
+
warn: log,
|
|
699
|
+
});
|
|
355
700
|
if (command === "probe") {
|
|
356
701
|
if (values.deep && !values.write)
|
|
357
702
|
throw new Error("probe --deep creates a temporary board, a token set and two components in the open file and removes them again. Run it on a scratch file, with --write to confirm.");
|
|
358
|
-
|
|
703
|
+
const capabilityReport = await probe(endpoint, guarded, { integrity: values.integrity === true });
|
|
704
|
+
output.log(JSON.stringify(capabilityReport, null, 2));
|
|
705
|
+
if (capabilityReport.integrity?.supported && capabilityReport.integrity.valid === false)
|
|
706
|
+
return 1;
|
|
359
707
|
if (values.deep) {
|
|
360
|
-
const client = await
|
|
708
|
+
const client = await guarded();
|
|
361
709
|
try {
|
|
362
|
-
const report = await runDeepCheck(client, { onWait: (m) =>
|
|
363
|
-
|
|
364
|
-
|
|
710
|
+
const report = await runDeepCheck(client, { onWait: (m) => output.error(`… ${m}`) });
|
|
711
|
+
const kitReport = values.kit && report.ok ? await runKitSmoke(client, root, { apply: true, onStep: log, redact: endpointRedactor(endpoint) }) : undefined;
|
|
712
|
+
output.result({ ...report, ...(kitReport ? { kit: kitReport } : {}), capabilities: capabilityReport });
|
|
713
|
+
output.log(`\n${formatDeepReport(report)}`);
|
|
714
|
+
return report.ok && (!kitReport || kitReport.verified) ? 0 : 1;
|
|
365
715
|
}
|
|
366
716
|
finally {
|
|
367
717
|
await client.close();
|
|
@@ -369,16 +719,45 @@ async function main(argv, cli) {
|
|
|
369
719
|
}
|
|
370
720
|
return 0;
|
|
371
721
|
}
|
|
372
|
-
const client = await
|
|
373
|
-
|
|
722
|
+
const client = await guarded();
|
|
723
|
+
commandRedactor(endpointRedactor(endpoint));
|
|
724
|
+
const options = { signal: commandSignal(), redact: endpointRedactor(endpoint), onWait: (m) => output.error(`… ${m}`), onStep: (m) => output.error(`… ${m}`) };
|
|
725
|
+
const writeOptions = (operation) => ({
|
|
726
|
+
...options, apply: values.apply === true, redact: (text) => redactEndpoint(text, endpoint),
|
|
727
|
+
...(values.apply ? { journal: {
|
|
728
|
+
root, forceLock: values["force-lock"] === true, runId: values.resume, resume: Boolean(values.resume), requireCheckpoint: values.checkpoint === true,
|
|
729
|
+
...(values.checkpoint ? { checkpoint: (runId) => createCheckpoint(client, { label: operation, runId }, { apply: true }) } : {}),
|
|
730
|
+
} } : {}),
|
|
731
|
+
});
|
|
374
732
|
try {
|
|
733
|
+
if (command === "backup" && rest[0] === "file") {
|
|
734
|
+
const out = rest[1];
|
|
735
|
+
if (!out || !out.endsWith(".penpot"))
|
|
736
|
+
throw new Error("usage: penpair backup file <new.penpot>");
|
|
737
|
+
const archive = await exportFileArchive(client);
|
|
738
|
+
if (archive.status !== "exported") {
|
|
739
|
+
output.error(redactEndpoint(archive.message, endpoint));
|
|
740
|
+
return 1;
|
|
741
|
+
}
|
|
742
|
+
await atomicWriteFile(out, archive.bytes);
|
|
743
|
+
output.log(JSON.stringify({ file: out, fileId: archive.fileId, revn: archive.revn, bytes: archive.bytes.length, sha256: archive.sha256, restoreVerified: false }, null, 2));
|
|
744
|
+
output.error("Archive bytes saved. Restore is not verified: import into a scratch project and compare content and renders.");
|
|
745
|
+
return 0;
|
|
746
|
+
}
|
|
747
|
+
if (command === "run-file") {
|
|
748
|
+
const report = await runFileScript(client, preparedFile, writeOptions("run-file"));
|
|
749
|
+
output.log(JSON.stringify(report, null, 2));
|
|
750
|
+
if (report.phase === "inspected")
|
|
751
|
+
output.error("Nothing was written. Review the inspection, then use --apply.");
|
|
752
|
+
return report.complete === false ? 3 : report.phase === "applied" && !report.verified ? 1 : 0;
|
|
753
|
+
}
|
|
375
754
|
if (command === "run") {
|
|
376
755
|
const [name] = rest;
|
|
377
756
|
if (!name)
|
|
378
757
|
throw new Error("usage: penpair run <snippet> [--param key=value ...] [--apply]");
|
|
379
|
-
const { registry, problems } = await registryFor();
|
|
758
|
+
const { registry, problems } = await registryFor(name);
|
|
380
759
|
for (const problem of problems)
|
|
381
|
-
|
|
760
|
+
output.error(`warning: ${problem}`);
|
|
382
761
|
const raw = {};
|
|
383
762
|
for (const pair of values.param ?? []) {
|
|
384
763
|
const at = pair.indexOf("=");
|
|
@@ -390,68 +769,190 @@ async function main(argv, cli) {
|
|
|
390
769
|
raw[key] = earlier === undefined ? value : [...(Array.isArray(earlier) ? earlier : [earlier]), value];
|
|
391
770
|
}
|
|
392
771
|
const entry = registry.get(name);
|
|
393
|
-
|
|
394
|
-
|
|
772
|
+
if (entry.kind === "read" && (values.resume || values.checkpoint))
|
|
773
|
+
throw new Error("Read snippets have no write run to resume or checkpoint");
|
|
774
|
+
const result = await registry.run(client, name, coerceParams(entry.params, raw), writeOptions(name));
|
|
775
|
+
output.log(JSON.stringify(result, null, 2));
|
|
395
776
|
if (entry.kind === "write" && !values.apply)
|
|
396
|
-
|
|
777
|
+
output.error("Nothing was written. Run again with --apply to do it.");
|
|
397
778
|
return result.phase === "applied" && !result.verified ? 1 : 0;
|
|
398
779
|
}
|
|
399
780
|
if (command === "baselines") {
|
|
400
|
-
const config = (await
|
|
781
|
+
const config = (await loadCommandConfig(root)).baselines;
|
|
401
782
|
if (!config)
|
|
402
783
|
throw new Error('penpair.config.json has no "baselines" block. Add {"page": "<page>", "root": "tests/baselines", "frames": [{"frame": "<frame name>", "id": "<file name>", "route": "<route>"}]}; see docs/guide.md.');
|
|
403
|
-
const report = await exportBaselines(client, root, config, { ...options, apply: values.apply === true, update: values.update === true });
|
|
784
|
+
const report = await exportBaselines(client, root, config, { ...options, apply: values.apply === true, update: values.update === true, forceReason: values["force-reason"] });
|
|
785
|
+
output.result(report);
|
|
404
786
|
for (const r of report.results)
|
|
405
|
-
|
|
787
|
+
output.log(`${r.status.padEnd(9)} ${r.id.padEnd(28)} ${r.frame}${r.imageWidth ? ` (${r.imageWidth}x${r.imageHeight})` : ""}${r.written ? " written" : ""}`);
|
|
406
788
|
for (const file of report.wrote)
|
|
407
|
-
|
|
789
|
+
output.log(`wrote ${file}`);
|
|
408
790
|
const changed = report.results.filter((r) => r.status === "changed" && !r.written);
|
|
409
791
|
if (!values.apply)
|
|
410
|
-
|
|
792
|
+
output.error("Nothing was written. Run again with --apply to write new images.");
|
|
411
793
|
if (changed.length > 0)
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
794
|
+
output.error(`${changed.length} design(s) differ from the image in the folder. That is a change of the approved design: review it in Penpot, then run again with --apply --update to approve it.`);
|
|
795
|
+
for (const row of report.coverage.rows)
|
|
796
|
+
if (row.status !== "approved-design-intent")
|
|
797
|
+
output.error(`coverage ${row.status}: ${row.requirement.route} ${row.requirement.theme}/${row.requirement.state} ${row.requirement.viewport.width}x${row.requirement.viewport.height}${row.reason ? ": " + row.reason : ""}`);
|
|
798
|
+
if (!report.complete || report.coverage.configured && !report.coverage.designCasesComplete)
|
|
799
|
+
return 3;
|
|
415
800
|
return changed.length > 0 ? 2 : 0;
|
|
416
801
|
}
|
|
802
|
+
if (command === "audit" && (values.rules || projectConfig?.audit)) {
|
|
803
|
+
if (!values.page || !projectConfig?.audit || rest.length || values.prefix || values.steps || values.ignore || values["token-source"])
|
|
804
|
+
throw new CommandArgumentError("Configured audit requires --page and explicit config rules, without legacy filters");
|
|
805
|
+
const shapes = await captureInspection(client, { kind: "shape", shapeId: "@page-root", page: values.page, depth: 10000, maxNodes: 50000 });
|
|
806
|
+
const tokens = projectConfig.audit.contrast ? await captureInspection(client, { kind: "tokens" }) : undefined;
|
|
807
|
+
if (!shapes.complete || tokens && !tokens.complete) {
|
|
808
|
+
output.result({ complete: false, reason: "Audit inspection incomplete" });
|
|
809
|
+
return 3;
|
|
810
|
+
}
|
|
811
|
+
const report = auditDesignRules(shapes, projectConfig.audit, tokens);
|
|
812
|
+
output.result(report);
|
|
813
|
+
output.log(JSON.stringify(report, null, 2));
|
|
814
|
+
return report.complete ? 0 : 3;
|
|
815
|
+
}
|
|
417
816
|
if (command === "pages")
|
|
418
|
-
|
|
817
|
+
output.log(JSON.stringify(await runSnippet(client, pages(), options), null, 2));
|
|
419
818
|
else if (command === "tree") {
|
|
420
819
|
if (!values.page)
|
|
421
820
|
throw new Error("--page is required");
|
|
422
|
-
const record = await runOnPage(client, tree({ page: values.page, depth: values.depth ? Number(values.depth) : 3, namePrefix: values.prefix }), values.page, options);
|
|
423
|
-
|
|
821
|
+
const record = await runOnPage(client, tree({ page: values.page, depth: values.depth ? Number(values.depth) : 3, limit: values.limit ? Number(values.limit) : 400, namePrefix: values.prefix }), values.page, options);
|
|
822
|
+
if (values.json)
|
|
823
|
+
output.log(JSON.stringify(record));
|
|
824
|
+
else
|
|
825
|
+
output.log((record.lines ?? []).join("\n"));
|
|
826
|
+
if (record.truncated === true) {
|
|
827
|
+
output.error("Tree is a limited preview; use inspect --all for full text or narrow the tree scope.");
|
|
828
|
+
return 3;
|
|
829
|
+
}
|
|
424
830
|
}
|
|
425
831
|
else if (command === "audit") {
|
|
426
832
|
if (!values.page || !values.prefix)
|
|
427
833
|
throw new Error("--page and --prefix are required");
|
|
428
834
|
const steps = values.steps ? values.steps.split(",").map(Number) : undefined;
|
|
429
835
|
const record = await runOnPage(client, audit({ page: values.page, framePrefix: values.prefix, spacingSteps: steps, ignoreUnbound: list(values.ignore) }), values.page, options);
|
|
430
|
-
|
|
836
|
+
output.log(JSON.stringify(record, null, 2));
|
|
837
|
+
if (record.truncated === true)
|
|
838
|
+
return 3;
|
|
431
839
|
}
|
|
432
|
-
else if (command === "
|
|
433
|
-
const
|
|
434
|
-
|
|
840
|
+
else if (command === "snapshot") {
|
|
841
|
+
const snapshot = await captureStructuralSnapshot(client, options);
|
|
842
|
+
if (values.write) {
|
|
843
|
+
const manifest = await writeStructuralSnapshot(root, values.out ?? "design/snapshots", snapshot, endpointRedactor(endpoint));
|
|
844
|
+
output.result(manifest);
|
|
845
|
+
output.log(JSON.stringify(manifest, null, 2));
|
|
846
|
+
}
|
|
847
|
+
else {
|
|
848
|
+
output.result(snapshot);
|
|
849
|
+
output.log(JSON.stringify(snapshot, null, 2));
|
|
850
|
+
}
|
|
851
|
+
return 0;
|
|
852
|
+
}
|
|
853
|
+
else if (command === "approve") {
|
|
854
|
+
if (rest.length || !values.page || !values.frame || values.write && (!values.note?.trim() || !values.by?.trim()) || !values.write && (values.note || values.by))
|
|
855
|
+
throw new CommandArgumentError("approve --page PAGE --frame ID [--case CASE_ID] previews evidence; --write requires --note exact-owner-words and --by identity");
|
|
856
|
+
let intent;
|
|
857
|
+
if (values.case) {
|
|
858
|
+
const baseline = projectConfig?.baselines, wanted = baseline?.frames.find(f => f.id === values.case);
|
|
859
|
+
if (!wanted?.case || baseline?.page !== values.page)
|
|
860
|
+
throw new Error("Approval case must reference an explicitly configured frame on this page");
|
|
861
|
+
const found = await findShapesByName(client, wanted.frame, values.page);
|
|
862
|
+
if (found.matches.length !== 1 || found.matches[0].id !== values.frame)
|
|
863
|
+
throw new Error("Approval frame ID does not match the configured case");
|
|
864
|
+
intent = wanted.case;
|
|
865
|
+
}
|
|
866
|
+
const captured = await exportShapePng(client, values.frame, { ...options, page: values.page, stable: true });
|
|
867
|
+
if (!captured.stable)
|
|
868
|
+
throw new Error("Approval export did not stabilize");
|
|
869
|
+
const sha256 = artifactSha(captured.png);
|
|
870
|
+
const report = values.write ? await recordApproval(root, captured, { note: values.note, by: values.by }, intent, endpointRedactor(endpoint)) : { schemaVersion: 1, kind: "approval-candidate", capture: captured.capture, sha256, fingerprint: approvalFingerprint(captured.capture, sha256, intent), recorded: false };
|
|
871
|
+
output.result(report);
|
|
872
|
+
output.log(JSON.stringify(report, null, 2));
|
|
873
|
+
return 0;
|
|
874
|
+
}
|
|
875
|
+
else if (command === "spec") {
|
|
876
|
+
const capture = await captureInspection(client, { shapeId: values.frame, page: values.page, kind: "shape" });
|
|
877
|
+
if (!capture.complete || capture.kind !== "shape-inspection") {
|
|
878
|
+
output.result(capture);
|
|
879
|
+
return 3;
|
|
880
|
+
}
|
|
881
|
+
const mapping = values.map ? validateComponentMap(await readDesignJson(root, values.map, 1024 * 1024)) : emptyComponentMap;
|
|
882
|
+
const spec = buildDesignSpec(capture, mapping), markdown = renderDesignSpec(spec);
|
|
883
|
+
if (values.out)
|
|
884
|
+
await writeDesignSpec(root, values.out, markdown, endpointRedactor(endpoint));
|
|
885
|
+
output.result(spec);
|
|
886
|
+
if (!values.json)
|
|
887
|
+
output.log(markdown);
|
|
888
|
+
return 0;
|
|
889
|
+
}
|
|
890
|
+
else if (command === "inspect" || command === "tokens" && rest[0] === "dump") {
|
|
891
|
+
const { query, budget } = inspectionRequest;
|
|
892
|
+
const result = values.cursor || command === "inspect" && !values.all ? await inspectChunk(client, query) : await captureInspection(client, query, budget);
|
|
893
|
+
const redact = endpointRedactor(endpoint), fragments = endpointRedactor(endpoint, true);
|
|
894
|
+
let altered = redact(JSON.stringify(result)) !== JSON.stringify(result);
|
|
895
|
+
const chunks = result.kind === "inspection-chunk" ? [result] : result.kind === "inspection-partial" ? result.chunks : [];
|
|
896
|
+
for (const chunk of chunks) {
|
|
897
|
+
const safe = fragments(chunk.chunk);
|
|
898
|
+
if (safe !== chunk.chunk) {
|
|
899
|
+
altered = true;
|
|
900
|
+
chunk.chunk = safe;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
if (altered) {
|
|
904
|
+
result.complete = false;
|
|
905
|
+
Object.assign(result, { redacted: true, reason: "Output required credential redaction; source evidence is incomplete" });
|
|
906
|
+
if ("nextCursor" in result)
|
|
907
|
+
result.nextCursor = null;
|
|
908
|
+
}
|
|
909
|
+
output.log(JSON.stringify(result, null, 2));
|
|
910
|
+
if (!result.complete)
|
|
911
|
+
return 3;
|
|
912
|
+
if (values.out) {
|
|
913
|
+
const serialized = JSON.stringify(result, null, 2) + "\n";
|
|
914
|
+
const target = await resolveProjectOutput(root, values.out);
|
|
915
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
916
|
+
await atomicWriteFile(target, serialized, { replace: true });
|
|
917
|
+
}
|
|
918
|
+
return 0;
|
|
435
919
|
}
|
|
436
920
|
else if (command === "export") {
|
|
437
|
-
const [shapeId, out] = rest;
|
|
438
|
-
if (!
|
|
439
|
-
throw new Error("usage: penpair export <shapeId> <out.png>
|
|
440
|
-
const
|
|
441
|
-
await
|
|
442
|
-
|
|
921
|
+
const [shapeId, out] = values.name ? [undefined, rest[0]] : rest;
|
|
922
|
+
if (!out || rest.length !== (values.name ? 1 : 2) || values.name && !values.page)
|
|
923
|
+
throw new Error("usage: penpair export <shapeId> <out.png>, or export --name <exact-name> --page <name> <out.png>");
|
|
924
|
+
const exportOptions = { ...options, page: values.page, scale: values.scale ? Number(values.scale) : 1 };
|
|
925
|
+
const result = values.name ? await exportShapeByName(client, values.name, values.page, exportOptions) : await exportShapePng(client, shapeId, exportOptions);
|
|
926
|
+
if (!result.stable)
|
|
927
|
+
throw new PenpairError("incomplete-output", "Export did not stabilize; previous output was preserved");
|
|
928
|
+
await atomicWriteFile(out, result.png, { replace: true });
|
|
929
|
+
output.log(JSON.stringify({ file: out, bytes: result.png.length, stable: result.stable, exports: result.exports, layout: result.layout, capture: result.capture }, null, 2));
|
|
930
|
+
}
|
|
931
|
+
else if (command === "shots") {
|
|
932
|
+
if (!values.page || rest.length)
|
|
933
|
+
throw new Error("usage: penpair shots --page <name> [--prefix <prefix>] [--out design/shots]");
|
|
934
|
+
const report = await captureShots(client, root, { page: values.page, prefix: values.prefix, out: values.out }, { ...options, scale: values.scale ? Number(values.scale) : 1 });
|
|
935
|
+
output.log(JSON.stringify(report, null, 2));
|
|
936
|
+
return report.complete ? 0 : 3;
|
|
937
|
+
}
|
|
938
|
+
else if (command === "tokens" && tokenEdit) {
|
|
939
|
+
const report = await runOperation(client, tokenEditOperation, tokenEdit, writeOptions("tokens-edit"));
|
|
940
|
+
output.log(JSON.stringify(report, null, 2));
|
|
941
|
+
if (!values.apply)
|
|
942
|
+
output.error("Nothing was written. Run again with --apply to execute this token edit.");
|
|
943
|
+
return report.phase === "applied" && !report.verified ? 1 : 0;
|
|
443
944
|
}
|
|
444
945
|
else if (command === "archive") {
|
|
445
946
|
if (!values.page || !values.name)
|
|
446
947
|
throw new Error("--page and --name are required");
|
|
447
|
-
const report = await runOperation(client, archiveOperation, { page: values.page, archiveName: values.name, keepPrefixes: list(values.keep), partPrefixes: list(values.parts), x: values.x ? Number(values.x) : 9000 },
|
|
448
|
-
|
|
948
|
+
const report = await runOperation(client, archiveOperation, { page: values.page, archiveName: values.name, keepPrefixes: list(values.keep), partPrefixes: list(values.parts), x: values.x ? Number(values.x) : 9000 }, writeOptions("archive"));
|
|
949
|
+
output.log(JSON.stringify(report, null, 2));
|
|
449
950
|
if (report.phase === "inspected")
|
|
450
|
-
|
|
951
|
+
output.error("Nothing was written. Run again with --apply to do it.");
|
|
451
952
|
return report.phase === "applied" && !report.verified ? 1 : 0;
|
|
452
953
|
}
|
|
453
954
|
else {
|
|
454
|
-
|
|
955
|
+
output.log(usage(Boolean(cli.local), cli.adapters, cli.configuredAdapters));
|
|
455
956
|
return 1;
|
|
456
957
|
}
|
|
457
958
|
return 0;
|
|
@@ -461,18 +962,23 @@ async function main(argv, cli) {
|
|
|
461
962
|
}
|
|
462
963
|
}
|
|
463
964
|
/** Runs one command of an adapter with a context that can reach the project's Penpot. */
|
|
464
|
-
async function
|
|
465
|
-
const config = await
|
|
965
|
+
async function adapterContext(root, args) {
|
|
966
|
+
const config = await loadCommandConfig(root);
|
|
466
967
|
const rawConfig = await readRawConfig(root);
|
|
968
|
+
let redact = (text) => redactEndpoint(text);
|
|
467
969
|
const context = {
|
|
970
|
+
redact: (text) => redact(text),
|
|
468
971
|
root,
|
|
469
972
|
config,
|
|
470
973
|
rawConfig,
|
|
471
|
-
|
|
974
|
+
output,
|
|
975
|
+
log: (message) => output.error(`… ${message}`),
|
|
472
976
|
async withPenpot(work) {
|
|
473
|
-
const endpoint = await
|
|
977
|
+
const endpoint = await projectEndpoint(root);
|
|
978
|
+
redact = (text) => redactEndpoint(text, endpoint);
|
|
979
|
+
commandRedactor(endpointRedactor(endpoint));
|
|
474
980
|
assertEndpointIsInstance(endpoint.description, config);
|
|
475
|
-
const client = await connect(endpoint);
|
|
981
|
+
const client = guardFile(await connect(endpoint), config.file, { anyFile: args.includes("--any-file"), warn: context.log });
|
|
476
982
|
try {
|
|
477
983
|
return await work(client);
|
|
478
984
|
}
|
|
@@ -481,27 +987,152 @@ async function runAdapterCommand(command, root, args) {
|
|
|
481
987
|
}
|
|
482
988
|
},
|
|
483
989
|
};
|
|
484
|
-
return
|
|
990
|
+
return context;
|
|
991
|
+
}
|
|
992
|
+
async function runAdapterCommand(command, root, args) {
|
|
993
|
+
const context = await adapterContext(root, args);
|
|
994
|
+
commandSignal()?.throwIfAborted();
|
|
995
|
+
return command.run(context, args.filter((arg) => arg !== "--any-file"));
|
|
485
996
|
}
|
|
486
997
|
/** Runs the command line and returns the exit code. The `penpair` command calls it with nothing extra; a package that adds a way to run Penpot calls it with its profile. */
|
|
487
|
-
|
|
998
|
+
async function runCliInternal(argv, options = {}) {
|
|
488
999
|
try {
|
|
489
|
-
const root =
|
|
1000
|
+
const root = projectRoot();
|
|
1001
|
+
if (argv[0] === "onboard") {
|
|
1002
|
+
if (argv.slice(1).some((arg) => arg !== "--json"))
|
|
1003
|
+
throw new Error("usage: penpair onboard [--json]");
|
|
1004
|
+
const report = onboardingChecklist(await loadCommandConfig(root));
|
|
1005
|
+
if (argv.includes("--json"))
|
|
1006
|
+
output.log(JSON.stringify(report, null, 2));
|
|
1007
|
+
else {
|
|
1008
|
+
output.log(`Join ${report.project}: ${report.origin}`);
|
|
1009
|
+
if (report.fileUrl)
|
|
1010
|
+
output.log(`File: ${report.fileUrl}`);
|
|
1011
|
+
report.steps.forEach((step, index) => output.log(`${index + 1}. ${step}`));
|
|
1012
|
+
report.warnings.forEach((warning) => output.log(`Note: ${warning}`));
|
|
1013
|
+
}
|
|
1014
|
+
return 0;
|
|
1015
|
+
}
|
|
1016
|
+
if (argv[0] === "runs") {
|
|
1017
|
+
if (argv.filter((arg) => arg !== "--json").length > 2 || argv[1]?.startsWith("--") && argv[1] !== "--json")
|
|
1018
|
+
throw new Error("usage: penpair runs [run-id]");
|
|
1019
|
+
output.log(JSON.stringify(argv[1] && argv[1] !== "--json" ? await readJournal(root, argv[1]) : await listJournals(root), null, 2));
|
|
1020
|
+
return 0;
|
|
1021
|
+
}
|
|
1022
|
+
if (argv[0] === "config") {
|
|
1023
|
+
if (argv[1] === "schema" && argv.slice(2).every((arg) => arg === "--json")) {
|
|
1024
|
+
output.log(JSON.stringify(projectConfigSchema, null, 2));
|
|
1025
|
+
return 0;
|
|
1026
|
+
}
|
|
1027
|
+
if (argv[1] !== "check" || argv.slice(2).some((arg) => arg !== "--json"))
|
|
1028
|
+
throw new ConfigError("usage: penpair config check [--json] | penpair config schema");
|
|
1029
|
+
try {
|
|
1030
|
+
await loadCommandConfig(root);
|
|
1031
|
+
}
|
|
1032
|
+
catch (error) {
|
|
1033
|
+
if (!argv.includes("--json") || !(error instanceof ConfigError))
|
|
1034
|
+
throw error;
|
|
1035
|
+
commandFailure(error);
|
|
1036
|
+
output.log(JSON.stringify({ valid: false, file: "penpair.config.json", error: { code: error.code, message: error.message } }));
|
|
1037
|
+
return 1;
|
|
1038
|
+
}
|
|
1039
|
+
output.log(argv.includes("--json") ? JSON.stringify({ valid: true, file: "penpair.config.json" }) : "ok penpair.config.json is valid (extension settings are validated by their adapters)");
|
|
1040
|
+
return 0;
|
|
1041
|
+
}
|
|
490
1042
|
const raw = await readRawConfig(root);
|
|
491
1043
|
const specifiers = Array.isArray(raw.adapters) ? raw.adapters.filter((entry) => typeof entry === "string") : [];
|
|
492
|
-
const loaded = options.adapters ? { adapters: options.adapters, problems: [] } : specifiers.length > 0 ? await loadAdapters(root, specifiers) : { adapters: [], problems: [] };
|
|
493
1044
|
const [first] = argv;
|
|
1045
|
+
const builtIn = first === undefined || first.startsWith("--") || reservedCommands.includes(first);
|
|
1046
|
+
if (builtIn)
|
|
1047
|
+
return await main(argv, { ...options, configuredAdapters: specifiers });
|
|
1048
|
+
// Dynamic discovery is necessary only for a requested extension command; it executes trusted project modules.
|
|
1049
|
+
const loaded = options.adapters ? { adapters: options.adapters, problems: [] } : await loadAdapters(root, specifiers);
|
|
494
1050
|
const adapterCommand = loaded.adapters.flatMap((adapter) => adapter.commands).find((command) => command.name === first);
|
|
495
1051
|
if (adapterCommand)
|
|
496
1052
|
return await runAdapterCommand(adapterCommand, root, argv.slice(1));
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
console.error(`warning: ${problem}`);
|
|
501
|
-
return await main(argv, { ...options, adapters: loaded.adapters });
|
|
1053
|
+
for (const problem of loaded.problems)
|
|
1054
|
+
output.error(`warning: ${problem}`);
|
|
1055
|
+
return await main(argv, { ...options, adapters: loaded.adapters, configuredAdapters: specifiers });
|
|
502
1056
|
}
|
|
503
1057
|
catch (error) {
|
|
504
|
-
|
|
505
|
-
|
|
1058
|
+
commandFailure(error);
|
|
1059
|
+
output.error(error instanceof PenpairError ? error.message : error instanceof ConfigError || error instanceof ConnectError || error instanceof LifecycleError || error instanceof RegistryError ? `penpair: ${error.message}` : `penpair: ${String(error?.message ?? error)}`);
|
|
1060
|
+
const value = error;
|
|
1061
|
+
return value.code === "write-uncertain" || value.outcome === "uncertain" ? 4 : value.code === "incomplete-output" ? 3 : value.code === "cancelled" ? 130 : value.code === "deadline" ? 124 : 1;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
export async function runCommand(argv, options = {}) {
|
|
1065
|
+
const args = [...argv.filter((arg) => arg !== "--json"), "--json"];
|
|
1066
|
+
return collectCommand(args[0] ?? "help", async () => {
|
|
1067
|
+
if (["setup", "connect"].includes(argv[0] ?? ""))
|
|
1068
|
+
throw new Error("Interactive setup/connect require a terminal; run without --json and use doctor --json afterwards");
|
|
1069
|
+
return commandProjects.run({ root: path.resolve(options.root ?? projectRoot()), projectEndpointOnly: options.projectEndpointOnly, pinnedConfig: options.pinnedConfig, strictFileGuard: options.strictFileGuard }, () => runCliInternal(args, options));
|
|
1070
|
+
}, { signal: options.signal });
|
|
1071
|
+
}
|
|
1072
|
+
async function runCliBound(argv, options = {}) {
|
|
1073
|
+
if (argv[0] === "mcp") {
|
|
1074
|
+
try {
|
|
1075
|
+
const parsed = parseCommandArgs({ args: argv.slice(1), allowPositionals: true, options: { root: { type: "string" }, "allow-writes": { type: "boolean" } } });
|
|
1076
|
+
if (!parsed.values.root || !path.isAbsolute(parsed.values.root) || parsed.positionals.length)
|
|
1077
|
+
throw new Error("mcp requires an explicit absolute --root PROJECT; optionally --allow-writes");
|
|
1078
|
+
const { serveMcp } = await import("./mcp-server.js");
|
|
1079
|
+
await serveMcp({ root: parsed.values.root, allowWrites: parsed.values["allow-writes"] === true, cli: options });
|
|
1080
|
+
return 0;
|
|
1081
|
+
}
|
|
1082
|
+
catch (error) {
|
|
1083
|
+
console.error(redactEndpoint(String(error.message)));
|
|
1084
|
+
return 1;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
const controller = new AbortController();
|
|
1088
|
+
const interrupt = () => controller.abort(new CommandInterruptedError("cancelled"));
|
|
1089
|
+
let timer;
|
|
1090
|
+
const args = [...argv];
|
|
1091
|
+
const timeoutAt = args.indexOf("--timeout-ms");
|
|
1092
|
+
let invalidTimeout = false;
|
|
1093
|
+
if (timeoutAt !== -1) {
|
|
1094
|
+
const milliseconds = Number(args[timeoutAt + 1]);
|
|
1095
|
+
invalidTimeout = !Number.isInteger(milliseconds) || milliseconds < 1 || milliseconds > 1_800_000 || args.indexOf("--timeout-ms", timeoutAt + 1) !== -1;
|
|
1096
|
+
args.splice(timeoutAt, 2);
|
|
1097
|
+
if (!invalidTimeout)
|
|
1098
|
+
timer = setTimeout(() => controller.abort(new CommandInterruptedError("deadline")), milliseconds);
|
|
1099
|
+
}
|
|
1100
|
+
const json = args.includes("--json");
|
|
1101
|
+
if (!json && ["setup", "connect"].includes(args[0] ?? "")) {
|
|
1102
|
+
if (timer)
|
|
1103
|
+
clearTimeout(timer);
|
|
1104
|
+
if (timeoutAt !== -1) {
|
|
1105
|
+
console.error("--timeout-ms is not supported for interactive setup/connect");
|
|
1106
|
+
return 1;
|
|
1107
|
+
}
|
|
1108
|
+
return withCommandSignal(controller.signal, () => runCliInternal(args, options));
|
|
1109
|
+
}
|
|
1110
|
+
process.on("SIGINT", interrupt);
|
|
1111
|
+
process.on("SIGTERM", interrupt);
|
|
1112
|
+
try {
|
|
1113
|
+
if (invalidTimeout) {
|
|
1114
|
+
const result = await collectCommand(args[0] ?? "help", async () => { throw new Error("--timeout-ms must be one integer from 1 to 1800000"); });
|
|
1115
|
+
if (json)
|
|
1116
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1117
|
+
else
|
|
1118
|
+
console.error(result.errors[0]?.message);
|
|
1119
|
+
return result.exitCode;
|
|
1120
|
+
}
|
|
1121
|
+
return await withCommandSignal(controller.signal, async () => {
|
|
1122
|
+
if (!json)
|
|
1123
|
+
return runCliInternal(args, options);
|
|
1124
|
+
const result = await runCommand(args, { ...options, signal: controller.signal });
|
|
1125
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1126
|
+
return result.exitCode;
|
|
1127
|
+
});
|
|
506
1128
|
}
|
|
1129
|
+
finally {
|
|
1130
|
+
if (timer)
|
|
1131
|
+
clearTimeout(timer);
|
|
1132
|
+
process.removeListener("SIGINT", interrupt);
|
|
1133
|
+
process.removeListener("SIGTERM", interrupt);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
export async function runCli(argv, options = {}) {
|
|
1137
|
+
return commandProjects.run({ root: path.resolve(options.root ?? projectRoot()), projectEndpointOnly: options.projectEndpointOnly, pinnedConfig: options.pinnedConfig, strictFileGuard: options.strictFileGuard }, () => runCliBound(argv, options));
|
|
507
1138
|
}
|