@penpair/core 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +1621 -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-instructions.js +91 -22
- package/dist/skills.js +129 -39
- 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 +1 -1
- package/skills/penpair-design/SKILL.md +3 -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 +2 -0
- package/skills/penpair-handoff/references/comparison-companion.md +13 -0
- package/skills/penpair-handoff/references/handoff-flow.md +2 -0
- package/skills/penpair-recipes/SKILL.md +5 -0
- package/skills/penpair-setup/SKILL.md +3 -1
- 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,14 +1,34 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { commandSignal, withCommandSignal } from "../command-result.js";
|
|
3
|
+
import { redactEndpoint } from "../endpoint.js";
|
|
4
|
+
import { isExternal, } from "./config.js";
|
|
2
5
|
/** What Penpot itself, the connection and the checks need, whichever way Penpot is run. Nothing here knows about Docker. */
|
|
3
6
|
export class LifecycleError extends Error {
|
|
4
7
|
}
|
|
5
8
|
export const frontendUrl = (config) => `http://127.0.0.1:${config.ports.frontend}`;
|
|
6
9
|
export function fileIdentityCheck(config, actual) {
|
|
7
10
|
const ok = !config.file || config.file.id === actual.fileId;
|
|
8
|
-
return {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
return {
|
|
12
|
+
check: "file identity",
|
|
13
|
+
ok,
|
|
14
|
+
layer: "file-pin",
|
|
15
|
+
observedAt: new Date().toISOString(),
|
|
16
|
+
detail: config.file
|
|
17
|
+
? `expected ${config.file.name ?? config.file.id} (${config.file.id}); connected ${actual.file} (${actual.fileId ?? "unknown id"})`
|
|
18
|
+
: "No file ID pinned; run penpair connect --pin-file",
|
|
19
|
+
...(!ok
|
|
20
|
+
? {
|
|
21
|
+
remedy: "Open the project's expected design file and reconnect its MCP plugin.",
|
|
22
|
+
}
|
|
23
|
+
: {}),
|
|
24
|
+
file: {
|
|
25
|
+
expected: config.file ?? null,
|
|
26
|
+
actual: {
|
|
27
|
+
id: actual.fileId,
|
|
28
|
+
name: actual.file,
|
|
29
|
+
},
|
|
30
|
+
ok,
|
|
31
|
+
},
|
|
12
32
|
};
|
|
13
33
|
}
|
|
14
34
|
/**
|
|
@@ -17,13 +37,15 @@ export function fileIdentityCheck(config, actual) {
|
|
|
17
37
|
*/
|
|
18
38
|
export function assertEndpointIsInstance(endpoint, config) {
|
|
19
39
|
if (isExternal(config)) {
|
|
20
|
-
if (sameOrigin(endpoint.origin, config.profile.origin))
|
|
40
|
+
if (sameOrigin(endpoint.origin, config.profile.origin)) {
|
|
21
41
|
return;
|
|
42
|
+
}
|
|
22
43
|
throw new LifecycleError(`The MCP endpoint (from ${endpoint.source}) points at ${endpoint.origin}, but this project uses ${config.profile.origin}. ` +
|
|
23
44
|
"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.");
|
|
24
45
|
}
|
|
25
|
-
if (endpointIsInstance(endpoint.origin, config))
|
|
46
|
+
if (endpointIsInstance(endpoint.origin, config)) {
|
|
26
47
|
return;
|
|
48
|
+
}
|
|
27
49
|
throw new LifecycleError(`The MCP endpoint (from ${endpoint.source}) points at ${endpoint.origin}, but this project's instance is ${frontendUrl(config)}. ` +
|
|
28
50
|
"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.");
|
|
29
51
|
}
|
|
@@ -35,11 +57,139 @@ const sameOrigin = (a, b) => {
|
|
|
35
57
|
return false;
|
|
36
58
|
}
|
|
37
59
|
};
|
|
60
|
+
/** Readiness is a bounded fresh execution, separate from a reachable origin and connected transport. */
|
|
61
|
+
export async function doctorEditor(config, options) {
|
|
62
|
+
if (!options.mcp) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const started = Date.now();
|
|
66
|
+
const out = [];
|
|
67
|
+
const challenge = randomUUID();
|
|
68
|
+
const timeoutMs = options.timeoutMs ?? 5000;
|
|
69
|
+
let connected = false;
|
|
70
|
+
let finished = false;
|
|
71
|
+
const transport = () => {
|
|
72
|
+
if (!finished && !connected) {
|
|
73
|
+
connected = true;
|
|
74
|
+
out.push({
|
|
75
|
+
check: "MCP transport",
|
|
76
|
+
layer: "transport",
|
|
77
|
+
ok: true,
|
|
78
|
+
detail: "Connected to MCP transport",
|
|
79
|
+
observedAt: new Date().toISOString(),
|
|
80
|
+
durationMs: Date.now() - started,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
try {
|
|
85
|
+
const actual = await doctorDeadline(timeoutMs, (signal) => options.mcp.connect({
|
|
86
|
+
challenge,
|
|
87
|
+
signal,
|
|
88
|
+
timeoutMs,
|
|
89
|
+
onTransport: transport,
|
|
90
|
+
}));
|
|
91
|
+
if (!connected) {
|
|
92
|
+
throw new Error("MCP transport readiness was not independently observed");
|
|
93
|
+
}
|
|
94
|
+
if (actual.challenge !== challenge ||
|
|
95
|
+
typeof actual.fileId !== "string" ||
|
|
96
|
+
!actual.fileId ||
|
|
97
|
+
typeof actual.pageId !== "string" ||
|
|
98
|
+
!actual.pageId ||
|
|
99
|
+
typeof actual.file !== "string" ||
|
|
100
|
+
!actual.file ||
|
|
101
|
+
actual.file === "?") {
|
|
102
|
+
throw new Error("Fresh execution challenge or file/page identity did not match; editor readiness is unproven");
|
|
103
|
+
}
|
|
104
|
+
out.push({
|
|
105
|
+
check: "editor and plugin",
|
|
106
|
+
layer: "execution",
|
|
107
|
+
ok: true,
|
|
108
|
+
detail: `Fresh read from file "${actual.file}"`,
|
|
109
|
+
observedAt: new Date().toISOString(),
|
|
110
|
+
durationMs: Date.now() - started,
|
|
111
|
+
evidence: {
|
|
112
|
+
challenge,
|
|
113
|
+
pageId: actual.pageId,
|
|
114
|
+
...(typeof actual.apiVersion === "string" && actual.apiVersion
|
|
115
|
+
? {
|
|
116
|
+
apiVersion: actual.apiVersion,
|
|
117
|
+
}
|
|
118
|
+
: {}),
|
|
119
|
+
heartbeat: "unknown",
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
out.push(fileIdentityCheck(config, actual));
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (!connected) {
|
|
126
|
+
out.push({
|
|
127
|
+
check: "MCP transport",
|
|
128
|
+
layer: "transport",
|
|
129
|
+
ok: false,
|
|
130
|
+
detail: "Transport connection not confirmed",
|
|
131
|
+
observedAt: new Date().toISOString(),
|
|
132
|
+
durationMs: Date.now() - started,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
out.push({
|
|
136
|
+
check: "editor and plugin",
|
|
137
|
+
layer: "execution",
|
|
138
|
+
ok: false,
|
|
139
|
+
detail: redactEndpoint(error instanceof Error ? error.message : String(error)),
|
|
140
|
+
observedAt: new Date().toISOString(),
|
|
141
|
+
durationMs: Date.now() - started,
|
|
142
|
+
remedy: "Open the intended file, connect the MCP plugin and keep the editor responsive; rerun doctor for a fresh read.",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
finished = true;
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
async function doctorDeadline(timeoutMs, work) {
|
|
149
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30000) {
|
|
150
|
+
throw new Error("Invalid doctor budget");
|
|
151
|
+
}
|
|
152
|
+
const controller = new AbortController();
|
|
153
|
+
const signal = AbortSignal.any([
|
|
154
|
+
controller.signal,
|
|
155
|
+
...(commandSignal()
|
|
156
|
+
? [
|
|
157
|
+
commandSignal(),
|
|
158
|
+
]
|
|
159
|
+
: []),
|
|
160
|
+
]);
|
|
161
|
+
let timer;
|
|
162
|
+
let abort = () => undefined;
|
|
163
|
+
const interrupted = new Promise((_resolve, reject) => {
|
|
164
|
+
abort = () => reject(signal.reason);
|
|
165
|
+
signal.addEventListener("abort", abort, {
|
|
166
|
+
once: true,
|
|
167
|
+
});
|
|
168
|
+
timer = setTimeout(() => controller.abort(new Error("Doctor deadline exceeded; no fresh editor execution was confirmed")), timeoutMs);
|
|
169
|
+
});
|
|
170
|
+
try {
|
|
171
|
+
signal.throwIfAborted();
|
|
172
|
+
return await withCommandSignal(signal, () => Promise.race([
|
|
173
|
+
work(signal),
|
|
174
|
+
interrupted,
|
|
175
|
+
]));
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
if (timer) {
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
}
|
|
181
|
+
signal.removeEventListener("abort", abort);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
38
184
|
/** Whether a configured MCP origin is this instance's frontend. */
|
|
39
185
|
export function endpointIsInstance(origin, config) {
|
|
40
186
|
try {
|
|
41
187
|
const url = new URL(origin);
|
|
42
|
-
return [
|
|
188
|
+
return ([
|
|
189
|
+
"localhost",
|
|
190
|
+
"127.0.0.1",
|
|
191
|
+
].includes(url.hostname) &&
|
|
192
|
+
Number(url.port || (url.protocol === "https:" ? 443 : 80)) === config.ports.frontend);
|
|
43
193
|
}
|
|
44
194
|
catch {
|
|
45
195
|
return false;
|
|
@@ -48,30 +198,53 @@ export function endpointIsInstance(origin, config) {
|
|
|
48
198
|
/** The checks that make sense for a Penpot someone else runs: it answers, the key is for it, the editor and plugin are connected. */
|
|
49
199
|
export async function doctorExternal(config, options = {}) {
|
|
50
200
|
const out = [];
|
|
51
|
-
const add = (check, ok, detail, remedy) => void out.push({
|
|
201
|
+
const add = (check, ok, detail, remedy) => void out.push({
|
|
202
|
+
check,
|
|
203
|
+
ok,
|
|
204
|
+
detail,
|
|
205
|
+
...(remedy && !ok
|
|
206
|
+
? {
|
|
207
|
+
remedy,
|
|
208
|
+
}
|
|
209
|
+
: {}),
|
|
210
|
+
});
|
|
211
|
+
const started = Date.now();
|
|
212
|
+
const timeoutMs = options.timeoutMs ?? 5000;
|
|
213
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 30000) {
|
|
214
|
+
throw new Error("doctor --timeout-ms must be an integer from 1000 to 30000");
|
|
215
|
+
}
|
|
52
216
|
let reachable = false;
|
|
53
217
|
try {
|
|
54
|
-
reachable =
|
|
218
|
+
reachable =
|
|
219
|
+
(await doctorDeadline(timeoutMs, (signal) => (options.fetch ?? fetch)(config.profile.origin, {
|
|
220
|
+
redirect: "manual",
|
|
221
|
+
signal,
|
|
222
|
+
}))).status < 500;
|
|
55
223
|
}
|
|
56
224
|
catch {
|
|
57
225
|
reachable = false;
|
|
58
226
|
}
|
|
59
227
|
add("penpot", reachable, reachable ? `${config.profile.origin} answers` : `${config.profile.origin} does not answer`, "Check the address and your network, then try again.");
|
|
60
|
-
|
|
228
|
+
Object.assign(out[0], {
|
|
229
|
+
layer: "origin",
|
|
230
|
+
observedAt: new Date().toISOString(),
|
|
231
|
+
durationMs: Date.now() - started,
|
|
232
|
+
});
|
|
233
|
+
if (!options.mcp) {
|
|
61
234
|
return out;
|
|
235
|
+
}
|
|
62
236
|
if (!sameOrigin(options.mcp.origin, config.profile.origin)) {
|
|
63
|
-
add("editor and plugin", false, `the MCP endpoint points at ${options.mcp.origin}, which is not ${config.profile.origin}`, "
|
|
237
|
+
add("editor and plugin", false, `the MCP endpoint points at ${options.mcp.origin}, which is not ${config.profile.origin}`, "Configure this project's intended Penpot endpoint.");
|
|
64
238
|
return out;
|
|
65
239
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (config.file)
|
|
71
|
-
out.push(fileIdentityCheck(config, actual));
|
|
72
|
-
}
|
|
73
|
-
catch (error) {
|
|
74
|
-
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 in a separate window if needed.");
|
|
240
|
+
const remaining = timeoutMs - (Date.now() - started);
|
|
241
|
+
if (remaining <= 0) {
|
|
242
|
+
add("editor and plugin", false, "Doctor deadline exhausted during origin check; execution unproven");
|
|
243
|
+
return out;
|
|
75
244
|
}
|
|
245
|
+
out.push(...(await doctorEditor(config, {
|
|
246
|
+
...options,
|
|
247
|
+
timeoutMs: remaining,
|
|
248
|
+
})));
|
|
76
249
|
return out;
|
|
77
250
|
}
|
|
@@ -10,10 +10,13 @@ export async function ensureGitignoreEntry(root, entry, options) {
|
|
|
10
10
|
catch {
|
|
11
11
|
// no .gitignore yet
|
|
12
12
|
}
|
|
13
|
-
if (text.split(/\r?\n/).some((line) => line.trim() === entry || line.trim() === `${entry}/`))
|
|
13
|
+
if (text.split(/\r?\n/).some((line) => line.trim() === entry || line.trim() === `${entry}/`)) {
|
|
14
14
|
return false;
|
|
15
|
+
}
|
|
15
16
|
await appendFile(file, `${text.length > 0 && !text.endsWith("\n") ? "\n" : ""}# ${options.comment}\n${entry}${options.directory ? "/" : ""}\n`);
|
|
16
17
|
return true;
|
|
17
18
|
}
|
|
18
19
|
/** The MCP key goes in .env.local; whoever writes it makes sure git never sees it. */
|
|
19
|
-
export const ensureKeyFileIgnored = (root) => ensureGitignoreEntry(root, ".env.local", {
|
|
20
|
+
export const ensureKeyFileIgnored = (root) => ensureGitignoreEntry(root, ".env.local", {
|
|
21
|
+
comment: "penpair: your personal Penpot MCP key",
|
|
22
|
+
});
|
package/dist/instance/init.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { requireLocalProfile } from "../local-profile.js";
|
|
4
|
-
import { configFileName, ConfigError, isExternal, loadConfig, validateProjectConfig } from "./config.js";
|
|
4
|
+
import { configFileName, ConfigError, isExternal, loadConfig, validateProjectConfig, } from "./config.js";
|
|
5
5
|
import { ensureKeyFileIgnored } from "./gitignore.js";
|
|
6
6
|
/** A short, valid instance name from the folder's name; "design" when nothing usable is left. */
|
|
7
7
|
export function defaultInstanceName(root) {
|
|
8
|
-
const name = path
|
|
8
|
+
const name = path
|
|
9
|
+
.basename(path.resolve(root))
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
12
|
+
.replace(/^[^a-z]+/, "")
|
|
13
|
+
.slice(0, 31);
|
|
9
14
|
return name.length > 0 ? name : "design";
|
|
10
15
|
}
|
|
11
16
|
/**
|
|
@@ -18,15 +23,35 @@ export async function init(root, options = {}, deps = {}) {
|
|
|
18
23
|
existing = await loadConfig(root);
|
|
19
24
|
}
|
|
20
25
|
catch (error) {
|
|
21
|
-
if (!(error instanceof ConfigError) || error.code !== "missing")
|
|
26
|
+
if (!(error instanceof ConfigError) || error.code !== "missing") {
|
|
22
27
|
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (existing && isExternal(existing)) {
|
|
31
|
+
return {
|
|
32
|
+
config: existing,
|
|
33
|
+
createdConfig: false,
|
|
34
|
+
createdSecrets: false,
|
|
35
|
+
files: [],
|
|
36
|
+
gitignoreAdded: options.gitignore ? await ensureKeyFileIgnored(root) : false,
|
|
37
|
+
};
|
|
23
38
|
}
|
|
24
|
-
if (existing && isExternal(existing))
|
|
25
|
-
return { config: existing, createdConfig: false, createdSecrets: false, files: [], gitignoreAdded: options.gitignore ? await ensureKeyFileIgnored(root) : false };
|
|
26
39
|
if (!existing && options.external) {
|
|
27
|
-
const config = validateProjectConfig({
|
|
40
|
+
const config = validateProjectConfig({
|
|
41
|
+
instance: options.instance ?? defaultInstanceName(root),
|
|
42
|
+
profile: {
|
|
43
|
+
type: "external",
|
|
44
|
+
origin: options.external,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
28
47
|
await writeFile(path.join(root, configFileName), `${JSON.stringify(config, null, 2)}\n`);
|
|
29
|
-
return {
|
|
48
|
+
return {
|
|
49
|
+
config,
|
|
50
|
+
createdConfig: true,
|
|
51
|
+
createdSecrets: false,
|
|
52
|
+
files: [],
|
|
53
|
+
gitignoreAdded: options.gitignore ? await ensureKeyFileIgnored(root) : false,
|
|
54
|
+
};
|
|
30
55
|
}
|
|
31
56
|
return requireLocalProfile(deps.local).init(root, options);
|
|
32
57
|
}
|
package/dist/instance/runner.js
CHANGED
|
@@ -3,34 +3,56 @@ import { createReadStream, createWriteStream } from "node:fs";
|
|
|
3
3
|
import { once } from "node:events";
|
|
4
4
|
export const nodeRunner = {
|
|
5
5
|
async run(command, args, options = {}) {
|
|
6
|
-
const child = spawn(command, args, {
|
|
6
|
+
const child = spawn(command, args, {
|
|
7
|
+
stdio: [
|
|
8
|
+
options.input !== undefined || options.inFile ? "pipe" : "ignore",
|
|
9
|
+
"pipe",
|
|
10
|
+
"pipe",
|
|
11
|
+
],
|
|
12
|
+
});
|
|
7
13
|
const { stdout: out, stderr: err } = child;
|
|
8
|
-
if (!out || !err)
|
|
14
|
+
if (!out || !err) {
|
|
9
15
|
throw new Error("the command has no output streams");
|
|
16
|
+
}
|
|
10
17
|
let stdout = "";
|
|
11
18
|
let stderr = "";
|
|
12
19
|
const writing = options.outFile ? createWriteStream(options.outFile) : undefined;
|
|
13
20
|
// Registered at once: the file can finish closing before the child does, and a missed event would hang forever.
|
|
14
|
-
const written = writing
|
|
15
|
-
|
|
21
|
+
const written = writing
|
|
22
|
+
? new Promise((resolve) => writing.once("close", () => resolve()))
|
|
23
|
+
: undefined;
|
|
24
|
+
if (writing) {
|
|
16
25
|
out.pipe(writing);
|
|
17
|
-
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
18
28
|
out.on("data", (chunk) => (stdout += chunk.toString("utf8")));
|
|
29
|
+
}
|
|
19
30
|
err.on("data", (chunk) => (stderr += chunk.toString("utf8")));
|
|
20
|
-
if (options.inFile && child.stdin)
|
|
31
|
+
if (options.inFile && child.stdin) {
|
|
21
32
|
createReadStream(options.inFile).pipe(child.stdin);
|
|
22
|
-
|
|
33
|
+
}
|
|
34
|
+
else if (options.input !== undefined && child.stdin) {
|
|
23
35
|
child.stdin.end(options.input);
|
|
36
|
+
}
|
|
24
37
|
try {
|
|
25
38
|
const [code] = (await once(child, "close"));
|
|
26
|
-
if (written)
|
|
39
|
+
if (written) {
|
|
27
40
|
await written;
|
|
28
|
-
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
code: code ?? 1,
|
|
44
|
+
stdout,
|
|
45
|
+
stderr,
|
|
46
|
+
};
|
|
29
47
|
}
|
|
30
48
|
catch (error) {
|
|
31
49
|
// The command could not start at all (not installed, not executable).
|
|
32
50
|
writing?.destroy();
|
|
33
|
-
return {
|
|
51
|
+
return {
|
|
52
|
+
code: 127,
|
|
53
|
+
stdout,
|
|
54
|
+
stderr: String(error.message),
|
|
55
|
+
};
|
|
34
56
|
}
|
|
35
57
|
},
|
|
36
58
|
};
|
package/dist/instance/setup.js
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
import { ConfigError, isExternal, loadConfig, requireLocal } from "./config.js";
|
|
2
|
-
import { connectProject, penpotAddress } from "./connect.js";
|
|
2
|
+
import { connectProject, penpotAddress, } from "./connect.js";
|
|
3
3
|
import { init } from "./init.js";
|
|
4
4
|
import { requireLocalProfile } from "../local-profile.js";
|
|
5
5
|
export const hostedOrigin = "https://design.penpot.app";
|
|
6
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(hasLocal
|
|
9
|
+
io.say(hasLocal
|
|
10
|
+
? " 2 On this computer, in Docker. Works offline; needs Docker Desktop and a 2 GB download."
|
|
11
|
+
: " 2 On this computer, in Docker. Needs the @penpair/docker package, which this install does not include.");
|
|
10
12
|
const answer = (await io.ask("Press Enter for 1, or type 2: ", "1")).trim();
|
|
11
|
-
if (answer === "2")
|
|
12
|
-
return {
|
|
13
|
+
if (answer === "2") {
|
|
14
|
+
return {
|
|
15
|
+
type: "docker",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
13
18
|
const origin = (await io.ask(`Penpot address (Enter for ${hostedOrigin}): `, hostedOrigin)).trim();
|
|
14
|
-
return {
|
|
19
|
+
return {
|
|
20
|
+
type: "web",
|
|
21
|
+
origin,
|
|
22
|
+
};
|
|
15
23
|
}
|
|
16
24
|
/**
|
|
17
25
|
* One guided path from nothing to "the assistant can read my Penpot file": choose where Penpot is, write the config, start
|
|
@@ -25,32 +33,57 @@ export async function setup(root, io, options) {
|
|
|
25
33
|
io.say(`This folder is already set up for ${isExternal(config) ? `Penpot at ${config.profile.origin}` : `Penpot on this computer (port ${config.ports.frontend})`}. Continuing from there.`);
|
|
26
34
|
}
|
|
27
35
|
catch (error) {
|
|
28
|
-
if (!(error instanceof ConfigError) || error.code !== "missing")
|
|
36
|
+
if (!(error instanceof ConfigError) || error.code !== "missing") {
|
|
29
37
|
throw error;
|
|
38
|
+
}
|
|
30
39
|
}
|
|
31
40
|
if (!config) {
|
|
32
41
|
const where = options.where ?? (await chooseWhere(io, Boolean(options.local)));
|
|
33
|
-
const made = await init(root, {
|
|
42
|
+
const made = await init(root, {
|
|
43
|
+
gitignore: true,
|
|
44
|
+
instance: options.instance,
|
|
45
|
+
port: options.port,
|
|
46
|
+
...(where.type === "web"
|
|
47
|
+
? {
|
|
48
|
+
external: where.origin,
|
|
49
|
+
}
|
|
50
|
+
: {}),
|
|
51
|
+
}, {
|
|
52
|
+
local: options.local,
|
|
53
|
+
});
|
|
34
54
|
config = made.config;
|
|
35
55
|
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
56
|
}
|
|
37
57
|
if (!isExternal(config)) {
|
|
38
58
|
const local = requireLocalProfile(options.local);
|
|
39
59
|
await local.preflight(options.runner);
|
|
40
|
-
const c = {
|
|
60
|
+
const c = {
|
|
61
|
+
root,
|
|
62
|
+
config: requireLocal(config),
|
|
63
|
+
runner: options.runner,
|
|
64
|
+
log: options.log ?? (() => undefined),
|
|
65
|
+
};
|
|
41
66
|
io.say("Starting Penpot. The first time it downloads about 2 GB and can take several minutes.");
|
|
42
67
|
const started = await local.up(c);
|
|
43
|
-
if (!started.ready)
|
|
68
|
+
if (!started.ready) {
|
|
44
69
|
throw new ConfigError(`Penpot started but ${started.url} does not answer yet. Wait a minute and run \`penpair setup\` again.`);
|
|
70
|
+
}
|
|
45
71
|
io.say("Penpot is running. Create your account on its start page (no email confirmation is needed), then create a design file.");
|
|
46
72
|
}
|
|
47
|
-
else
|
|
73
|
+
else {
|
|
48
74
|
io.say("In Penpot: log in, then create a project and a design file for this work if you have none.");
|
|
75
|
+
}
|
|
49
76
|
await io.openBrowser(penpotAddress(config)).catch(() => undefined);
|
|
50
|
-
const connect = await connectProject(root, config, io, {
|
|
77
|
+
const connect = await connectProject(root, config, io, {
|
|
78
|
+
wait: options.wait,
|
|
79
|
+
});
|
|
51
80
|
io.say("Everything answers. Last: the rules your assistant follows.");
|
|
52
|
-
if (options.installSkills)
|
|
81
|
+
if (options.installSkills) {
|
|
53
82
|
await options.installSkills();
|
|
83
|
+
}
|
|
54
84
|
io.say("Done. Try `penpair pages`, or tell your assistant to read the penpair-design skill and start.");
|
|
55
|
-
return {
|
|
85
|
+
return {
|
|
86
|
+
config,
|
|
87
|
+
connect,
|
|
88
|
+
};
|
|
56
89
|
}
|
package/dist/instance/tty.js
CHANGED
|
@@ -3,14 +3,16 @@ import { nodeRunner } from "./runner.js";
|
|
|
3
3
|
/** Reads everything piped into standard input (for `pbpaste | penpair connect`, where a key never appears in a command line). */
|
|
4
4
|
async function readPiped() {
|
|
5
5
|
let text = "";
|
|
6
|
-
for await (const chunk of process.stdin)
|
|
6
|
+
for await (const chunk of process.stdin) {
|
|
7
7
|
text += String(chunk);
|
|
8
|
+
}
|
|
8
9
|
return text.trim();
|
|
9
10
|
}
|
|
10
11
|
/** Asks for a secret without echoing it. With no terminal (a pipe), the whole input is the answer. */
|
|
11
12
|
export async function askHidden(question) {
|
|
12
|
-
if (!process.stdin.isTTY)
|
|
13
|
+
if (!process.stdin.isTTY) {
|
|
13
14
|
return readPiped();
|
|
15
|
+
}
|
|
14
16
|
process.stderr.write(question);
|
|
15
17
|
const stdin = process.stdin;
|
|
16
18
|
stdin.setRawMode(true);
|
|
@@ -31,10 +33,12 @@ export async function askHidden(question) {
|
|
|
31
33
|
resolve(value.trim());
|
|
32
34
|
return;
|
|
33
35
|
}
|
|
34
|
-
if (char === "\u007f" || char === "\b")
|
|
36
|
+
if (char === "\u007f" || char === "\b") {
|
|
35
37
|
value = value.slice(0, -1);
|
|
36
|
-
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
37
40
|
value += char;
|
|
41
|
+
}
|
|
38
42
|
}
|
|
39
43
|
};
|
|
40
44
|
const cleanup = () => {
|
|
@@ -47,9 +51,13 @@ export async function askHidden(question) {
|
|
|
47
51
|
}
|
|
48
52
|
/** One visible line of input; the fallback is taken on an empty answer or when there is no terminal. */
|
|
49
53
|
export async function askLine(question, fallback) {
|
|
50
|
-
if (!process.stdin.isTTY)
|
|
54
|
+
if (!process.stdin.isTTY) {
|
|
51
55
|
return fallback;
|
|
52
|
-
|
|
56
|
+
}
|
|
57
|
+
const rl = createInterface({
|
|
58
|
+
input: process.stdin,
|
|
59
|
+
output: process.stderr,
|
|
60
|
+
});
|
|
53
61
|
try {
|
|
54
62
|
return await new Promise((resolve) => rl.question(question, (answer) => resolve(answer.trim() || fallback)));
|
|
55
63
|
}
|
|
@@ -59,8 +67,18 @@ export async function askLine(question, fallback) {
|
|
|
59
67
|
}
|
|
60
68
|
export async function openInBrowser(url) {
|
|
61
69
|
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
62
|
-
const args = process.platform === "win32"
|
|
70
|
+
const args = process.platform === "win32"
|
|
71
|
+
? [
|
|
72
|
+
"/c",
|
|
73
|
+
"start",
|
|
74
|
+
"",
|
|
75
|
+
url,
|
|
76
|
+
]
|
|
77
|
+
: [
|
|
78
|
+
url,
|
|
79
|
+
];
|
|
63
80
|
const result = await nodeRunner.run(command, args);
|
|
64
|
-
if (result.code !== 0)
|
|
81
|
+
if (result.code !== 0) {
|
|
65
82
|
throw new Error("Browser launch failed; open the configured public Penpot address manually.");
|
|
83
|
+
}
|
|
66
84
|
}
|
package/dist/journal.d.ts
CHANGED
|
@@ -16,6 +16,13 @@ export interface JournalStep<T> {
|
|
|
16
16
|
verify(current: T, before: T, result?: unknown): boolean;
|
|
17
17
|
reconcile(before: T, current: T, result?: unknown): "complete" | "pending" | "conflict";
|
|
18
18
|
}
|
|
19
|
+
export interface JournalTraceEvent {
|
|
20
|
+
event: "lease-acquire-start" | "lease-acquired" | "lease-check" | "inspection-start" | "inspection-end" | "revision-observed" | "lease-release-start" | "lease-released" | "lease-release-failed";
|
|
21
|
+
at: string;
|
|
22
|
+
elapsedMs: number;
|
|
23
|
+
step?: number;
|
|
24
|
+
target?: JournalTarget;
|
|
25
|
+
}
|
|
19
26
|
export interface OperationJournal {
|
|
20
27
|
schemaVersion: 1;
|
|
21
28
|
engineVersion: string;
|
|
@@ -29,6 +36,7 @@ export interface OperationJournal {
|
|
|
29
36
|
createdAt: string;
|
|
30
37
|
updatedAt: string;
|
|
31
38
|
redacted: boolean;
|
|
39
|
+
trace?: JournalTraceEvent[];
|
|
32
40
|
checkpoint?: unknown;
|
|
33
41
|
error?: string;
|
|
34
42
|
steps: {
|
|
@@ -62,6 +70,8 @@ export interface JournalOptions {
|
|
|
62
70
|
requireCheckpoint?: boolean;
|
|
63
71
|
finalVerify?(): Promise<boolean>;
|
|
64
72
|
redact?(text: string): string;
|
|
73
|
+
trace?: boolean;
|
|
74
|
+
onTrace?(event: JournalTraceEvent): void;
|
|
65
75
|
onStep?(message: string): void;
|
|
66
76
|
}
|
|
67
77
|
/** Stable JSON hashing; observation comparisons may explicitly ignore transport execution metadata. */
|