@penpair/core 0.1.0 → 0.1.2
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/README.md +9 -212
- package/dist/adapters.d.ts +49 -0
- package/dist/adapters.js +71 -0
- package/dist/cli-main.d.ts +10 -0
- package/dist/cli-main.js +507 -0
- package/dist/cli.js +2 -455
- package/dist/docker-profile.d.ts +114 -0
- package/dist/docker-profile.js +18 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +9 -1
- package/dist/instance/config.d.ts +3 -0
- package/dist/instance/config.js +4 -0
- package/dist/instance/init.d.ts +9 -6
- package/dist/instance/init.js +18 -58
- package/dist/instance/setup.d.ts +3 -0
- package/dist/instance/setup.js +9 -10
- package/dist/local-profile.d.ts +95 -0
- package/dist/local-profile.js +8 -0
- package/dist/skills.d.ts +8 -0
- package/dist/skills.js +16 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +4 -9
- package/skills/design-judgment/SKILL.md +1 -1
- package/skills/{penpot-design → penpair-design}/SKILL.md +2 -2
- package/skills/{penpot-design → penpair-design}/references/penpot-plugin-gotchas.md +2 -0
- package/skills/penpair-setup/SKILL.md +12 -12
- package/skills/penpair-setup/references/web-or-docker.md +2 -2
- package/docs/compatibility.md +0 -29
- package/docs/getting-started.md +0 -97
- package/docs/guide.md +0 -93
- package/docs/releasing.md +0 -45
- /package/skills/{penpot-design → penpair-design}/references/auto-layout.md +0 -0
- /package/skills/{penpot-design → penpair-design}/references/library-in-penpot.md +0 -0
- /package/skills/{penpot-design → penpair-design}/references/project-facts.md +0 -0
- /package/skills/{penpot-design → penpair-design}/references/write-protocol.md +0 -0
package/dist/instance/config.js
CHANGED
|
@@ -39,6 +39,8 @@ export function validateConfig(value) {
|
|
|
39
39
|
}
|
|
40
40
|
if (v.baselines !== undefined)
|
|
41
41
|
validateBaselines(v.baselines, fail);
|
|
42
|
+
if (v.adapters !== undefined && !(Array.isArray(v.adapters) && v.adapters.every((e) => typeof e === "string" && e.length > 0)))
|
|
43
|
+
fail("adapters must be a list of package names or relative paths");
|
|
42
44
|
return v;
|
|
43
45
|
}
|
|
44
46
|
const instanceNamePattern = /^[a-z][a-z0-9-]{0,30}$/;
|
|
@@ -67,6 +69,8 @@ export function validateProjectConfig(value) {
|
|
|
67
69
|
fail("snippets must be a list of relative paths that stay inside the project");
|
|
68
70
|
if (v.baselines !== undefined)
|
|
69
71
|
validateBaselines(v.baselines, fail);
|
|
72
|
+
if (v.adapters !== undefined && !(Array.isArray(v.adapters) && v.adapters.every((e) => typeof e === "string" && e.length > 0)))
|
|
73
|
+
fail("adapters must be a list of package names or relative paths");
|
|
70
74
|
return v;
|
|
71
75
|
}
|
|
72
76
|
/** The config of a project that runs its own Penpot; anything else cannot start, stop or back up containers. */
|
package/dist/instance/init.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
+
import { type LocalProfile } from "../local-profile.js";
|
|
1
2
|
import { type ProjectConfig } from "./config.js";
|
|
2
|
-
export declare function isPortFree(port: number, host?: string): Promise<boolean>;
|
|
3
|
-
export declare function firstFreePort(from: number, avoid?: number[], free?: (port: number) => Promise<boolean>): Promise<number>;
|
|
4
3
|
export interface InitOptions {
|
|
5
4
|
instance?: string;
|
|
6
5
|
port?: number;
|
|
@@ -10,7 +9,7 @@ export interface InitOptions {
|
|
|
10
9
|
composeProject?: string;
|
|
11
10
|
/** 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. */
|
|
12
11
|
external?: string;
|
|
13
|
-
/** Add the instance directory to .gitignore. */
|
|
12
|
+
/** Add the instance directory and the key file to .gitignore. */
|
|
14
13
|
gitignore?: boolean;
|
|
15
14
|
isFree?: (port: number) => Promise<boolean>;
|
|
16
15
|
}
|
|
@@ -21,8 +20,12 @@ export interface InitResult {
|
|
|
21
20
|
files: string[];
|
|
22
21
|
gitignoreAdded: boolean;
|
|
23
22
|
}
|
|
23
|
+
/** A short, valid instance name from the folder's name; "design" when nothing usable is left. */
|
|
24
|
+
export declare function defaultInstanceName(root: string): string;
|
|
24
25
|
/**
|
|
25
|
-
* Sets
|
|
26
|
-
*
|
|
26
|
+
* 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
|
|
27
|
+
* the local profile a package provides (`@penpair/docker`), which writes the config, the compose file and the secrets.
|
|
27
28
|
*/
|
|
28
|
-
export declare function init(root: string, options?: InitOptions
|
|
29
|
+
export declare function init(root: string, options?: InitOptions, deps?: {
|
|
30
|
+
local?: LocalProfile;
|
|
31
|
+
}): Promise<InitResult>;
|
package/dist/instance/init.js
CHANGED
|
@@ -1,72 +1,32 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
3
2
|
import path from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
export
|
|
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
|
|
29
|
-
*
|
|
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
|
|
33
|
-
let createdConfig = false;
|
|
15
|
+
export async function init(root, options = {}, deps = {}) {
|
|
16
|
+
let existing;
|
|
34
17
|
try {
|
|
35
|
-
|
|
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(
|
|
59
|
-
return { config, createdConfig, createdSecrets: false, files: [], gitignoreAdded: options.gitignore ? await ensureKeyFileIgnored(root) : false };
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
31
|
+
return requireLocalProfile(deps.local).init(root, options);
|
|
72
32
|
}
|
package/dist/instance/setup.d.ts
CHANGED
|
@@ -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 {
|
package/dist/instance/setup.js
CHANGED
|
@@ -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 {
|
|
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.");
|
|
@@ -52,6 +51,6 @@ export async function setup(root, io, options) {
|
|
|
52
51
|
io.say("Everything answers. Last: the rules your assistant follows.");
|
|
53
52
|
if (options.installSkills)
|
|
54
53
|
await options.installSkills();
|
|
55
|
-
io.say("Done. Try `penpair pages`, or tell your assistant to read the
|
|
54
|
+
io.say("Done. Try `penpair pages`, or tell your assistant to read the penpair-design skill and start.");
|
|
56
55
|
return { config, connect };
|
|
57
56
|
}
|
|
@@ -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
|
+
}
|
package/dist/skills.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface InstallOptions {
|
|
|
20
20
|
/** For tests. */
|
|
21
21
|
source?: string;
|
|
22
22
|
}
|
|
23
|
+
/** Skills that changed name. Installing the new one leaves the old folder behind, which `installSkills` then reports. */
|
|
24
|
+
export declare const renamedSkills: Record<string, string>;
|
|
23
25
|
export interface InstallReport {
|
|
24
26
|
installed: string[];
|
|
25
27
|
unchanged: string[];
|
|
@@ -28,5 +30,11 @@ export interface InstallReport {
|
|
|
28
30
|
linked: string[];
|
|
29
31
|
/** One line per skill for the project's AGENTS.md. */
|
|
30
32
|
agentsRows: string[];
|
|
33
|
+
/** Folders of skills that were renamed and are still in the project, and whether their project-facts file was carried over. */
|
|
34
|
+
legacy: {
|
|
35
|
+
folder: string;
|
|
36
|
+
renamedTo: string;
|
|
37
|
+
carriedFacts: boolean;
|
|
38
|
+
}[];
|
|
31
39
|
}
|
|
32
40
|
export declare function installSkills(options: InstallOptions): Promise<InstallReport>;
|
package/dist/skills.js
CHANGED
|
@@ -44,6 +44,8 @@ export async function describeSkill(skillDirectory) {
|
|
|
44
44
|
const description = /^description:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? "";
|
|
45
45
|
return { name, summary: description.split(/(?<=\.)\s/)[0] ?? description };
|
|
46
46
|
}
|
|
47
|
+
/** Skills that changed name. Installing the new one leaves the old folder behind, which `installSkills` then reports. */
|
|
48
|
+
export const renamedSkills = { "penpot-design": "penpair-design" };
|
|
47
49
|
export async function installSkills(options) {
|
|
48
50
|
const source = options.source ?? bundledSkillsDirectory();
|
|
49
51
|
const to = options.to ?? ".skills";
|
|
@@ -52,7 +54,7 @@ export async function installSkills(options) {
|
|
|
52
54
|
for (const name of chosen)
|
|
53
55
|
if (!names.includes(name))
|
|
54
56
|
throw new Error(`no skill named ${name}; shipped: ${names.join(", ")}`);
|
|
55
|
-
const report = { installed: [], unchanged: [], kept: [], linked: [], agentsRows: [] };
|
|
57
|
+
const report = { installed: [], unchanged: [], kept: [], linked: [], agentsRows: [], legacy: [] };
|
|
56
58
|
for (const name of chosen) {
|
|
57
59
|
const from = path.join(source, name);
|
|
58
60
|
for (const file of await filesUnder(from)) {
|
|
@@ -87,5 +89,18 @@ export async function installSkills(options) {
|
|
|
87
89
|
const { summary } = await describeSkill(from);
|
|
88
90
|
report.agentsRows.push(`| [${name}](${path.posix.join(to, name, "SKILL.md")}) | ${summary} |`);
|
|
89
91
|
}
|
|
92
|
+
for (const [oldName, newName] of Object.entries(renamedSkills)) {
|
|
93
|
+
if (!chosen.includes(newName) || !(await exists(path.join(options.root, to, oldName))))
|
|
94
|
+
continue;
|
|
95
|
+
// The project-facts file is the project's own work: carry it over when the new copy is still the empty template.
|
|
96
|
+
const oldFacts = await readIfExists(path.join(options.root, to, oldName, "references/project-facts.md"));
|
|
97
|
+
const newFactsFile = path.join(options.root, to, newName, "references/project-facts.md");
|
|
98
|
+
const template = await readIfExists(path.join(source, newName, "references/project-facts.md"));
|
|
99
|
+
const current = await readIfExists(newFactsFile);
|
|
100
|
+
const carriedFacts = oldFacts !== undefined && oldFacts !== template && current === template;
|
|
101
|
+
if (carriedFacts)
|
|
102
|
+
await writeFile(newFactsFile, oldFacts);
|
|
103
|
+
report.legacy.push({ folder: path.posix.join(to, oldName), renamedTo: newName, carriedFacts });
|
|
104
|
+
}
|
|
90
105
|
return report;
|
|
91
106
|
}
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@penpair/core",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Design together. Build together.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|
|
@@ -20,19 +20,14 @@
|
|
|
20
20
|
"types": "./dist/index.d.ts",
|
|
21
21
|
"exports": {
|
|
22
22
|
".": {
|
|
23
|
-
"
|
|
24
|
-
"
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js"
|
|
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
|
],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: design-judgment
|
|
3
|
-
description: Decide how a screen is structured, filled and judged before it is built. Use when designing or redesigning a page or flow (list, detail, queue, dashboard, admin), choosing a composition or density, deciding what fills a card or panel, defining loading, empty and error states, mapping status or feedback to components, comparing layout options, or critiquing a design or a rendered screen. Do not use for writing to Penpot (
|
|
3
|
+
description: Decide how a screen is structured, filled and judged before it is built. Use when designing or redesigning a page or flow (list, detail, queue, dashboard, admin), choosing a composition or density, deciding what fills a card or panel, defining loading, empty and error states, mapping status or feedback to components, comparing layout options, or critiquing a design or a rendered screen. Do not use for writing to Penpot (penpair-design) or for picking and verifying components of a specific library.
|
|
4
4
|
metadata:
|
|
5
5
|
version: "0.1.0"
|
|
6
6
|
scope: design reasoning
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
name:
|
|
2
|
+
name: penpair-design
|
|
3
3
|
description: Work in a Penpot file through penpair: inspect, build, change, audit, export and archive frames and components, bind every value to a token, keep layout as flat auto-layout, and run Penpot as one instance per project. Use when creating, editing, inspecting or exporting anything in Penpot, adding or binding tokens, writing a project snippet, setting up or checking a Penpot instance, or diagnosing a plugin or connection error. Do not use for deciding how a screen should be structured (design-judgment).
|
|
4
4
|
metadata:
|
|
5
5
|
version: "0.1.0"
|
|
@@ -8,7 +8,7 @@ metadata:
|
|
|
8
8
|
compatibility: A running Penpot (local Docker, a team server or the hosted service) with the plugin connected in a visible editor tab, Node 22, penpair installed.
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
-
#
|
|
11
|
+
# penpair-design
|
|
12
12
|
|
|
13
13
|
Penpot is the authority for the design; the code adapts to the frames. The project's component library defines what exists: a component, token or layout the library has wins, and only what it lacks becomes a project-specific component or token.
|
|
14
14
|
|
|
@@ -43,6 +43,8 @@ Each one cost real time. They are written as the behaviour to use. Penpot 2.18.3
|
|
|
43
43
|
- **Exports can fail once and then work, and can be stale right after a write or a page switch.** Export again, and compare two in a row.
|
|
44
44
|
- The MCP key is personal, one per account, shown once. A key for another instance silently drives another file; penpair refuses it unless told otherwise.
|
|
45
45
|
- A snippet receives data only through its parameters; it cannot read repository files.
|
|
46
|
+
- **Token values can be changed in place** (`token.value = ...`, `token.description = ...`), and `token.remove()`, `set.remove()`, `theme.addSet(set)`, `theme.removeSet(set)` and `theme.remove()` all work. **Not for a typography token:** creating one normalises it (`fontFamilies: "Inter"` becomes `fontFamily: ["Inter"]`), but assigning a new value stores the object as given, un-normalised. Remove the token and add it again, and compare typography values in their normalised form. (Checked on Penpot 2.18.4, hosted.)
|
|
47
|
+
- A new token set is inactive and a set's `resolvedValue` is null until a theme activates it; a file with no active theme resolves no tokens at all.
|
|
46
48
|
|
|
47
49
|
## Rendering differences to design for
|
|
48
50
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: penpair-setup
|
|
3
|
-
description: Set up penpair in a project and connect it to a Penpot, on the web (the default) or in Docker on this computer. Use when a project has no penpair.config.json, when `penpair doctor` reports a failure, when the user asks to install, start, connect, repair or move their Penpot connection, or when an agent is told "set up penpair". Do not use for editing a Penpot file (
|
|
3
|
+
description: Set up penpair in a project and connect it to a Penpot, on the web (the default) or in Docker on this computer. Use when a project has no penpair.config.json, when `penpair doctor` reports a failure, when the user asks to install, start, connect, repair or move their Penpot connection, or when an agent is told "set up penpair". Do not use for editing a Penpot file (penpair-design) or deciding how a screen looks (design-judgment).
|
|
4
4
|
metadata:
|
|
5
5
|
version: "0.1.0"
|
|
6
6
|
scope: setup and connection
|
|
@@ -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
|
|
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 |
|
|
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/
|
|
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/penpair-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`.
|
package/docs/compatibility.md
DELETED
|
@@ -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.
|