@penpair/core 0.2.0 → 0.3.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.
Files changed (101) hide show
  1. package/dist/adapters.js +58 -11
  2. package/dist/approvals.js +143 -29
  3. package/dist/atomic-output.js +9 -4
  4. package/dist/audit-rules.js +345 -79
  5. package/dist/backup-note.js +29 -8
  6. package/dist/baseline-coverage.js +169 -35
  7. package/dist/baselines-config.js +119 -29
  8. package/dist/baselines.js +280 -57
  9. package/dist/cli-arguments.js +18 -6
  10. package/dist/cli-main.js +1621 -392
  11. package/dist/client.d.ts +5 -2
  12. package/dist/client.js +127 -28
  13. package/dist/command-result.js +341 -52
  14. package/dist/coordination.js +91 -29
  15. package/dist/credentials.js +57 -13
  16. package/dist/deep-check.js +56 -12
  17. package/dist/design-spec.js +189 -49
  18. package/dist/endpoint.js +102 -34
  19. package/dist/export.js +86 -18
  20. package/dist/file-archive.js +33 -7
  21. package/dist/file-guard.js +29 -11
  22. package/dist/formats.js +215 -14
  23. package/dist/index.d.ts +8 -7
  24. package/dist/index.js +8 -7
  25. package/dist/inspection.d.ts +1 -1
  26. package/dist/inspection.js +378 -65
  27. package/dist/instance/config-schema.js +400 -41
  28. package/dist/instance/config.js +101 -30
  29. package/dist/instance/connect.js +151 -44
  30. package/dist/instance/connection.d.ts +26 -5
  31. package/dist/instance/connection.js +194 -21
  32. package/dist/instance/gitignore.js +5 -2
  33. package/dist/instance/init.js +32 -7
  34. package/dist/instance/runner.js +32 -10
  35. package/dist/instance/setup.js +46 -13
  36. package/dist/instance/tty.js +26 -8
  37. package/dist/journal.d.ts +10 -0
  38. package/dist/journal.js +212 -49
  39. package/dist/kit-smoke.js +82 -20
  40. package/dist/kit-source.js +10 -40
  41. package/dist/kit.js +102 -34
  42. package/dist/layout-policy.d.ts +2 -0
  43. package/dist/layout-policy.js +86 -0
  44. package/dist/layout.d.ts +7 -1
  45. package/dist/layout.js +27 -70
  46. package/dist/live-smoke.js +13 -4
  47. package/dist/local-profile.js +2 -1
  48. package/dist/mcp-server.js +360 -47
  49. package/dist/native-api.js +47 -11
  50. package/dist/onboard.js +33 -7
  51. package/dist/operation.d.ts +2 -2
  52. package/dist/operation.js +279 -69
  53. package/dist/page-session.d.ts +23 -0
  54. package/dist/page-session.js +142 -0
  55. package/dist/png.js +18 -3
  56. package/dist/portable-tokens.js +254 -57
  57. package/dist/probe.d.ts +1 -1
  58. package/dist/probe.js +48 -11
  59. package/dist/project-input.js +24 -10
  60. package/dist/project-snippets.js +18 -6
  61. package/dist/project-status.js +133 -24
  62. package/dist/registry.js +217 -60
  63. package/dist/run-file.js +129 -30
  64. package/dist/shots.js +108 -26
  65. package/dist/skill-instructions.js +91 -22
  66. package/dist/skills.js +129 -39
  67. package/dist/snippet.js +27 -7
  68. package/dist/snippets/read.js +8 -2
  69. package/dist/structure-snapshot.d.ts +12 -0
  70. package/dist/structure-snapshot.js +439 -85
  71. package/dist/token-edit.js +219 -64
  72. package/dist/token-projection.d.ts +56 -0
  73. package/dist/token-projection.js +286 -0
  74. package/dist/tokens-diff.js +125 -32
  75. package/dist/version.d.ts +1 -1
  76. package/dist/version.js +1 -1
  77. package/package.json +1 -1
  78. package/skills/penpair-design/SKILL.md +3 -0
  79. package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
  80. package/skills/penpair-design/references/project-facts.md +2 -0
  81. package/skills/penpair-handoff/SKILL.md +2 -0
  82. package/skills/penpair-handoff/references/comparison-companion.md +13 -0
  83. package/skills/penpair-handoff/references/handoff-flow.md +2 -0
  84. package/skills/penpair-recipes/SKILL.md +5 -0
  85. package/skills/penpair-setup/SKILL.md +3 -1
  86. package/dist/cli.d.ts +0 -2
  87. package/dist/cli.js +0 -3
  88. package/dist/docker/lifecycle.d.ts +0 -96
  89. package/dist/docker/lifecycle.js +0 -273
  90. package/dist/docker/secrets.d.ts +0 -11
  91. package/dist/docker/secrets.js +0 -38
  92. package/dist/docker/template.d.ts +0 -8
  93. package/dist/docker/template.js +0 -158
  94. package/dist/docker-profile.d.ts +0 -114
  95. package/dist/docker-profile.js +0 -18
  96. package/dist/instance/lifecycle.d.ts +0 -129
  97. package/dist/instance/lifecycle.js +0 -336
  98. package/dist/instance/secrets.d.ts +0 -11
  99. package/dist/instance/secrets.js +0 -38
  100. package/dist/instance/template.d.ts +0 -8
  101. package/dist/instance/template.js +0 -158
