@penpair/core 0.1.0 → 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.
@@ -1,72 +1,32 @@
1
- import { createServer } from "node:net";
2
- import { mkdir, writeFile } from "node:fs/promises";
1
+ import { writeFile } from "node:fs/promises";
3
2
  import path from "node:path";
4
- import { configFileName, instancePaths, isExternal, loadConfig, testedPenpotVersion, validateConfig, validateProjectConfig, ConfigError } from "./config.js";
5
- import { generateSecrets, renderEnv, writeSecretFile } from "../docker/secrets.js";
6
- import { renderCompose } from "../docker/template.js";
7
- import { ensureGitignoreEntry, ensureKeyFileIgnored } from "./gitignore.js";
8
- export async function isPortFree(port, host = "127.0.0.1") {
9
- return new Promise((resolve) => {
10
- const server = createServer();
11
- server.once("error", () => resolve(false));
12
- server.once("listening", () => server.close(() => resolve(true)));
13
- server.listen(port, host);
14
- });
15
- }
16
- export async function firstFreePort(from, avoid = [], free = isPortFree) {
17
- for (let port = from; port < from + 200; port++)
18
- if (!avoid.includes(port) && (await free(port)))
19
- return port;
20
- throw new Error(`no free port from ${from}`);
21
- }
22
- function defaultInstanceName(root) {
3
+ import { requireLocalProfile } from "../local-profile.js";
4
+ import { configFileName, ConfigError, isExternal, loadConfig, validateProjectConfig } from "./config.js";
5
+ import { ensureKeyFileIgnored } from "./gitignore.js";
6
+ /** A short, valid instance name from the folder's name; "design" when nothing usable is left. */
7
+ export function defaultInstanceName(root) {
23
8
  const name = path.basename(path.resolve(root)).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^[^a-z]+/, "").slice(0, 31);
24
9
  return name.length > 0 ? name : "design";
25
10
  }
26
- const ensureGitignore = (root, entry) => ensureGitignoreEntry(root, entry, { directory: true, comment: "penpair: generated compose file, secrets and backups" });
27
11
  /**
28
- * Sets an instance up in a project: config (committed), then the generated compose file and secrets (in a git-ignored
29
- * directory). Running it again rewrites the compose file from the config; existing secrets are never replaced.
12
+ * Sets a project up. For Penpot on the web (`external`) it writes only the config, here. For Penpot on this computer it hands over to
13
+ * the local profile a package provides (`@penpair/docker`), which writes the config, the compose file and the secrets.
30
14
  */
