@penpair/core 0.1.0 → 0.1.2
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/README.md +9 -212
- package/dist/adapters.d.ts +49 -0
- package/dist/adapters.js +71 -0
- package/dist/cli-main.d.ts +10 -0
- package/dist/cli-main.js +507 -0
- package/dist/cli.js +2 -455
- package/dist/docker-profile.d.ts +114 -0
- package/dist/docker-profile.js +18 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +9 -1
- package/dist/instance/config.d.ts +3 -0
- package/dist/instance/config.js +4 -0
- package/dist/instance/init.d.ts +9 -6
- package/dist/instance/init.js +18 -58
- package/dist/instance/setup.d.ts +3 -0
- package/dist/instance/setup.js +9 -10
- package/dist/local-profile.d.ts +95 -0
- package/dist/local-profile.js +8 -0
- package/dist/skills.d.ts +8 -0
- package/dist/skills.js +16 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +4 -9
- package/skills/design-judgment/SKILL.md +1 -1
- package/skills/{penpot-design → penpair-design}/SKILL.md +2 -2
- package/skills/{penpot-design → penpair-design}/references/penpot-plugin-gotchas.md +2 -0
- package/skills/penpair-setup/SKILL.md +12 -12
- package/skills/penpair-setup/references/web-or-docker.md +2 -2
- package/docs/compatibility.md +0 -29
- package/docs/getting-started.md +0 -97
- package/docs/guide.md +0 -93
- package/docs/releasing.md +0 -45
- /package/skills/{penpot-design → penpair-design}/references/auto-layout.md +0 -0
- /package/skills/{penpot-design → penpair-design}/references/library-in-penpot.md +0 -0
- /package/skills/{penpot-design → penpair-design}/references/project-facts.md +0 -0
- /package/skills/{penpot-design → penpair-design}/references/write-protocol.md +0 -0
package/dist/cli-main.js
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import { writeFile, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { connect, executeCode } from "./client.js";
|
|
5
|
+
import { redactEndpoint, resolveEndpoint } from "./endpoint.js";
|
|
6
|
+
import { PenpairError } from "./errors.js";
|
|
7
|
+
import { exportShapePng } from "./export.js";
|
|
8
|
+
import { exportBaselines } from "./baselines.js";
|
|
9
|
+
import { archiveOperation, runOnPage, runOperation } from "./operation.js";
|
|
10
|
+
import { formatDeepReport, runDeepCheck } from "./deep-check.js";
|
|
11
|
+
import { probe } from "./probe.js";
|
|
12
|
+
import { installSkills, listBundledSkills } from "./skills.js";
|
|
13
|
+
import { checkProjectSnippets, loadProjectSnippets } from "./project-snippets.js";
|
|
14
|
+
import { builtInRegistry, coerceParams, RegistryError } from "./registry.js";
|
|
15
|
+
import { runSnippet } from "./snippet.js";
|
|
16
|
+
import { audit, pages, tokensDump, tree } from "./snippets/read.js";
|
|
17
|
+
import { diffEffective, effectiveValues } from "./tokens-diff.js";
|
|
18
|
+
import { ConfigError, isExternal, loadConfig, requireLocal } from "./instance/config.js";
|
|
19
|
+
import { init } from "./instance/init.js";
|
|
20
|
+
import { ConnectError, connectProject, keyFileCheck } from "./instance/connect.js";
|
|
21
|
+
import { hostedOrigin, setup } from "./instance/setup.js";
|
|
22
|
+
import { askHidden, askLine, openInBrowser } from "./instance/tty.js";
|
|
23
|
+
import { loadAdapters, readRawConfig, reservedCommands } from "./adapters.js";
|
|
24
|
+
import { requireLocalProfile } from "./local-profile.js";
|
|
25
|
+
import { assertEndpointIsInstance, doctorExternal, LifecycleError } from "./instance/connection.js";
|
|
26
|
+
import { nodeRunner } from "./instance/runner.js";
|
|
27
|
+
import { version } from "./version.js";
|
|
28
|
+
const usageHead = `penpair ${version}
|
|
29
|
+
Start here (guided, plain language; safe to run again):
|
|
30
|
+
penpair setup asks where your Penpot is (web is the default), sets everything up and checks it
|
|
31
|
+
penpair setup --web [--external <address>] Penpot on the web: design.penpot.app, or your team's address; no questions about it
|
|
32
|
+
@@SETUP@@ penpair connect [--replace] only the connection: takes your personal key without showing it, waits for the editor
|
|
33
|
+
(a pipe works too: pbpaste | penpair connect)
|
|
34
|
+
Read-only:
|
|
35
|
+
penpair probe what the connected Penpot and its MCP offer, and the limits
|
|
36
|
+
penpair probe --deep --write also tries layout, tokens, components, swap, clone and export on a temporary
|
|
37
|
+
board it removes again (it touches the open file, so --write is required)
|
|
38
|
+
(in a project with an instance every command below acts on that instance only; --any-instance overrides)
|
|
39
|
+
penpair pages the file, its revision, pages and their sizes
|
|
40
|
+
penpair tree --page <name> [--depth 3] [--prefix <text>]
|
|
41
|
+
penpair audit --page <name> --prefix <frame name start> [--steps 0,2,4,8] [--ignore <name starts>]
|
|
42
|
+
penpair tokens dump token sets and themes as JSON
|
|
43
|
+
penpair tokens diff <before.json> <after.json> --theme <name>
|
|
44
|
+
penpair export <shapeId> <out.png> [--page <name>] [--scale 1]
|
|
45
|
+
@@INSTANCE@@Skills (the rules an AI agent follows, shipped with penpair):
|
|
46
|
+
penpair skills list them
|
|
47
|
+
penpair skills install [--to .skills] [--only <name>] [--force] copy them into the project and make them discoverable
|
|
48
|
+
Snippets (the built-in ones and the project's own, listed under "snippets" in penpair.config.json):
|
|
49
|
+
penpair snippets what can be run, with its parameters
|
|
50
|
+
penpair snippets check validate the project's snippets without a connection
|
|
51
|
+
penpair run <name> [--param key=value ...] [--apply] run one; a snippet that changes things only looks unless --apply
|
|
52
|
+
Hand a page's design to a check (Reflection or any image comparison):
|
|
53
|
+
penpair baselines [--apply] [--update] export the frames listed under "baselines" in penpair.config.json as approved images,
|
|
54
|
+
a manifest and ready-made Reflection cases. Looks first; --apply writes new images;
|
|
55
|
+
--update also replaces images whose design changed (approving the new design)
|
|
56
|
+
Changes in a Penpot file (inspect first; nothing is written without --apply):
|
|
57
|
+
penpair archive --page <name> --name <archive board> --keep <prefixes> --parts <prefixes> [--x 9000] [--apply]
|
|
58
|
+
penpair --version`;
|
|
59
|
+
const usageLocalInstance = `An instance on this computer (one Penpot per project, configured in penpair.config.json):
|
|
60
|
+
penpair init [--instance <name>] [--port 9001] [--gitignore] set up the config, the compose file and the secrets
|
|
61
|
+
penpair up | down | status start, stop (data stays) and look at the containers
|
|
62
|
+
penpair backup database and assets, with checksums and counts
|
|
63
|
+
penpair backup verify <dir> restore into a throwaway database and compare
|
|
64
|
+
penpair restore <dir> --yes replace the instance's data with a backup (safety backup first)
|
|
65
|
+
`;
|
|
66
|
+
const usageNoLocalInstance = `Penpot on this computer is not part of this install; it comes with the package @penpair/docker.
|
|
67
|
+
`;
|
|
68
|
+
const usageConnection = `Connecting to a Penpot (one project, configured in penpair.config.json):
|
|
69
|
+
penpair init --external <origin> use a Penpot someone else runs (a team server or https://design.penpot.app)
|
|
70
|
+
penpair doctor [--json] what is up, what is not, and what to do about it (--json for scripts and agents)
|
|
71
|
+
`;
|
|
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);
|
|
73
|
+
/** The commands that adapters from penpair.config.json add, after the core's own. */
|
|
74
|
+
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
|
+
const list = (value) => (value ?? "").split(",").map((v) => v.trim()).filter(Boolean);
|
|
76
|
+
async function main(argv, cli) {
|
|
77
|
+
const { values, positionals } = parseArgs({
|
|
78
|
+
args: argv,
|
|
79
|
+
allowPositionals: true,
|
|
80
|
+
options: {
|
|
81
|
+
page: { type: "string" },
|
|
82
|
+
depth: { type: "string" },
|
|
83
|
+
prefix: { type: "string" },
|
|
84
|
+
steps: { type: "string" },
|
|
85
|
+
ignore: { type: "string" },
|
|
86
|
+
theme: { type: "string" },
|
|
87
|
+
scale: { type: "string" },
|
|
88
|
+
name: { type: "string" },
|
|
89
|
+
keep: { type: "string" },
|
|
90
|
+
parts: { type: "string" },
|
|
91
|
+
x: { type: "string" },
|
|
92
|
+
apply: { type: "boolean" },
|
|
93
|
+
instance: { type: "string" },
|
|
94
|
+
external: { type: "string" },
|
|
95
|
+
port: { type: "string" },
|
|
96
|
+
gitignore: { type: "boolean" },
|
|
97
|
+
yes: { type: "boolean" },
|
|
98
|
+
"any-instance": { type: "boolean" },
|
|
99
|
+
web: { type: "boolean" },
|
|
100
|
+
docker: { type: "boolean" },
|
|
101
|
+
replace: { type: "boolean" },
|
|
102
|
+
update: { type: "boolean" },
|
|
103
|
+
json: { type: "boolean" },
|
|
104
|
+
deep: { type: "boolean" },
|
|
105
|
+
force: { type: "boolean" },
|
|
106
|
+
to: { type: "string" },
|
|
107
|
+
only: { type: "string", multiple: true },
|
|
108
|
+
write: { type: "boolean" },
|
|
109
|
+
param: { type: "string", multiple: true },
|
|
110
|
+
version: { type: "boolean" },
|
|
111
|
+
help: { type: "boolean" },
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
const [command, ...rest] = positionals;
|
|
115
|
+
if (values.version) {
|
|
116
|
+
console.log(version);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
if (!command || values.help) {
|
|
120
|
+
console.log(usage(Boolean(cli.local), cli.adapters));
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
if (command === "tokens" && rest[0] === "diff") {
|
|
124
|
+
const [, beforeFile, afterFile] = rest;
|
|
125
|
+
if (!beforeFile || !afterFile || !values.theme)
|
|
126
|
+
throw new Error("usage: penpair tokens diff <before.json> <after.json> --theme <name>");
|
|
127
|
+
const load = async (file) => JSON.parse(await readFile(file, "utf8"));
|
|
128
|
+
const diff = diffEffective(effectiveValues(await load(beforeFile), values.theme), effectiveValues(await load(afterFile), values.theme));
|
|
129
|
+
console.log(JSON.stringify(diff, null, 2));
|
|
130
|
+
return diff.added.length + diff.removed.length + diff.changed.length === 0 ? 0 : 2;
|
|
131
|
+
}
|
|
132
|
+
const root = process.cwd();
|
|
133
|
+
const log = (message) => console.error(`… ${message}`);
|
|
134
|
+
/** What `doctor` needs to look for the editor and plugin, or nothing when no endpoint is set yet. */
|
|
135
|
+
async function mcpProbeFor() {
|
|
136
|
+
try {
|
|
137
|
+
const endpoint = await resolveEndpoint();
|
|
138
|
+
return {
|
|
139
|
+
origin: endpoint.description.origin,
|
|
140
|
+
connect: async () => {
|
|
141
|
+
const client = await connect(endpoint);
|
|
142
|
+
try {
|
|
143
|
+
const { result } = await executeCode(client, "return { file: penpot.currentFile.name };", { retries: 0 });
|
|
144
|
+
return { file: String(result.file ?? "?") };
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
await client.close();
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function registryFor() {
|
|
157
|
+
const registry = builtInRegistry();
|
|
158
|
+
let snippets;
|
|
159
|
+
try {
|
|
160
|
+
snippets = (await loadConfig(root)).snippets;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// not a penpair project: only the built-in snippets
|
|
164
|
+
}
|
|
165
|
+
const report = await loadProjectSnippets(root, snippets, registry);
|
|
166
|
+
return { registry, problems: report.problems, files: report.files };
|
|
167
|
+
}
|
|
168
|
+
const connectIo = () => ({
|
|
169
|
+
say: (line) => console.log(line),
|
|
170
|
+
askHidden,
|
|
171
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
172
|
+
openFile: async (url) => {
|
|
173
|
+
const endpoint = await resolveEndpoint({ environment: { PENPAIR_MCP_URL: url }, envFiles: [] });
|
|
174
|
+
try {
|
|
175
|
+
const client = await connect(endpoint);
|
|
176
|
+
try {
|
|
177
|
+
const { result } = await executeCode(client, "return { file: penpot.currentFile.name, pages: penpot.currentFile.pages.map((p) => p.name) };", { retries: 0 });
|
|
178
|
+
const read = result;
|
|
179
|
+
return { file: String(read.file ?? "?"), pages: read.pages ?? [] };
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
await client.close();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
throw new Error(redactEndpoint(String(error.message ?? error), endpoint));
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
if (command === "setup" || command === "connect") {
|
|
191
|
+
if (command === "connect") {
|
|
192
|
+
const project = await loadConfig(root);
|
|
193
|
+
await connectProject(root, project, connectIo(), { replace: values.replace === true });
|
|
194
|
+
console.log("Next: `penpair doctor`, then `penpair skills install` if you have not yet.");
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
let where;
|
|
198
|
+
if (values.docker)
|
|
199
|
+
where = { type: "docker" };
|
|
200
|
+
else if (values.web || values.external)
|
|
201
|
+
where = { type: "web", origin: values.external ?? hostedOrigin };
|
|
202
|
+
await setup(root, { ...connectIo(), ask: (question, fallback) => askLine(question, fallback), openBrowser: openInBrowser }, {
|
|
203
|
+
where,
|
|
204
|
+
instance: values.instance,
|
|
205
|
+
port: values.port ? Number(values.port) : undefined,
|
|
206
|
+
runner: nodeRunner,
|
|
207
|
+
log,
|
|
208
|
+
local: cli.local,
|
|
209
|
+
installSkills: async () => {
|
|
210
|
+
const report = await installSkills({ root });
|
|
211
|
+
console.log(`Installed ${report.installed.length} rule file(s) in .skills/ (${report.unchanged.length} already there). Add these rows to your AGENTS.md skills table:\n${report.agentsRows.join("\n")}`);
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
if (command === "skills") {
|
|
217
|
+
if (rest[0] === "install") {
|
|
218
|
+
const report = await installSkills({ root, to: values.to, only: values.only, force: values.force });
|
|
219
|
+
for (const file of report.installed)
|
|
220
|
+
console.log(`wrote ${file}`);
|
|
221
|
+
for (const file of report.kept)
|
|
222
|
+
console.log(`kept ${file} (differs from the shipped one; --force replaces it, except the project-owned files)`);
|
|
223
|
+
for (const link of report.linked)
|
|
224
|
+
console.log(`linked ${link}`);
|
|
225
|
+
console.log(`${report.unchanged.length} file(s) already up to date`);
|
|
226
|
+
console.log("\nAdd to your AGENTS.md skills table:\n" + report.agentsRows.join("\n"));
|
|
227
|
+
for (const old of report.legacy)
|
|
228
|
+
console.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.`);
|
|
229
|
+
console.log("\nThen fill in references/project-facts.md in penpair-design: it is yours and is never replaced.");
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
for (const name of await listBundledSkills())
|
|
233
|
+
console.log(name);
|
|
234
|
+
return 0;
|
|
235
|
+
}
|
|
236
|
+
if (command === "snippets") {
|
|
237
|
+
const { registry, problems, files } = await registryFor();
|
|
238
|
+
if (rest[0] === "check") {
|
|
239
|
+
const found = [...problems, ...checkProjectSnippets(registry)];
|
|
240
|
+
const project = registry.list().filter((entry) => entry.source === "project");
|
|
241
|
+
console.log(`${project.length} project snippet(s) from ${files.length} file(s)`);
|
|
242
|
+
for (const problem of found)
|
|
243
|
+
console.log(`FAIL ${problem}`);
|
|
244
|
+
if (found.length === 0)
|
|
245
|
+
console.log("ok every snippet is valid plugin code and small enough");
|
|
246
|
+
return found.length === 0 ? 0 : 1;
|
|
247
|
+
}
|
|
248
|
+
for (const entry of registry.list()) {
|
|
249
|
+
console.log(`${entry.name.padEnd(14)} ${entry.kind === "write" ? "changes " : "reads "} ${entry.source === "project" ? "[project] " : ""}${entry.description}`);
|
|
250
|
+
for (const p of entry.params)
|
|
251
|
+
console.log(`${"".padEnd(14)} --param ${p.name}=<${p.type}>${p.required ? " (required)" : p.default !== undefined ? ` (default ${JSON.stringify(p.default)})` : ""}${p.description ? ` ${p.description}` : ""}`);
|
|
252
|
+
}
|
|
253
|
+
for (const problem of problems)
|
|
254
|
+
console.error(`warning: ${problem}`);
|
|
255
|
+
return 0;
|
|
256
|
+
}
|
|
257
|
+
if (command === "init") {
|
|
258
|
+
const result = await init(root, { instance: values.instance, port: values.port ? Number(values.port) : undefined, gitignore: values.gitignore, external: values.external }, { local: cli.local });
|
|
259
|
+
if (isExternal(result.config)) {
|
|
260
|
+
console.log(`${result.createdConfig ? "created" : "kept"} penpair.config.json (external Penpot at ${result.config.profile.origin})`);
|
|
261
|
+
console.log("next: penpair connect (takes your personal key without showing it, then waits for the editor)");
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
console.log(`${result.createdConfig ? "created" : "kept"} penpair.config.json (instance ${result.config.instance}, Penpot ${result.config.penpotVersion}, port ${result.config.ports.frontend})`);
|
|
265
|
+
console.log(`wrote ${result.config.directory}/docker-compose.yaml; secrets ${result.createdSecrets ? "generated" : "kept"} in ${result.config.directory}/.env`);
|
|
266
|
+
if (!result.gitignoreAdded)
|
|
267
|
+
console.log(`keep ${result.config.directory}/ out of git (add it to .gitignore, or run init with --gitignore)`);
|
|
268
|
+
console.log("next: penpair up, then penpair connect");
|
|
269
|
+
return 0;
|
|
270
|
+
}
|
|
271
|
+
/** 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
|
+
async function report(checks, mcp) {
|
|
273
|
+
const keyCheck = await keyFileCheck(root, nodeRunner);
|
|
274
|
+
const all = keyCheck ? [...checks, keyCheck] : checks;
|
|
275
|
+
const ok = all.every((check) => check.ok);
|
|
276
|
+
if (values.json) {
|
|
277
|
+
console.log(JSON.stringify({ ok, mcpConfigured: Boolean(mcp), checks: all }, null, 2));
|
|
278
|
+
return ok ? 0 : 1;
|
|
279
|
+
}
|
|
280
|
+
for (const check of all)
|
|
281
|
+
console.log(`${check.ok ? "ok " : "FAIL"} ${check.check}: ${check.detail}${"remedy" in check && check.remedy ? `\n → ${check.remedy}` : ""}`);
|
|
282
|
+
if (!mcp)
|
|
283
|
+
console.log("skip editor and plugin: no key yet. Run `penpair connect`.");
|
|
284
|
+
return ok ? 0 : 1;
|
|
285
|
+
}
|
|
286
|
+
const lifecycle = new Set(["up", "down", "status", "doctor", "backup", "restore"]);
|
|
287
|
+
if (lifecycle.has(command)) {
|
|
288
|
+
const project = await loadConfig(root);
|
|
289
|
+
if (isExternal(project) && command !== "doctor")
|
|
290
|
+
requireLocal(project);
|
|
291
|
+
if (isExternal(project)) {
|
|
292
|
+
const mcp = await mcpProbeFor();
|
|
293
|
+
return report(await doctorExternal(project, { mcp }), mcp);
|
|
294
|
+
}
|
|
295
|
+
const local = requireLocalProfile(cli.local);
|
|
296
|
+
const c = { root, config: requireLocal(project), runner: nodeRunner, log };
|
|
297
|
+
if (command === "up") {
|
|
298
|
+
const result = await local.up(c);
|
|
299
|
+
console.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
|
+
return result.ready ? 0 : 1;
|
|
301
|
+
}
|
|
302
|
+
if (command === "down") {
|
|
303
|
+
await local.down(c);
|
|
304
|
+
console.log("stopped; the data volumes are kept");
|
|
305
|
+
return 0;
|
|
306
|
+
}
|
|
307
|
+
if (command === "status") {
|
|
308
|
+
const state = await local.status(c);
|
|
309
|
+
for (const s of state.services)
|
|
310
|
+
console.log(`${s.state.padEnd(10)} ${s.service}${s.health ? ` (${s.health})` : ""}`);
|
|
311
|
+
console.log(`${state.frontendReachable ? "answers" : "does not answer"} at ${state.url}; backend ${state.backendReady ? "ready" : "not ready"}`);
|
|
312
|
+
return state.frontendReachable && state.backendReady ? 0 : 1;
|
|
313
|
+
}
|
|
314
|
+
if (command === "doctor") {
|
|
315
|
+
const mcp = await mcpProbeFor();
|
|
316
|
+
return report(await local.doctor(c, { mcp }), mcp);
|
|
317
|
+
}
|
|
318
|
+
if (command === "backup") {
|
|
319
|
+
if (rest[0] === "verify") {
|
|
320
|
+
if (!rest[1])
|
|
321
|
+
throw new Error("usage: penpair backup verify <backup directory>");
|
|
322
|
+
const result = await local.verifyBackup(c, rest[1]);
|
|
323
|
+
for (const check of result.checks)
|
|
324
|
+
console.log(`${check.ok ? "ok " : "FAIL"} ${check.check}: ${check.detail}`);
|
|
325
|
+
return result.ok ? 0 : 1;
|
|
326
|
+
}
|
|
327
|
+
const result = await local.backup(c);
|
|
328
|
+
console.log(`backup in ${result.directory} (${result.manifest.counts.files} files, ${result.manifest.counts.storageObjects} assets). It holds account data: never commit it.`);
|
|
329
|
+
return 0;
|
|
330
|
+
}
|
|
331
|
+
if (command === "restore") {
|
|
332
|
+
if (!rest[0])
|
|
333
|
+
throw new Error("usage: penpair restore <backup directory> --yes");
|
|
334
|
+
const result = await local.restore(c, rest[0], { yes: values.yes === true });
|
|
335
|
+
for (const check of result.checks)
|
|
336
|
+
console.log(`${check.ok ? "ok " : "FAIL"} ${check.check}: ${check.detail}`);
|
|
337
|
+
if (result.safetyBackup)
|
|
338
|
+
console.log(`safety backup of the previous data: ${result.safetyBackup}`);
|
|
339
|
+
return result.ok ? 0 : 1;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const endpoint = await resolveEndpoint();
|
|
343
|
+
// In a project with an instance, act only on that instance unless told otherwise.
|
|
344
|
+
if (!values["any-instance"]) {
|
|
345
|
+
let projectConfig;
|
|
346
|
+
try {
|
|
347
|
+
projectConfig = await loadConfig(root);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
// not a penpair project: any endpoint will do
|
|
351
|
+
}
|
|
352
|
+
if (projectConfig)
|
|
353
|
+
assertEndpointIsInstance(endpoint.description, projectConfig);
|
|
354
|
+
}
|
|
355
|
+
if (command === "probe") {
|
|
356
|
+
if (values.deep && !values.write)
|
|
357
|
+
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
|
+
console.log(JSON.stringify(await probe(endpoint, () => connect(endpoint)), null, 2));
|
|
359
|
+
if (values.deep) {
|
|
360
|
+
const client = await connect(endpoint);
|
|
361
|
+
try {
|
|
362
|
+
const report = await runDeepCheck(client, { onWait: (m) => console.error(`… ${m}`) });
|
|
363
|
+
console.log(`\n${formatDeepReport(report)}`);
|
|
364
|
+
return report.ok ? 0 : 1;
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
await client.close();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return 0;
|
|
371
|
+
}
|
|
372
|
+
const client = await connect(endpoint);
|
|
373
|
+
const options = { onWait: (m) => console.error(`… ${m}`), onStep: (m) => console.error(`… ${m}`) };
|
|
374
|
+
try {
|
|
375
|
+
if (command === "run") {
|
|
376
|
+
const [name] = rest;
|
|
377
|
+
if (!name)
|
|
378
|
+
throw new Error("usage: penpair run <snippet> [--param key=value ...] [--apply]");
|
|
379
|
+
const { registry, problems } = await registryFor();
|
|
380
|
+
for (const problem of problems)
|
|
381
|
+
console.error(`warning: ${problem}`);
|
|
382
|
+
const raw = {};
|
|
383
|
+
for (const pair of values.param ?? []) {
|
|
384
|
+
const at = pair.indexOf("=");
|
|
385
|
+
if (at < 1)
|
|
386
|
+
throw new RegistryError(`--param needs key=value, got ${JSON.stringify(pair)}`);
|
|
387
|
+
const key = pair.slice(0, at);
|
|
388
|
+
const value = pair.slice(at + 1);
|
|
389
|
+
const earlier = raw[key];
|
|
390
|
+
raw[key] = earlier === undefined ? value : [...(Array.isArray(earlier) ? earlier : [earlier]), value];
|
|
391
|
+
}
|
|
392
|
+
const entry = registry.get(name);
|
|
393
|
+
const result = await registry.run(client, name, coerceParams(entry.params, raw), { ...options, apply: values.apply === true });
|
|
394
|
+
console.log(JSON.stringify(result, null, 2));
|
|
395
|
+
if (entry.kind === "write" && !values.apply)
|
|
396
|
+
console.error("Nothing was written. Run again with --apply to do it.");
|
|
397
|
+
return result.phase === "applied" && !result.verified ? 1 : 0;
|
|
398
|
+
}
|
|
399
|
+
if (command === "baselines") {
|
|
400
|
+
const config = (await loadConfig(root)).baselines;
|
|
401
|
+
if (!config)
|
|
402
|
+
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 });
|
|
404
|
+
for (const r of report.results)
|
|
405
|
+
console.log(`${r.status.padEnd(9)} ${r.id.padEnd(28)} ${r.frame}${r.imageWidth ? ` (${r.imageWidth}x${r.imageHeight})` : ""}${r.written ? " written" : ""}`);
|
|
406
|
+
for (const file of report.wrote)
|
|
407
|
+
console.log(`wrote ${file}`);
|
|
408
|
+
const changed = report.results.filter((r) => r.status === "changed" && !r.written);
|
|
409
|
+
if (!values.apply)
|
|
410
|
+
console.error("Nothing was written. Run again with --apply to write new images.");
|
|
411
|
+
if (changed.length > 0)
|
|
412
|
+
console.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.`);
|
|
413
|
+
if (report.results.some((r) => r.status === "missing" || r.status === "unstable"))
|
|
414
|
+
return 1;
|
|
415
|
+
return changed.length > 0 ? 2 : 0;
|
|
416
|
+
}
|
|
417
|
+
if (command === "pages")
|
|
418
|
+
console.log(JSON.stringify(await runSnippet(client, pages(), options), null, 2));
|
|
419
|
+
else if (command === "tree") {
|
|
420
|
+
if (!values.page)
|
|
421
|
+
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
|
+
console.log((record.lines ?? []).join("\n"));
|
|
424
|
+
}
|
|
425
|
+
else if (command === "audit") {
|
|
426
|
+
if (!values.page || !values.prefix)
|
|
427
|
+
throw new Error("--page and --prefix are required");
|
|
428
|
+
const steps = values.steps ? values.steps.split(",").map(Number) : undefined;
|
|
429
|
+
const record = await runOnPage(client, audit({ page: values.page, framePrefix: values.prefix, spacingSteps: steps, ignoreUnbound: list(values.ignore) }), values.page, options);
|
|
430
|
+
console.log(JSON.stringify(record.frames, null, 2));
|
|
431
|
+
}
|
|
432
|
+
else if (command === "tokens" && rest[0] === "dump") {
|
|
433
|
+
const { ok: _ok, readOnly: _readOnly, ...dump } = await runSnippet(client, tokensDump(), options);
|
|
434
|
+
console.log(JSON.stringify(dump, null, 2));
|
|
435
|
+
}
|
|
436
|
+
else if (command === "export") {
|
|
437
|
+
const [shapeId, out] = rest;
|
|
438
|
+
if (!shapeId || !out)
|
|
439
|
+
throw new Error("usage: penpair export <shapeId> <out.png> [--page <name>] [--scale 1]");
|
|
440
|
+
const result = await exportShapePng(client, shapeId, { ...options, page: values.page, scale: values.scale ? Number(values.scale) : 1 });
|
|
441
|
+
await writeFile(out, result.png);
|
|
442
|
+
console.log(`saved ${out} (${result.png.length} bytes, ${result.stable ? "stable" : "not confirmed stable"} after ${result.exports} exports)`);
|
|
443
|
+
}
|
|
444
|
+
else if (command === "archive") {
|
|
445
|
+
if (!values.page || !values.name)
|
|
446
|
+
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 }, { ...options, apply: values.apply === true });
|
|
448
|
+
console.log(JSON.stringify(report, null, 2));
|
|
449
|
+
if (report.phase === "inspected")
|
|
450
|
+
console.error("Nothing was written. Run again with --apply to do it.");
|
|
451
|
+
return report.phase === "applied" && !report.verified ? 1 : 0;
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
console.log(usage(Boolean(cli.local), cli.adapters));
|
|
455
|
+
return 1;
|
|
456
|
+
}
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
459
|
+
finally {
|
|
460
|
+
await client.close();
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/** Runs one command of an adapter with a context that can reach the project's Penpot. */
|
|
464
|
+
async function runAdapterCommand(command, root, args) {
|
|
465
|
+
const config = await loadConfig(root);
|
|
466
|
+
const rawConfig = await readRawConfig(root);
|
|
467
|
+
const context = {
|
|
468
|
+
root,
|
|
469
|
+
config,
|
|
470
|
+
rawConfig,
|
|
471
|
+
log: (message) => console.error(`… ${message}`),
|
|
472
|
+
async withPenpot(work) {
|
|
473
|
+
const endpoint = await resolveEndpoint();
|
|
474
|
+
assertEndpointIsInstance(endpoint.description, config);
|
|
475
|
+
const client = await connect(endpoint);
|
|
476
|
+
try {
|
|
477
|
+
return await work(client);
|
|
478
|
+
}
|
|
479
|
+
finally {
|
|
480
|
+
await client.close();
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
return command.run(context, args);
|
|
485
|
+
}
|
|
486
|
+
/** 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
|
+
export async function runCli(argv, options = {}) {
|
|
488
|
+
try {
|
|
489
|
+
const root = process.cwd();
|
|
490
|
+
const raw = await readRawConfig(root);
|
|
491
|
+
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
|
+
const [first] = argv;
|
|
494
|
+
const adapterCommand = loaded.adapters.flatMap((adapter) => adapter.commands).find((command) => command.name === first);
|
|
495
|
+
if (adapterCommand)
|
|
496
|
+
return await runAdapterCommand(adapterCommand, root, argv.slice(1));
|
|
497
|
+
const builtIn = reservedCommands.includes(first ?? "");
|
|
498
|
+
if (loaded.problems.length > 0 && (!builtIn || argv.includes("--help")))
|
|
499
|
+
for (const problem of loaded.problems)
|
|
500
|
+
console.error(`warning: ${problem}`);
|
|
501
|
+
return await main(argv, { ...options, adapters: loaded.adapters });
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
console.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)}`);
|
|
505
|
+
return 1;
|
|
506
|
+
}
|
|
507
|
+
}
|