@penpair/docker 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,4 +2,6 @@
2
2
  import { runCli } from "@penpair/core";
3
3
  import { dockerProfile } from "./index.js";
4
4
  // The same `penpair` command as @penpair/core, with the commands that run Penpot in Docker added.
5
- process.exitCode = await runCli(process.argv.slice(2), { local: dockerProfile });
5
+ process.exitCode = await runCli(process.argv.slice(2), {
6
+ local: dockerProfile,
7
+ });
package/dist/index.js CHANGED
@@ -10,9 +10,14 @@ export { renderCompose } from "./template.js";
10
10
  /** The Docker way of running Penpot: what the `penpair` command is started with. */
11
11
  export const dockerProfile = {
12
12
  async preflight(runner) {
13
- const version = await runner.run("docker", ["version", "--format", "{{.Server.Version}}"]);
14
- if (version.code !== 0)
13
+ const version = await runner.run("docker", [
14
+ "version",
15
+ "--format",
16
+ "{{.Server.Version}}",
17
+ ]);
18
+ if (version.code !== 0) {
15
19
  throw new ConfigError("Docker is not running. Start Docker Desktop, wait until it says it is running, then run the command again.");
20
+ }
16
21
  },
17
22
  init: initDocker,
18
23
  up,
package/dist/init.js CHANGED
@@ -13,12 +13,17 @@ export async function isPortFree(port, host = "127.0.0.1") {
13
13
  });
14
14
  }
15
15
  export async function firstFreePort(from, avoid = [], free = isPortFree) {
16
- for (let port = from; port < from + 200; port++)
17
- if (!avoid.includes(port) && (await free(port)))
16
+ for (let port = from; port < from + 200; port++) {
17
+ if (!avoid.includes(port) && (await free(port))) {
18
18
  return port;
19
+ }
20
+ }
19
21
  throw new Error(`no free port from ${from}`);
20
22
  }
21
- const ensureGitignore = (root, entry) => ensureGitignoreEntry(root, entry, { directory: true, comment: "penpair: generated compose file, secrets and backups" });
23
+ const ensureGitignore = (root, entry) => ensureGitignoreEntry(root, entry, {
24
+ directory: true,
25
+ comment: "penpair: generated compose file, secrets and backups",
26
+ });
22
27
  /**
23
28
  * Sets an instance up in a project: config (committed), then the generated compose file and secrets (in a git-ignored
24
29
  * directory). Running it again rewrites the compose file from the config; existing secrets are never replaced.
@@ -30,27 +35,43 @@ export async function initDocker(root, options = {}) {
30
35
  config = await loadConfig(root);
31
36
  }
32
37
  catch (error) {
33
- if (!(error instanceof ConfigError) || error.code !== "missing")
38
+ if (!(error instanceof ConfigError) || error.code !== "missing") {
34
39
  throw error;
40
+ }
35
41
  const free = options.isFree ?? isPortFree;
36
42
  const frontend = options.port ?? (await firstFreePort(9001, [], free));
37
- const mail = options.mailPort ?? (await firstFreePort(1080, [frontend], free));
43
+ const mail = options.mailPort ??
44
+ (await firstFreePort(1080, [
45
+ frontend,
46
+ ], free));
38
47
  config = validateConfig({
39
48
  instance: options.instance ?? defaultInstanceName(root),
40
49
  penpotVersion: options.penpotVersion ?? testedPenpotVersion,
41
- ports: { frontend, mail },
50
+ ports: {
51
+ frontend,
52
+ mail,
53
+ },
42
54
  directory: options.directory ?? ".penpair",
43
- ...(options.composeProject ? { composeProject: options.composeProject } : {}),
55
+ ...(options.composeProject
56
+ ? {
57
+ composeProject: options.composeProject,
58
+ }
59
+ : {}),
44
60
  });
45
61
  await writeFile(path.join(root, configFileName), `${JSON.stringify(config, null, 2)}\n`);
46
62
  createdConfig = true;
47
63
  }
48
- if (isExternal(config))
64
+ if (isExternal(config)) {
49
65
  throw new ConfigError("This project uses a Penpot that someone else runs; there is nothing for Docker to set up.");
66
+ }
50
67
  const paths = instancePaths(root, config);
51
- await mkdir(paths.directory, { recursive: true });
68
+ await mkdir(paths.directory, {
69
+ recursive: true,
70
+ });
52
71
  await writeFile(paths.compose, renderCompose(config));
53
- const files = [paths.compose];
72
+ const files = [
73
+ paths.compose,
74
+ ];
54
75
  const createdSecrets = await writeSecretFile(paths.env, renderEnv(config, generateSecrets()));
55
76
  files.push(paths.env);
56
77
  let gitignoreAdded = false;
@@ -58,5 +79,11 @@ export async function initDocker(root, options = {}) {
58
79
  const directoryAdded = await ensureGitignore(root, config.directory);
59
80
  gitignoreAdded = (await ensureKeyFileIgnored(root)) || directoryAdded;
60
81
  }
61
- return { config, createdConfig, createdSecrets, files, gitignoreAdded };
82
+ return {
83
+ config,
84
+ createdConfig,
85
+ createdSecrets,
86
+ files,
87
+ gitignoreAdded,
88
+ };
62
89
  }
package/dist/lifecycle.js CHANGED
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { createReadStream } from "node:fs";
3
3
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { composeProjectOf, endpointIsInstance, frontendUrl, fileIdentityCheck, instancePaths, LifecycleError, } from "@penpair/core";
5
+ import { composeProjectOf, endpointIsInstance, frontendUrl, doctorEditor, instancePaths, LifecycleError, } from "@penpair/core";
6
6
  import { readEnvValues } from "./secrets.js";
7
7
  const defaultSleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
8
8
  const sleepOf = (c) => c.sleep ?? defaultSleep;
@@ -10,12 +10,22 @@ const say = (c, message) => c.log?.(message);
10
10
  /** `docker compose` arguments for this instance: its own project name, compose file and secrets. */
