@penpair/core 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters.d.ts +16 -1
- package/dist/adapters.js +62 -19
- package/dist/approvals.d.ts +32 -0
- package/dist/approvals.js +213 -0
- package/dist/atomic-output.d.ts +5 -0
- package/dist/atomic-output.js +33 -0
- package/dist/audit-rules.d.ts +60 -0
- package/dist/audit-rules.js +443 -0
- package/dist/backup-note.d.ts +4 -0
- package/dist/backup-note.js +49 -0
- package/dist/baseline-coverage.d.ts +25 -0
- package/dist/baseline-coverage.js +248 -0
- package/dist/baselines-config.d.ts +25 -0
- package/dist/baselines-config.js +150 -11
- package/dist/baselines.d.ts +37 -8
- package/dist/baselines.js +362 -59
- package/dist/cli-arguments.d.ts +7 -0
- package/dist/cli-arguments.js +47 -0
- package/dist/cli-main.d.ts +13 -1
- package/dist/cli-main.js +2107 -247
- package/dist/client.d.ts +30 -6
- package/dist/client.js +160 -35
- package/dist/command-result.d.ts +159 -0
- package/dist/command-result.js +468 -0
- package/dist/coordination.d.ts +15 -0
- package/dist/coordination.js +184 -0
- package/dist/credentials.d.ts +21 -0
- package/dist/credentials.js +105 -0
- package/dist/deep-check.js +57 -9
- package/dist/design-spec.d.ts +55 -0
- package/dist/design-spec.js +275 -0
- package/dist/endpoint.d.ts +8 -1
- package/dist/endpoint.js +133 -21
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +8 -4
- package/dist/export.d.ts +18 -0
- package/dist/export.js +113 -10
- package/dist/file-archive.d.ts +16 -0
- package/dist/file-archive.js +58 -0
- package/dist/file-guard.d.ts +8 -0
- package/dist/file-guard.js +58 -0
- package/dist/formats.d.ts +4 -0
- package/dist/formats.js +229 -0
- package/dist/index.d.ts +34 -1
- package/dist/index.js +34 -1
- package/dist/inspection.d.ts +106 -0
- package/dist/inspection.js +657 -0
- package/dist/instance/config-schema.d.ts +422 -0
- package/dist/instance/config-schema.js +435 -0
- package/dist/instance/config.d.ts +29 -0
- package/dist/instance/config.js +191 -29
- package/dist/instance/connect.d.ts +10 -1
- package/dist/instance/connect.js +178 -30
- package/dist/instance/connection.d.ts +39 -4
- package/dist/instance/connection.js +198 -14
- 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 +28 -8
- package/dist/journal.d.ts +87 -0
- package/dist/journal.js +423 -0
- package/dist/kit-smoke.d.ts +31 -0
- package/dist/kit-smoke.js +145 -0
- package/dist/kit-source.d.ts +2 -0
- package/dist/kit-source.js +279 -0
- package/dist/kit.d.ts +25 -0
- package/dist/kit.js +158 -0
- package/dist/layout-policy.d.ts +2 -0
- package/dist/layout-policy.js +86 -0
- package/dist/layout.d.ts +21 -0
- package/dist/layout.js +36 -0
- package/dist/live-smoke.d.ts +8 -0
- package/dist/live-smoke.js +23 -0
- package/dist/local-profile.js +2 -1
- package/dist/mcp-server.d.ts +12 -0
- package/dist/mcp-server.js +415 -0
- package/dist/native-api.d.ts +15 -0
- package/dist/native-api.js +78 -0
- package/dist/onboard.d.ts +10 -0
- package/dist/onboard.js +41 -0
- package/dist/operation.d.ts +22 -3
- package/dist/operation.js +341 -31
- package/dist/page-session.d.ts +23 -0
- package/dist/page-session.js +142 -0
- package/dist/png.d.ts +5 -0
- package/dist/png.js +22 -0
- package/dist/portable-tokens.d.ts +22 -0
- package/dist/portable-tokens.js +331 -0
- package/dist/probe.d.ts +6 -2
- package/dist/probe.js +64 -11
- package/dist/project-input.d.ts +6 -0
- package/dist/project-input.js +65 -0
- package/dist/project-snippets.js +29 -12
- package/dist/project-status.d.ts +24 -0
- package/dist/project-status.js +188 -0
- package/dist/registry.d.ts +2 -1
- package/dist/registry.js +224 -53
- package/dist/run-file.d.ts +26 -0
- package/dist/run-file.js +199 -0
- package/dist/shots.d.ts +43 -0
- package/dist/shots.js +172 -0
- package/dist/skill-instructions.d.ts +13 -0
- package/dist/skill-instructions.js +144 -0
- package/dist/skills.d.ts +7 -0
- package/dist/skills.js +163 -46
- package/dist/snippet.d.ts +3 -2
- package/dist/snippet.js +33 -9
- package/dist/snippets/archive.d.ts +9 -0
- package/dist/snippets/archive.js +10 -4
- package/dist/snippets/read.d.ts +1 -1
- package/dist/snippets/read.js +78 -41
- package/dist/structure-snapshot.d.ts +85 -0
- package/dist/structure-snapshot.js +525 -0
- package/dist/token-edit.d.ts +53 -0
- package/dist/token-edit.js +352 -0
- package/dist/token-projection.d.ts +56 -0
- package/dist/token-projection.js +286 -0
- package/dist/tokens-diff.d.ts +19 -6
- package/dist/tokens-diff.js +182 -18
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/penpair-design/SKILL.md +20 -45
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +58 -2
- package/skills/penpair-design/references/project-facts.md +3 -1
- package/skills/penpair-design/references/write-protocol.md +15 -3
- package/skills/penpair-handoff/SKILL.md +16 -0
- package/skills/penpair-handoff/references/comparison-companion.md +13 -0
- package/skills/penpair-handoff/references/handoff-flow.md +13 -0
- package/skills/penpair-recipes/SKILL.md +21 -0
- package/skills/penpair-recipes/references/build-flow.md +11 -0
- package/skills/penpair-setup/SKILL.md +8 -2
- 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
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import { setTimeout as wait } from "node:timers/promises";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import { redactEndpoint } from "./endpoint.js";
|
|
4
|
+
const scope = new AsyncLocalStorage();
|
|
5
|
+
const line = (values) => values.map((value) => (typeof value === "string" ? value : JSON.stringify(value))).join(" ");
|
|
6
|
+
export const commandOutput = {
|
|
7
|
+
log(...values) {
|
|
8
|
+
const current = scope.getStore();
|
|
9
|
+
if (!current) {
|
|
10
|
+
console.log(...values.map((value) => renderHuman(value)));
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const message = line(values);
|
|
14
|
+
try {
|
|
15
|
+
current.data.push(JSON.parse(message));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
current.messages.push(message);
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
error(...values) {
|
|
22
|
+
const current = scope.getStore();
|
|
23
|
+
if (current) {
|
|
24
|
+
const message = current.redactors.reduce((text, redact) => redact(text), redactEndpoint(line(values)));
|
|
25
|
+
if (message !== line(values)) {
|
|
26
|
+
current.redacted = true;
|
|
27
|
+
}
|
|
28
|
+
current.warnings.push(message);
|
|
29
|
+
console.error(message);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
console.error(...values.map((value) => renderHuman(value)));
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
result(value) {
|
|
36
|
+
const current = scope.getStore();
|
|
37
|
+
if (current) {
|
|
38
|
+
current.authoritative = value;
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
export function commandRedactor(redact) {
|
|
43
|
+
(scope.getStore()?.redactors ?? signals.getStore()?.redactors)?.push(redact);
|
|
44
|
+
}
|
|
45
|
+
export function commandFailure(error) {
|
|
46
|
+
const current = scope.getStore();
|
|
47
|
+
if (!current) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const value = error;
|
|
51
|
+
if (current.signal?.aborted &&
|
|
52
|
+
value?.code !== "write-uncertain" &&
|
|
53
|
+
value?.outcome !== "uncertain") {
|
|
54
|
+
const reason = current.signal.reason;
|
|
55
|
+
current.errors.push({
|
|
56
|
+
code: reason?.code === "deadline" ? "deadline" : "cancelled",
|
|
57
|
+
message: reason?.code === "deadline" ? "Command deadline exceeded" : "Command cancelled",
|
|
58
|
+
remedy: "Inspect any recorded run and current state before starting again.",
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
current.errors.push({
|
|
63
|
+
code: typeof value?.code === "string"
|
|
64
|
+
? value.code
|
|
65
|
+
: typeof value?.outcome === "string"
|
|
66
|
+
? value.outcome
|
|
67
|
+
: "command-failed",
|
|
68
|
+
message: error instanceof Error ? error.message : String(error),
|
|
69
|
+
remedy: typeof value?.remedy === "string"
|
|
70
|
+
? value.remedy
|
|
71
|
+
: "Inspect the command result and current project state before trying again.",
|
|
72
|
+
...(typeof value?.runId === "string"
|
|
73
|
+
? {
|
|
74
|
+
runId: value.runId,
|
|
75
|
+
}
|
|
76
|
+
: {}),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const signals = new AsyncLocalStorage();
|
|
80
|
+
function redactHuman(text) {
|
|
81
|
+
return (signals.getStore()?.redactors ?? []).reduce((value, redact) => redact(value), redactEndpoint(text));
|
|
82
|
+
}
|
|
83
|
+
function renderHuman(value) {
|
|
84
|
+
const clean = (entry) => typeof entry === "string"
|
|
85
|
+
? redactHuman(entry)
|
|
86
|
+
: Array.isArray(entry)
|
|
87
|
+
? entry.map(clean)
|
|
88
|
+
: entry && typeof entry === "object"
|
|
89
|
+
? Object.fromEntries(Object.entries(entry).map(([key, child]) => [
|
|
90
|
+
redactHuman(key),
|
|
91
|
+
clean(child),
|
|
92
|
+
]))
|
|
93
|
+
: entry;
|
|
94
|
+
if (typeof value !== "string") {
|
|
95
|
+
return clean(value);
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
return JSON.stringify(clean(JSON.parse(value)), null, 2);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return redactHuman(value);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export function withCommandSignal(signal, work) {
|
|
105
|
+
return signals.run({
|
|
106
|
+
signal,
|
|
107
|
+
redactors: [],
|
|
108
|
+
}, work);
|
|
109
|
+
}
|
|
110
|
+
const cleanupSignals = new AsyncLocalStorage();
|
|
111
|
+
/** Bounded explicit restoration work may finish after the main command is cancelled. */
|
|
112
|
+
export async function withCleanupDeadline(work, milliseconds = 10000) {
|
|
113
|
+
if (!Number.isInteger(milliseconds) || milliseconds < 1 || milliseconds > 30000) {
|
|
114
|
+
throw new Error("Cleanup deadline must be from 1 to 30000 ms");
|
|
115
|
+
}
|
|
116
|
+
const controller = new AbortController();
|
|
117
|
+
const timer = setTimeout(() => controller.abort(new Error("Restoration deadline exceeded")), milliseconds);
|
|
118
|
+
try {
|
|
119
|
+
return await cleanupSignals.run(controller.signal, work);
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function commandSignal() {
|
|
126
|
+
return cleanupSignals.getStore() ?? scope.getStore()?.signal ?? signals.getStore()?.signal;
|
|
127
|
+
}
|
|
128
|
+
export async function commandSleep(milliseconds) {
|
|
129
|
+
const signal = commandSignal();
|
|
130
|
+
try {
|
|
131
|
+
await wait(milliseconds, undefined, {
|
|
132
|
+
signal,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (signal?.aborted) {
|
|
137
|
+
throw signal.reason;
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export class CommandInterruptedError extends Error {
|
|
143
|
+
code;
|
|
144
|
+
constructor(code) {
|
|
145
|
+
super(code === "deadline" ? "Command deadline exceeded" : "Command cancelled");
|
|
146
|
+
this.code = code;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** Shared service boundary for CLI and future tool surfaces; no stdout or global console interception. */
|
|
150
|
+
export async function collectCommand(command, work, options = {}) {
|
|
151
|
+
const started = Date.now();
|
|
152
|
+
const capture = {
|
|
153
|
+
data: [],
|
|
154
|
+
messages: [],
|
|
155
|
+
warnings: [],
|
|
156
|
+
errors: [],
|
|
157
|
+
redactors: [],
|
|
158
|
+
signal: options.signal,
|
|
159
|
+
};
|
|
160
|
+
let exitCode;
|
|
161
|
+
try {
|
|
162
|
+
exitCode = await scope.run(capture, async () => {
|
|
163
|
+
options.signal?.throwIfAborted();
|
|
164
|
+
const code = await work();
|
|
165
|
+
options.signal?.throwIfAborted();
|
|
166
|
+
return code;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
await scope.run(capture, async () => commandFailure(error));
|
|
171
|
+
exitCode = 1;
|
|
172
|
+
}
|
|
173
|
+
if (![
|
|
174
|
+
0,
|
|
175
|
+
1,
|
|
176
|
+
2,
|
|
177
|
+
3,
|
|
178
|
+
4,
|
|
179
|
+
124,
|
|
180
|
+
130,
|
|
181
|
+
].includes(exitCode)) {
|
|
182
|
+
capture.warnings.push("Unsupported extension exit code was normalized to failure");
|
|
183
|
+
exitCode = 1;
|
|
184
|
+
}
|
|
185
|
+
const data = capture.authoritative !== undefined
|
|
186
|
+
? capture.authoritative
|
|
187
|
+
: capture.data.length === 1
|
|
188
|
+
? capture.data[0]
|
|
189
|
+
: capture.data.length
|
|
190
|
+
? capture.data
|
|
191
|
+
: null;
|
|
192
|
+
const record = data && typeof data === "object" && !Array.isArray(data)
|
|
193
|
+
? data
|
|
194
|
+
: {};
|
|
195
|
+
const errorCode = capture.errors[0]?.code;
|
|
196
|
+
const incomplete = record.complete === false || record.truncated === true || errorCode === "incomplete-output";
|
|
197
|
+
let outcome = exitCode === 0
|
|
198
|
+
? "success"
|
|
199
|
+
: exitCode === 2
|
|
200
|
+
? "drift"
|
|
201
|
+
: exitCode === 3
|
|
202
|
+
? "incomplete"
|
|
203
|
+
: exitCode === 4
|
|
204
|
+
? "uncertain"
|
|
205
|
+
: exitCode === 130
|
|
206
|
+
? "cancelled"
|
|
207
|
+
: exitCode === 124
|
|
208
|
+
? "deadline"
|
|
209
|
+
: "failed";
|
|
210
|
+
if (errorCode === "write-uncertain" || errorCode === "uncertain") {
|
|
211
|
+
outcome = "uncertain";
|
|
212
|
+
exitCode = 4;
|
|
213
|
+
}
|
|
214
|
+
else if (errorCode === "cancelled" || errorCode === "deadline") {
|
|
215
|
+
outcome = errorCode;
|
|
216
|
+
exitCode = errorCode === "cancelled" ? 130 : 124;
|
|
217
|
+
}
|
|
218
|
+
else if (incomplete) {
|
|
219
|
+
outcome = "incomplete";
|
|
220
|
+
exitCode = 3;
|
|
221
|
+
}
|
|
222
|
+
if (exitCode !== 0 &&
|
|
223
|
+
!capture.errors.length &&
|
|
224
|
+
![
|
|
225
|
+
"drift",
|
|
226
|
+
"incomplete",
|
|
227
|
+
].includes(outcome)) {
|
|
228
|
+
capture.errors.push({
|
|
229
|
+
code: "command-failed",
|
|
230
|
+
message: "Command checks failed; inspect data and diagnostics.",
|
|
231
|
+
remedy: "Resolve the reported checks before proceeding.",
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
const identity = (record.capture ?? record.target ?? record);
|
|
235
|
+
const target = typeof identity.fileId === "string"
|
|
236
|
+
? {
|
|
237
|
+
fileId: identity.fileId,
|
|
238
|
+
...(typeof identity.pageId === "string"
|
|
239
|
+
? {
|
|
240
|
+
pageId: identity.pageId,
|
|
241
|
+
}
|
|
242
|
+
: {}),
|
|
243
|
+
}
|
|
244
|
+
: null;
|
|
245
|
+
const journal = record.journal;
|
|
246
|
+
const sanitize = (value) => {
|
|
247
|
+
if (value === undefined ||
|
|
248
|
+
typeof value === "function" ||
|
|
249
|
+
typeof value === "symbol" ||
|
|
250
|
+
typeof value === "bigint" ||
|
|
251
|
+
(typeof value === "number" && !Number.isFinite(value))) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
if (typeof value === "string") {
|
|
255
|
+
return capture.redactors.reduce((text, redact) => redact(text), redactEndpoint(value));
|
|
256
|
+
}
|
|
257
|
+
if (Array.isArray(value)) {
|
|
258
|
+
return value.map(sanitize);
|
|
259
|
+
}
|
|
260
|
+
if (value && typeof value === "object") {
|
|
261
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
|
|
262
|
+
String(sanitize(key)),
|
|
263
|
+
sanitize(entry),
|
|
264
|
+
]));
|
|
265
|
+
}
|
|
266
|
+
return value;
|
|
267
|
+
};
|
|
268
|
+
const raw = {
|
|
269
|
+
schemaVersion: 1,
|
|
270
|
+
command,
|
|
271
|
+
outcome,
|
|
272
|
+
phase: typeof record.phase === "string"
|
|
273
|
+
? record.phase
|
|
274
|
+
: outcome === "uncertain" || outcome === "cancelled" || outcome === "deadline"
|
|
275
|
+
? "interrupted"
|
|
276
|
+
: "finished",
|
|
277
|
+
exitCode,
|
|
278
|
+
target,
|
|
279
|
+
runId: capture.errors.find((error) => error.runId)?.runId ??
|
|
280
|
+
(typeof journal?.runId === "string" ? journal.runId : null),
|
|
281
|
+
data,
|
|
282
|
+
messages: capture.messages,
|
|
283
|
+
warnings: capture.warnings,
|
|
284
|
+
errors: capture.errors,
|
|
285
|
+
completeness: {
|
|
286
|
+
complete: outcome === "success" || outcome === "drift",
|
|
287
|
+
...(incomplete
|
|
288
|
+
? {
|
|
289
|
+
reason: "Output is truncated or required observations are missing",
|
|
290
|
+
}
|
|
291
|
+
: {}),
|
|
292
|
+
},
|
|
293
|
+
timings: {
|
|
294
|
+
startedAt: new Date(started).toISOString(),
|
|
295
|
+
durationMs: Math.max(0, Date.now() - started),
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
const result = sanitize(raw);
|
|
299
|
+
if (record.redacted === true ||
|
|
300
|
+
capture.redacted ||
|
|
301
|
+
JSON.stringify(result) !== JSON.stringify(raw)) {
|
|
302
|
+
result.redacted = true;
|
|
303
|
+
result.warnings.push("Sensitive or non-JSON output was redacted; hashes describe original evidence.");
|
|
304
|
+
if (JSON.stringify(result.data) !== JSON.stringify(data)) {
|
|
305
|
+
result.completeness = {
|
|
306
|
+
complete: false,
|
|
307
|
+
reason: "Displayed data required redaction and is not lossless source evidence",
|
|
308
|
+
};
|
|
309
|
+
if (result.outcome === "success" || result.outcome === "drift") {
|
|
310
|
+
result.outcome = "incomplete";
|
|
311
|
+
result.exitCode = 3;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return result;
|
|
316
|
+
}
|
|
317
|
+
/** Outer transport contract. Command-specific data remains owned by the command service. */
|
|
318
|
+
export const commandResultSchema = {
|
|
319
|
+
type: "object",
|
|
320
|
+
additionalProperties: false,
|
|
321
|
+
required: [
|
|
322
|
+
"schemaVersion",
|
|
323
|
+
"command",
|
|
324
|
+
"outcome",
|
|
325
|
+
"phase",
|
|
326
|
+
"exitCode",
|
|
327
|
+
"target",
|
|
328
|
+
"runId",
|
|
329
|
+
"data",
|
|
330
|
+
"messages",
|
|
331
|
+
"warnings",
|
|
332
|
+
"errors",
|
|
333
|
+
"completeness",
|
|
334
|
+
"timings",
|
|
335
|
+
],
|
|
336
|
+
properties: {
|
|
337
|
+
schemaVersion: {
|
|
338
|
+
const: 1,
|
|
339
|
+
},
|
|
340
|
+
redacted: {
|
|
341
|
+
type: "boolean",
|
|
342
|
+
},
|
|
343
|
+
command: {
|
|
344
|
+
type: "string",
|
|
345
|
+
},
|
|
346
|
+
outcome: {
|
|
347
|
+
enum: [
|
|
348
|
+
"success",
|
|
349
|
+
"drift",
|
|
350
|
+
"failed",
|
|
351
|
+
"incomplete",
|
|
352
|
+
"uncertain",
|
|
353
|
+
"cancelled",
|
|
354
|
+
"deadline",
|
|
355
|
+
],
|
|
356
|
+
},
|
|
357
|
+
phase: {
|
|
358
|
+
type: "string",
|
|
359
|
+
},
|
|
360
|
+
exitCode: {
|
|
361
|
+
enum: [
|
|
362
|
+
0,
|
|
363
|
+
1,
|
|
364
|
+
2,
|
|
365
|
+
3,
|
|
366
|
+
4,
|
|
367
|
+
124,
|
|
368
|
+
130,
|
|
369
|
+
],
|
|
370
|
+
},
|
|
371
|
+
target: {
|
|
372
|
+
anyOf: [
|
|
373
|
+
{
|
|
374
|
+
type: "null",
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
type: "object",
|
|
378
|
+
properties: {
|
|
379
|
+
fileId: {
|
|
380
|
+
type: "string",
|
|
381
|
+
},
|
|
382
|
+
pageId: {
|
|
383
|
+
type: "string",
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
additionalProperties: false,
|
|
387
|
+
},
|
|
388
|
+
],
|
|
389
|
+
},
|
|
390
|
+
runId: {
|
|
391
|
+
type: [
|
|
392
|
+
"string",
|
|
393
|
+
"null",
|
|
394
|
+
],
|
|
395
|
+
},
|
|
396
|
+
data: {},
|
|
397
|
+
messages: {
|
|
398
|
+
type: "array",
|
|
399
|
+
items: {
|
|
400
|
+
type: "string",
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
warnings: {
|
|
404
|
+
type: "array",
|
|
405
|
+
items: {
|
|
406
|
+
type: "string",
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
errors: {
|
|
410
|
+
type: "array",
|
|
411
|
+
items: {
|
|
412
|
+
type: "object",
|
|
413
|
+
required: [
|
|
414
|
+
"code",
|
|
415
|
+
"message",
|
|
416
|
+
"remedy",
|
|
417
|
+
],
|
|
418
|
+
properties: {
|
|
419
|
+
code: {
|
|
420
|
+
type: "string",
|
|
421
|
+
},
|
|
422
|
+
message: {
|
|
423
|
+
type: "string",
|
|
424
|
+
},
|
|
425
|
+
remedy: {
|
|
426
|
+
type: "string",
|
|
427
|
+
},
|
|
428
|
+
runId: {
|
|
429
|
+
type: "string",
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
additionalProperties: false,
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
completeness: {
|
|
436
|
+
type: "object",
|
|
437
|
+
required: [
|
|
438
|
+
"complete",
|
|
439
|
+
],
|
|
440
|
+
properties: {
|
|
441
|
+
complete: {
|
|
442
|
+
type: "boolean",
|
|
443
|
+
},
|
|
444
|
+
reason: {
|
|
445
|
+
type: "string",
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
additionalProperties: false,
|
|
449
|
+
},
|
|
450
|
+
timings: {
|
|
451
|
+
type: "object",
|
|
452
|
+
required: [
|
|
453
|
+
"startedAt",
|
|
454
|
+
"durationMs",
|
|
455
|
+
],
|
|
456
|
+
properties: {
|
|
457
|
+
startedAt: {
|
|
458
|
+
type: "string",
|
|
459
|
+
},
|
|
460
|
+
durationMs: {
|
|
461
|
+
type: "number",
|
|
462
|
+
minimum: 0,
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
additionalProperties: false,
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PenpotToolClient } from "./client.js";
|
|
2
|
+
import type { JournalTarget } from "./journal.js";
|
|
3
|
+
export interface WriteLease {
|
|
4
|
+
check(): Promise<void>;
|
|
5
|
+
release(): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export interface CoordinationOptions {
|
|
8
|
+
root: string;
|
|
9
|
+
operation: string;
|
|
10
|
+
forceLock?: boolean;
|
|
11
|
+
warn?(message: string): void;
|
|
12
|
+
}
|
|
13
|
+
export declare function writeCoordinator(client: PenpotToolClient, options: CoordinationOptions): (runId: string, target: JournalTarget) => Promise<WriteLease>;
|
|
14
|
+
/** Revision precondition is checked inside the delivered mutation, after all guard calls. It is not a server CAS. */
|
|
15
|
+
export declare function guardRevision(client: PenpotToolClient, target: JournalTarget): PenpotToolClient;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { inheritPageSession } from "./page-session.js";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { hostname, userInfo } from "node:os";
|
|
4
|
+
import { lstat, mkdir, open, readFile, unlink } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { ensureGitignoreEntry } from "./instance/gitignore.js";
|
|
7
|
+
import { runSnippet, snippet } from "./snippet.js";
|
|
8
|
+
import { maxSnippetBytes } from "./client.js";
|
|
9
|
+
import { PenpairError } from "./errors.js";
|
|
10
|
+
/** Exclusive only within this checkout. Shared file markers below are advisory, not compare-and-swap. */
|
|
11
|
+
async function localLease(root, fileId, runId, force) {
|
|
12
|
+
await ensureGitignoreEntry(root, ".penpair", {
|
|
13
|
+
directory: true,
|
|
14
|
+
comment: "penpair: private local operation evidence",
|
|
15
|
+
});
|
|
16
|
+
const parent = path.join(root, ".penpair");
|
|
17
|
+
await mkdir(parent, {
|
|
18
|
+
mode: 0o700,
|
|
19
|
+
}).catch((error) => {
|
|
20
|
+
if (error.code !== "EEXIST") {
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
const parentInfo = await lstat(parent);
|
|
25
|
+
if (!parentInfo.isDirectory() || parentInfo.isSymbolicLink()) {
|
|
26
|
+
throw new Error("Write locks require a real local directory");
|
|
27
|
+
}
|
|
28
|
+
const directory = path.join(parent, "locks");
|
|
29
|
+
await mkdir(directory, {
|
|
30
|
+
mode: 0o700,
|
|
31
|
+
}).catch((error) => {
|
|
32
|
+
if (error.code !== "EEXIST") {
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
const info = await lstat(directory);
|
|
37
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
38
|
+
throw new Error("Write locks require a real local directory");
|
|
39
|
+
}
|
|
40
|
+
const destination = path.join(directory, `${createHash("sha256").update(fileId).digest("hex")}.json`);
|
|
41
|
+
const token = randomUUID();
|
|
42
|
+
const entry = {
|
|
43
|
+
token,
|
|
44
|
+
runId,
|
|
45
|
+
pid: process.pid,
|
|
46
|
+
host: hostname(),
|
|
47
|
+
createdAt: new Date().toISOString(),
|
|
48
|
+
};
|
|
49
|
+
const create = async () => {
|
|
50
|
+
const handle = await open(destination, "wx", 0o600);
|
|
51
|
+
try {
|
|
52
|
+
await handle.writeFile(JSON.stringify(entry));
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
await handle.close();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
try {
|
|
59
|
+
await create();
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (error.code !== "EEXIST") {
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
const previous = JSON.parse(await readFile(destination, "utf8"));
|
|
66
|
+
let running = false;
|
|
67
|
+
if (previous.host === hostname() && Number.isInteger(previous.pid) && previous.pid > 0) {
|
|
68
|
+
try {
|
|
69
|
+
process.kill(previous.pid, 0);
|
|
70
|
+
running = true;
|
|
71
|
+
}
|
|
72
|
+
catch (failure) {
|
|
73
|
+
if (failure.code !== "ESRCH") {
|
|
74
|
+
running = true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!force || running) {
|
|
79
|
+
throw new Error(`Local writer ${previous.runId} holds this file. ${running ? "Its process is still running; wait for it to finish." : "Inspect its journal, then use --force-lock to remove the abandoned local lock."}`);
|
|
80
|
+
}
|
|
81
|
+
// Explicit owner override only. Simultaneous force operations are not an exclusion guarantee.
|
|
82
|
+
await unlink(destination);
|
|
83
|
+
await create();
|
|
84
|
+
}
|
|
85
|
+
return async () => {
|
|
86
|
+
const current = JSON.parse(await readFile(destination, "utf8"));
|
|
87
|
+
if (current.token === token) {
|
|
88
|
+
await unlink(destination);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const readMarker = `const f = penpot.currentFile;
|
|
93
|
+
if (typeof f.getSharedPluginData !== "function" || typeof f.setSharedPluginData !== "function") return { ok: true, supported: false };
|
|
94
|
+
const raw = f.getSharedPluginData("penpair", "writer");
|
|
95
|
+
let marker = null;
|
|
96
|
+
if (raw) { try { marker = JSON.parse(raw); } catch { return { ok: false, message: "Malformed shared writer marker; inspect before overriding" }; } }
|
|
97
|
+
`;
|
|
98
|
+
export function writeCoordinator(client, options) {
|
|
99
|
+
return async (runId, target) => {
|
|
100
|
+
const releaseLocal = await localLease(options.root, target.fileId, runId, options.forceLock === true);
|
|
101
|
+
const marker = {
|
|
102
|
+
schemaVersion: 1,
|
|
103
|
+
token: randomUUID(),
|
|
104
|
+
runId,
|
|
105
|
+
operation: options.operation,
|
|
106
|
+
owner: userInfo().username,
|
|
107
|
+
createdAt: Date.now(),
|
|
108
|
+
expiresAt: Date.now() + 15 * 60_000,
|
|
109
|
+
};
|
|
110
|
+
const params = {
|
|
111
|
+
marker,
|
|
112
|
+
fileId: target.fileId,
|
|
113
|
+
force: options.forceLock === true,
|
|
114
|
+
};
|
|
115
|
+
const targetGuard = 'if (penpot.currentFile.id !== CFG.fileId) return { ok:false, message:"Writer marker target changed" };';
|
|
116
|
+
try {
|
|
117
|
+
const before = await runSnippet(client, snippet("writer-inspect", targetGuard + readMarker + "return { ok:true, supported:true, marker };", params, "read"));
|
|
118
|
+
if (before.supported === false) {
|
|
119
|
+
options.warn?.("Shared writer markers unavailable; local checkout lock only. Coordinate with colleagues before writing.");
|
|
120
|
+
return {
|
|
121
|
+
check: async () => undefined,
|
|
122
|
+
release: releaseLocal,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const acquired = await runSnippet(client, snippet("writer-acquire", targetGuard +
|
|
126
|
+
readMarker +
|
|
127
|
+
`
|
|
128
|
+
if (marker && marker.token !== CFG.marker.token && (!Number.isFinite(marker.expiresAt) || marker.expiresAt > Date.now()) && !CFG.force)
|
|
129
|
+
return { ok:false, message:"Another writer is running " + String(marker.operation) + " (run " + String(marker.runId) + "). Coordinate before --force-lock." };
|
|
130
|
+
f.setSharedPluginData("penpair", "writer", JSON.stringify(CFG.marker));
|
|
131
|
+
return { ok:true, supported:true };`, params, "write"));
|
|
132
|
+
if (acquired.supported !== true) {
|
|
133
|
+
throw new Error("Writer marker capability changed during acquisition");
|
|
134
|
+
}
|
|
135
|
+
const check = async () => {
|
|
136
|
+
const observed = await runSnippet(client, snippet("writer-check", targetGuard +
|
|
137
|
+
readMarker +
|
|
138
|
+
"return { ok:true, supported:true, owned: !!marker && marker.token === CFG.marker.token && marker.expiresAt > Date.now() };", params, "read"));
|
|
139
|
+
if (observed.supported !== true || observed.owned !== true) {
|
|
140
|
+
throw new Error("Shared writer marker was lost or expired; stop and coordinate before resuming");
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
await check();
|
|
144
|
+
return {
|
|
145
|
+
check,
|
|
146
|
+
release: async () => {
|
|
147
|
+
try {
|
|
148
|
+
await runSnippet(client, snippet("writer-release", targetGuard +
|
|
149
|
+
readMarker +
|
|
150
|
+
'if (marker && marker.token === CFG.marker.token) f.setSharedPluginData("penpair", "writer", ""); return { ok:true };', params, "write"));
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
await releaseLocal();
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
await releaseLocal();
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/** Revision precondition is checked inside the delivered mutation, after all guard calls. It is not a server CAS. */
|
|
165
|
+
export function guardRevision(client, target) {
|
|
166
|
+
return inheritPageSession({
|
|
167
|
+
...client,
|
|
168
|
+
close: () => client.close(),
|
|
169
|
+
async callTool(name, args, options) {
|
|
170
|
+
if (name !== "execute_code" || typeof args.code !== "string") {
|
|
171
|
+
throw new Error("Revision guards require execute_code");
|
|
172
|
+
}
|
|
173
|
+
const code = `{ if (penpot.currentFile.id !== ${JSON.stringify(target.fileId)} || penpot.currentFile.revn !== ${JSON.stringify(target.revision)}) return {ok:false, message:"File revision changed before mutation; inspect again"}; }\n` +
|
|
174
|
+
args.code;
|
|
175
|
+
if (Buffer.byteLength(code) > maxSnippetBytes) {
|
|
176
|
+
throw new PenpairError("snippet-too-large", "Snippet including revision guard exceeds 90 KB");
|
|
177
|
+
}
|
|
178
|
+
return client.callTool(name, {
|
|
179
|
+
...args,
|
|
180
|
+
code,
|
|
181
|
+
}, options);
|
|
182
|
+
},
|
|
183
|
+
}, client);
|
|
184
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type CredentialReference = {
|
|
2
|
+
type: "file";
|
|
3
|
+
path: string;
|
|
4
|
+
} | {
|
|
5
|
+
type: "keychain";
|
|
6
|
+
service: string;
|
|
7
|
+
account: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function validateCredentialReference(raw: unknown): CredentialReference;
|
|
10
|
+
type KeychainRead = (command: string, args: string[], options: {
|
|
11
|
+
timeout: number;
|
|
12
|
+
maxBuffer: number;
|
|
13
|
+
encoding: "utf8";
|
|
14
|
+
}) => Promise<string>;
|
|
15
|
+
/** Resolve only at connection time. No URL, key, child stderr or command error object enters diagnostics. */
|
|
16
|
+
export declare function readCredential(reference: CredentialReference, options: {
|
|
17
|
+
root: string;
|
|
18
|
+
platform?: string;
|
|
19
|
+
keychainRead?: KeychainRead;
|
|
20
|
+
}): Promise<string>;
|
|
21
|
+
export {};
|