@@ -1,96 +0,0 @@
1
- import { type InstanceConfig } from "../instance/config.js";
2
- import { type Check, type DoctorCheck, type DoctorOptions } from "../instance/connection.js";
3
- import type { Runner } from "../instance/runner.js";
4
- export interface Context {
5
- root: string;
6
- config: InstanceConfig;
7
- runner: Runner;
8
- log?: (message: string) => void;
9
- sleep?: (milliseconds: number) => Promise<void>;
10
- fetch?: typeof fetch;
11
- now?: () => Date;
12
- }
13
- /** `docker compose` arguments for this instance: its own project name, compose file and secrets. */
14
- export declare function composeArgs(c: Context, ...rest: string[]): string[];
15
- /** The frontend answers a few seconds before the backend does (502 until then); /readyz is 200 only when the backend is ready. */
16
- export declare const readyUrl: (config: InstanceConfig) => string;
17
- export declare function waitForHttp(c: Context, url: string, timeoutMs?: number, stepMs?: number): Promise<boolean>;
18
- export declare function up(c: Context): Promise<{
19
- url: string;
20
- ready: boolean;
21
- }>;
22
- /** Stops and removes the containers. Volumes are never removed: the design data stays. */
23
- export declare function down(c: Context): Promise<void>;
24
- export interface ServiceState {
25
- service: string;
26
- state: string;
27
- health: string;
28
- }
29
- export declare function parseComposePs(output: string): ServiceState[];
30
- export declare function status(c: Context): Promise<{
31
- services: ServiceState[];
32
- frontendReachable: boolean;
33
- backendReady: boolean;
34
- url: string;
35
- }>;
36
- export interface Counts {
37
- files: number;
38
- profiles: number;
39
- teams: number;
40
- storageObjects: number;
41
- fileRevisions: {
42
- id: string;
43
- name: string;
44
- revn: number;
45
- }[];
46
- }
47
- export interface BackupManifest {
48
- instance: string;
49
- penpotVersion: string;
50
- createdAt: string;
51
- label?: string;
52
- files: {
53
- db: {
54
- name: string;
55
- sha256: string;
56
- bytes: number;
57
- };
58
- assets: {
59
- name: string;
60
- sha256: string;
61
- bytes: number;
62
- };
63
- };
64
- counts: Counts;
65
- note: string;
66
- }
67
- /**
68
- * A backup of the whole instance: a Postgres dump and a tarball of the asset volume, with checksums and the counts
69
- * that `backup verify` later checks a restore against. It holds account data (password hashes): never commit it.
70
- */
71
- export declare function backup(c: Context, label?: string): Promise<{
72
- directory: string;
73
- manifest: BackupManifest;
74
- }>;
75
- /** Proves a backup can be restored without touching the instance: restores the dump into a throwaway Postgres and compares. */
76
- export declare function verifyBackup(c: Context, directory: string): Promise<{
77
- ok: boolean;
78
- checks: Check[];
79
- }>;
80
- export interface RestoreOptions {
81
- /** Restoring replaces the instance's data; it only runs when this is true. */
82
- yes: boolean;
83
- /** Skip the safety backup of the current data. */
84
- skipSafetyBackup?: boolean;
85
- }
86
- /**
87
- * Replaces the instance's database and assets with a backup. A backup of the current data is taken first unless told
88
- * not to, the checksums are checked before anything is touched, and the counts are compared afterwards.
89
- */
90
- export declare function restore(c: Context, directory: string, options: RestoreOptions): Promise<{
91
- ok: boolean;
92
- checks: Check[];
93
- safetyBackup?: string;
94
- }>;
95
- /** What is up, what is not, and what to do about it. */
96
- export declare function doctor(c: Context, options?: DoctorOptions): Promise<DoctorCheck[]>;
@@ -1,273 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { createReadStream } from "node:fs";
3
- import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
4
- import path from "node:path";
5
- import { composeProjectOf, instancePaths } from "../instance/config.js";
6
- import { endpointIsInstance, frontendUrl, LifecycleError } from "../instance/connection.js";
7
- import { readEnvValues } from "./secrets.js";
8
- const defaultSleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
9
- const sleepOf = (c) => c.sleep ?? defaultSleep;
10
- const say = (c, message) => c.log?.(message);
11
- /** `docker compose` arguments for this instance: its own project name, compose file and secrets. */
12
- export function composeArgs(c, ...rest) {
13
- const paths = instancePaths(c.root, c.config);
14
- return ["compose", "-p", composeProjectOf(c.config), "-f", paths.compose, "--env-file", paths.env, ...rest];
15
- }
16
- async function docker(c, args, what) {
17
- const result = await c.runner.run("docker", args);
18
- if (result.code !== 0)
19
- throw new LifecycleError(`${what} failed: ${result.stderr.trim().split("\n").slice(-3).join(" ") || `exit ${result.code}`}`);
20
- return result.stdout;
21
- }
22
- /** The frontend answers a few seconds before the backend does (502 until then); /readyz is 200 only when the backend is ready. */
23
- export const readyUrl = (config) => `${frontendUrl(config)}/readyz`;
24
- export async function waitForHttp(c, url, timeoutMs = 240_000, stepMs = 3_000) {
25
- const doFetch = c.fetch ?? fetch;
26
- for (let waited = 0; waited <= timeoutMs; waited += stepMs) {
27
- try {
28
- const response = await doFetch(url, { redirect: "manual" });
29
- if (response.status < 500)
30
- return true;
31
- }
32
- catch {
33
- // not up yet
34
- }
35
- await sleepOf(c)(stepMs);
36
- }
37
- return false;
38
- }
39
- export async function up(c) {
40
- say(c, "starting the containers");
41
- await docker(c, composeArgs(c, "up", "-d"), "docker compose up");
42
- say(c, "waiting for Penpot to answer (the first start can take a few minutes)");
43
- return { url: frontendUrl(c.config), ready: await waitForHttp(c, readyUrl(c.config)) };
44
- }
45
- /** Stops and removes the containers. Volumes are never removed: the design data stays. */
46
- export async function down(c) {
47
- await docker(c, composeArgs(c, "down"), "docker compose down");
48
- }
49
- export function parseComposePs(output) {
50
- const rows = [];
51
- const text = output.trim();
52
- if (text.startsWith("["))
53
- rows.push(...JSON.parse(text));
54
- else
55
- for (const line of text.split("\n").filter(Boolean))
56
- rows.push(JSON.parse(line));
57
- return rows.map((row) => {
58
- const r = row;
59
- return { service: r.Service ?? r.Name ?? "?", state: r.State ?? "?", health: r.Health ?? "" };
60
- });
61
- }
62
- export async function status(c) {
63
- const output = await docker(c, composeArgs(c, "ps", "--all", "--format", "json"), "docker compose ps");
64
- const services = output.trim() ? parseComposePs(output) : [];
65
- const url = frontendUrl(c.config);
66
- const answers = async (target, ok) => {
67
- try {
68
- return ok((await (c.fetch ?? fetch)(target, { redirect: "manual" })).status);
69
- }
70
- catch {
71
- return false;
72
- }
73
- };
74
- return { services, frontendReachable: await answers(url, (code) => code < 500), backendReady: await answers(readyUrl(c.config), (code) => code === 200), url };
75
- }
76
- async function containerOf(c, service) {
77
- const id = (await docker(c, composeArgs(c, "ps", "-q", service), `docker compose ps ${service}`)).trim().split("\n")[0];
78
- if (!id)
79
- throw new LifecycleError(`The ${service} container is not running. Run \`penpair up\` first.`);
80
- return id;
81
- }
82
- const countsSql = `select json_build_object('files',(select count(*) from file where deleted_at is null),'profiles',(select count(*) from profile),'teams',(select count(*) from team),'storageObjects',(select count(*) from storage_object),'fileRevisions',(select coalesce(json_agg(json_build_object('id',id,'name',name,'revn',revn) order by name,id),'[]'::json) from file where deleted_at is null))`;
83
- async function readCounts(c, container) {
84
- const out = await docker(c, ["exec", container, "psql", "-U", "penpot", "-d", "penpot", "-tAc", countsSql], "reading the database counts");
85
- return JSON.parse(out.trim());
86
- }
87
- async function sha256File(file) {
88
- const hash = createHash("sha256");
89
- for await (const chunk of createReadStream(file))
90
- hash.update(chunk);
91
- return hash.digest("hex");
92
- }
93
- /**
94
- * A backup of the whole instance: a Postgres dump and a tarball of the asset volume, with checksums and the counts
95
- * that `backup verify` later checks a restore against. It holds account data (password hashes): never commit it.
96
- */
97
- export async function backup(c, label) {
98
- const paths = instancePaths(c.root, c.config);
99
- const stamp = (c.now?.() ?? new Date()).toISOString().replace(/[:.]/g, "-");
100
- const directory = path.join(paths.backups, label ? `${stamp}-${label}` : stamp);
101
- await mkdir(directory, { recursive: true });
102
- const postgres = await containerOf(c, "penpot-postgres");
103
- const backend = await containerOf(c, "penpot-backend");
104
- say(c, "dumping the database");
105
- const dump = await c.runner.run("docker", ["exec", postgres, "pg_dump", "-U", "penpot", "-d", "penpot", "-Fc"], { outFile: path.join(directory, "db.dump") });
106
- if (dump.code !== 0)
107
- throw new LifecycleError(`pg_dump failed: ${dump.stderr.trim().slice(-200)}`);
108
- say(c, "archiving the assets");
109
- const assets = await c.runner.run("docker", ["exec", backend, "tar", "czf", "-", "-C", "/opt/data/assets", "."], { outFile: path.join(directory, "assets.tgz") });
110
- if (assets.code !== 0)
111
- throw new LifecycleError(`archiving the assets failed: ${assets.stderr.trim().slice(-200)}`);
112
- const entry = async (name) => ({ name, sha256: await sha256File(path.join(directory, name)), bytes: (await stat(path.join(directory, name))).size });
113
- const manifest = {
114
- instance: c.config.instance,
115
- penpotVersion: c.config.penpotVersion,
116
- createdAt: (c.now?.() ?? new Date()).toISOString(),
117
- ...(label ? { label } : {}),
118
- files: { db: await entry("db.dump"), assets: await entry("assets.tgz") },
119
- counts: await readCounts(c, postgres),
120
- note: "Contains account data (password hashes). Never commit it. Keep the instance's .env with it.",
121
- };
122
- await writeFile(path.join(directory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
123
- return { directory, manifest };
124
- }
125
- async function loadManifest(directory) {
126
- try {
127
- return JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8"));
128
- }
129
- catch {
130
- throw new LifecycleError(`${directory} is not a penpair backup (no manifest.json)`);
131
- }
132
- }
133
- async function checksumChecks(directory, manifest) {
134
- const checks = [];
135
- for (const key of ["db", "assets"]) {
136
- const wanted = manifest.files[key];
137
- let actual = "missing";
138
- try {
139
- actual = await sha256File(path.join(directory, wanted.name));
140
- }
141
- catch {
142
- // reported below
143
- }
144
- checks.push({ check: `${wanted.name} checksum`, ok: actual === wanted.sha256, detail: actual === wanted.sha256 ? "matches the manifest" : `expected ${wanted.sha256.slice(0, 12)}, found ${actual.slice(0, 12)}` });
145
- }
146
- return checks;
147
- }
148
- const compareCounts = (wanted, found) => [
149
- ...["files", "profiles", "teams", "storageObjects"].map((key) => ({ check: `restored ${key}`, ok: wanted[key] === found[key], detail: `${found[key]} (backup says ${wanted[key]})` })),
150
- { check: "file revisions", ok: JSON.stringify(wanted.fileRevisions) === JSON.stringify(found.fileRevisions), detail: `${found.fileRevisions.length} files, ids, names and revisions ${JSON.stringify(wanted.fileRevisions) === JSON.stringify(found.fileRevisions) ? "match" : "differ"}` },
151
- ];
152
- /** Proves a backup can be restored without touching the instance: restores the dump into a throwaway Postgres and compares. */
153
- export async function verifyBackup(c, directory) {
154
- const manifest = await loadManifest(directory);
155
- const checks = await checksumChecks(directory, manifest);
156
- const name = `penpair-verify-${Math.random().toString(36).slice(2, 8)}`;
157
- say(c, "starting a throwaway Postgres");
158
- await docker(c, ["run", "-d", "--name", name, "-e", "POSTGRES_USER=penpot", "-e", "POSTGRES_PASSWORD=verify", "-e", "POSTGRES_DB=penpot", "-v", `${path.resolve(directory)}:/backup:ro`, "postgres:15"], "starting the throwaway Postgres");
159
- try {
160
- let ready = false;
161
- for (let i = 0; i < 40 && !ready; i++) {
162
- ready = (await c.runner.run("docker", ["exec", name, "pg_isready", "-U", "penpot", "-d", "penpot"])).code === 0;
163
- if (!ready)
164
- await sleepOf(c)(1_000);
165
- }
166
- if (!ready)
167
- throw new LifecycleError("the throwaway Postgres did not become ready");
168
- say(c, "restoring the dump");
169
- await c.runner.run("docker", ["exec", name, "pg_restore", "-U", "penpot", "-d", "penpot", "--no-owner", "/backup/db.dump"]);
170
- checks.push(...compareCounts(manifest.counts, await readCounts(c, name)));
171
- const listing = await c.runner.run("tar", ["tzf", path.join(directory, manifest.files.assets.name)]);
172
- const entries = listing.stdout.split("\n").filter((line) => line && !line.endsWith("/")).length;
173
- checks.push({ check: "asset files", ok: entries === manifest.counts.storageObjects, detail: `${entries} files in the tarball, ${manifest.counts.storageObjects} storage objects` });
174
- }
175
- finally {
176
- await c.runner.run("docker", ["rm", "-f", name]);
177
- }
178
- return { ok: checks.every((check) => check.ok), checks };
179
- }
180
- /**
181
- * Replaces the instance's database and assets with a backup. A backup of the current data is taken first unless told
182
- * not to, the checksums are checked before anything is touched, and the counts are compared afterwards.
183
- */
184
- export async function restore(c, directory, options) {
185
- if (!options.yes)
186
- throw new LifecycleError("Restoring replaces this instance's data. Run again with --yes to confirm.");
187
- const manifest = await loadManifest(directory);
188
- const checks = await checksumChecks(directory, manifest);
189
- if (!checks.every((check) => check.ok))
190
- return { ok: false, checks };
191
- let safetyBackup;
192
- if (!options.skipSafetyBackup) {
193
- try {
194
- say(c, "taking a safety backup of the current data first");
195
- safetyBackup = (await backup(c, "pre-restore")).directory;
196
- }
197
- catch (error) {
198
- if (!(error instanceof LifecycleError) || !/not running/.test(error.message))
199
- throw error;
200
- say(c, "the instance is not running, so there is no current data to back up");
201
- }
202
- }
203
- say(c, "stopping the application containers");
204
- await docker(c, composeArgs(c, "stop", "penpot-frontend", "penpot-backend", "penpot-admin-console", "penpot-exporter", "penpot-mcp"), "stopping the application containers");
205
- await docker(c, composeArgs(c, "up", "-d", "penpot-postgres", "penpot-valkey"), "starting the database");
206
- const postgres = await containerOf(c, "penpot-postgres");
207
- for (let i = 0; i < 60; i++) {
208
- if ((await c.runner.run("docker", ["exec", postgres, "pg_isready", "-U", "penpot", "-d", "penpot"])).code === 0)
209
- break;
210
- await sleepOf(c)(1_000);
211
- }
212
- say(c, "replacing the database");
213
- await docker(c, ["exec", postgres, "psql", "-U", "penpot", "-d", "postgres", "-c", "drop database penpot with (force)"], "dropping the database");
214
- await docker(c, ["exec", postgres, "psql", "-U", "penpot", "-d", "postgres", "-c", "create database penpot"], "creating the database");
215
- const loaded = await c.runner.run("docker", ["exec", "-i", postgres, "pg_restore", "-U", "penpot", "-d", "penpot", "--no-owner"], { inFile: path.join(directory, manifest.files.db.name) });
216
- if (loaded.code !== 0)
217
- throw new LifecycleError(`pg_restore failed: ${loaded.stderr.trim().slice(-200)}`);
218
- say(c, "replacing the assets");
219
- const volume = `${composeProjectOf(c.config)}_penpot_assets`;
220
- const assets = await c.runner.run("docker", ["run", "--rm", "-i", "-v", `${volume}:/data`, "--entrypoint", "sh", `penpotapp/backend:${c.config.penpotVersion}`, "-c", "find /data -mindepth 1 -delete && tar xzf - -C /data"], { inFile: path.join(directory, manifest.files.assets.name) });
221
- if (assets.code !== 0)
222
- throw new LifecycleError(`restoring the assets failed: ${assets.stderr.trim().slice(-200)}`);
223
- say(c, "starting everything again");
224
- await up(c);
225
- checks.push(...compareCounts(manifest.counts, await readCounts(c, await containerOf(c, "penpot-postgres"))));
226
- return { ok: checks.every((check) => check.ok), checks, ...(safetyBackup ? { safetyBackup } : {}) };
227
- }
228
- /** What is up, what is not, and what to do about it. */
229
- export async function doctor(c, options = {}) {
230
- const out = [];
231
- const add = (check, ok, detail, remedy) => void out.push({ check, ok, detail, ...(remedy && !ok ? { remedy } : {}) });
232
- const version = await c.runner.run("docker", ["version", "--format", "{{.Server.Version}}"]);
233
- add("docker", version.code === 0, version.code === 0 ? `engine ${version.stdout.trim()}` : "not answering", "Start Docker Desktop (or the Docker daemon).");
234
- if (version.code !== 0)
235
- return out;
236
- const paths = instancePaths(c.root, c.config);
237
- try {
238
- const mode = (await stat(paths.env)).mode & 0o777;
239
- const values = await readEnvValues(paths.env);
240
- add("secrets file", mode === 0o600 && Boolean(values.PENPOT_SECRET_KEY) && Boolean(values.PENPOT_DATABASE_PASSWORD), `permissions ${mode.toString(8)}, keys ${values.PENPOT_SECRET_KEY && values.PENPOT_DATABASE_PASSWORD ? "present" : "missing"}`, "Run `penpair init` to create it, and `chmod 600` the file.");
241
- }
242
- catch {
243
- add("secrets file", false, "missing", "Run `penpair init`.");
244
- }
245
- let state;
246
- try {
247
- state = await status(c);
248
- }
249
- catch (error) {
250
- add("containers", false, String(error.message), "Run `penpair up`.");
251
- }
252
- if (state) {
253
- const running = state.services.filter((s) => s.state === "running").map((s) => s.service);
254
- const needed = ["penpot-frontend", "penpot-backend", "penpot-postgres", "penpot-mcp"];
255
- const missing = needed.filter((service) => !running.includes(service));
256
- add("containers", missing.length === 0, missing.length === 0 ? `${running.length} running` : `not running: ${missing.join(", ")}`, "Run `penpair up`.");
257
- add("frontend", state.frontendReachable, state.frontendReachable ? state.url : `${state.url} does not answer`, "Run `penpair up` and wait for Penpot to start.");
258
- add("backend", state.backendReady, state.backendReady ? "ready" : "not ready yet (the proxy answers, the backend does not)", "Wait a few seconds after `penpair up`; if it stays like this, read the backend log with `docker compose logs penpot-backend`.");
259
- }
260
- if (options.mcp && !endpointIsInstance(options.mcp.origin, c.config)) {
261
- add("editor and plugin", false, `the MCP endpoint points at ${options.mcp.origin}, which is not this instance (${frontendUrl(c.config)})`, "Put this instance's MCP URL (Account, Integrations, MCP Server) in this project's .env.local as PENPAIR_MCP_URL.");
262
- }
263
- else if (options.mcp) {
264
- try {
265
- const { file } = await options.mcp.connect();
266
- add("editor and plugin", true, `connected to file "${file}"`);
267
- }
268
- catch (error) {
269
- add("editor and plugin", false, String(error.message).split("\n")[0] ?? "not connected", "Open a file in Penpot, choose File, MCP Server, Connect, and keep the tab visible.");
270
- }
271
- }
272
- return out;
273
- }
@@ -1,11 +0,0 @@
1
- import type { InstanceConfig } from "../instance/config.js";
2
- export interface Secrets {
3
- secretKey: string;
4
- databasePassword: string;
5
- }
6
- /** A 512-bit master key for Penpot and a database password. Generated once; changing the password later breaks an existing database volume. */
7
- export declare function generateSecrets(): Secrets;
8
- export declare function renderEnv(config: InstanceConfig, secrets: Secrets): string;
9
- /** Writes a file readable by its owner only. Refuses to overwrite unless told to. */
10
- export declare function writeSecretFile(file: string, content: string, overwrite?: boolean): Promise<boolean>;
11
- export declare function readEnvValues(file: string): Promise<Record<string, string>>;
@@ -1,38 +0,0 @@
1
- import { randomBytes } from "node:crypto";
2
- import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
- import path from "node:path";
4
- /** A 512-bit master key for Penpot and a database password. Generated once; changing the password later breaks an existing database volume. */
5
- export function generateSecrets() {
6
- return { secretKey: randomBytes(64).toString("base64url"), databasePassword: randomBytes(24).toString("base64url") };
7
- }
8
- export function renderEnv(config, secrets) {
9
- return `# Generated by penpair. Secrets: never commit, never print. Keep this file with the backups; the database password
10
- # must stay the same for as long as the database volume exists.
11
- PENPOT_VERSION=${config.penpotVersion}
12
- PENPOT_SECRET_KEY=${secrets.secretKey}
13
- PENPOT_DATABASE_PASSWORD=${secrets.databasePassword}
14
- `;
15
- }
16
- /** Writes a file readable by its owner only. Refuses to overwrite unless told to. */
17
- export async function writeSecretFile(file, content, overwrite = false) {
18
- await mkdir(path.dirname(file), { recursive: true });
19
- try {
20
- await writeFile(file, content, { mode: 0o600, flag: overwrite ? "w" : "wx" });
21
- }
22
- catch (error) {
23
- if (error.code === "EEXIST")
24
- return false;
25
- throw error;
26
- }
27
- await chmod(file, 0o600);
28
- return true;
29
- }
30
- export async function readEnvValues(file) {
31
- const out = {};
32
- for (const line of (await readFile(file, "utf8")).split(/\r?\n/)) {
33
- const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim());
34
- if (match)
35
- out[match[1]] = match[2];
36
- }
37
- return out;
38
- }
@@ -1,8 +0,0 @@
1
- import type { InstanceConfig } from "../instance/config.js";
2
- /**
3
- * The compose file for one instance. It follows the official Penpot compose file; the local changes are the defaults
4
- * worth keeping: a pinned version, ports bound to 127.0.0.1, telemetry off, secrets only from `.env`, local mail capture,
5
- * the MCP server on. Container ports of the database, cache, admin console, exporter and MCP are not published; the
6
- * MCP is reached through the frontend at /mcp/stream.
7
- */
8
- export declare function renderCompose(config: InstanceConfig): string;
@@ -1,158 +0,0 @@
1
- /**
2
- * The compose file for one instance. It follows the official Penpot compose file; the local changes are the defaults
3
- * worth keeping: a pinned version, ports bound to 127.0.0.1, telemetry off, secrets only from `.env`, local mail capture,
4
- * the MCP server on. Container ports of the database, cache, admin console, exporter and MCP are not published; the
5
- * MCP is reached through the frontend at /mcp/stream.
6
- */
7
- export function renderCompose(config) {
8
- const { frontend, mail } = config.ports;
9
- return `# Generated by penpair. Edit penpair.config.json and run \`penpair init\` again instead of editing this file.
10
- x-flags: &penpot-flags
11
- PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp enable-admin-console
12
-
13
- x-uri: &penpot-public-uri
14
- PENPOT_PUBLIC_URI: http://localhost:${frontend}
15
-
16
- x-body-size: &penpot-http-body-size
17
- PENPOT_HTTP_SERVER_MAX_BODY_SIZE: 367001600
18
- PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE: 367001600
19
-
20
- x-secret-key: &penpot-secret-key
21
- PENPOT_SECRET_KEY: \${PENPOT_SECRET_KEY:?Set PENPOT_SECRET_KEY in .env}
22
-
23
- x-database: &penpot-database
24
- PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot
25
- PENPOT_DATABASE_USERNAME: penpot
26
- PENPOT_DATABASE_PASSWORD: \${PENPOT_DATABASE_PASSWORD:?Set PENPOT_DATABASE_PASSWORD in .env}
27
-
28
- networks:
29
- penpot:
30
-
31
- volumes:
32
- penpot_postgres_v15:
33
- penpot_assets:
34
-
35
- services:
36
- penpot-frontend:
37
- image: "penpotapp/frontend:\${PENPOT_VERSION:-${config.penpotVersion}}"
38
- restart: always
39
- ports:
40
- - "127.0.0.1:${frontend}:8080"
41
- volumes:
42
- - penpot_assets:/opt/data/assets
43
- depends_on:
44
- - penpot-backend
45
- - penpot-admin-console
46
- - penpot-mcp
47
- - penpot-exporter
48
- networks:
49
- - penpot
50
- environment:
51
- << : [*penpot-flags, *penpot-http-body-size, *penpot-public-uri]
52
- PENPOT_ADMIN_CONSOLE_URI: http://penpot-admin-console:3000
53
-
54
- penpot-backend:
55
- image: "penpotapp/backend:\${PENPOT_VERSION:-${config.penpotVersion}}"
56
- restart: always
57
- volumes:
58
- - penpot_assets:/opt/data/assets
59
- depends_on:
60
- penpot-postgres:
61
- condition: service_healthy
62
- penpot-valkey:
63
- condition: service_healthy
64
- networks:
65
- - penpot
66
- environment:
67
- << : [*penpot-flags, *penpot-public-uri, *penpot-http-body-size, *penpot-secret-key, *penpot-database]
68
- PENPOT_ADMIN_CONSOLE_URI: http://penpot-admin-console:3000
69
- PENPOT_REDIS_URI: redis://penpot-valkey/0
70
- PENPOT_OBJECTS_STORAGE_BACKEND: fs
71
- PENPOT_OBJECTS_STORAGE_FS_DIRECTORY: /opt/data/assets
72
- PENPOT_TELEMETRY_ENABLED: "false"
73
- PENPOT_TELEMETRY_REFERER: compose
74
- PENPOT_SMTP_DEFAULT_FROM: no-reply@example.com
75
- PENPOT_SMTP_DEFAULT_REPLY_TO: no-reply@example.com
76
- PENPOT_SMTP_HOST: penpot-mailcatch
77
- PENPOT_SMTP_PORT: 1025
78
- PENPOT_SMTP_USERNAME:
79
- PENPOT_SMTP_PASSWORD:
80
- PENPOT_SMTP_TLS: "false"
81
- PENPOT_SMTP_SSL: "false"
82
-
83
- penpot-admin-console:
84
- image: "penpotapp/admin-console:\${PENPOT_VERSION:-${config.penpotVersion}}"
85
- restart: always
86
- depends_on:
87
- penpot-postgres:
88
- condition: service_healthy
89
- networks:
90
- - penpot
91
- environment:
92
- << : [*penpot-public-uri, *penpot-secret-key, *penpot-database]
93
- PENPOT_INTERNAL_URI: http://penpot-frontend:8080
94
-
95
- penpot-mcp:
96
- image: "penpotapp/mcp:\${PENPOT_VERSION:-${config.penpotVersion}}"
97
- restart: always
98
- networks:
99
- - penpot
100
-
101
- penpot-exporter:
102
- image: "penpotapp/exporter:\${PENPOT_VERSION:-${config.penpotVersion}}"
103
- restart: always
104
- depends_on:
105
- penpot-valkey:
106
- condition: service_healthy
107
- networks:
108
- - penpot
109
- environment:
110
- << : [*penpot-flags, *penpot-secret-key, *penpot-public-uri]
111
- PENPOT_INTERNAL_URI: http://penpot-frontend:8080
112
- PENPOT_REDIS_URI: redis://penpot-valkey/0
113
-
114
- penpot-postgres:
115
- image: "postgres:15"
116
- restart: always
117
- stop_signal: SIGINT
118
- healthcheck:
119
- test: ["CMD-SHELL", "pg_isready -U penpot"]
120
- interval: 2s
121
- timeout: 10s
122
- retries: 5
123
- start_period: 2s
124
- volumes:
125
- - penpot_postgres_v15:/var/lib/postgresql/data
126
- networks:
127
- - penpot
128
- environment:
129
- - POSTGRES_INITDB_ARGS=--data-checksums
130
- - POSTGRES_DB=penpot
131
- - POSTGRES_USER=penpot
132
- - POSTGRES_PASSWORD=\${PENPOT_DATABASE_PASSWORD:?Set PENPOT_DATABASE_PASSWORD in .env}
133
-
134
- penpot-valkey:
135
- image: valkey/valkey:8.1
136
- restart: always
137
- healthcheck:
138
- test: ["CMD-SHELL", "valkey-cli ping | grep PONG"]
139
- interval: 1s
140
- timeout: 3s
141
- retries: 5
142
- start_period: 3s
143
- networks:
144
- - penpot
145
- environment:
146
- - VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu
147
-
148
- penpot-mailcatch:
149
- image: sj26/mailcatcher:latest
150
- restart: always
151
- expose:
152
- - '1025'
153
- ports:
154
- - "127.0.0.1:${mail}:1080"
155
- networks:
156
- - penpot
157
- `;
158
- }