11
11
  export function composeArgs(c, ...rest) {
12
12
  const paths = instancePaths(c.root, c.config);
13
- return ["compose", "-p", composeProjectOf(c.config), "-f", paths.compose, "--env-file", paths.env, ...rest];
13
+ return [
14
+ "compose",
15
+ "-p",
16
+ composeProjectOf(c.config),
17
+ "-f",
18
+ paths.compose,
19
+ "--env-file",
20
+ paths.env,
21
+ ...rest,
22
+ ];
14
23
  }
15
24
  async function docker(c, args, what) {
16
25
  const result = await c.runner.run("docker", args);
17
- if (result.code !== 0)
26
+ if (result.code !== 0) {
18
27
  throw new LifecycleError(`${what} failed: ${result.stderr.trim().split("\n").slice(-3).join(" ") || `exit ${result.code}`}`);
28
+ }
19
29
  return result.stdout;
20
30
  }
21
31
  /** The frontend answers a few seconds before the backend does (502 until then); /readyz is 200 only when the backend is ready. */
@@ -24,9 +34,12 @@ export async function waitForHttp(c, url, timeoutMs = 240_000, stepMs = 3_000) {
24
34
  const doFetch = c.fetch ?? fetch;
25
35
  for (let waited = 0; waited <= timeoutMs; waited += stepMs) {
26
36
  try {
27
- const response = await doFetch(url, { redirect: "manual" });
28
- if (response.status < 500)
37
+ const response = await doFetch(url, {
38
+ redirect: "manual",
39
+ });
40
+ if (response.status < 500) {
29
41
  return true;
42
+ }
30
43
  }
31
44
  catch {
32
45
  // not up yet
@@ -39,7 +52,10 @@ export async function up(c) {
39
52
  say(c, "starting the containers");
40
53
  await docker(c, composeArgs(c, "up", "-d"), "docker compose up");
41
54
  say(c, "waiting for Penpot to answer (the first start can take a few minutes)");
42
- return { url: frontendUrl(c.config), ready: await waitForHttp(c, readyUrl(c.config)) };
55
+ return {
56
+ url: frontendUrl(c.config),
57
+ ready: await waitForHttp(c, readyUrl(c.config)),
58
+ };
43
59
  }
44
60
  /** Stops and removes the containers. Volumes are never removed: the design data stays. */
45
61
  export async function down(c) {
@@ -48,14 +64,21 @@ export async function down(c) {
48
64
  export function parseComposePs(output) {
49
65
  const rows = [];
50
66
  const text = output.trim();
51
- if (text.startsWith("["))
67
+ if (text.startsWith("[")) {
52
68
  rows.push(...JSON.parse(text));
53
- else
54
- for (const line of text.split("\n").filter(Boolean))
69
+ }
70
+ else {
71
+ for (const line of text.split("\n").filter(Boolean)) {
55
72
  rows.push(JSON.parse(line));
73
+ }
74
+ }
56
75
  return rows.map((row) => {
57
76
  const r = row;
58
- return { service: r.Service ?? r.Name ?? "?", state: r.State ?? "?", health: r.Health ?? "" };
77
+ return {
78
+ service: r.Service ?? r.Name ?? "?",
79
+ state: r.State ?? "?",
80
+ health: r.Health ?? "",
81
+ };
59
82
  });
60
83
  }
61
84
  export async function status(c) {
@@ -64,29 +87,50 @@ export async function status(c) {
64
87
  const url = frontendUrl(c.config);
65
88
  const answers = async (target, ok) => {
66
89
  try {
67
- return ok((await (c.fetch ?? fetch)(target, { redirect: "manual" })).status);
90
+ return ok((await (c.fetch ?? fetch)(target, {
91
+ redirect: "manual",
92
+ })).status);
68
93
  }
69
94
  catch {
70
95
  return false;
71
96
  }
72
97
  };
73
- return { services, frontendReachable: await answers(url, (code) => code < 500), backendReady: await answers(readyUrl(c.config), (code) => code === 200), url };
98
+ return {
99
+ services,
100
+ frontendReachable: await answers(url, (code) => code < 500),
101
+ backendReady: await answers(readyUrl(c.config), (code) => code === 200),
102
+ url,
103
+ };
74
104
  }
75
105
  async function containerOf(c, service) {
76
- const id = (await docker(c, composeArgs(c, "ps", "-q", service), `docker compose ps ${service}`)).trim().split("\n")[0];
77
- if (!id)
106
+ const id = (await docker(c, composeArgs(c, "ps", "-q", service), `docker compose ps ${service}`))
107
+ .trim()
108
+ .split("\n")[0];
109
+ if (!id) {
78
110
  throw new LifecycleError(`The ${service} container is not running. Run \`penpair up\` first.`);
111
+ }
79
112
  return id;
80
113
  }
81
114
  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))`;
82
115
  async function readCounts(c, container) {
83
- const out = await docker(c, ["exec", container, "psql", "-U", "penpot", "-d", "penpot", "-tAc", countsSql], "reading the database counts");
116
+ const out = await docker(c, [
117
+ "exec",
118
+ container,
119
+ "psql",
120
+ "-U",
121
+ "penpot",
122
+ "-d",
123
+ "penpot",
124
+ "-tAc",
125
+ countsSql,
126
+ ], "reading the database counts");
84
127
  return JSON.parse(out.trim());
85
128
  }
86
129
  async function sha256File(file) {
87
130
  const hash = createHash("sha256");
88
- for await (const chunk of createReadStream(file))
131
+ for await (const chunk of createReadStream(file)) {
89
132
  hash.update(chunk);
133
+ }
90
134
  return hash.digest("hex");
91
135
  }
92
136
  /**
@@ -97,29 +141,69 @@ export async function backup(c, label) {
97
141
  const paths = instancePaths(c.root, c.config);
98
142
  const stamp = (c.now?.() ?? new Date()).toISOString().replace(/[:.]/g, "-");
99
143
  const directory = path.join(paths.backups, label ? `${stamp}-${label}` : stamp);
100
- await mkdir(directory, { recursive: true });
144
+ await mkdir(directory, {
145
+ recursive: true,
146
+ });
101
147
  const postgres = await containerOf(c, "penpot-postgres");
102
148
  const backend = await containerOf(c, "penpot-backend");
103
149
  say(c, "dumping the database");
104
- const dump = await c.runner.run("docker", ["exec", postgres, "pg_dump", "-U", "penpot", "-d", "penpot", "-Fc"], { outFile: path.join(directory, "db.dump") });
105
- if (dump.code !== 0)
150
+ const dump = await c.runner.run("docker", [
151
+ "exec",
152
+ postgres,
153
+ "pg_dump",
154
+ "-U",
155
+ "penpot",
156
+ "-d",
157
+ "penpot",
158
+ "-Fc",
159
+ ], {
160
+ outFile: path.join(directory, "db.dump"),
161
+ });
162
+ if (dump.code !== 0) {
106
163
  throw new LifecycleError(`pg_dump failed: ${dump.stderr.trim().slice(-200)}`);
164
+ }
107
165
  say(c, "archiving the assets");
108
- const assets = await c.runner.run("docker", ["exec", backend, "tar", "czf", "-", "-C", "/opt/data/assets", "."], { outFile: path.join(directory, "assets.tgz") });
109
- if (assets.code !== 0)
166
+ const assets = await c.runner.run("docker", [
167
+ "exec",
168
+ backend,
169
+ "tar",
170
+ "czf",
171
+ "-",
172
+ "-C",
173
+ "/opt/data/assets",
174
+ ".",
175
+ ], {
176
+ outFile: path.join(directory, "assets.tgz"),
177
+ });
178
+ if (assets.code !== 0) {
110
179
  throw new LifecycleError(`archiving the assets failed: ${assets.stderr.trim().slice(-200)}`);
111
- const entry = async (name) => ({ name, sha256: await sha256File(path.join(directory, name)), bytes: (await stat(path.join(directory, name))).size });
180
+ }
181
+ const entry = async (name) => ({
182
+ name,
183
+ sha256: await sha256File(path.join(directory, name)),
184
+ bytes: (await stat(path.join(directory, name))).size,
185
+ });
112
186
  const manifest = {
113
187
  instance: c.config.instance,
114
188
  penpotVersion: c.config.penpotVersion,
115
189
  createdAt: (c.now?.() ?? new Date()).toISOString(),
116
- ...(label ? { label } : {}),
117
- files: { db: await entry("db.dump"), assets: await entry("assets.tgz") },
190
+ ...(label
191
+ ? {
192
+ label,
193
+ }
194
+ : {}),
195
+ files: {
196
+ db: await entry("db.dump"),
197
+ assets: await entry("assets.tgz"),
198
+ },
118
199
  counts: await readCounts(c, postgres),
119
200
  note: "Contains account data (password hashes). Never commit it. Keep the instance's .env with it.",
120
201
  };
121
202
  await writeFile(path.join(directory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
122
- return { directory, manifest };
203
+ return {
204
+ directory,
205
+ manifest,
206
+ };
123
207
  }
124
208
  async function loadManifest(directory) {
125
209
  try {
@@ -131,7 +215,10 @@ async function loadManifest(directory) {
131
215
  }
132
216
  async function checksumChecks(directory, manifest) {
133
217
  const checks = [];
134
- for (const key of ["db", "assets"]) {
218
+ for (const key of [
219
+ "db",
220
+ "assets",
221
+ ]) {
135
222
  const wanted = manifest.files[key];
136
223
  let actual = "missing";
137
224
  try {
@@ -140,13 +227,32 @@ async function checksumChecks(directory, manifest) {
140
227
  catch {
141
228
  // reported below
142
229
  }
143
- 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)}` });
230
+ checks.push({
231
+ check: `${wanted.name} checksum`,
232
+ ok: actual === wanted.sha256,
233
+ detail: actual === wanted.sha256
234
+ ? "matches the manifest"
235
+ : `expected ${wanted.sha256.slice(0, 12)}, found ${actual.slice(0, 12)}`,
236
+ });
144
237
  }
145
238
  return checks;
146
239
  }
