@xenonbyte/da-vinci-workflow 0.1.15 → 0.1.17
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/CHANGELOG.md +17 -1
- package/README.md +35 -1
- package/README.zh-CN.md +35 -1
- package/SKILL.md +18 -0
- package/commands/claude/dv/design.md +7 -0
- package/commands/codex/prompts/dv-design.md +7 -0
- package/commands/gemini/dv/design.toml +7 -0
- package/docs/mode-use-cases.md +5 -1
- package/docs/prompt-presets/README.md +3 -0
- package/docs/prompt-presets/desktop-app.md +16 -1
- package/docs/prompt-presets/mobile-app.md +16 -1
- package/docs/prompt-presets/tablet-app.md +16 -1
- package/docs/prompt-presets/web-app.md +16 -1
- package/docs/visual-assist-presets/README.md +5 -0
- package/docs/workflow-examples.md +22 -8
- package/docs/zh-CN/mode-use-cases.md +13 -4
- package/docs/zh-CN/prompt-presets/README.md +3 -0
- package/docs/zh-CN/prompt-presets/desktop-app.md +16 -1
- package/docs/zh-CN/prompt-presets/mobile-app.md +16 -1
- package/docs/zh-CN/prompt-presets/tablet-app.md +16 -1
- package/docs/zh-CN/prompt-presets/web-app.md +16 -1
- package/docs/zh-CN/visual-assist-presets/README.md +5 -0
- package/docs/zh-CN/workflow-examples.md +22 -8
- package/lib/audit.js +66 -2
- package/lib/cli.js +356 -1
- package/lib/mcp-runtime-gate.js +53 -1
- package/lib/pen-persistence.js +515 -0
- package/lib/pencil-lock.js +128 -0
- package/lib/pencil-preflight.js +438 -0
- package/lib/pencil-session.js +229 -0
- package/package.json +6 -2
- package/references/artifact-templates.md +2 -0
- package/references/checkpoints.md +14 -0
- package/references/pencil-design-to-code.md +15 -0
- package/scripts/fixtures/complex-sample.pen +295 -0
- package/scripts/test-mcp-runtime-gate.js +88 -0
- package/scripts/test-pen-persistence.js +250 -0
- package/scripts/test-pencil-preflight.js +153 -0
- package/scripts/test-pencil-session.js +152 -0
- package/scripts/test-persistence-flows.js +315 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const crypto = require("crypto");
|
|
5
|
+
const { spawnSync } = require("child_process");
|
|
6
|
+
|
|
7
|
+
const DEFAULT_PEN_VERSION = "2.9";
|
|
8
|
+
const DEFAULT_READ_DEPTH = 50;
|
|
9
|
+
|
|
10
|
+
function isPlainObject(value) {
|
|
11
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function extractFirstJson(text) {
|
|
15
|
+
const source = String(text || "");
|
|
16
|
+
const decoder = new JSONDecoder();
|
|
17
|
+
return decoder.decodeFirst(source);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class JSONDecoder {
|
|
21
|
+
constructor() {
|
|
22
|
+
this.decoder = JSON;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
decodeFirst(text) {
|
|
26
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
27
|
+
const ch = text[index];
|
|
28
|
+
if (ch !== "{" && ch !== "[") {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const { value } = this.rawDecode(text.slice(index));
|
|
34
|
+
return value;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
throw new Error("No JSON payload found.");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
rawDecode(text) {
|
|
44
|
+
let end = 0;
|
|
45
|
+
let inString = false;
|
|
46
|
+
let escape = false;
|
|
47
|
+
let depth = 0;
|
|
48
|
+
const opening = text[0];
|
|
49
|
+
const closing = opening === "{" ? "}" : "]";
|
|
50
|
+
|
|
51
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
52
|
+
const ch = text[index];
|
|
53
|
+
|
|
54
|
+
if (inString) {
|
|
55
|
+
if (escape) {
|
|
56
|
+
escape = false;
|
|
57
|
+
} else if (ch === "\\") {
|
|
58
|
+
escape = true;
|
|
59
|
+
} else if (ch === "\"") {
|
|
60
|
+
inString = false;
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (ch === "\"") {
|
|
66
|
+
inString = true;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (ch === opening) {
|
|
71
|
+
depth += 1;
|
|
72
|
+
} else if (ch === closing) {
|
|
73
|
+
depth -= 1;
|
|
74
|
+
if (depth === 0) {
|
|
75
|
+
end = index + 1;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (end === 0) {
|
|
82
|
+
throw new Error("Incomplete JSON payload.");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
value: JSON.parse(text.slice(0, end)),
|
|
87
|
+
end
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readJsonPayload(filePath) {
|
|
93
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(raw);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
return extractFirstJson(raw);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function hasTruncatedChildren(value) {
|
|
102
|
+
if (Array.isArray(value)) {
|
|
103
|
+
return value.some((item) => hasTruncatedChildren(item));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!isPlainObject(value)) {
|
|
107
|
+
return value === "...";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return Object.values(value).some((item) => hasTruncatedChildren(item));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeNodesPayload(payload) {
|
|
114
|
+
const nodes = getNodesArray(payload);
|
|
115
|
+
|
|
116
|
+
if (!nodes) {
|
|
117
|
+
throw new Error("Nodes payload must be an array or an object with a `nodes` array.");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (hasTruncatedChildren(nodes)) {
|
|
121
|
+
throw new Error("Nodes payload is truncated (`...`). Re-read the live document with a deeper batch_get before persisting.");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return nodes;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getNodesArray(payload) {
|
|
128
|
+
if (Array.isArray(payload)) {
|
|
129
|
+
return payload;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (payload && Array.isArray(payload.nodes)) {
|
|
133
|
+
return payload.nodes;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function normalizeVariablesPayload(payload) {
|
|
140
|
+
if (!payload) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (isPlainObject(payload.variables)) {
|
|
145
|
+
return payload.variables;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (isPlainObject(payload)) {
|
|
149
|
+
return payload;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
throw new Error("Variables payload must be an object or an object with a `variables` key.");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function canonicalizeJson(value) {
|
|
156
|
+
if (Array.isArray(value)) {
|
|
157
|
+
return value.map((item) => canonicalizeJson(item));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!isPlainObject(value)) {
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return Object.keys(value)
|
|
165
|
+
.sort()
|
|
166
|
+
.reduce((accumulator, key) => {
|
|
167
|
+
accumulator[key] = canonicalizeJson(value[key]);
|
|
168
|
+
return accumulator;
|
|
169
|
+
}, {});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function buildPenDocument({ version = DEFAULT_PEN_VERSION, nodes, variables }) {
|
|
173
|
+
const document = {
|
|
174
|
+
version,
|
|
175
|
+
children: normalizeNodesPayload(nodes)
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const normalizedVariables = normalizeVariablesPayload(variables);
|
|
179
|
+
if (normalizedVariables && Object.keys(normalizedVariables).length > 0) {
|
|
180
|
+
document.variables = normalizedVariables;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return document;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function hashPenDocument(document) {
|
|
187
|
+
return crypto
|
|
188
|
+
.createHash("sha256")
|
|
189
|
+
.update(JSON.stringify(canonicalizeJson(document)))
|
|
190
|
+
.digest("hex");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function getStandardPenStatePath(outputPath) {
|
|
194
|
+
const targetPath = path.resolve(outputPath);
|
|
195
|
+
const marker = `${path.sep}.da-vinci${path.sep}designs${path.sep}`;
|
|
196
|
+
const markerIndex = targetPath.indexOf(marker);
|
|
197
|
+
|
|
198
|
+
if (markerIndex < 0) {
|
|
199
|
+
return `${targetPath}.meta.json`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const projectRoot = targetPath.slice(0, markerIndex);
|
|
203
|
+
const relativePenPath = targetPath.slice(markerIndex + marker.length);
|
|
204
|
+
const stateFileName = `${relativePenPath.split(path.sep).join("__")}.json`;
|
|
205
|
+
return path.join(projectRoot, ".da-vinci", "state", "pens", stateFileName);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function writePenDocumentAtomic(outputPath, document) {
|
|
209
|
+
const targetPath = path.resolve(outputPath);
|
|
210
|
+
const targetDir = path.dirname(targetPath);
|
|
211
|
+
const tempPath = path.join(
|
|
212
|
+
targetDir,
|
|
213
|
+
`.${path.basename(targetPath)}.tmp-${process.pid}-${Date.now()}`
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
217
|
+
fs.writeFileSync(tempPath, JSON.stringify(document, null, 2) + "\n", "utf8");
|
|
218
|
+
fs.renameSync(tempPath, targetPath);
|
|
219
|
+
return targetPath;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function writeJsonFileAtomic(outputPath, payload) {
|
|
223
|
+
const targetPath = path.resolve(outputPath);
|
|
224
|
+
const targetDir = path.dirname(targetPath);
|
|
225
|
+
const tempPath = path.join(
|
|
226
|
+
targetDir,
|
|
227
|
+
`.${path.basename(targetPath)}.tmp-${process.pid}-${Date.now()}`
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
231
|
+
fs.writeFileSync(tempPath, JSON.stringify(payload, null, 2) + "\n", "utf8");
|
|
232
|
+
fs.renameSync(tempPath, targetPath);
|
|
233
|
+
return targetPath;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function buildPenState(outputPath, document, options = {}) {
|
|
237
|
+
return {
|
|
238
|
+
schema: 1,
|
|
239
|
+
source: options.source || "write-pen",
|
|
240
|
+
penPath: path.resolve(outputPath),
|
|
241
|
+
version: document.version || DEFAULT_PEN_VERSION,
|
|
242
|
+
snapshotHash: hashPenDocument(document),
|
|
243
|
+
topLevelIds: Array.isArray(document.children) ? document.children.map((node) => node.id) : [],
|
|
244
|
+
topLevelCount: Array.isArray(document.children) ? document.children.length : 0,
|
|
245
|
+
persistedAt: options.persistedAt || new Date().toISOString()
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function writePenStateAtomic(outputPath, state) {
|
|
250
|
+
const statePath = getStandardPenStatePath(outputPath);
|
|
251
|
+
writeJsonFileAtomic(statePath, state);
|
|
252
|
+
return statePath;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function readPenState(outputPath) {
|
|
256
|
+
const statePath = getStandardPenStatePath(outputPath);
|
|
257
|
+
if (!fs.existsSync(statePath)) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
return readJsonPayload(statePath);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function runPencilInteractive(inputPath, commands, options = {}) {
|
|
264
|
+
const pencilBin = options.pencilBin || "pencil";
|
|
265
|
+
const unusedOutput = path.join(
|
|
266
|
+
os.tmpdir(),
|
|
267
|
+
`da-vinci-verify-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.pen`
|
|
268
|
+
);
|
|
269
|
+
const payload = Array.isArray(commands) ? commands.join("\n") : String(commands || "");
|
|
270
|
+
const result = spawnSync(
|
|
271
|
+
pencilBin,
|
|
272
|
+
["interactive", "-i", path.resolve(inputPath), "-o", unusedOutput],
|
|
273
|
+
{
|
|
274
|
+
input: `${payload}\nexit()\n`,
|
|
275
|
+
encoding: "utf8",
|
|
276
|
+
maxBuffer: 8 * 1024 * 1024
|
|
277
|
+
}
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
fs.rmSync(unusedOutput, { force: true });
|
|
281
|
+
|
|
282
|
+
const stdout = result.stdout || "";
|
|
283
|
+
const stderr = result.stderr || "";
|
|
284
|
+
if (result.status !== 0) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`Pencil interactive failed for ${inputPath}.\n${stdout}${stderr}`.trim()
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return `${stdout}${stderr}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function verifyPenFileWithPencil(filePath, options = {}) {
|
|
294
|
+
const output = runPencilInteractive(filePath, ['batch_get({ readDepth: 1 })'], options);
|
|
295
|
+
const payload = extractFirstJson(output);
|
|
296
|
+
const nodes = getNodesArray(payload);
|
|
297
|
+
const expectedTopLevelIds = options.expectedTopLevelIds || [];
|
|
298
|
+
|
|
299
|
+
if (!nodes) {
|
|
300
|
+
throw new Error("Pencil reopen verification did not return a `nodes` array.");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (expectedTopLevelIds.length > 0) {
|
|
304
|
+
const actualIds = nodes.map((node) => node.id);
|
|
305
|
+
if (JSON.stringify(actualIds) !== JSON.stringify(expectedTopLevelIds)) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`Reopened .pen file top-level ids do not match. Expected ${expectedTopLevelIds.join(", ")}, got ${actualIds.join(", ")}.`
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
topLevelCount: nodes.length,
|
|
314
|
+
topLevelIds: nodes.map((node) => node.id)
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function writePenFromPayloadFiles(options) {
|
|
319
|
+
const outputPath = path.resolve(options.outputPath);
|
|
320
|
+
const nodes = readJsonPayload(options.nodesFile);
|
|
321
|
+
const variables = options.variablesFile ? readJsonPayload(options.variablesFile) : undefined;
|
|
322
|
+
const document = buildPenDocument({
|
|
323
|
+
version: options.version || DEFAULT_PEN_VERSION,
|
|
324
|
+
nodes,
|
|
325
|
+
variables
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
writePenDocumentAtomic(outputPath, document);
|
|
329
|
+
const state = buildPenState(outputPath, document, { source: "write-pen" });
|
|
330
|
+
const statePath = writePenStateAtomic(outputPath, state);
|
|
331
|
+
|
|
332
|
+
const verification = options.verifyWithPencil
|
|
333
|
+
? verifyPenFileWithPencil(outputPath, {
|
|
334
|
+
pencilBin: options.pencilBin,
|
|
335
|
+
expectedTopLevelIds: document.children.map((node) => node.id)
|
|
336
|
+
})
|
|
337
|
+
: null;
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
outputPath,
|
|
341
|
+
document,
|
|
342
|
+
verification,
|
|
343
|
+
state,
|
|
344
|
+
statePath
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function readPenVersion(inputPath) {
|
|
349
|
+
const payload = JSON.parse(fs.readFileSync(inputPath, "utf8"));
|
|
350
|
+
return payload.version || DEFAULT_PEN_VERSION;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function capturePenSnapshot(inputPath, options = {}) {
|
|
354
|
+
const readDepth = options.readDepth || DEFAULT_READ_DEPTH;
|
|
355
|
+
const nodesOutput = runPencilInteractive(
|
|
356
|
+
inputPath,
|
|
357
|
+
[
|
|
358
|
+
`batch_get({ readDepth: ${readDepth}, includePathGeometry: true, resolveInstances: false, resolveVariables: false })`
|
|
359
|
+
],
|
|
360
|
+
options
|
|
361
|
+
);
|
|
362
|
+
const variablesOutput = runPencilInteractive(inputPath, ["get_variables()"], options);
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
nodes: normalizeNodesPayload(extractFirstJson(nodesOutput)),
|
|
366
|
+
variables: normalizeVariablesPayload(extractFirstJson(variablesOutput))
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function snapshotPenFile(options) {
|
|
371
|
+
const inputPath = path.resolve(options.inputPath);
|
|
372
|
+
const outputPath = path.resolve(options.outputPath);
|
|
373
|
+
const snapshot = capturePenSnapshot(inputPath, options);
|
|
374
|
+
const document = buildPenDocument({
|
|
375
|
+
version: options.version || readPenVersion(inputPath),
|
|
376
|
+
nodes: snapshot.nodes,
|
|
377
|
+
variables: snapshot.variables
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
writePenDocumentAtomic(outputPath, document);
|
|
381
|
+
const state = buildPenState(outputPath, document, { source: "snapshot-pen" });
|
|
382
|
+
const statePath = writePenStateAtomic(outputPath, state);
|
|
383
|
+
|
|
384
|
+
const verification = options.verifyWithPencil
|
|
385
|
+
? verifyPenFileWithPencil(outputPath, {
|
|
386
|
+
pencilBin: options.pencilBin,
|
|
387
|
+
expectedTopLevelIds: document.children.map((node) => node.id)
|
|
388
|
+
})
|
|
389
|
+
: null;
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
inputPath,
|
|
393
|
+
outputPath,
|
|
394
|
+
document,
|
|
395
|
+
verification,
|
|
396
|
+
state,
|
|
397
|
+
statePath
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function readPenDocument(inputPath) {
|
|
402
|
+
return readJsonPayload(inputPath);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function ensurePenFile(options) {
|
|
406
|
+
const outputPath = path.resolve(options.outputPath);
|
|
407
|
+
const exists = fs.existsSync(outputPath);
|
|
408
|
+
let document;
|
|
409
|
+
let created = false;
|
|
410
|
+
|
|
411
|
+
if (exists) {
|
|
412
|
+
document = readPenDocument(outputPath);
|
|
413
|
+
if (!Array.isArray(document.children)) {
|
|
414
|
+
throw new Error(`Existing .pen file is invalid or missing \`children\`: ${outputPath}`);
|
|
415
|
+
}
|
|
416
|
+
} else {
|
|
417
|
+
document = buildPenDocument({
|
|
418
|
+
version: options.version || DEFAULT_PEN_VERSION,
|
|
419
|
+
nodes: { nodes: [] },
|
|
420
|
+
variables: {}
|
|
421
|
+
});
|
|
422
|
+
writePenDocumentAtomic(outputPath, document);
|
|
423
|
+
created = true;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const state = buildPenState(outputPath, document, {
|
|
427
|
+
source: created ? "ensure-pen:create" : "ensure-pen:existing"
|
|
428
|
+
});
|
|
429
|
+
const statePath = writePenStateAtomic(outputPath, state);
|
|
430
|
+
|
|
431
|
+
const verification = options.verifyWithPencil
|
|
432
|
+
? verifyPenFileWithPencil(outputPath, {
|
|
433
|
+
pencilBin: options.pencilBin,
|
|
434
|
+
expectedTopLevelIds: document.children.map((node) => node.id)
|
|
435
|
+
})
|
|
436
|
+
: null;
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
outputPath,
|
|
440
|
+
created,
|
|
441
|
+
document,
|
|
442
|
+
verification,
|
|
443
|
+
state,
|
|
444
|
+
statePath
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function hashPayloadFiles(options) {
|
|
449
|
+
const nodes = readJsonPayload(options.nodesFile);
|
|
450
|
+
const variables = options.variablesFile ? readJsonPayload(options.variablesFile) : undefined;
|
|
451
|
+
const document = buildPenDocument({
|
|
452
|
+
version: options.version || DEFAULT_PEN_VERSION,
|
|
453
|
+
nodes,
|
|
454
|
+
variables
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
document,
|
|
459
|
+
snapshotHash: hashPenDocument(document)
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function comparePenSync(options) {
|
|
464
|
+
const penPath = path.resolve(options.penPath);
|
|
465
|
+
if (!fs.existsSync(penPath)) {
|
|
466
|
+
throw new Error(`Registered .pen file does not exist: ${penPath}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const persistedDocument = readPenDocument(penPath);
|
|
470
|
+
const live = hashPayloadFiles({
|
|
471
|
+
...options,
|
|
472
|
+
version: options.version || persistedDocument.version || DEFAULT_PEN_VERSION
|
|
473
|
+
});
|
|
474
|
+
const persistedState = readPenState(penPath);
|
|
475
|
+
const persistedHash = persistedState && persistedState.snapshotHash
|
|
476
|
+
? String(persistedState.snapshotHash)
|
|
477
|
+
: hashPenDocument(persistedDocument);
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
penPath,
|
|
481
|
+
statePath: getStandardPenStatePath(penPath),
|
|
482
|
+
persistedHash,
|
|
483
|
+
liveHash: live.snapshotHash,
|
|
484
|
+
inSync: persistedHash === live.snapshotHash,
|
|
485
|
+
usedStateFile: Boolean(persistedState && persistedState.snapshotHash),
|
|
486
|
+
state: persistedState
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
module.exports = {
|
|
491
|
+
DEFAULT_PEN_VERSION,
|
|
492
|
+
DEFAULT_READ_DEPTH,
|
|
493
|
+
extractFirstJson,
|
|
494
|
+
readJsonPayload,
|
|
495
|
+
normalizeNodesPayload,
|
|
496
|
+
normalizeVariablesPayload,
|
|
497
|
+
canonicalizeJson,
|
|
498
|
+
buildPenDocument,
|
|
499
|
+
hashPenDocument,
|
|
500
|
+
getStandardPenStatePath,
|
|
501
|
+
writePenDocumentAtomic,
|
|
502
|
+
writeJsonFileAtomic,
|
|
503
|
+
buildPenState,
|
|
504
|
+
writePenStateAtomic,
|
|
505
|
+
readPenState,
|
|
506
|
+
runPencilInteractive,
|
|
507
|
+
verifyPenFileWithPencil,
|
|
508
|
+
writePenFromPayloadFiles,
|
|
509
|
+
capturePenSnapshot,
|
|
510
|
+
snapshotPenFile,
|
|
511
|
+
readPenDocument,
|
|
512
|
+
ensurePenFile,
|
|
513
|
+
hashPayloadFiles,
|
|
514
|
+
comparePenSync
|
|
515
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
|
|
5
|
+
function getLockPath(options = {}) {
|
|
6
|
+
const homeDir = options.homeDir ? path.resolve(options.homeDir) : os.homedir();
|
|
7
|
+
return path.join(homeDir, ".da-vinci", "pencil-mcp.lock");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function readLock(lockPath) {
|
|
11
|
+
const resolvedLockPath = path.resolve(lockPath);
|
|
12
|
+
if (!fs.existsSync(resolvedLockPath)) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return JSON.parse(fs.readFileSync(resolvedLockPath, "utf8"));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function writeLock(lockPath, payload) {
|
|
20
|
+
const resolvedLockPath = path.resolve(lockPath);
|
|
21
|
+
fs.mkdirSync(path.dirname(resolvedLockPath), { recursive: true });
|
|
22
|
+
const handle = fs.openSync(resolvedLockPath, "wx");
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
fs.writeFileSync(handle, JSON.stringify(payload, null, 2) + "\n", "utf8");
|
|
26
|
+
} finally {
|
|
27
|
+
fs.closeSync(handle);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function acquirePencilLock(options = {}) {
|
|
32
|
+
const projectPath = path.resolve(options.projectPath || process.cwd());
|
|
33
|
+
const owner = options.owner || path.basename(projectPath);
|
|
34
|
+
const waitMs = Number.isFinite(Number(options.waitMs)) ? Number(options.waitMs) : 0;
|
|
35
|
+
const pollIntervalMs = Math.max(50, Number(options.pollIntervalMs) || 100);
|
|
36
|
+
const lockPath = getLockPath(options);
|
|
37
|
+
const deadline = Date.now() + Math.max(0, waitMs);
|
|
38
|
+
const payload = {
|
|
39
|
+
schema: 1,
|
|
40
|
+
projectPath,
|
|
41
|
+
owner,
|
|
42
|
+
pid: process.pid,
|
|
43
|
+
acquiredAt: new Date().toISOString()
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
while (true) {
|
|
47
|
+
try {
|
|
48
|
+
writeLock(lockPath, payload);
|
|
49
|
+
return {
|
|
50
|
+
lockPath,
|
|
51
|
+
acquired: true,
|
|
52
|
+
alreadyHeld: false,
|
|
53
|
+
lock: payload
|
|
54
|
+
};
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error.code !== "EEXIST") {
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const current = readLock(lockPath);
|
|
61
|
+
if (current && current.projectPath === projectPath) {
|
|
62
|
+
return {
|
|
63
|
+
lockPath,
|
|
64
|
+
acquired: true,
|
|
65
|
+
alreadyHeld: true,
|
|
66
|
+
lock: current
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (Date.now() >= deadline) {
|
|
71
|
+
const holder = current
|
|
72
|
+
? `${current.projectPath} (owner: ${current.owner || "unknown"}, pid: ${current.pid || "unknown"})`
|
|
73
|
+
: "unknown holder";
|
|
74
|
+
throw new Error(`Pencil MCP lock is already held by ${holder}.`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const sleepUntil = Date.now() + pollIntervalMs;
|
|
78
|
+
while (Date.now() < sleepUntil) {
|
|
79
|
+
// Busy wait to keep the CLI synchronous without additional runtime dependencies.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function releasePencilLock(options = {}) {
|
|
86
|
+
const projectPath = options.projectPath ? path.resolve(options.projectPath) : "";
|
|
87
|
+
const lockPath = getLockPath(options);
|
|
88
|
+
const current = readLock(lockPath);
|
|
89
|
+
|
|
90
|
+
if (!current) {
|
|
91
|
+
return {
|
|
92
|
+
lockPath,
|
|
93
|
+
released: false,
|
|
94
|
+
hadLock: false,
|
|
95
|
+
lock: null
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (projectPath && current.projectPath !== projectPath && !options.force) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`Pencil MCP lock is held by a different project: ${current.projectPath}`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fs.rmSync(lockPath, { force: true });
|
|
106
|
+
return {
|
|
107
|
+
lockPath,
|
|
108
|
+
released: true,
|
|
109
|
+
hadLock: true,
|
|
110
|
+
lock: current
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function getPencilLockStatus(options = {}) {
|
|
115
|
+
const lockPath = getLockPath(options);
|
|
116
|
+
return {
|
|
117
|
+
lockPath,
|
|
118
|
+
lock: readLock(lockPath)
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = {
|
|
123
|
+
getLockPath,
|
|
124
|
+
readLock,
|
|
125
|
+
acquirePencilLock,
|
|
126
|
+
releasePencilLock,
|
|
127
|
+
getPencilLockStatus
|
|
128
|
+
};
|