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