@penpair/core 0.1.3 → 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 (149) hide show
  1. package/dist/adapters.d.ts +16 -1
  2. package/dist/adapters.js +62 -19
  3. package/dist/approvals.d.ts +32 -0
  4. package/dist/approvals.js +213 -0
  5. package/dist/atomic-output.d.ts +5 -0
  6. package/dist/atomic-output.js +33 -0
  7. package/dist/audit-rules.d.ts +60 -0
  8. package/dist/audit-rules.js +443 -0
  9. package/dist/backup-note.d.ts +4 -0
  10. package/dist/backup-note.js +49 -0
  11. package/dist/baseline-coverage.d.ts +25 -0
  12. package/dist/baseline-coverage.js +248 -0
  13. package/dist/baselines-config.d.ts +25 -0
  14. package/dist/baselines-config.js +150 -11
  15. package/dist/baselines.d.ts +37 -8
  16. package/dist/baselines.js +362 -59
  17. package/dist/cli-arguments.d.ts +7 -0
  18. package/dist/cli-arguments.js +47 -0
  19. package/dist/cli-main.d.ts +13 -1
  20. package/dist/cli-main.js +2107 -247
  21. package/dist/client.d.ts +30 -6
  22. package/dist/client.js +160 -35
  23. package/dist/command-result.d.ts +159 -0
  24. package/dist/command-result.js +468 -0
  25. package/dist/coordination.d.ts +15 -0
  26. package/dist/coordination.js +184 -0
  27. package/dist/credentials.d.ts +21 -0
  28. package/dist/credentials.js +105 -0
  29. package/dist/deep-check.js +57 -9
  30. package/dist/design-spec.d.ts +55 -0
  31. package/dist/design-spec.js +275 -0
  32. package/dist/endpoint.d.ts +8 -1
  33. package/dist/endpoint.js +133 -21
  34. package/dist/errors.d.ts +2 -1
  35. package/dist/errors.js +8 -4
  36. package/dist/export.d.ts +18 -0
  37. package/dist/export.js +113 -10
  38. package/dist/file-archive.d.ts +16 -0
  39. package/dist/file-archive.js +58 -0
  40. package/dist/file-guard.d.ts +8 -0
  41. package/dist/file-guard.js +58 -0
  42. package/dist/formats.d.ts +4 -0
  43. package/dist/formats.js +229 -0
  44. package/dist/index.d.ts +34 -1
  45. package/dist/index.js +34 -1
  46. package/dist/inspection.d.ts +106 -0
  47. package/dist/inspection.js +657 -0
  48. package/dist/instance/config-schema.d.ts +422 -0
  49. package/dist/instance/config-schema.js +435 -0
  50. package/dist/instance/config.d.ts +29 -0
  51. package/dist/instance/config.js +191 -29
  52. package/dist/instance/connect.d.ts +10 -1
  53. package/dist/instance/connect.js +178 -30
  54. package/dist/instance/connection.d.ts +39 -4
  55. package/dist/instance/connection.js +198 -14
  56. package/dist/instance/gitignore.js +5 -2
  57. package/dist/instance/init.js +32 -7
  58. package/dist/instance/runner.js +32 -10
  59. package/dist/instance/setup.js +46 -13
  60. package/dist/instance/tty.js +28 -8
  61. package/dist/journal.d.ts +87 -0
  62. package/dist/journal.js +423 -0
  63. package/dist/kit-smoke.d.ts +31 -0
  64. package/dist/kit-smoke.js +145 -0
  65. package/dist/kit-source.d.ts +2 -0
  66. package/dist/kit-source.js +279 -0
  67. package/dist/kit.d.ts +25 -0
  68. package/dist/kit.js +158 -0
  69. package/dist/layout-policy.d.ts +2 -0
  70. package/dist/layout-policy.js +86 -0
  71. package/dist/layout.d.ts +21 -0
  72. package/dist/layout.js +36 -0
  73. package/dist/live-smoke.d.ts +8 -0
  74. package/dist/live-smoke.js +23 -0
  75. package/dist/local-profile.js +2 -1
  76. package/dist/mcp-server.d.ts +12 -0
  77. package/dist/mcp-server.js +415 -0
  78. package/dist/native-api.d.ts +15 -0
  79. package/dist/native-api.js +78 -0
  80. package/dist/onboard.d.ts +10 -0
  81. package/dist/onboard.js +41 -0
  82. package/dist/operation.d.ts +22 -3
  83. package/dist/operation.js +341 -31
  84. package/dist/page-session.d.ts +23 -0
  85. package/dist/page-session.js +142 -0
  86. package/dist/png.d.ts +5 -0
  87. package/dist/png.js +22 -0
  88. package/dist/portable-tokens.d.ts +22 -0
  89. package/dist/portable-tokens.js +331 -0
  90. package/dist/probe.d.ts +6 -2
  91. package/dist/probe.js +64 -11
  92. package/dist/project-input.d.ts +6 -0
  93. package/dist/project-input.js +65 -0
  94. package/dist/project-snippets.js +29 -12
  95. package/dist/project-status.d.ts +24 -0
  96. package/dist/project-status.js +188 -0
  97. package/dist/registry.d.ts +2 -1
  98. package/dist/registry.js +224 -53
  99. package/dist/run-file.d.ts +26 -0
  100. package/dist/run-file.js +199 -0
  101. package/dist/shots.d.ts +43 -0
  102. package/dist/shots.js +172 -0
  103. package/dist/skill-instructions.d.ts +13 -0
  104. package/dist/skill-instructions.js +144 -0
  105. package/dist/skills.d.ts +7 -0
  106. package/dist/skills.js +163 -46
  107. package/dist/snippet.d.ts +3 -2
  108. package/dist/snippet.js +33 -9
  109. package/dist/snippets/archive.d.ts +9 -0
  110. package/dist/snippets/archive.js +10 -4
  111. package/dist/snippets/read.d.ts +1 -1
  112. package/dist/snippets/read.js +78 -41
  113. package/dist/structure-snapshot.d.ts +85 -0
  114. package/dist/structure-snapshot.js +525 -0
  115. package/dist/token-edit.d.ts +53 -0
  116. package/dist/token-edit.js +352 -0
  117. package/dist/token-projection.d.ts +56 -0
  118. package/dist/token-projection.js +286 -0
  119. package/dist/tokens-diff.d.ts +19 -6
  120. package/dist/tokens-diff.js +182 -18
  121. package/dist/version.d.ts +1 -1
  122. package/dist/version.js +1 -1
  123. package/package.json +1 -1
  124. package/skills/penpair-design/SKILL.md +20 -45
  125. package/skills/penpair-design/references/penpot-plugin-gotchas.md +58 -2
  126. package/skills/penpair-design/references/project-facts.md +3 -1
  127. package/skills/penpair-design/references/write-protocol.md +15 -3
  128. package/skills/penpair-handoff/SKILL.md +16 -0
  129. package/skills/penpair-handoff/references/comparison-companion.md +13 -0
  130. package/skills/penpair-handoff/references/handoff-flow.md +13 -0
  131. package/skills/penpair-recipes/SKILL.md +21 -0
  132. package/skills/penpair-recipes/references/build-flow.md +11 -0
  133. package/skills/penpair-setup/SKILL.md +8 -2
  134. package/dist/cli.d.ts +0 -2
  135. package/dist/cli.js +0 -3
  136. package/dist/docker/lifecycle.d.ts +0 -96
  137. package/dist/docker/lifecycle.js +0 -273
  138. package/dist/docker/secrets.d.ts +0 -11
  139. package/dist/docker/secrets.js +0 -38
  140. package/dist/docker/template.d.ts +0 -8
  141. package/dist/docker/template.js +0 -158
  142. package/dist/docker-profile.d.ts +0 -114
  143. package/dist/docker-profile.js +0 -18
  144. package/dist/instance/lifecycle.d.ts +0 -129
  145. package/dist/instance/lifecycle.js +0 -336
  146. package/dist/instance/secrets.d.ts +0 -11
  147. package/dist/instance/secrets.js +0 -38
  148. package/dist/instance/template.d.ts +0 -8
  149. package/dist/instance/template.js +0 -158
