@penpair/core 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/adapters.js +58 -11
- package/dist/approvals.js +143 -29
- package/dist/atomic-output.js +9 -4
- package/dist/audit-rules.js +345 -79
- package/dist/backup-note.js +29 -8
- package/dist/baseline-coverage.js +169 -35
- package/dist/baselines-config.js +119 -29
- package/dist/baselines.js +280 -57
- package/dist/cli-arguments.js +18 -6
- package/dist/cli-main.js +1645 -392
- package/dist/client.d.ts +5 -2
- package/dist/client.js +127 -28
- package/dist/command-result.js +341 -52
- package/dist/coordination.js +91 -29
- package/dist/credentials.js +57 -13
- package/dist/deep-check.js +56 -12
- package/dist/design-spec.js +189 -49
- package/dist/endpoint.js +102 -34
- package/dist/export.js +86 -18
- package/dist/file-archive.js +33 -7
- package/dist/file-guard.js +29 -11
- package/dist/formats.js +215 -14
- package/dist/index.d.ts +8 -7
- package/dist/index.js +8 -7
- package/dist/inspection.d.ts +1 -1
- package/dist/inspection.js +378 -65
- package/dist/instance/config-schema.js +400 -41
- package/dist/instance/config.js +101 -30
- package/dist/instance/connect.js +151 -44
- package/dist/instance/connection.d.ts +26 -5
- package/dist/instance/connection.js +194 -21
- package/dist/instance/gitignore.js +5 -2
- package/dist/instance/init.js +32 -7
- package/dist/instance/runner.js +32 -10
- package/dist/instance/setup.js +46 -13
- package/dist/instance/tty.js +26 -8
- package/dist/journal.d.ts +10 -0
- package/dist/journal.js +212 -49
- package/dist/kit-smoke.js +82 -20
- package/dist/kit-source.js +10 -40
- package/dist/kit.js +102 -34
- package/dist/layout-policy.d.ts +2 -0
- package/dist/layout-policy.js +86 -0
- package/dist/layout.d.ts +7 -1
- package/dist/layout.js +27 -70
- package/dist/live-smoke.js +13 -4
- package/dist/local-profile.js +2 -1
- package/dist/mcp-server.js +360 -47
- package/dist/native-api.js +47 -11
- package/dist/onboard.js +33 -7
- package/dist/operation.d.ts +2 -2
- package/dist/operation.js +279 -69
- package/dist/page-session.d.ts +23 -0
- package/dist/page-session.js +142 -0
- package/dist/png.js +18 -3
- package/dist/portable-tokens.js +254 -57
- package/dist/probe.d.ts +1 -1
- package/dist/probe.js +48 -11
- package/dist/project-input.js +24 -10
- package/dist/project-snippets.js +18 -6
- package/dist/project-status.js +133 -24
- package/dist/registry.js +217 -60
- package/dist/run-file.js +129 -30
- package/dist/shots.js +108 -26
- package/dist/skill-check-entry.d.ts +17 -0
- package/dist/skill-check-entry.js +89 -0
- package/dist/skill-check-instructions.d.ts +7 -0
- package/dist/skill-check-instructions.js +39 -0
- package/dist/skill-check.d.ts +15 -0
- package/dist/skill-check.js +36 -0
- package/dist/skill-file.d.ts +2 -0
- package/dist/skill-file.js +31 -0
- package/dist/skill-instructions.d.ts +4 -0
- package/dist/skill-instructions.js +97 -22
- package/dist/skill-metadata.d.ts +8 -0
- package/dist/skill-metadata.js +52 -0
- package/dist/skill-routing.d.ts +2 -0
- package/dist/skill-routing.js +36 -0
- package/dist/skills.js +131 -41
- package/dist/snippet.js +27 -7
- package/dist/snippets/read.js +8 -2
- package/dist/structure-snapshot.d.ts +12 -0
- package/dist/structure-snapshot.js +439 -85
- package/dist/token-edit.js +219 -64
- package/dist/token-projection.d.ts +56 -0
- package/dist/token-projection.js +286 -0
- package/dist/tokens-diff.js +125 -32
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
- package/skills/design-judgment/SKILL.md +1 -1
- package/skills/design-judgment/agents/openai.yaml +6 -0
- package/skills/penpair-design/SKILL.md +14 -1
- package/skills/penpair-design/agents/openai.yaml +6 -0
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
- package/skills/penpair-design/references/project-facts.md +2 -0
- package/skills/penpair-handoff/SKILL.md +14 -1
- package/skills/penpair-handoff/agents/openai.yaml +6 -0
- package/skills/penpair-handoff/references/comparison-companion.md +16 -0
- package/skills/penpair-handoff/references/handoff-flow.md +2 -0
- package/skills/penpair-recipes/SKILL.md +13 -1
- package/skills/penpair-recipes/agents/openai.yaml +6 -0
- package/skills/penpair-recipes/assets/visual-checkpoint.md +48 -0
- package/skills/penpair-recipes/references/visual-checkpoint.md +37 -0
- package/skills/penpair-setup/SKILL.md +9 -3
- package/skills/penpair-setup/agents/openai.yaml +6 -0
- package/skills/penpair-verify/SKILL.md +49 -0
- package/skills/penpair-verify/agents/openai.yaml +6 -0
- package/skills/penpair-verify/references/browser-evidence.md +33 -0
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -3
- package/dist/docker/lifecycle.d.ts +0 -96
- package/dist/docker/lifecycle.js +0 -273
- package/dist/docker/secrets.d.ts +0 -11
- package/dist/docker/secrets.js +0 -38
- package/dist/docker/template.d.ts +0 -8
- package/dist/docker/template.js +0 -158
- package/dist/docker-profile.d.ts +0 -114
- package/dist/docker-profile.js +0 -18
- package/dist/instance/lifecycle.d.ts +0 -129
- package/dist/instance/lifecycle.js +0 -336
- package/dist/instance/secrets.d.ts +0 -11
- package/dist/instance/secrets.js +0 -38
- package/dist/instance/template.d.ts +0 -8
- package/dist/instance/template.js +0 -158
|
@@ -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>>;
|
package/dist/instance/secrets.js
DELETED
|
@@ -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;
|