147
240
  const compareCounts = (wanted, found) => [
148
- ...["files", "profiles", "teams", "storageObjects"].map((key) => ({ check: `restored ${key}`, ok: wanted[key] === found[key], detail: `${found[key]} (backup says ${wanted[key]})` })),
149
- { 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"}` },
241
+ ...[
242
+ "files",
243
+ "profiles",
244
+ "teams",
245
+ "storageObjects",
246
+ ].map((key) => ({
247
+ check: `restored ${key}`,
248
+ ok: wanted[key] === found[key],
249
+ detail: `${found[key]} (backup says ${wanted[key]})`,
250
+ })),
251
+ {
252
+ check: "file revisions",
253
+ ok: JSON.stringify(wanted.fileRevisions) === JSON.stringify(found.fileRevisions),
254
+ detail: `${found.fileRevisions.length} files, ids, names and revisions ${JSON.stringify(wanted.fileRevisions) === JSON.stringify(found.fileRevisions) ? "match" : "differ"}`,
255
+ },
150
256
  ];
151
257
  /** Proves a backup can be restored without touching the instance: restores the dump into a throwaway Postgres and compares. */
152
258
  export async function verifyBackup(c, directory) {
@@ -154,39 +260,93 @@ export async function verifyBackup(c, directory) {
154
260
  const checks = await checksumChecks(directory, manifest);
155
261
  const name = `penpair-verify-${Math.random().toString(36).slice(2, 8)}`;
156
262
  say(c, "starting a throwaway Postgres");
157
- 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");
263
+ await docker(c, [
264
+ "run",
265
+ "-d",
266
+ "--name",
267
+ name,
268
+ "-e",
269
+ "POSTGRES_USER=penpot",
270
+ "-e",
271
+ "POSTGRES_PASSWORD=verify",
272
+ "-e",
273
+ "POSTGRES_DB=penpot",
274
+ "-v",
275
+ `${path.resolve(directory)}:/backup:ro`,
276
+ "postgres:15",
277
+ ], "starting the throwaway Postgres");
158
278
  try {
159
279
  let ready = false;
160
280
  for (let i = 0; i < 40 && !ready; i++) {
161
- ready = (await c.runner.run("docker", ["exec", name, "pg_isready", "-U", "penpot", "-d", "penpot"])).code === 0;
162
- if (!ready)
281
+ ready =
282
+ (await c.runner.run("docker", [
283
+ "exec",
284
+ name,
285
+ "pg_isready",
286
+ "-U",
287
+ "penpot",
288
+ "-d",
289
+ "penpot",
290
+ ])).code === 0;
291
+ if (!ready) {
163
292
  await sleepOf(c)(1_000);
293
+ }
164
294
  }
165
- if (!ready)
295
+ if (!ready) {
166
296
  throw new LifecycleError("the throwaway Postgres did not become ready");
297
+ }
167
298
  say(c, "restoring the dump");
168
- await c.runner.run("docker", ["exec", name, "pg_restore", "-U", "penpot", "-d", "penpot", "--no-owner", "/backup/db.dump"]);
299
+ await c.runner.run("docker", [
300
+ "exec",
301
+ name,
302
+ "pg_restore",
303
+ "-U",
304
+ "penpot",
305
+ "-d",
306
+ "penpot",
307
+ "--no-owner",
308
+ "/backup/db.dump",
309
+ ]);
169
310
  checks.push(...compareCounts(manifest.counts, await readCounts(c, name)));
170
- const listing = await c.runner.run("tar", ["tzf", path.join(directory, manifest.files.assets.name)]);
311
+ const listing = await c.runner.run("tar", [
312
+ "tzf",
313
+ path.join(directory, manifest.files.assets.name),
314
+ ]);
171
315
  const entries = listing.stdout.split("\n").filter((line) => line && !line.endsWith("/")).length;
172
- checks.push({ check: "asset files", ok: entries === manifest.counts.storageObjects, detail: `${entries} files in the tarball, ${manifest.counts.storageObjects} storage objects` });
316
+ checks.push({
317
+ check: "asset files",
318
+ ok: entries === manifest.counts.storageObjects,
319
+ detail: `${entries} files in the tarball, ${manifest.counts.storageObjects} storage objects`,
320
+ });
173
321
  }
174
322
  finally {
175
- await c.runner.run("docker", ["rm", "-f", name]);
323
+ await c.runner.run("docker", [
324
+ "rm",
325
+ "-f",
326
+ name,
327
+ ]);
176
328
  }
177
- return { ok: checks.every((check) => check.ok), checks };
329
+ return {
330
+ ok: checks.every((check) => check.ok),
331
+ checks,
332
+ };
178
333
  }
179
334
  /**
180
335
  * Replaces the instance's database and assets with a backup. A backup of the current data is taken first unless told
181
336
  * not to, the checksums are checked before anything is touched, and the counts are compared afterwards.
182
337
  */
183
338
  export async function restore(c, directory, options) {
184
- if (!options.yes)
339
+ if (!options.yes) {
185
340
  throw new LifecycleError("Restoring replaces this instance's data. Run again with --yes to confirm.");
341
+ }
186
342
  const manifest = await loadManifest(directory);
187
343
  const checks = await checksumChecks(directory, manifest);
188
- if (!checks.every((check) => check.ok))
189
- return { ok: false, checks };
344
+ if (!checks.every((check) => check.ok)) {
345
+ return {
346
+ ok: false,
347
+ checks,
348
+ };
349
+ }
190
350
  let safetyBackup;
191
351
  if (!options.skipSafetyBackup) {
192
352
  try {
@@ -194,8 +354,9 @@ export async function restore(c, directory, options) {
194
354
  safetyBackup = (await backup(c, "pre-restore")).directory;
195
355
  }
196
356
  catch (error) {
197
- if (!(error instanceof LifecycleError) || !/not running/.test(error.message))
357
+ if (!(error instanceof LifecycleError) || !/not running/.test(error.message)) {
198
358
  throw error;
359
+ }
199
360
  say(c, "the instance is not running, so there is no current data to back up");
200
361
  }
201
362
  }
@@ -204,39 +365,119 @@ export async function restore(c, directory, options) {
204
365
  await docker(c, composeArgs(c, "up", "-d", "penpot-postgres", "penpot-valkey"), "starting the database");
205
366
  const postgres = await containerOf(c, "penpot-postgres");
206
367
  for (let i = 0; i < 60; i++) {
207
- if ((await c.runner.run("docker", ["exec", postgres, "pg_isready", "-U", "penpot", "-d", "penpot"])).code === 0)
368
+ if ((await c.runner.run("docker", [
369
+ "exec",
370
+ postgres,
371
+ "pg_isready",
372
+ "-U",
373
+ "penpot",
374
+ "-d",
375
+ "penpot",
376
+ ])).code === 0) {
208
377
  break;
378
+ }
209
379
  await sleepOf(c)(1_000);
210
380
  }
211
381
  say(c, "replacing the database");
212
- await docker(c, ["exec", postgres, "psql", "-U", "penpot", "-d", "postgres", "-c", "drop database penpot with (force)"], "dropping the database");
213
- await docker(c, ["exec", postgres, "psql", "-U", "penpot", "-d", "postgres", "-c", "create database penpot"], "creating the database");
214
- 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) });
215
- if (loaded.code !== 0)
382
+ await docker(c, [
383
+ "exec",
384
+ postgres,
385
+ "psql",
386
+ "-U",
387
+ "penpot",
388
+ "-d",
389
+ "postgres",
390
+ "-c",
391
+ "drop database penpot with (force)",
392
+ ], "dropping the database");
393
+ await docker(c, [
394
+ "exec",
395
+ postgres,
396
+ "psql",
397
+ "-U",
398
+ "penpot",
399
+ "-d",
400
+ "postgres",
401
+ "-c",
402
+ "create database penpot",
403
+ ], "creating the database");
404
+ const loaded = await c.runner.run("docker", [
405
+ "exec",
406
+ "-i",
407
+ postgres,
408
+ "pg_restore",
409
+ "-U",
410
+ "penpot",
411
+ "-d",
412
+ "penpot",
413
+ "--no-owner",
414
+ ], {
415
+ inFile: path.join(directory, manifest.files.db.name),
416
+ });
417
+ if (loaded.code !== 0) {
216
418
  throw new LifecycleError(`pg_restore failed: ${loaded.stderr.trim().slice(-200)}`);
419
+ }
217
420
  say(c, "replacing the assets");
218
421
  const volume = `${composeProjectOf(c.config)}_penpot_assets`;
219
- 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) });
220
- if (assets.code !== 0)
422
+ const assets = await c.runner.run("docker", [
423
+ "run",
424
+ "--rm",
425
+ "-i",
426
+ "-v",
427
+ `${volume}:/data`,
428
+ "--entrypoint",
429
+ "sh",
430
+ `penpotapp/backend:${c.config.penpotVersion}`,
431
+ "-c",
432
+ "find /data -mindepth 1 -delete && tar xzf - -C /data",
433
+ ], {
434
+ inFile: path.join(directory, manifest.files.assets.name),
435
+ });
436
+ if (assets.code !== 0) {
221
437
  throw new LifecycleError(`restoring the assets failed: ${assets.stderr.trim().slice(-200)}`);
438
+ }
222
439
  say(c, "starting everything again");