@@ -1,114 +0,0 @@
1
- import { type InstanceConfig, type ProjectConfig } from "./instance/config.js";
2
- import type { Check, DoctorCheck, DoctorOptions } from "./instance/connection.js";
3
- import type { Runner } from "./instance/runner.js";
4
- /**
5
- * Running Penpot in Docker on this computer is one way to run it, and it lives in its own package, `@penpair/docker`, so a project
6
- * that uses Penpot on the web carries none of it. This file is the whole contract between the two: the core knows these types and
7
- * loads the package only when a project's profile or a command needs it. The package implements `DockerProfile`.
8
- */
9
- /** What a Docker action needs to know about the project. Injectable so it can be tested without Docker. */
10
- export interface DockerContext {
11
- root: string;
12
- config: InstanceConfig;
13
- runner: Runner;
14
- log?: (message: string) => void;
15
- sleep?: (milliseconds: number) => Promise<void>;
16
- fetch?: typeof fetch;
17
- now?: () => Date;
18
- }
19
- export interface ServiceState {
20
- service: string;
21
- state: string;
22
- health: string;
23
- }
24
- export interface Counts {
25
- files: number;
26
- profiles: number;
27
- teams: number;
28
- storageObjects: number;
29
- fileRevisions: {
30
- id: string;
31
- name: string;
32
- revn: number;
33
- }[];
34
- }
35
- export interface BackupManifest {
36
- instance: string;
37
- penpotVersion: string;
38
- createdAt: string;
39
- label?: string;
40
- files: {
41
- db: {
42
- name: string;
43
- sha256: string;
44
- bytes: number;
45
- };
46
- assets: {
47
- name: string;
48
- sha256: string;
49
- bytes: number;
50
- };
51
- };
52
- counts: Counts;
53
- note: string;
54
- }
55
- export interface RestoreOptions {
56
- /** Restoring replaces the instance's data; it only runs when this is true. */
57
- yes: boolean;
58
- /** Skip the safety backup of the current data. */
59
- skipSafetyBackup?: boolean;
60
- }
61
- export interface InitOptions {
62
- instance?: string;
63
- port?: number;
64
- mailPort?: number;
65
- penpotVersion?: string;
66
- directory?: string;
67
- composeProject?: string;
68
- /** Use a Penpot someone else runs (a team server or the hosted service) instead of starting one: its origin, such as https://design.penpot.app. */
69
- external?: string;
70
- /** Add the instance directory and the key file to .gitignore. */
71
- gitignore?: boolean;
72
- isFree?: (port: number) => Promise<boolean>;
73
- }
74
- export interface InitResult {
75
- config: ProjectConfig;
76
- createdConfig: boolean;
77
- createdSecrets: boolean;
78
- files: string[];
79
- gitignoreAdded: boolean;
80
- }
81
- export interface DockerProfile {
82
- /** Throws a plain-language ConfigError when Docker is not running. */
83
- preflight(runner: Runner): Promise<void>;
84
- /** Writes the config when missing, the compose file and the secrets (never replaced). */
85
- init(root: string, options: InitOptions): Promise<InitResult>;
86
- up(c: DockerContext): Promise<{
87
- url: string;
88
- ready: boolean;
89
- }>;
90
- down(c: DockerContext): Promise<void>;
91
- status(c: DockerContext): Promise<{
92
- services: ServiceState[];
93
- frontendReachable: boolean;
94
- backendReady: boolean;
95
- url: string;
96
- }>;
97
- doctor(c: DockerContext, options?: DoctorOptions): Promise<DoctorCheck[]>;
98
- backup(c: DockerContext, label?: string): Promise<{
99
- directory: string;
100
- manifest: BackupManifest;
101
- }>;
102
- verifyBackup(c: DockerContext, directory: string): Promise<{
103
- ok: boolean;
104
- checks: Check[];
105
- }>;
106
- restore(c: DockerContext, directory: string, options: RestoreOptions): Promise<{
107
- ok: boolean;
108
- checks: Check[];
109
- safetyBackup?: string;
110
- }>;
111
- }
112
- export declare const dockerPackage = "@penpair/docker";
113
- /** Loads `@penpair/docker`, or says in plain words how to get it. The specifier is a variable so the core never depends on the package. */
114
- export declare function loadDockerProfile(importer?: (name: string) => Promise<unknown>): Promise<DockerProfile>;
@@ -1,18 +0,0 @@
1
- import { ConfigError } from "./instance/config.js";
2
- export const dockerPackage = "@penpair/docker";
3
- /** Loads `@penpair/docker`, or says in plain words how to get it. The specifier is a variable so the core never depends on the package. */
4
- export async function loadDockerProfile(importer = (name) => import(name)) {
5
- let loaded;
6
- try {
7
- loaded = (await importer(dockerPackage));
8
- }
9
- catch (error) {
10
- const code = error.code;
11
- if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND")
12
- throw new ConfigError("Running Penpot in Docker on this computer is a separate package that is not installed here. Install it with `npm i -D @penpair/docker`, or for a one-off run `npx -p @penpair/core -p @penpair/docker penpair <command>`. Penpot on the web needs nothing extra.");
13
- throw error;
14
- }
15
- if (!loaded.dockerProfile)
16
- throw new ConfigError(`${dockerPackage} is installed but does not export a Docker profile; update it to match this version of @penpair/core.`);
17
- return loaded.dockerProfile;
18
- }
@@ -1,129 +0,0 @@
1
- import { type ExternalConfig, type InstanceConfig, type ProjectConfig } from "./config.js";
2
- import type { Runner } from "./runner.js";
3
- export interface Context {
4
- root: string;
5
- config: InstanceConfig;
6
- runner: Runner;
7
- log?: (message: string) => void;
8
- sleep?: (milliseconds: number) => Promise<void>;
9
- fetch?: typeof fetch;
10
- now?: () => Date;
11
- }
12
- export declare class LifecycleError extends Error {
13
- }
14
- /** `docker compose` arguments for this instance: its own project name, compose file and secrets. */
15
- export declare function composeArgs(c: Context, ...rest: string[]): string[];
16
- export declare const frontendUrl: (config: InstanceConfig) => string;
17
- /** The frontend answers a few seconds before the backend does (502 until then); /readyz is 200 only when the backend is ready. */
18
- export declare const readyUrl: (config: InstanceConfig) => string;
19
- export declare function waitForHttp(c: Context, url: string, timeoutMs?: number, stepMs?: number): Promise<boolean>;
20
- export declare function up(c: Context): Promise<{
21
- url: string;
22
- ready: boolean;
23
- }>;
24
- /** Stops and removes the containers. Volumes are never removed: the design data stays. */
25
- export declare function down(c: Context): Promise<void>;
26
- export interface ServiceState {
27
- service: string;
28
- state: string;
29
- health: string;
30
- }
31
- export declare function parseComposePs(output: string): ServiceState[];
32
- export declare function status(c: Context): Promise<{
33
- services: ServiceState[];
34
- frontendReachable: boolean;
35
- backendReady: boolean;
36
- url: string;
37
- }>;
38
- export interface Counts {
39
- files: number;
40
- profiles: number;
41
- teams: number;
42
- storageObjects: number;
43
- fileRevisions: {
44
- id: string;
45
- name: string;
46
- revn: number;
47
- }[];
48
- }
49
- export interface BackupManifest {
50
- instance: string;
51
- penpotVersion: string;
52
- createdAt: string;
53
- label?: string;
54
- files: {
55
- db: {
56
- name: string;
57
- sha256: string;
58
- bytes: number;
59
- };
60
- assets: {
61
- name: string;
62
- sha256: string;
63
- bytes: number;
64
- };
65
- };
66
- counts: Counts;
67
- note: string;
68
- }
69
- /**
70
- * A backup of the whole instance: a Postgres dump and a tarball of the asset volume, with checksums and the counts
71
- * that `backup verify` later checks a restore against. It holds account data (password hashes): never commit it.
72
- */
73
- export declare function backup(c: Context, label?: string): Promise<{
74
- directory: string;
75
- manifest: BackupManifest;
76
- }>;
77
- export interface Check {
78
- check: string;
79
- ok: boolean;
80
- detail: string;
81
- }
82
- /** Proves a backup can be restored without touching the instance: restores the dump into a throwaway Postgres and compares. */
83
- export declare function verifyBackup(c: Context, directory: string): Promise<{
84
- ok: boolean;
85
- checks: Check[];
86
- }>;
87
- export interface RestoreOptions {
88
- /** Restoring replaces the instance's data; it only runs when this is true. */
89
- yes: boolean;
90
- /** Skip the safety backup of the current data. */
91
- skipSafetyBackup?: boolean;
92
- }
93
- /**
94
- * Replaces the instance's database and assets with a backup. A backup of the current data is taken first unless told
95
- * not to, the checksums are checked before anything is touched, and the counts are compared afterwards.
96
- */
97
- export declare function restore(c: Context, directory: string, options: RestoreOptions): Promise<{
98
- ok: boolean;
99
- checks: Check[];
100
- safetyBackup?: string;
101
- }>;
102
- export interface DoctorCheck extends Check {
103
- remedy?: string;
104
- }
105
- /**
106
- * A command that acts on a Penpot file must act on this project's instance. A key left in the agent's global configuration
107
- * (or any other key) can point at a different instance and silently drive the wrong file, so a mismatch is refused.
108
- */
109
- export declare function assertEndpointIsInstance(endpoint: {
110
- source: string;
111
- origin: string;
112
- }, config: ProjectConfig): void;
113
- export interface DoctorOptions {
114
- /** Looks for the editor tab and the plugin through the MCP. `origin` is where the endpoint points, so a key for another instance is noticed. */
115
- mcp?: {
116
- origin: string;
117
- connect: () => Promise<{
118
- file: string;
119
- }>;
120
- };
121
- }
122
- /** Whether a configured MCP origin is this instance's frontend. */
123
- export declare function endpointIsInstance(origin: string, config: InstanceConfig): boolean;
124
- /** What is up, what is not, and what to do about it. */
125
- export declare function doctor(c: Context, options?: DoctorOptions): Promise<DoctorCheck[]>;
126
- /** The checks that make sense for a Penpot someone else runs: it answers, the key is for it, the editor and plugin are connected. */
127
- export declare function doctorExternal(config: ExternalConfig, options?: DoctorOptions & {
128
- fetch?: typeof fetch;
129
- }): Promise<DoctorCheck[]>;
@@ -1,336 +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, isExternal } from "./config.js";
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
- export class LifecycleError extends Error {
11
- }
12
- /** `docker compose` arguments for this instance: its own project name, compose file and secrets. */
13
- export function composeArgs(c, ...rest) {
14
- const paths = instancePaths(c.root, c.config);
15
- return ["compose", "-p", composeProjectOf(c.config), "-f", paths.compose, "--env-file", paths.env, ...rest];
16
- }
17
- async function docker(c, args, what) {
18
- const result = await c.runner.run("docker", args);
19
- if (result.code !== 0)
20
- throw new LifecycleError(`${what} failed: ${result.stderr.trim().split("\n").slice(-3).join(" ") || `exit ${result.code}`}`);
21
- return result.stdout;
22
- }
23
- export const frontendUrl = (config) => `http://127.0.0.1:${config.ports.frontend}`;
24
- /** The frontend answers a few seconds before the backend does (502 until then); /readyz is 200 only when the backend is ready. */
25
- export const readyUrl = (config) => `${frontendUrl(config)}/readyz`;
26
- export async function waitForHttp(c, url, timeoutMs = 240_000, stepMs = 3_000) {
27
- const doFetch = c.fetch ?? fetch;
28
- for (let waited = 0; waited <= timeoutMs; waited += stepMs) {
29
- try {
30
- const response = await doFetch(url, { redirect: "manual" });
31
- if (response.status < 500)
32
- return true;
33
- }
34
- catch {
35
- // not up yet
36
- }
37
- await sleepOf(c)(stepMs);
38
- }
39
- return false;
40
- }
41
- export async function up(c) {
42
- say(c, "starting the containers");
43
- await docker(c, composeArgs(c, "up", "-d"), "docker compose up");
44
- say(c, "waiting for Penpot to answer (the first start can take a few minutes)");
45
- return { url: frontendUrl(c.config), ready: await waitForHttp(c, readyUrl(c.config)) };
46
- }
47
- /** Stops and removes the containers. Volumes are never removed: the design data stays. */
48
- export async function down(c) {
49
- await docker(c, composeArgs(c, "down"), "docker compose down");
50
- }
51
- export function parseComposePs(output) {
52
- const rows = [];
53
- const text = output.trim();
54
- if (text.startsWith("["))
55
- rows.push(...JSON.parse(text));
56
- else
57
- for (const line of text.split("\n").filter(Boolean))
58
- rows.push(JSON.parse(line));
59
- return rows.map((row) => {
60
- const r = row;
61
- return { service: r.Service ?? r.Name ?? "?", state: r.State ?? "?", health: r.Health ?? "" };
62
- });
63
- }
64
- export async function status(c) {
65
- const output = await docker(c, composeArgs(c, "ps", "--all", "--format", "json"), "docker compose ps");
66
- const services = output.trim() ? parseComposePs(output) : [];
67
- const url = frontendUrl(c.config);
68
- const answers = async (target, ok) => {
69
- try {
70
- return ok((await (c.fetch ?? fetch)(target, { redirect: "manual" })).status);
71
- }
72
- catch {
73
- return false;
74
- }
75
- };
76
- return { services, frontendReachable: await answers(url, (code) => code < 500), backendReady: await answers(readyUrl(c.config), (code) => code === 200), url };
77
- }
78
- async function containerOf(c, service) {
79
- const id = (await docker(c, composeArgs(c, "ps", "-q", service), `docker compose ps ${service}`)).trim().split("\n")[0];
80
- if (!id)
81
- throw new LifecycleError(`The ${service} container is not running. Run \`penpair up\` first.`);
82
- return id;
83
- }
84
- 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))`;
85
- async function readCounts(c, container) {
86
- const out = await docker(c, ["exec", container, "psql", "-U", "penpot", "-d", "penpot", "-tAc", countsSql], "reading the database counts");
87
- return JSON.parse(out.trim());
88
- }
89
- async function sha256File(file) {
90
- const hash = createHash("sha256");
91
- for await (const chunk of createReadStream(file))
92
- hash.update(chunk);
93
- return hash.digest("hex");
94
- }
95
- /**
96
- * A backup of the whole instance: a Postgres dump and a tarball of the asset volume, with checksums and the counts
97
- * that `backup verify` later checks a restore against. It holds account data (password hashes): never commit it.
98
- */
99
- export async function backup(c, label) {
100
- const paths = instancePaths(c.root, c.config);
101
- const stamp = (c.now?.() ?? new Date()).toISOString().replace(/[:.]/g, "-");
102
- const directory = path.join(paths.backups, label ? `${stamp}-${label}` : stamp);
103
- await mkdir(directory, { recursive: true });
104
- const postgres = await containerOf(c, "penpot-postgres");
105
- const backend = await containerOf(c, "penpot-backend");
106
- say(c, "dumping the database");
107
- const dump = await c.runner.run("docker", ["exec", postgres, "pg_dump", "-U", "penpot", "-d", "penpot", "-Fc"], { outFile: path.join(directory, "db.dump") });
108
- if (dump.code !== 0)
109
- throw new LifecycleError(`pg_dump failed: ${dump.stderr.trim().slice(-200)}`);
110
- say(c, "archiving the assets");
111
- const assets = await c.runner.run("docker", ["exec", backend, "tar", "czf", "-", "-C", "/opt/data/assets", "."], { outFile: path.join(directory, "assets.tgz") });
112
- if (assets.code !== 0)
113
- throw new LifecycleError(`archiving the assets failed: ${assets.stderr.trim().slice(-200)}`);
114
- const entry = async (name) => ({ name, sha256: await sha256File(path.join(directory, name)), bytes: (await stat(path.join(directory, name))).size });
115
- const manifest = {
116
- instance: c.config.instance,
117
- penpotVersion: c.config.penpotVersion,
118
- createdAt: (c.now?.() ?? new Date()).toISOString(),
119
- ...(label ? { label } : {}),
120
- files: { db: await entry("db.dump"), assets: await entry("assets.tgz") },
121
- counts: await readCounts(c, postgres),
122
- note: "Contains account data (password hashes). Never commit it. Keep the instance's .env with it.",
123
- };
124
- await writeFile(path.join(directory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
125
- return { directory, manifest };
126
- }
127
- async function loadManifest(directory) {
128
- try {
129
- return JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8"));
130
- }
131
- catch {
132
- throw new LifecycleError(`${directory} is not a penpair backup (no manifest.json)`);
133
- }
134
- }
135
- async function checksumChecks(directory, manifest) {
136
- const checks = [];
137
- for (const key of ["db", "assets"]) {
138
- const wanted = manifest.files[key];
139
- let actual = "missing";
140
- try {
141
- actual = await sha256File(path.join(directory, wanted.name));
142
- }
143
- catch {
144
- // reported below
145
- }
146
- 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)}` });
147
- }
148
- return checks;
149
- }
150
- const compareCounts = (wanted, found) => [
151
- ...["files", "profiles", "teams", "storageObjects"].map((key) => ({ check: `restored ${key}`, ok: wanted[key] === found[key], detail: `${found[key]} (backup says ${wanted[key]})` })),
152
- { 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"}` },
153
- ];
154
- /** Proves a backup can be restored without touching the instance: restores the dump into a throwaway Postgres and compares. */
155
- export async function verifyBackup(c, directory) {
156
- const manifest = await loadManifest(directory);
157
- const checks = await checksumChecks(directory, manifest);
158
- const name = `penpair-verify-${Math.random().toString(36).slice(2, 8)}`;
159
- say(c, "starting a throwaway Postgres");
160
- 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");
161
- try {
162
- let ready = false;
163
- for (let i = 0; i < 40 && !ready; i++) {
164
- ready = (await c.runner.run("docker", ["exec", name, "pg_isready", "-U", "penpot", "-d", "penpot"])).code === 0;
165
- if (!ready)
166
- await sleepOf(c)(1_000);
167
- }
168
- if (!ready)
169
- throw new LifecycleError("the throwaway Postgres did not become ready");
170
- say(c, "restoring the dump");
171
- await c.runner.run("docker", ["exec", name, "pg_restore", "-U", "penpot", "-d", "penpot", "--no-owner", "/backup/db.dump"]);
172
- checks.push(...compareCounts(manifest.counts, await readCounts(c, name)));
173
- const listing = await c.runner.run("tar", ["tzf", path.join(directory, manifest.files.assets.name)]);
174
- const entries = listing.stdout.split("\n").filter((line) => line && !line.endsWith("/")).length;
175
- checks.push({ check: "asset files", ok: entries === manifest.counts.storageObjects, detail: `${entries} files in the tarball, ${manifest.counts.storageObjects} storage objects` });
176
- }
177
- finally {
178
- await c.runner.run("docker", ["rm", "-f", name]);
179
- }
180
- return { ok: checks.every((check) => check.ok), checks };
181
- }
182
- /**
183
- * Replaces the instance's database and assets with a backup. A backup of the current data is taken first unless told
184
- * not to, the checksums are checked before anything is touched, and the counts are compared afterwards.
185
- */
186
- export async function restore(c, directory, options) {
187
- if (!options.yes)
188
- throw new LifecycleError("Restoring replaces this instance's data. Run again with --yes to confirm.");
189
- const manifest = await loadManifest(directory);
190
- const checks = await checksumChecks(directory, manifest);
191
- if (!checks.every((check) => check.ok))
192
- return { ok: false, checks };
193
- let safetyBackup;
194
- if (!options.skipSafetyBackup) {
195
- try {
196
- say(c, "taking a safety backup of the current data first");
197
- safetyBackup = (await backup(c, "pre-restore")).directory;
198
- }
199
- catch (error) {
200
- if (!(error instanceof LifecycleError) || !/not running/.test(error.message))
201
- throw error;
202
- say(c, "the instance is not running, so there is no current data to back up");
203
- }
204
- }
205
- say(c, "stopping the application containers");
206
- await docker(c, composeArgs(c, "stop", "penpot-frontend", "penpot-backend", "penpot-admin-console", "penpot-exporter", "penpot-mcp"), "stopping the application containers");
207
- await docker(c, composeArgs(c, "up", "-d", "penpot-postgres", "penpot-valkey"), "starting the database");
208
- const postgres = await containerOf(c, "penpot-postgres");
209
- for (let i = 0; i < 60; i++) {
210
- if ((await c.runner.run("docker", ["exec", postgres, "pg_isready", "-U", "penpot", "-d", "penpot"])).code === 0)
211
- break;
212
- await sleepOf(c)(1_000);
213
- }
214
- say(c, "replacing the database");
215
- await docker(c, ["exec", postgres, "psql", "-U", "penpot", "-d", "postgres", "-c", "drop database penpot with (force)"], "dropping the database");
216
- await docker(c, ["exec", postgres, "psql", "-U", "penpot", "-d", "postgres", "-c", "create database penpot"], "creating the database");
217
- 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) });
218
- if (loaded.code !== 0)
219
- throw new LifecycleError(`pg_restore failed: ${loaded.stderr.trim().slice(-200)}`);
220
- say(c, "replacing the assets");
221
- const volume = `${composeProjectOf(c.config)}_penpot_assets`;
222
- 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) });
223
- if (assets.code !== 0)
224
- throw new LifecycleError(`restoring the assets failed: ${assets.stderr.trim().slice(-200)}`);
225
- say(c, "starting everything again");
226
- await up(c);
227
- checks.push(...compareCounts(manifest.counts, await readCounts(c, await containerOf(c, "penpot-postgres"))));
228
- return { ok: checks.every((check) => check.ok), checks, ...(safetyBackup ? { safetyBackup } : {}) };
229
- }
230
- /**
231
- * A command that acts on a Penpot file must act on this project's instance. A key left in the agent's global configuration
232
- * (or any other key) can point at a different instance and silently drive the wrong file, so a mismatch is refused.
233
- */
234
- export function assertEndpointIsInstance(endpoint, config) {
235
- if (isExternal(config)) {
236
- if (sameOrigin(endpoint.origin, config.profile.origin))
237
- return;
238
- throw new LifecycleError(`The MCP endpoint (from ${endpoint.source}) points at ${endpoint.origin}, but this project uses ${config.profile.origin}. ` +
239
- "Put that Penpot's MCP URL (Account, Integrations, MCP Server) in this project's .env.local as PENPAIR_MCP_URL, or run again with --any-instance if the other one is what you mean.");
240
- }
241
- if (endpointIsInstance(endpoint.origin, config))
242
- return;
243
- throw new LifecycleError(`The MCP endpoint (from ${endpoint.source}) points at ${endpoint.origin}, but this project's instance is ${frontendUrl(config)}. ` +
244
- "Put this instance's MCP URL (Account, Integrations, MCP Server) in this project's .env.local as PENPAIR_MCP_URL, or run again with --any-instance if the other instance is what you mean.");
245
- }
246
- const sameOrigin = (a, b) => {
247
- try {
248
- return new URL(a).origin === new URL(b).origin;
249
- }
250
- catch {
251
- return false;
252
- }
253
- };
254
- /** Whether a configured MCP origin is this instance's frontend. */
255
- export function endpointIsInstance(origin, config) {
256
- try {
257
- const url = new URL(origin);
258
- return ["localhost", "127.0.0.1"].includes(url.hostname) && Number(url.port || (url.protocol === "https:" ? 443 : 80)) === config.ports.frontend;
259
- }
260
- catch {
261
- return false;
262
- }
263
- }
264
- /** What is up, what is not, and what to do about it. */
265
- export async function doctor(c, options = {}) {
266
- const out = [];
267
- const add = (check, ok, detail, remedy) => void out.push({ check, ok, detail, ...(remedy && !ok ? { remedy } : {}) });
268
- const version = await c.runner.run("docker", ["version", "--format", "{{.Server.Version}}"]);
269
- add("docker", version.code === 0, version.code === 0 ? `engine ${version.stdout.trim()}` : "not answering", "Start Docker Desktop (or the Docker daemon).");
270
- if (version.code !== 0)
271
- return out;
272
- const paths = instancePaths(c.root, c.config);
273
- try {
274
- const mode = (await stat(paths.env)).mode & 0o777;
275
- const values = await readEnvValues(paths.env);
276
- 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.");
277
- }
278
- catch {
279
- add("secrets file", false, "missing", "Run `penpair init`.");
280
- }
281
- let state;
282
- try {
283
- state = await status(c);
284
- }
285
- catch (error) {
286
- add("containers", false, String(error.message), "Run `penpair up`.");
287
- }
288
- if (state) {
289
- const running = state.services.filter((s) => s.state === "running").map((s) => s.service);
290
- const needed = ["penpot-frontend", "penpot-backend", "penpot-postgres", "penpot-mcp"];
291
- const missing = needed.filter((service) => !running.includes(service));
292
- add("containers", missing.length === 0, missing.length === 0 ? `${running.length} running` : `not running: ${missing.join(", ")}`, "Run `penpair up`.");
293
- add("frontend", state.frontendReachable, state.frontendReachable ? state.url : `${state.url} does not answer`, "Run `penpair up` and wait for Penpot to start.");
294
- 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`.");
295
- }
296
- if (options.mcp && !endpointIsInstance(options.mcp.origin, c.config)) {
297
- 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.");
298
- }
299
- else if (options.mcp) {
300
- try {
301
- const { file } = await options.mcp.connect();
302
- add("editor and plugin", true, `connected to file "${file}"`);
303
- }
304
- catch (error) {
305
- 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.");
306
- }
307
- }
308
- return out;
309
- }
310
- /** The checks that make sense for a Penpot someone else runs: it answers, the key is for it, the editor and plugin are connected. */
311
- export async function doctorExternal(config, options = {}) {
312
- const out = [];
313
- const add = (check, ok, detail, remedy) => void out.push({ check, ok, detail, ...(remedy && !ok ? { remedy } : {}) });
314
- let reachable = false;
315
- try {
316
- reachable = (await (options.fetch ?? fetch)(config.profile.origin, { redirect: "manual" })).status < 500;
317
- }
318
- catch {
319
- reachable = false;
320
- }
321
- add("penpot", reachable, reachable ? `${config.profile.origin} answers` : `${config.profile.origin} does not answer`, "Check the address and your network, then try again.");
322
- if (!options.mcp)
323
- return out;
324
- if (!sameOrigin(options.mcp.origin, config.profile.origin)) {
325
- add("editor and plugin", false, `the MCP endpoint points at ${options.mcp.origin}, which is not ${config.profile.origin}`, "Put this Penpot's MCP URL (Account, Integrations, MCP Server) in this project's .env.local as PENPAIR_MCP_URL.");
326
- return out;
327
- }
328
- try {
329
- const { file } = await options.mcp.connect();
330
- add("editor and plugin", true, `connected to file "${file}"`);
331
- }
332
- catch (error) {
333
- 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.");
334
- }
335
- return out;
336
- }
@@ -1,11 +0,0 @@
1
- import type { InstanceConfig } from "./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 "./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;