@penpair/docker 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +15 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +5 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +25 -0
- package/dist/init.d.ts +8 -0
- package/dist/init.js +62 -0
- package/dist/lifecycle.d.ts +46 -0
- package/dist/lifecycle.js +272 -0
- package/dist/secrets.d.ts +11 -0
- package/dist/secrets.js +38 -0
- package/dist/template.d.ts +8 -0
- package/dist/template.js +158 -0
- package/package.json +52 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Niklas Westman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @penpair/docker
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**The `penpair` command with Penpot in Docker on your computer. Design together. Build together.**
|
|
4
|
+
|
|
5
|
+
[penpair](https://github.com/niklas-westman/penpair) connects your AI coding assistant to a [Penpot](https://penpot.app) design file. This package is the same command as [`@penpair/web`](https://www.npmjs.com/package/@penpair/web) plus the commands that run Penpot on your own machine: one private Penpot per project, started with one command, with generated secrets kept out of git, and `backup`, `backup verify` and `restore`.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i -D @penpair/docker
|
|
9
|
+
npx penpair setup --docker
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
or, for a one-off run without installing: `npx @penpair/docker setup --docker`.
|
|
13
|
+
|
|
14
|
+
It includes everything in [`@penpair/core`](https://www.npmjs.com/package/@penpair/core) (and everything `@penpair/web` has), so install it **instead of** `@penpair/web`, not beside it: both provide the command `penpair`.
|
|
15
|
+
|
|
16
|
+
It needs Docker Desktop running and a 2 GB first download. The guide and the comparison of web and Docker are in the [repository](https://github.com/niklas-westman/penpair#web-or-docker).
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runCli } from "@penpair/core";
|
|
3
|
+
import { dockerProfile } from "./index.js";
|
|
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 });
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type LocalProfile } from "@penpair/core";
|
|
2
|
+
export * from "@penpair/core";
|
|
3
|
+
export { composeArgs, parseComposePs, readyUrl, waitForHttp } from "./lifecycle.js";
|
|
4
|
+
export { firstFreePort, initDocker, isPortFree } from "./init.js";
|
|
5
|
+
export { generateSecrets, readEnvValues, renderEnv, writeSecretFile } from "./secrets.js";
|
|
6
|
+
export { renderCompose } from "./template.js";
|
|
7
|
+
/** The Docker way of running Penpot: what the `penpair` command is started with. */
|
|
8
|
+
export declare const dockerProfile: LocalProfile;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ConfigError } from "@penpair/core";
|
|
2
|
+
import { initDocker } from "./init.js";
|
|
3
|
+
import { backup, doctor, down, restore, status, up, verifyBackup } from "./lifecycle.js";
|
|
4
|
+
// Everything the core has, so that a project with the Docker package imports from one place.
|
|
5
|
+
export * from "@penpair/core";
|
|
6
|
+
export { composeArgs, parseComposePs, readyUrl, waitForHttp } from "./lifecycle.js";
|
|
7
|
+
export { firstFreePort, initDocker, isPortFree } from "./init.js";
|
|
8
|
+
export { generateSecrets, readEnvValues, renderEnv, writeSecretFile } from "./secrets.js";
|
|
9
|
+
export { renderCompose } from "./template.js";
|
|
10
|
+
/** The Docker way of running Penpot: what the `penpair` command is started with. */
|
|
11
|
+
export const dockerProfile = {
|
|
12
|
+
async preflight(runner) {
|
|
13
|
+
const version = await runner.run("docker", ["version", "--format", "{{.Server.Version}}"]);
|
|
14
|
+
if (version.code !== 0)
|
|
15
|
+
throw new ConfigError("Docker is not running. Start Docker Desktop, wait until it says it is running, then run the command again.");
|
|
16
|
+
},
|
|
17
|
+
init: initDocker,
|
|
18
|
+
up,
|
|
19
|
+
down,
|
|
20
|
+
status,
|
|
21
|
+
doctor,
|
|
22
|
+
backup,
|
|
23
|
+
verifyBackup,
|
|
24
|
+
restore,
|
|
25
|
+
};
|
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type InitOptions, type InitResult } from "@penpair/core";
|
|
2
|
+
export declare function isPortFree(port: number, host?: string): Promise<boolean>;
|
|
3
|
+
export declare function firstFreePort(from: number, avoid?: number[], free?: (port: number) => Promise<boolean>): Promise<number>;
|
|
4
|
+
/**
|
|
5
|
+
* Sets an instance up in a project: config (committed), then the generated compose file and secrets (in a git-ignored
|
|
6
|
+
* directory). Running it again rewrites the compose file from the config; existing secrets are never replaced.
|
|
7
|
+
*/
|
|
8
|
+
export declare function initDocker(root: string, options?: InitOptions): Promise<InitResult>;
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createServer } from "node:net";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { configFileName, ConfigError, defaultInstanceName, ensureGitignoreEntry, ensureKeyFileIgnored, instancePaths, isExternal, loadConfig, testedPenpotVersion, validateConfig, } from "@penpair/core";
|
|
5
|
+
import { generateSecrets, renderEnv, writeSecretFile } from "./secrets.js";
|
|
6
|
+
import { renderCompose } from "./template.js";
|
|
7
|
+
export async function isPortFree(port, host = "127.0.0.1") {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
const server = createServer();
|
|
10
|
+
server.once("error", () => resolve(false));
|
|
11
|
+
server.once("listening", () => server.close(() => resolve(true)));
|
|
12
|
+
server.listen(port, host);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
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)))
|
|
18
|
+
return port;
|
|
19
|
+
throw new Error(`no free port from ${from}`);
|
|
20
|
+
}
|
|
21
|
+
const ensureGitignore = (root, entry) => ensureGitignoreEntry(root, entry, { directory: true, comment: "penpair: generated compose file, secrets and backups" });
|
|
22
|
+
/**
|
|
23
|
+
* Sets an instance up in a project: config (committed), then the generated compose file and secrets (in a git-ignored
|
|
24
|
+
* directory). Running it again rewrites the compose file from the config; existing secrets are never replaced.
|
|
25
|
+
*/
|
|
26
|
+
export async function initDocker(root, options = {}) {
|
|
27
|
+
let config;
|
|
28
|
+
let createdConfig = false;
|
|
29
|
+
try {
|
|
30
|
+
config = await loadConfig(root);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (!(error instanceof ConfigError) || !/No penpair\.config\.json/.test(error.message))
|
|
34
|
+
throw error;
|
|
35
|
+
const free = options.isFree ?? isPortFree;
|
|
36
|
+
const frontend = options.port ?? (await firstFreePort(9001, [], free));
|
|
37
|
+
const mail = options.mailPort ?? (await firstFreePort(1080, [frontend], free));
|
|
38
|
+
config = validateConfig({
|
|
39
|
+
instance: options.instance ?? defaultInstanceName(root),
|
|
40
|
+
penpotVersion: options.penpotVersion ?? testedPenpotVersion,
|
|
41
|
+
ports: { frontend, mail },
|
|
42
|
+
directory: options.directory ?? ".penpair",
|
|
43
|
+
...(options.composeProject ? { composeProject: options.composeProject } : {}),
|
|
44
|
+
});
|
|
45
|
+
await writeFile(path.join(root, configFileName), `${JSON.stringify(config, null, 2)}\n`);
|
|
46
|
+
createdConfig = true;
|
|
47
|
+
}
|
|
48
|
+
if (isExternal(config))
|
|
49
|
+
throw new ConfigError("This project uses a Penpot that someone else runs; there is nothing for Docker to set up.");
|
|
50
|
+
const paths = instancePaths(root, config);
|
|
51
|
+
await mkdir(paths.directory, { recursive: true });
|
|
52
|
+
await writeFile(paths.compose, renderCompose(config));
|
|
53
|
+
const files = [paths.compose];
|
|
54
|
+
const createdSecrets = await writeSecretFile(paths.env, renderEnv(config, generateSecrets()));
|
|
55
|
+
files.push(paths.env);
|
|
56
|
+
let gitignoreAdded = false;
|
|
57
|
+
if (options.gitignore) {
|
|
58
|
+
const directoryAdded = await ensureGitignore(root, config.directory);
|
|
59
|
+
gitignoreAdded = (await ensureKeyFileIgnored(root)) || directoryAdded;
|
|
60
|
+
}
|
|
61
|
+
return { config, createdConfig, createdSecrets, files, gitignoreAdded };
|
|
62
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type BackupManifest, type Check, type LocalContext, type DoctorCheck, type DoctorOptions, type InstanceConfig, type RestoreOptions, type ServiceState } from "@penpair/core";
|
|
2
|
+
/** Kept under its old name inside this package. */
|
|
3
|
+
type Context = LocalContext;
|
|
4
|
+
/** `docker compose` arguments for this instance: its own project name, compose file and secrets. */
|
|
5
|
+
export declare function composeArgs(c: Context, ...rest: string[]): string[];
|
|
6
|
+
/** The frontend answers a few seconds before the backend does (502 until then); /readyz is 200 only when the backend is ready. */
|
|
7
|
+
export declare const readyUrl: (config: InstanceConfig) => string;
|
|
8
|
+
export declare function waitForHttp(c: Context, url: string, timeoutMs?: number, stepMs?: number): Promise<boolean>;
|
|
9
|
+
export declare function up(c: Context): Promise<{
|
|
10
|
+
url: string;
|
|
11
|
+
ready: boolean;
|
|
12
|
+
}>;
|
|
13
|
+
/** Stops and removes the containers. Volumes are never removed: the design data stays. */
|
|
14
|
+
export declare function down(c: Context): Promise<void>;
|
|
15
|
+
export declare function parseComposePs(output: string): ServiceState[];
|
|
16
|
+
export declare function status(c: Context): Promise<{
|
|
17
|
+
services: ServiceState[];
|
|
18
|
+
frontendReachable: boolean;
|
|
19
|
+
backendReady: boolean;
|
|
20
|
+
url: string;
|
|
21
|
+
}>;
|
|
22
|
+
/**
|
|
23
|
+
* A backup of the whole instance: a Postgres dump and a tarball of the asset volume, with checksums and the counts
|
|
24
|
+
* that `backup verify` later checks a restore against. It holds account data (password hashes): never commit it.
|
|
25
|
+
*/
|
|
26
|
+
export declare function backup(c: Context, label?: string): Promise<{
|
|
27
|
+
directory: string;
|
|
28
|
+
manifest: BackupManifest;
|
|
29
|
+
}>;
|
|
30
|
+
/** Proves a backup can be restored without touching the instance: restores the dump into a throwaway Postgres and compares. */
|
|
31
|
+
export declare function verifyBackup(c: Context, directory: string): Promise<{
|
|
32
|
+
ok: boolean;
|
|
33
|
+
checks: Check[];
|
|
34
|
+
}>;
|
|
35
|
+
/**
|
|
36
|
+
* Replaces the instance's database and assets with a backup. A backup of the current data is taken first unless told
|
|
37
|
+
* not to, the checksums are checked before anything is touched, and the counts are compared afterwards.
|
|
38
|
+
*/
|
|
39
|
+
export declare function restore(c: Context, directory: string, options: RestoreOptions): Promise<{
|
|
40
|
+
ok: boolean;
|
|
41
|
+
checks: Check[];
|
|
42
|
+
safetyBackup?: string;
|
|
43
|
+
}>;
|
|
44
|
+
/** What is up, what is not, and what to do about it. */
|
|
45
|
+
export declare function doctor(c: Context, options?: DoctorOptions): Promise<DoctorCheck[]>;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,272 @@
|
|
|
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, endpointIsInstance, frontendUrl, instancePaths, LifecycleError, } from "@penpair/core";
|
|
6
|
+
import { readEnvValues } from "./secrets.js";
|
|
7
|
+
const defaultSleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
8
|
+
const sleepOf = (c) => c.sleep ?? defaultSleep;
|
|
9
|
+
const say = (c, message) => c.log?.(message);
|
|
10
|
+
/** `docker compose` arguments for this instance: its own project name, compose file and secrets. */
|
|
11
|
+
export function composeArgs(c, ...rest) {
|
|
12
|
+
const paths = instancePaths(c.root, c.config);
|
|
13
|
+
return ["compose", "-p", composeProjectOf(c.config), "-f", paths.compose, "--env-file", paths.env, ...rest];
|
|
14
|
+
}
|
|
15
|
+
async function docker(c, args, what) {
|
|
16
|
+
const result = await c.runner.run("docker", args);
|
|
17
|
+
if (result.code !== 0)
|
|
18
|
+
throw new LifecycleError(`${what} failed: ${result.stderr.trim().split("\n").slice(-3).join(" ") || `exit ${result.code}`}`);
|
|
19
|
+
return result.stdout;
|
|
20
|
+
}
|
|
21
|
+
/** The frontend answers a few seconds before the backend does (502 until then); /readyz is 200 only when the backend is ready. */
|
|
22
|
+
export const readyUrl = (config) => `${frontendUrl(config)}/readyz`;
|
|
23
|
+
export async function waitForHttp(c, url, timeoutMs = 240_000, stepMs = 3_000) {
|
|
24
|
+
const doFetch = c.fetch ?? fetch;
|
|
25
|
+
for (let waited = 0; waited <= timeoutMs; waited += stepMs) {
|
|
26
|
+
try {
|
|
27
|
+
const response = await doFetch(url, { redirect: "manual" });
|
|
28
|
+
if (response.status < 500)
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// not up yet
|
|
33
|
+
}
|
|
34
|
+
await sleepOf(c)(stepMs);
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
export async function up(c) {
|
|
39
|
+
say(c, "starting the containers");
|
|
40
|
+
await docker(c, composeArgs(c, "up", "-d"), "docker compose up");
|
|
41
|
+
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)) };
|
|
43
|
+
}
|
|
44
|
+
/** Stops and removes the containers. Volumes are never removed: the design data stays. */
|
|
45
|
+
export async function down(c) {
|
|
46
|
+
await docker(c, composeArgs(c, "down"), "docker compose down");
|
|
47
|
+
}
|
|
48
|
+
export function parseComposePs(output) {
|
|
49
|
+
const rows = [];
|
|
50
|
+
const text = output.trim();
|
|
51
|
+
if (text.startsWith("["))
|
|
52
|
+
rows.push(...JSON.parse(text));
|
|
53
|
+
else
|
|
54
|
+
for (const line of text.split("\n").filter(Boolean))
|
|
55
|
+
rows.push(JSON.parse(line));
|
|
56
|
+
return rows.map((row) => {
|
|
57
|
+
const r = row;
|
|
58
|
+
return { service: r.Service ?? r.Name ?? "?", state: r.State ?? "?", health: r.Health ?? "" };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
export async function status(c) {
|
|
62
|
+
const output = await docker(c, composeArgs(c, "ps", "--all", "--format", "json"), "docker compose ps");
|
|
63
|
+
const services = output.trim() ? parseComposePs(output) : [];
|
|
64
|
+
const url = frontendUrl(c.config);
|
|
65
|
+
const answers = async (target, ok) => {
|
|
66
|
+
try {
|
|
67
|
+
return ok((await (c.fetch ?? fetch)(target, { redirect: "manual" })).status);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
return { services, frontendReachable: await answers(url, (code) => code < 500), backendReady: await answers(readyUrl(c.config), (code) => code === 200), url };
|
|
74
|
+
}
|
|
75
|
+
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)
|
|
78
|
+
throw new LifecycleError(`The ${service} container is not running. Run \`penpair up\` first.`);
|
|
79
|
+
return id;
|
|
80
|
+
}
|
|
81
|
+
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
|
+
async function readCounts(c, container) {
|
|
83
|
+
const out = await docker(c, ["exec", container, "psql", "-U", "penpot", "-d", "penpot", "-tAc", countsSql], "reading the database counts");
|
|
84
|
+
return JSON.parse(out.trim());
|
|
85
|
+
}
|
|
86
|
+
async function sha256File(file) {
|
|
87
|
+
const hash = createHash("sha256");
|
|
88
|
+
for await (const chunk of createReadStream(file))
|
|
89
|
+
hash.update(chunk);
|
|
90
|
+
return hash.digest("hex");
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A backup of the whole instance: a Postgres dump and a tarball of the asset volume, with checksums and the counts
|
|
94
|
+
* that `backup verify` later checks a restore against. It holds account data (password hashes): never commit it.
|
|
95
|
+
*/
|
|
96
|
+
export async function backup(c, label) {
|
|
97
|
+
const paths = instancePaths(c.root, c.config);
|
|
98
|
+
const stamp = (c.now?.() ?? new Date()).toISOString().replace(/[:.]/g, "-");
|
|
99
|
+
const directory = path.join(paths.backups, label ? `${stamp}-${label}` : stamp);
|
|
100
|
+
await mkdir(directory, { recursive: true });
|
|
101
|
+
const postgres = await containerOf(c, "penpot-postgres");
|
|
102
|
+
const backend = await containerOf(c, "penpot-backend");
|
|
103
|
+
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)
|
|
106
|
+
throw new LifecycleError(`pg_dump failed: ${dump.stderr.trim().slice(-200)}`);
|
|
107
|
+
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)
|
|
110
|
+
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 });
|
|
112
|
+
const manifest = {
|
|
113
|
+
instance: c.config.instance,
|
|
114
|
+
penpotVersion: c.config.penpotVersion,
|
|
115
|
+
createdAt: (c.now?.() ?? new Date()).toISOString(),
|
|
116
|
+
...(label ? { label } : {}),
|
|
117
|
+
files: { db: await entry("db.dump"), assets: await entry("assets.tgz") },
|
|
118
|
+
counts: await readCounts(c, postgres),
|
|
119
|
+
note: "Contains account data (password hashes). Never commit it. Keep the instance's .env with it.",
|
|
120
|
+
};
|
|
121
|
+
await writeFile(path.join(directory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
122
|
+
return { directory, manifest };
|
|
123
|
+
}
|
|
124
|
+
async function loadManifest(directory) {
|
|
125
|
+
try {
|
|
126
|
+
return JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8"));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new LifecycleError(`${directory} is not a penpair backup (no manifest.json)`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function checksumChecks(directory, manifest) {
|
|
133
|
+
const checks = [];
|
|
134
|
+
for (const key of ["db", "assets"]) {
|
|
135
|
+
const wanted = manifest.files[key];
|
|
136
|
+
let actual = "missing";
|
|
137
|
+
try {
|
|
138
|
+
actual = await sha256File(path.join(directory, wanted.name));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// reported below
|
|
142
|
+
}
|
|
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)}` });
|
|
144
|
+
}
|
|
145
|
+
return checks;
|
|
146
|
+
}
|
|
147
|
+
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"}` },
|
|
150
|
+
];
|
|
151
|
+
/** Proves a backup can be restored without touching the instance: restores the dump into a throwaway Postgres and compares. */
|
|
152
|
+
export async function verifyBackup(c, directory) {
|
|
153
|
+
const manifest = await loadManifest(directory);
|
|
154
|
+
const checks = await checksumChecks(directory, manifest);
|
|
155
|
+
const name = `penpair-verify-${Math.random().toString(36).slice(2, 8)}`;
|
|
156
|
+
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");
|
|
158
|
+
try {
|
|
159
|
+
let ready = false;
|
|
160
|
+
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)
|
|
163
|
+
await sleepOf(c)(1_000);
|
|
164
|
+
}
|
|
165
|
+
if (!ready)
|
|
166
|
+
throw new LifecycleError("the throwaway Postgres did not become ready");
|
|
167
|
+
say(c, "restoring the dump");
|
|
168
|
+
await c.runner.run("docker", ["exec", name, "pg_restore", "-U", "penpot", "-d", "penpot", "--no-owner", "/backup/db.dump"]);
|
|
169
|
+
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)]);
|
|
171
|
+
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` });
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
await c.runner.run("docker", ["rm", "-f", name]);
|
|
176
|
+
}
|
|
177
|
+
return { ok: checks.every((check) => check.ok), checks };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Replaces the instance's database and assets with a backup. A backup of the current data is taken first unless told
|
|
181
|
+
* not to, the checksums are checked before anything is touched, and the counts are compared afterwards.
|
|
182
|
+
*/
|
|
183
|
+
export async function restore(c, directory, options) {
|
|
184
|
+
if (!options.yes)
|
|
185
|
+
throw new LifecycleError("Restoring replaces this instance's data. Run again with --yes to confirm.");
|
|
186
|
+
const manifest = await loadManifest(directory);
|
|
187
|
+
const checks = await checksumChecks(directory, manifest);
|
|
188
|
+
if (!checks.every((check) => check.ok))
|
|
189
|
+
return { ok: false, checks };
|
|
190
|
+
let safetyBackup;
|
|
191
|
+
if (!options.skipSafetyBackup) {
|
|
192
|
+
try {
|
|
193
|
+
say(c, "taking a safety backup of the current data first");
|
|
194
|
+
safetyBackup = (await backup(c, "pre-restore")).directory;
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (!(error instanceof LifecycleError) || !/not running/.test(error.message))
|
|
198
|
+
throw error;
|
|
199
|
+
say(c, "the instance is not running, so there is no current data to back up");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
say(c, "stopping the application containers");
|
|
203
|
+
await docker(c, composeArgs(c, "stop", "penpot-frontend", "penpot-backend", "penpot-admin-console", "penpot-exporter", "penpot-mcp"), "stopping the application containers");
|
|
204
|
+
await docker(c, composeArgs(c, "up", "-d", "penpot-postgres", "penpot-valkey"), "starting the database");
|
|
205
|
+
const postgres = await containerOf(c, "penpot-postgres");
|
|
206
|
+
for (let i = 0; i < 60; i++) {
|
|
207
|
+
if ((await c.runner.run("docker", ["exec", postgres, "pg_isready", "-U", "penpot", "-d", "penpot"])).code === 0)
|
|
208
|
+
break;
|
|
209
|
+
await sleepOf(c)(1_000);
|
|
210
|
+
}
|
|
211
|
+
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)
|
|
216
|
+
throw new LifecycleError(`pg_restore failed: ${loaded.stderr.trim().slice(-200)}`);
|
|
217
|
+
say(c, "replacing the assets");
|
|
218
|
+
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)
|
|
221
|
+
throw new LifecycleError(`restoring the assets failed: ${assets.stderr.trim().slice(-200)}`);
|
|
222
|
+
say(c, "starting everything again");
|
|
223
|
+
await up(c);
|
|
224
|
+
checks.push(...compareCounts(manifest.counts, await readCounts(c, await containerOf(c, "penpot-postgres"))));
|
|
225
|
+
return { ok: checks.every((check) => check.ok), checks, ...(safetyBackup ? { safetyBackup } : {}) };
|
|
226
|
+
}
|
|
227
|
+
/** What is up, what is not, and what to do about it. */
|
|
228
|
+
export async function doctor(c, options = {}) {
|
|
229
|
+
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}}"]);
|
|
232
|
+
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)
|
|
234
|
+
return out;
|
|
235
|
+
const paths = instancePaths(c.root, c.config);
|
|
236
|
+
try {
|
|
237
|
+
const mode = (await stat(paths.env)).mode & 0o777;
|
|
238
|
+
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.");
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
add("secrets file", false, "missing", "Run `penpair init`.");
|
|
243
|
+
}
|
|
244
|
+
let state;
|
|
245
|
+
try {
|
|
246
|
+
state = await status(c);
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
add("containers", false, String(error.message), "Run `penpair up`.");
|
|
250
|
+
}
|
|
251
|
+
if (state) {
|
|
252
|
+
const running = state.services.filter((s) => s.state === "running").map((s) => s.service);
|
|
253
|
+
const needed = ["penpot-frontend", "penpot-backend", "penpot-postgres", "penpot-mcp"];
|
|
254
|
+
const missing = needed.filter((service) => !running.includes(service));
|
|
255
|
+
add("containers", missing.length === 0, missing.length === 0 ? `${running.length} running` : `not running: ${missing.join(", ")}`, "Run `penpair up`.");
|
|
256
|
+
add("frontend", state.frontendReachable, state.frontendReachable ? state.url : `${state.url} does not answer`, "Run `penpair up` and wait for Penpot to start.");
|
|
257
|
+
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`.");
|
|
258
|
+
}
|
|
259
|
+
if (options.mcp && !endpointIsInstance(options.mcp.origin, c.config)) {
|
|
260
|
+
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
|
+
}
|
|
262
|
+
else if (options.mcp) {
|
|
263
|
+
try {
|
|
264
|
+
const { file } = await options.mcp.connect();
|
|
265
|
+
add("editor and plugin", true, `connected to file "${file}"`);
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
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.");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { InstanceConfig } from "@penpair/core";
|
|
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>>;
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { InstanceConfig } from "@penpair/core";
|
|
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;
|
package/dist/template.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@penpair/docker",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/niklas-westman/penpair.git",
|
|
9
|
+
"directory": "packages/docker"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/niklas-westman/penpair#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/niklas-westman/penpair/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"import": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts"
|
|
25
|
+
},
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"keywords": [
|
|
34
|
+
"penpot",
|
|
35
|
+
"docker",
|
|
36
|
+
"design-systems",
|
|
37
|
+
"mcp",
|
|
38
|
+
"ai-agents",
|
|
39
|
+
"developer-tools"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@penpair/core": "0.1.1"
|
|
46
|
+
},
|
|
47
|
+
"bin": {
|
|
48
|
+
"penpair": "dist/cli.js"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsc -p tsconfig.json",
|
|
52
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
53
|
+
"test": "vitest run"
|
|
54
|
+
}
|
|
6
55
|
}
|