31
- export async function init(root, options = {}) {
32
- let config;
33
- let createdConfig = false;
15
+ export async function init(root, options = {}, deps = {}) {
16
+ let existing;
34
17
  try {
35
- config = await loadConfig(root);
18
+ existing = await loadConfig(root);
36
19
  }
37
20
  catch (error) {
38
21
  if (!(error instanceof ConfigError) || !/No penpair\.config\.json/.test(error.message))
39
22
  throw error;
40
- if (options.external) {
41
- config = validateProjectConfig({ instance: options.instance ?? defaultInstanceName(root), profile: { type: "external", origin: options.external } });
42
- await writeFile(path.join(root, configFileName), `${JSON.stringify(config, null, 2)}\n`);
43
- return { config, createdConfig: true, createdSecrets: false, files: [], gitignoreAdded: options.gitignore ? await ensureKeyFileIgnored(root) : false };
44
- }
45
- const free = options.isFree ?? isPortFree;
46
- const frontend = options.port ?? (await firstFreePort(9001, [], free));
47
- const mail = options.mailPort ?? (await firstFreePort(1080, [frontend], free));
48
- config = validateConfig({
49
- instance: options.instance ?? defaultInstanceName(root),
50
- penpotVersion: options.penpotVersion ?? testedPenpotVersion,
51
- ports: { frontend, mail },
52
- directory: options.directory ?? ".penpair",
53
- ...(options.composeProject ? { composeProject: options.composeProject } : {}),
54
- });
55
- await writeFile(path.join(root, configFileName), `${JSON.stringify(config, null, 2)}\n`);
56
- createdConfig = true;
57
23
  }
58
- if (isExternal(config))
59
- return { config, createdConfig, createdSecrets: false, files: [], gitignoreAdded: options.gitignore ? await ensureKeyFileIgnored(root) : false };
60
- const paths = instancePaths(root, config);
61
- await mkdir(paths.directory, { recursive: true });
62
- await writeFile(paths.compose, renderCompose(config));
63
- const files = [paths.compose];
64
- const createdSecrets = await writeSecretFile(paths.env, renderEnv(config, generateSecrets()));
65
- files.push(paths.env);
66
- let gitignoreAdded = false;
67
- if (options.gitignore) {
68
- const directoryAdded = await ensureGitignore(root, config.directory);
69
- gitignoreAdded = (await ensureKeyFileIgnored(root)) || directoryAdded;
24
+ if (existing && isExternal(existing))
25
+ return { config: existing, createdConfig: false, createdSecrets: false, files: [], gitignoreAdded: options.gitignore ? await ensureKeyFileIgnored(root) : false };
26
+ if (!existing && options.external) {
27
+ const config = validateProjectConfig({ instance: options.instance ?? defaultInstanceName(root), profile: { type: "external", origin: options.external } });
28
+ await writeFile(path.join(root, configFileName), `${JSON.stringify(config, null, 2)}\n`);
29
+ return { config, createdConfig: true, createdSecrets: false, files: [], gitignoreAdded: options.gitignore ? await ensureKeyFileIgnored(root) : false };
70
30
  }
71
- return { config, createdConfig, createdSecrets, files, gitignoreAdded };
31
+ return requireLocalProfile(deps.local).init(root, options);
72
32
  }
@@ -1,5 +1,6 @@
1
1
  import { type ProjectConfig } from "./config.js";
2
2
  import { type ConnectIo, type ConnectResult, type WaitOptions } from "./connect.js";
3
+ import { type LocalProfile } from "../local-profile.js";
3
4
  import type { Runner } from "./runner.js";
4
5
  export declare const hostedOrigin = "https://design.penpot.app";
5
6
  export type SetupWhere = {
@@ -23,6 +24,8 @@ export interface SetupOptions {
23
24
  /** Install the assistant's rules at the end without asking. */
24
25
  installSkills?: () => Promise<void>;
25
26
  runner: Runner;
27
+ /** A way to run Penpot on this computer, from a package that provides one; only used when that is chosen. */
28
+ local?: LocalProfile;
26
29
  log?: (message: string) => void;
27
30
  }
28
31
  export interface SetupResult {
@@ -1,12 +1,12 @@
1
1
  import { ConfigError, isExternal, loadConfig, requireLocal } from "./config.js";
2
2
  import { connectProject, penpotAddress } from "./connect.js";
3
3
  import { init } from "./init.js";
4
- import { up } from "../docker/lifecycle.js";
4
+ import { requireLocalProfile } from "../local-profile.js";
5
5
  export const hostedOrigin = "https://design.penpot.app";
6
- async function chooseWhere(io) {
6
+ async function chooseWhere(io, hasLocal) {
7
7
  io.say("Where is your Penpot?");
8
8
  io.say(" 1 On the web: design.penpot.app (free account) or your team's own Penpot. Nothing to install. (recommended)");
9
- io.say(" 2 On this computer, in Docker. Works offline; needs Docker Desktop and a 2 GB download.");
9
+ io.say(hasLocal ? " 2 On this computer, in Docker. Works offline; needs Docker Desktop and a 2 GB download." : " 2 On this computer, in Docker. Needs the @penpair/docker package, which this install does not include.");
10
10
  const answer = (await io.ask("Press Enter for 1, or type 2: ", "1")).trim();
11
11
  if (answer === "2")
12
12
  return { type: "docker" };
@@ -29,18 +29,17 @@ export async function setup(root, io, options) {
29
29
  throw error;
30
30
  }
31
31
  if (!config) {
32
- const where = options.where ?? (await chooseWhere(io));
33
- const made = await init(root, { gitignore: true, instance: options.instance, port: options.port, ...(where.type === "web" ? { external: where.origin } : {}) });
32
+ const where = options.where ?? (await chooseWhere(io, Boolean(options.local)));
33
+ const made = await init(root, { gitignore: true, instance: options.instance, port: options.port, ...(where.type === "web" ? { external: where.origin } : {}) }, { local: options.local });
34
34
  config = made.config;
35
35
  io.say(`Wrote penpair.config.json (${where.type === "web" ? `Penpot at ${where.origin}` : "Penpot on this computer"}). Commit that file; it holds no secrets.`);
36
36
  }
37
37
  if (!isExternal(config)) {
38
+ const local = requireLocalProfile(options.local);
39
+ await local.preflight(options.runner);
38
40
  const c = { root, config: requireLocal(config), runner: options.runner, log: options.log ?? (() => undefined) };
39
- const docker = await options.runner.run("docker", ["version", "--format", "{{.Server.Version}}"]);
40
- if (docker.code !== 0)
41
- throw new ConfigError("Docker is not running. Start Docker Desktop, wait until it says it is running, then run `penpair setup` again.");
42
41
  io.say("Starting Penpot. The first time it downloads about 2 GB and can take several minutes.");
43
- const started = await up(c);
42
+ const started = await local.up(c);
44
43
  if (!started.ready)
45
44
  throw new ConfigError(`Penpot started but ${started.url} does not answer yet. Wait a minute and run \`penpair setup\` again.`);
46
45
  io.say("Penpot is running. Create your account on its start page (no email confirmation is needed), then create a design file.");
@@ -0,0 +1,95 @@
1
+ import { type InstanceConfig } from "./instance/config.js";
2
+ import type { InitOptions, InitResult } from "./instance/init.js";
3
+ import type { Check, DoctorCheck, DoctorOptions } from "./instance/connection.js";
4
+ import type { Runner } from "./instance/runner.js";
5
+ /**
6
+ * Another package can add a way to run Penpot on this computer (`@penpair/docker` does). It depends on this one, never the other way
7
+ * round: it starts the command line with its implementation of `LocalProfile`, and this file is the whole contract. Without one, the
8
+ * commands that run Penpot locally say where they come from.
9
+ */
10
+ /** What an action on a local instance needs to know about the project. Injectable so it can be tested without the real thing. */
11
+ export interface LocalContext {
12
+ root: string;
13
+ config: InstanceConfig;
14
+ runner: Runner;
15
+ log?: (message: string) => void;
16
+ sleep?: (milliseconds: number) => Promise<void>;
17
+ fetch?: typeof fetch;
18
+ now?: () => Date;
19
+ }
20
+ export interface ServiceState {
21
+ service: string;
22
+ state: string;
23
+ health: string;
24
+ }
25
+ export interface Counts {
26
+ files: number;
27
+ profiles: number;
28
+ teams: number;
29
+ storageObjects: number;
30
+ fileRevisions: {
31
+ id: string;
32
+ name: string;
33
+ revn: number;
34
+ }[];
35
+ }
36
+ export interface BackupManifest {
37
+ instance: string;
38
+ penpotVersion: string;
39
+ createdAt: string;
40
+ label?: string;
41
+ files: {
42
+ db: {
43
+ name: string;
44
+ sha256: string;
45
+ bytes: number;
46
+ };
47
+ assets: {
48
+ name: string;
49
+ sha256: string;
50
+ bytes: number;
51
+ };
52
+ };
53
+ counts: Counts;
54
+ note: string;
55
+ }
56
+ export interface RestoreOptions {
57
+ /** Restoring replaces the instance's data; it only runs when this is true. */
58
+ yes: boolean;
59
+ /** Skip the safety backup of the current data. */
60
+ skipSafetyBackup?: boolean;
61
+ }
62
+ export interface LocalProfile {
63
+ /** Throws a plain-language ConfigError when what the instance runs on is not available (for example, Docker is not running). */
64
+ preflight(runner: Runner): Promise<void>;
65
+ /** Writes the config when missing, the compose file and the secrets (never replaced). */
66
+ init(root: string, options: InitOptions): Promise<InitResult>;
67
+ up(c: LocalContext): Promise<{
68
+ url: string;
69
+ ready: boolean;
70
+ }>;
71
+ down(c: LocalContext): Promise<void>;
72
+ status(c: LocalContext): Promise<{
73
+ services: ServiceState[];
74
+ frontendReachable: boolean;
75
+ backendReady: boolean;
76
+ url: string;
77
+ }>;
78
+ doctor(c: LocalContext, options?: DoctorOptions): Promise<DoctorCheck[]>;
79
+ backup(c: LocalContext, label?: string): Promise<{
80
+ directory: string;
81
+ manifest: BackupManifest;
82
+ }>;
83
+ verifyBackup(c: LocalContext, directory: string): Promise<{
84
+ ok: boolean;
85
+ checks: Check[];
86
+ }>;
87
+ restore(c: LocalContext, directory: string, options: RestoreOptions): Promise<{
88
+ ok: boolean;
89
+ checks: Check[];
90
+ safetyBackup?: string;
91
+ }>;
92
+ }
93
+ /** The commands that run Penpot on this computer exist only when a package provides a profile for them. */
94
+ export declare const needsLocalProfile = "Running Penpot on this computer is not part of this install. It comes with `@penpair/docker`: install it with `npm i -D @penpair/docker` and run `npx penpair \u2026` (or, once, `npx @penpair/docker <command>`). Penpot on the web needs nothing extra.";
95
+ export declare function requireLocalProfile(local: LocalProfile | undefined): LocalProfile;
@@ -0,0 +1,8 @@
1
+ import { ConfigError } from "./instance/config.js";
2
+ /** The commands that run Penpot on this computer exist only when a package provides a profile for them. */
3
+ export const needsLocalProfile = "Running Penpot on this computer is not part of this install. It comes with `@penpair/docker`: install it with `npm i -D @penpair/docker` and run `npx penpair …` (or, once, `npx @penpair/docker <command>`). Penpot on the web needs nothing extra.";
4
+ export function requireLocalProfile(local) {
5
+ if (!local)
6
+ throw new ConfigError(needsLocalProfile);
7
+ return local;
8
+ }
@@ -0,0 +1,2 @@
1
+ /** Kept in its own file so that the command line and the library entry can both read it without importing each other. Equals package.json (a test checks). */
2
+ export declare const version = "0.1.1";
@@ -0,0 +1,2 @@
1
+ /** Kept in its own file so that the command line and the library entry can both read it without importing each other. Equals package.json (a test checks). */
2
+ export const version = "0.1.1";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@penpair/core",
3
- "version": "0.1.0",
4
- "description": "Design together. Build together. Connect AI agents and people to a Penpot design file, safely: setup, a safe write protocol, snippets, skills, and an optional Docker Penpot per project.",
3
+ "version": "0.1.1",
4
+ "description": "Design together. Build together. The library and engine behind penpair: connect AI agents and people to a Penpot design file, safely. Install @penpair/web or @penpair/docker for the command.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -23,16 +23,11 @@
23
23
  "import": "./dist/index.js",
24
24
  "types": "./dist/index.d.ts"
25
25
  },
26
- "./cli": "./dist/cli.js",
27
26
  "./package.json": "./package.json"
28
27
  },
29
- "bin": {
30
- "penpair": "dist/cli.js"
31
- },
32
28
  "files": [
33
29
  "dist",
34
30
  "skills",
35
- "docs",
36
31
  "LICENSE",
37
32
  "README.md"
38
33
  ],
@@ -10,36 +10,36 @@ compatibility: Node 22 and a terminal; a Penpot account on the web, or Docker De
10
10
 
11
11
  # penpair-setup
12
12
 
13
- Two ways to run Penpot, one way to connect. **Web** (design.penpot.app, or a team's own Penpot) is the default: nothing to install. **Docker** puts a private Penpot on this computer: offline, own data, throwaway per project. Both end the same way: the person's personal key in `.env.local` as `PENPAIR_MCP_URL`, the Penpot file open in a visible browser tab with its plugin connected, and `penpair doctor` all `ok`.
13
+ Two ways to run Penpot, one way to connect. **Web** (design.penpot.app, or a team's own Penpot) is the default: the package `@penpair/web`, nothing else to install. **Docker** (the package `@penpair/docker`, which includes everything in `@penpair/web`) puts a private Penpot on this computer: offline, own data, throwaway per project. Both end the same way: the person's personal key in `.env.local` as `PENPAIR_MCP_URL`, the Penpot file open in a visible browser tab with its plugin connected, and `penpair doctor` all `ok`.
14
14
 
15
15
  ## The key never passes through you
16
16
 
17
- The MCP key is the person's password to their Penpot. Keep it out of chat, logs, commands and commits. The person pastes it themselves into a prompt that hides it (`penpair connect`), or pipes it from their clipboard (`pbpaste | npx penpair connect`). Your shell cannot answer a hidden prompt, so hand that one step to the person with the exact words below.
17
+ The MCP key is the person's password to their Penpot. Keep it out of chat, logs, commands and commits. The person pastes it themselves into a prompt that hides it (`penpair connect`), or pipes it from their clipboard (`pbpaste | npx @penpair/web connect`). Your shell cannot answer a hidden prompt, so hand that one step to the person with the exact words below.
18
18
 
19
19
  ## Steps
20
20
 
21
- 1. **Look.** If `penpair.config.json` exists, run `npx penpair doctor --json` and read it: `ok`, then each check. Done when you can name the first failing check, or say all pass. If all pass, stop here and say so.
21
+ 1. **Look.** If `penpair.config.json` exists, run `npx @penpair/web doctor --json` and read it: `ok`, then each check. Done when you can name the first failing check, or say all pass. If all pass, stop here and say so.
22
22
  2. **Choose where Penpot runs.** Recommend web. Choose Docker when the person asks for offline work, data that stays on this computer, or a disposable instance. Ask once, in one sentence, and take the answer. Done when the choice is stated. The decision table and what is checked: [references/web-or-docker.md](references/web-or-docker.md).
23
23
  3. **Initialise.**
24
- - Web: `npx penpair init --external https://design.penpot.app --gitignore` (a team's own Penpot: its address instead).
25
- - Docker: `npx penpair init --gitignore`, then `npx penpair up` (the first start downloads about 2 GB; Docker Desktop must be running). Done when `up` says Penpot is up.
26
- - The one-command alternative for a person at a terminal is `npx penpair setup` (add `--web` or `--docker` to skip the question).
24
+ - Web: `npx @penpair/web init --external https://design.penpot.app --gitignore` (a team's own Penpot: its address instead).
25
+ - Docker: `npm i -D @penpair/docker` (the same command as `@penpair/web` with the Docker commands added; install it instead of `@penpair/web`, not beside it), then `npx penpair init --gitignore` and `npx penpair up` (the first start downloads about 2 GB; Docker Desktop must be running). Done when `up` says Penpot is up. For a person at a terminal, `npx @penpair/docker setup --docker` does it in one go.
26
+ - The one-command alternative for a person at a terminal is `npx @penpair/web setup` (add `--web` or `--docker` to skip the question).
27
27
  4. **Hand the browser steps to the person**, in these words, adapted to the choice:
28
28
  1. Open Penpot (the address `init` or `up` printed), log in or create an account, create a design file.
29
29
  2. Account, Integrations, MCP Server: switch it on and copy the address.
30
- 3. In your own terminal, in this folder, run `npx penpair connect` and paste the address (nothing is shown as you paste).
30
+ 3. In your own terminal, in this folder, run `npx @penpair/web connect` and paste the address (nothing is shown as you paste).
31
31
  4. In the design file: File, then MCP Server, then Connect. Keep that tab in front while we work.
32
- 5. **Verify.** Run `npx penpair doctor --json` after the person says they are done. Done when `ok` is true and the check named `editor and plugin` says it is connected. A failing check carries a `remedy`; say it in plain words and let the person act. Common ones:
32
+ 5. **Verify.** Run `npx @penpair/web doctor --json` after the person says they are done. Done when `ok` is true and the check named `editor and plugin` says it is connected. A failing check carries a `remedy`; say it in plain words and let the person act. Common ones:
33
33
 
34
34
  | Check says | Meaning and fix |
35
35
  | --- | --- |
36
36
  | docker: not answering | Docker Desktop is not running. Start it, wait until it says running. |
37
- | containers / backend not ready | Run `npx penpair up`; wait ten seconds; check again. |
38
- | editor and plugin: points at another instance | The key in `.env.local` belongs to a different Penpot. Run `npx penpair connect --replace` and paste this Penpot's key. |
37
+ | containers / backend not ready | Docker projects only: run `npx penpair up`; wait ten seconds; check again. |
38
+ | editor and plugin: points at another instance | The key in `.env.local` belongs to a different Penpot. Run `npx @penpair/web connect --replace` and paste this Penpot's key. |
39
39
  | editor and plugin: not connected or suspended | The tab is asleep or the plugin is not connected. Do step 4.4 again; keep the tab in front. |
40
40
  | key file: git would commit it | Add `.env.local` to `.gitignore`; `penpair connect` does it. |
41
41
 
42
- 6. **Install the rules.** Run `npx penpair skills install`, add the printed rows to the project's agent guide, then fill in `.skills/penpot-design/references/project-facts.md` with the person (the Penpot file, token sets, naming, who approves). Done when that file has no `<...>` placeholders left.
42
+ 6. **Install the rules.** Run `npx @penpair/web skills install`, add the printed rows to the project's agent guide, then fill in `.skills/penpot-design/references/project-facts.md` with the person (the Penpot file, token sets, naming, who approves). Done when that file has no `<...>` placeholders left.
43
43
 
44
44
  ## Moving between web and Docker
45
45
 
@@ -13,14 +13,14 @@ penpair works the same against both. The only difference is who runs Penpot. Eve
13
13
 
14
14
  | | Web (the default) | Docker on this computer |
15
15
  | --- | --- | --- |
16
- | Install | Nothing. An account on design.penpot.app or your team's Penpot | Docker Desktop, a 2 GB download on first start |
16
+ | Install | Nothing extra (`@penpair/core` is the web variant). An account on design.penpot.app or your team's Penpot | `@penpair/docker` as well, Docker Desktop, a 2 GB download on first start |
17
17
  | Start | Open the Penpot address | `penpair up` (Docker must be running) |
18
18
  | Sharing with a team | An invite; Penpot's accounts decide who edits | One person's computer; others need the address and an account there |
19
19
  | Where the design lives | On the Penpot service; it survives your laptop | In Docker volumes on this computer; back them up |
20
20
  | Backup | Penpot's version history and the `.penpot` export in the File menu | `penpair backup`, `backup verify`, `restore` (proven on a scratch instance) |
21
21
  | Offline | No | Yes |
22
22
  | Data stays on your machine | No | Yes |
23
- | Setup command | `penpair setup --web` | `penpair setup --docker` |
23
+ | Setup command | `npx @penpair/web setup --web` | `npm i -D @penpair/core @penpair/docker`, then `npx penpair setup --docker` |
24
24
  | Ports and containers | None | One instance per project, its own ports and volumes |
25
25
 
26
26
  Pick **web** unless you need offline work, your data on your own machine, or an instance you can throw away. Teams that run their own Penpot use the web path with their own address: `penpair init --external https://penpot.example.org`.
@@ -1,29 +0,0 @@
1
- # Compatibility
2
-
3
- What has been checked, where, and what differs. Update it whenever a Penpot is probed. "Not run" means not run, not that it fails.
4
-
5
- | | Local Docker, Penpot 2.18.3 | Hosted, design.penpot.app, Penpot 2.18.4 (free account) |
6
- | --- | --- | --- |
7
- | Tools offered | `execute_code`, `high_level_overview`, `penpot_api_info`, `export_shape` | the same four |
8
- | Request body | 90 KB accepted, 110 KB refused | the same |
9
- | Response size | 3 MB returned | 1 MB returned (larger not tried) |
10
- | Plugin API: tokens, `uploadMediaData`, `createBoard`, `openPage`, file export | present | present |
11
- | `currentFile.export` | fails ("No matching clause") | not tried |
12
- | Deep check (`probe --deep --write`): flex layout, text, token set and binding, components and swap, clone, PNG export, clean-up | all steps pass, nothing left behind (run on a fresh instance's empty file) | all steps pass, nothing left behind (run twice) |
13
- | `pages`, `tree`, `audit`, `tokens dump`, `export` (waits for a stable render), `archive` with `--apply`, a project snippet that writes (`run seed-demo`) | all in use | all verified on seeded sample content |
14
- | `doctor` (external profile) | ok | ok |
15
- | `init`, `up`, `doctor` for a Penpot penpair starts (a fresh instance on its own port, then the owner's account, key and plugin connection) | ok: 16 seconds to answer with the images cached; `doctor` all ok | not applicable |
16
-
17
- ## Differences found
18
- - **A new token set starts inactive, with no themes in the file.** Seen on both the hosted Penpot and a fresh local instance. (An earlier note that local sets start active was wrong or applied to a file with themes.)
19
- - **A token binding is recorded after the call, not during it.** On the hosted Penpot it appeared after about half a second to a few seconds; reading it straight away shows nothing. Code that checks a binding has to wait for it.
20
- - **Applying the same token again removes the binding** (seen on the hosted Penpot, as in the local notes).
21
- - **A library component's name is split into a path and a name.** Naming the shapes "Demo / button" and creating the component gives a component whose path is "Demo" and whose name is "button"; setting the component's name to the full text afterwards makes the main shape "Demo / Demo / button". Create the component and leave its name.
22
- - **Exporting a small board took about 3 to 4 seconds on the hosted Penpot and about 7 seconds on the local one.** The first export after a change can be stale; `export` takes two until they match.
23
- - **Versions differ by one patch release** (2.18.3 local, 2.18.4 hosted). Nothing in the checks depended on it. Pin the version that was tested, and run the deep check before moving it.
24
-
25
- ## Not checked
26
- - The hosted Penpot on a large file, with real design content and a shared library.
27
- - Paid plans, and whether the free plan keeps the remote MCP.
28
- - Two people in the same hosted file while an agent writes.
29
- - Penpot's own version history as a backup for an external project.
@@ -1,97 +0,0 @@
1
- # Getting started
2
-
3
- Draft. Written for someone who has not used a terminal much. The steps marked **(not tried yet)** come from Penpot's help pages, not from a run; the first person to follow this guide on a clean machine should mark what differs.
4
-
5
- The normal way is to use Penpot on the web (or your team's own Penpot) and connect the AI assistant to it. Nothing runs on your computer except penpair. If you want your own copy of Penpot on your computer instead, see "Alternative: run Penpot on your own computer" at the end.
6
-
7
- There are two roles. You may be both.
8
-
9
- - **The designer** works in Penpot in the browser (go to "If you are the designer").
10
- - **The person who sets it up** connects the AI assistant to that Penpot (go to "If you set it up").
11
-
12
- ## If you are the designer
13
- You need a link to your team's Penpot and an account.
14
-
15
- 1. Open the link and log in.
16
- 2. Open the design file you will work on.
17
- 3. Choose **File, MCP Server, Connect** (not tried yet). A small window may appear; leave it open.
18
- 4. Keep this browser tab **visible** while the assistant works. If the browser puts the tab to sleep, the assistant stops and says the tab is suspended. Bring the tab to the front and it carries on.
19
- 5. Tell the assistant which page to work on. It works on the page you have open.
20
-
21
- That is all. The assistant looks first, changes things in one go, and checks its own work; you review the result in Penpot as usual.
22
-
23
- ## If you set it up
24
- You need: a Mac or Linux computer, **Node.js** (the current LTS version), a terminal, and an account on a Penpot (the hosted one at design.penpot.app is free to start; use your team's address if it runs its own).
25
-
26
- **The quick way:** in your project's folder run `npx penpair setup`. It asks where your Penpot is (press Enter for the web), does steps 1 to 5 below, and says in plain words what it is waiting for. Run it again if you stop halfway. The rest of this section is what it does, one step at a time, for when you want to do it by hand or something needs fixing.
27
-
28
- ### 1. Connect your project to that Penpot
29
- In the folder of your project:
30
-
31
- ```
32
- npx penpair init --external https://design.penpot.app --gitignore
33
- ```
34
-
35
- `init` writes a small settings file (`penpair.config.json`, commit it) and keeps your private files out of git for you.
36
-
37
- ### 2. Make your account and a file (once)
38
- Open the Penpot address, create an account and log in. Create a project and a design file for this work.
39
-
40
- ### 3. Connect the assistant (once)
41
- 1. In Penpot open **Account, Integrations, MCP Server** and switch it on (not tried yet). Copy the address it shows; it contains your personal key, so treat it like a password. The key belongs to you, not to the team; each person who uses the assistant makes their own.
42
- 2. In your project folder run `npx penpair connect` and paste the address when asked. Nothing is shown as you paste. It saves the address in `.env.local` (readable only by you, and added to `.gitignore`) and refuses an address that belongs to a different Penpot. (By hand instead: put the line `PENPAIR_MCP_URL=<the address>` in `.env.local` and keep that file out of git.)
43
- 3. Open your design file in Penpot, choose **File, MCP Server, Connect**, and keep that tab visible.
44
-
45
- ### 4. Check that it works
46
- ```
47
- npx penpair doctor
48
- ```
49
- Every line should say `ok`. If one says `FAIL`, the line under it says what to do. The usual ones:
50
-
51
- | It says | What to do |
52
- | --- | --- |
53
- | penpot: not answering | Check the address in the config and your internet connection |
54
- | editor and plugin: the MCP address points at another instance | The address in `.env.local` belongs to a different Penpot. Copy the one from this Penpot |
55
- | editor and plugin: not connected | Do step 3.3 again and keep the tab visible |
56
-
57
- Then try: `npx penpair pages` lists the pages in the open file.
58
-
59
- If you want to know what your Penpot offers before you start, `npx penpair probe` lists its tools and limits. If `execute_code` is missing from that list, penpair cannot work with it. The hosted service, checked 2026-10-20 on Penpot 2.18.4, offers the same four tools and the same limits as a local Penpot (see `compatibility.md`).
60
-
61
- ### 5. Give the assistant its rules (once)
62
- ```
63
- npx penpair skills install
64
- ```
65
- This copies the rules the assistant follows (how to work safely in Penpot, and how to judge a screen) into `.skills/` and makes them visible to Codex and Claude Code. It prints a line for each to add to your `AGENTS.md`. Then fill in `.skills/penpot-design/references/project-facts.md` (your file, never replaced): the Penpot file, the token sets, the names you use, who approves.
66
-
67
- ### Every day
68
- Open the file in Penpot, choose **File, MCP Server, Connect**, keep the tab visible, and start working with the assistant.
69
-
70
- ### Your safety net
71
- `up`, `down`, `status`, `backup` and `restore` belong to the Docker alternative and do not apply here. Your safety net is Penpot's own version history and the `.penpot` export in the File menu. Whether a `.penpot` export restores everything (components, token sets, themes) is not proven yet; see the [plan](https://github.com/niklas-westman/penpair/blob/main/PLAN.md).
72
-
73
- ## What it will not do
74
- It needs the Penpot tab open and visible while the assistant works, so it cannot run on its own overnight. It changes one Penpot at a time and only the page you have open.
75
-
76
- ## Alternative: run Penpot on your own computer
77
- Choose this when you want to work offline, keep the data on your machine, or have one Penpot per project that you can throw away. It needs **Docker Desktop** running in addition to Node.js. The first start downloads about 2 GB.
78
-
79
- ```
80
- npx penpair init --gitignore
81
- npx penpair up
82
- ```
83
-
84
- `init` also writes a hidden folder `.penpair/` with the Penpot settings and secret keys. When `up` says "Penpot is up at http://127.0.0.1:9001" (your port may differ; use `--port 9002` with `init` if one is taken), open that address and create an account (no email confirmation is needed). Then do steps 3 to 5 above in that Penpot. `npx penpair doctor` also checks Docker, the secrets file and the containers:
85
-
86
- | It says | What to do |
87
- | --- | --- |
88
- | docker: not answering | Start Docker Desktop and wait until it says it is running |
89
- | containers: not running | Run `npx penpair up` |
90
- | backend: not ready yet | Wait ten seconds and run `doctor` again |
91
-
92
- Every day: `npx penpair up` (if Docker was restarted) and `npx penpair down` to stop; your designs stay. Back up before big changes with `npx penpair backup`, and prove a backup works with `npx penpair backup verify <the folder it printed>`. The backup contains account data, so never put it in git.
93
-
94
- ## Still to make easier (see the [plan](https://github.com/niklas-westman/penpair/blob/main/PLAN.md))
95
- - One guided command (`penpair setup`) that does the steps above, waits for each browser step and says what it sees. It will ask "Penpot on the web, or on this computer?" and default to the web.
96
- - A short illustrated version with a screenshot of each browser step.
97
- - A shared Penpot for a team, so designers need no setup.
package/docs/guide.md DELETED
@@ -1,93 +0,0 @@
1
- # Guide (skeleton)
2
-
3
- The contract an adopting project works through. It grows with the code; each section ends in a checklist like Reflection's provider guide.
4
-
5
- ## Extension points
6
- | Point | A project supplies | First implementations |
7
- | --- | --- | --- |
8
- | Connection profile | endpoint, Penpot version, where the token comes from | local Docker, shared self-hosted, hosted |
9
- | Library adapter | where tokens and components come from | Marwes |
10
- | Snippet registry | named plugin snippets with inputs and a size check | tree, audit, tokens, archive |
11
- | Verification provider | what "matches the design" means | `penpair baselines` (images and Reflection cases, below), a page-verify gate |
12
- | Skill pack | rules the agent follows | the Penpot skill, design judgment |
13
-
14
- ## Sections to write
15
- - Starting from an empty machine
16
- - Instances (written, below): one per project, ports, volumes, secrets, backup and restore
17
- - The write protocol
18
- - Writing a snippet
19
- - Writing a library adapter
20
- - What breaks when Penpot is upgraded
21
-
22
- ## Instances
23
- One Penpot per project, configured in `penpair.config.json` (no secrets, commit it):
24
-
25
- ```json
26
- { "instance": "my-app", "penpotVersion": "2.18.3", "ports": { "frontend": 9002, "mail": 1081 }, "directory": ".penpair" }
27
- ```
28
-
29
- | Command | What it does |
30
- | --- | --- |
31
- | `penpair setup [--web \| --docker]` | The guided path: where is Penpot, config, start it if it is Docker, take the key (hidden), wait for the editor, read the file, install the skills |
32
- | `penpair connect [--replace]` | Only the key and the wait for the editor; accepts a pipe (`pbpaste \| penpair connect`) |
33
- | `penpair init [--instance <name>] [--port 9002] [--gitignore]` | Writes the config if missing, the compose file, and a secrets file (owner-only) that is never replaced |
34
- | `penpair up` / `down` / `status` | Start (waits until the backend answers), stop (the data volumes stay), look |
35
- | `penpair doctor [--json]` | Docker, secrets, containers, frontend, backend, then the editor and plugin through the MCP, and that `.env.local` is kept out of git (a web project checks only the address, the editor and the key file) |
36
- | `penpair backup` | A Postgres dump and a tarball of the assets, with checksums and counts, in `.penpair/backups/` |
37
- | `penpair backup verify <dir>` | Restores the dump into a throwaway database and compares counts, revisions and asset files |
38
- | `penpair restore <dir> --yes` | Replaces the instance's data; a safety backup of the current data comes first; the counts are compared afterwards |
39
-
40
- Things to know:
41
- - Two instances never collide: the compose project is `penpair-<instance>`, so containers and volumes have their own names. `composeProject` in the config adopts an instance that was started earlier under another name.
42
- - Keep `.penpair/` out of git. Backups hold account data (password hashes).
43
- - The database password must not change while the database volume exists; the secrets file is never regenerated.
44
- - The frontend answers a few seconds before the backend; `up` and `doctor` look at `/readyz`.
45
- - `doctor` compares the MCP key's port with the instance's, because a key for another instance silently drives the wrong file.
46
- - Penpot's accounts come from the first registration in the browser; the MCP key is made in Account, Integrations, MCP Server and goes in `.env.local` as `PENPAIR_MCP_URL`.
47
-
48
- ## Baselines for a check
49
-
50
- `penpair baselines` hands a page's design to a check that compares the implementation against images. It reads the `baselines` block of `penpair.config.json`:
51
-
52
- ```json
53
- "baselines": {
54
- "page": "Candidate",
55
- "root": "tests/baselines",
56
- "threshold": 0.05,
57
- "frames": [
58
- { "frame": "F / candidates", "id": "candidates-desktop", "route": "candidates" },
59
- { "frame": "F / my work", "id": "my-work" }
60
- ]
61
- }
62
- ```
63
-
64
- - `frame` is the exact name of a top-level frame on the page; two frames with the same name is an error, so the reference is never ambiguous.
65
- - `id` names the image (`<id>.png`) and the check. `route` is the route in your app this frame is the design of; a frame without a route is exported but gets no check.
66
- - Without `--apply` the command only looks and reports each frame as `new`, `same`, `changed`, `unstable` (the export never settled) or `missing`. `--apply` writes new images, `penpair-baselines.json` (file, revision, sizes, hashes) and `reflection-cases.json`. A `changed` image is replaced only with `--apply --update`: that is the act of approving the new design, and the owner's call. The exit code is 2 when a design differs from its image, 1 when a frame is missing or unstable.
67
-
68
- The generated `reflection-cases.json` holds, per route, the viewports (the image's exact size) and the `visualSmoke` cases (review-only, `strict: false`). A Reflection config uses it like this; only the route's own expectations are written by hand:
69
-
70
- ```ts
71
- import { defineReflection } from 'reflection-check';
72
- import cases from './tests/baselines/reflection-cases.json' with { type: 'json' };
73
-
74
- export default defineReflection({
75
- project: 'my-app',
76
- run: { defaultMode: 'smoke', ciMode: 'smoke' },
77
- contracts: {
78
- browser: {
79
- enabled: true,
80
- blocking: true,
81
- baseUrl: 'http://127.0.0.1:5173',
82
- routes: [
83
- { id: 'candidates', name: 'Candidates', path: '/candidates', viewports: cases.routes.candidates.viewports, expects: [{ screenshot: 'final' }] },
84
- ],
85
- visualSmoke: cases.visualSmoke as never,
86
- },
87
- },
88
- });
89
- ```
90
-
91
- `baselineRoot` in the generated cases is relative to the Reflection config's folder, so keep the two in the same project root. The same images work with any other image comparison; the Reflection cases are one output of the manifest.
92
-
93
- The percentage a comparison reports is not a verdict: a correct implementation differs from a Penpot render by about 1 to 2% on text alone, so read the diff image.
package/docs/releasing.md DELETED
@@ -1,45 +0,0 @@
1
- # Releasing
2
-
3
- Two packages are published from this repository, always with the same version: `@penpair/core` (the library, the command, the skills; `packages/core`) and `penpair` (the short name for the command; `packages/penpair`). The root of the repository is a private workspace and is never published.
4
-
5
- Publishing cannot be undone: a version, once published, cannot be replaced. Check first, publish once.
6
-
7
- ## One time
8
-
9
- 1. An npm organisation named `penpair` must exist (npmjs.com, Add organization, free for public packages). `@penpair/core` cannot be published without it.
10
- 2. `npm whoami` must print your user name. If it answers `401`, run `npm login`.
11
- 3. If npm asks for a one-time code, add `--otp=<code>` to each publish.
12
-
13
- ## Before publishing
14
-
15
- 1. Be on `main` with everything committed and pushed, and the version raised in both `packages/core/package.json` and `packages/penpair/package.json` (and `version` in `packages/core/src/index.ts`; a test fails if they differ). A change that existing users could not ignore is major, an additive change is minor, a fix is a patch. Add a section to `CHANGELOG.md`.
16
- 2. Run every check:
17
-
18
- ```bash
19
- pnpm check # typecheck, tests, build
20
- pnpm smoke:package # packs both packages, installs them in an empty project, runs the command and the library
21
- ```
22
-
23
- 3. Check what would be published: `npm pack --dry-run --ignore-scripts` in each package. The core ships `dist`, `skills`, `docs`, `LICENSE` and `README.md`; the wrapper ships `bin`, `LICENSE` and `README.md`. Nothing else. (`prepack` copies the root README and `docs/` into the core for the tarball; the copies are git-ignored.)
24
-
25
- ## Publish
26
-
27
- The core first, because the wrapper depends on it at this exact version. Use `pnpm publish` so `workspace:*` is replaced by the real version.
28
-
29
- ```bash
30
- cd packages/core && pnpm publish --access public
31
- cd ../penpair && pnpm publish --access public
32
- ```
33
-
34
- ## Verify
35
-
36
- ```bash
37
- npm view @penpair/core version
38
- npm view penpair version
39
- ```
40
-
41
- Then, in an empty folder, run `npx penpair --version` and `npx penpair setup --help`, so what was tested is what people get. Tag the release (`git tag v<version>`) and push the tag only if you want one.
42
-
43
- ## After publishing
44
-
45
- Update the version in `PLAN.md` and add the change log line. A problem found in a published version is answered with a new patch or minor version, never by changing a published one.