223
440
  await up(c);
224
441
  checks.push(...compareCounts(manifest.counts, await readCounts(c, await containerOf(c, "penpot-postgres"))));
225
- return { ok: checks.every((check) => check.ok), checks, ...(safetyBackup ? { safetyBackup } : {}) };
442
+ return {
443
+ ok: checks.every((check) => check.ok),
444
+ checks,
445
+ ...(safetyBackup
446
+ ? {
447
+ safetyBackup,
448
+ }
449
+ : {}),
450
+ };
226
451
  }
227
452
  /** What is up, what is not, and what to do about it. */
228
453
  export async function doctor(c, options = {}) {
229
454
  const out = [];
230
- const add = (check, ok, detail, remedy) => void out.push({ check, ok, detail, ...(remedy && !ok ? { remedy } : {}) });
231
- const version = await c.runner.run("docker", ["version", "--format", "{{.Server.Version}}"]);
455
+ const add = (check, ok, detail, remedy) => void out.push({
456
+ check,
457
+ ok,
458
+ detail,
459
+ ...(remedy && !ok
460
+ ? {
461
+ remedy,
462
+ }
463
+ : {}),
464
+ });
465
+ const version = await c.runner.run("docker", [
466
+ "version",
467
+ "--format",
468
+ "{{.Server.Version}}",
469
+ ]);
232
470
  add("docker", version.code === 0, version.code === 0 ? `engine ${version.stdout.trim()}` : "not answering", "Start Docker Desktop (or the Docker daemon).");
233
- if (version.code !== 0)
471
+ if (version.code !== 0) {
234
472
  return out;
473
+ }
235
474
  const paths = instancePaths(c.root, c.config);
236
475
  try {
237
476
  const mode = (await stat(paths.env)).mode & 0o777;
238
477
  const values = await readEnvValues(paths.env);
239
- 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.");
478
+ add("secrets file", mode === 0o600 &&
479
+ Boolean(values.PENPOT_SECRET_KEY) &&
480
+ 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.");
240
481
  }
241
482
  catch {
242
483
  add("secrets file", false, "missing", "Run `penpair init`.");
@@ -250,7 +491,12 @@ export async function doctor(c, options = {}) {
250
491
  }
251
492
  if (state) {
252
493
  const running = state.services.filter((s) => s.state === "running").map((s) => s.service);
253
- const needed = ["penpot-frontend", "penpot-backend", "penpot-postgres", "penpot-mcp"];
494
+ const needed = [
495
+ "penpot-frontend",
496
+ "penpot-backend",
497
+ "penpot-postgres",
498
+ "penpot-mcp",
499
+ ];
254
500
  const missing = needed.filter((service) => !running.includes(service));
255
501
  add("containers", missing.length === 0, missing.length === 0 ? `${running.length} running` : `not running: ${missing.join(", ")}`, "Run `penpair up`.");
256
502
  add("frontend", state.frontendReachable, state.frontendReachable ? state.url : `${state.url} does not answer`, "Run `penpair up` and wait for Penpot to start.");
@@ -260,16 +506,7 @@ export async function doctor(c, options = {}) {
260
506
  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.");
261
507
  }
262
508
  else if (options.mcp) {
263
- try {
264
- const actual = await options.mcp.connect();
265
- const { file } = actual;
266
- add("editor and plugin", true, `connected to file "${file}"`);
267
- if (c.config.file)
268
- out.push(fileIdentityCheck(c.config, actual));
269
- }
270
- catch (error) {
271
- add("editor and plugin", false, String(error.message).split("\n")[0] ?? "not connected", "Open a file in Penpot, choose File, MCP Server, Connect, and keep the tab visible in a separate window if needed.");
272
- }
509
+ out.push(...(await doctorEditor(c.config, options)));
273
510
  }
274
511
  return out;
275
512
  }
package/dist/secrets.js CHANGED
@@ -3,7 +3,10 @@ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  /** A 512-bit master key for Penpot and a database password. Generated once; changing the password later breaks an existing database volume. */
5
5
  export function generateSecrets() {
6
- return { secretKey: randomBytes(64).toString("base64url"), databasePassword: randomBytes(24).toString("base64url") };
6
+ return {
7
+ secretKey: randomBytes(64).toString("base64url"),
8
+ databasePassword: randomBytes(24).toString("base64url"),
9
+ };
7
10
  }
8
11
  export function renderEnv(config, secrets) {
9
12
  return `# Generated by penpair. Secrets: never commit, never print. Keep this file with the backups; the database password
@@ -15,13 +18,19 @@ PENPOT_DATABASE_PASSWORD=${secrets.databasePassword}
15
18
  }
16
19
  /** Writes a file readable by its owner only. Refuses to overwrite unless told to. */
17
20
  export async function writeSecretFile(file, content, overwrite = false) {
18
- await mkdir(path.dirname(file), { recursive: true });
21
+ await mkdir(path.dirname(file), {
22
+ recursive: true,
23
+ });
19
24
  try {
20
- await writeFile(file, content, { mode: 0o600, flag: overwrite ? "w" : "wx" });
25
+ await writeFile(file, content, {
26
+ mode: 0o600,
27
+ flag: overwrite ? "w" : "wx",
28
+ });
21
29
  }
22
30
  catch (error) {
23
- if (error.code === "EEXIST")
31
+ if (error.code === "EEXIST") {
24
32
  return false;
33
+ }
25
34
  throw error;
26
35
  }
27
36
  await chmod(file, 0o600);
@@ -31,8 +40,9 @@ export async function readEnvValues(file) {
31
40
  const out = {};
32
41
  for (const line of (await readFile(file, "utf8")).split(/\r?\n/)) {
33
42
  const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim());
34
- if (match)
43
+ if (match) {
35
44
  out[match[1]] = match[2];
45
+ }
36
46
  }
37
47
  return out;
38
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@penpair/docker",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Everything in @penpair/core, plus running Penpot in Docker on your computer: one instance per project, generated secrets, up and down, backup, verify and restore.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,7 +42,7 @@
42
42
  "access": "public"
43
43
  },
44
44
  "dependencies": {
45
- "@penpair/core": "0.2.0"
45
+ "@penpair/core": "0.3.1"
46
46
  },
47
47
  "bin": {
48
48
  "penpair": "dist/cli.js"