@itsthecsguy/linework 0.1.4 → 0.2.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/README.md +21 -1
- package/dist/api.js +66 -9
- package/dist/cli.js +53 -286
- package/dist/commands.js +346 -0
- package/dist/daemon.js +193 -0
- package/dist/drawio/merge.js +47 -8
- package/dist/layout/layout.js +44 -1
- package/dist/lint/check.js +16 -0
- package/dist/mcp/server.js +4 -4
- package/dist/patch.js +167 -0
- package/dist/report.js +2 -0
- package/dist/spec/parse.js +2 -0
- package/package.json +1 -1
- package/skills/linework/SKILL.md +57 -200
- package/skills/linework/references/troubleshooting.md +11 -0
package/dist/commands.js
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// Every command, written against an output sink so the same code runs in the
|
|
2
|
+
// CLI process and in the background process that keeps Chromium warm.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
export const USAGE = `linework: AI-written, hand-editable diagrams (YAML spec -> .drawio.svg)
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
linework build <spec.yaml|-> -o <out.drawio.svg> [--png preview.png] [--relayout]
|
|
9
|
+
Lay out and write the diagram, then lint it. "-" reads the spec from stdin.
|
|
10
|
+
If the file exists and someone edited it by hand, the spec is merged in:
|
|
11
|
+
existing positions stay, new nodes are placed next to their neighbours.
|
|
12
|
+
--relayout throws the existing layout away (hand edits included).
|
|
13
|
+
linework set <file.drawio.svg> <id.field=value | theme=..|title=..|direction=..>...
|
|
14
|
+
linework add <file.drawio.svg> <"id: { label: .., in: .. }" | "a -> b: label" | "group id: { label: .. }">...
|
|
15
|
+
linework rm <file.drawio.svg> <id | "a -> b">...
|
|
16
|
+
Change a diagram in place, one command per change, no spec file. Everything
|
|
17
|
+
already in the diagram keeps its position, hand edits included.
|
|
18
|
+
linework set d.drawio.svg api.label="Checkout API v2" api.highlight=true
|
|
19
|
+
linework add d.drawio.svg "fraud: { label: Fraud check, in: vpc }" "api -> fraud: score"
|
|
20
|
+
linework rm d.drawio.svg cache "api -> stripe"
|
|
21
|
+
linework show <file.drawio.svg> [--geometry]
|
|
22
|
+
Print the spec of an existing diagram (including hand edits) as YAML.
|
|
23
|
+
--geometry appends node positions as comments.
|
|
24
|
+
linework render <file.drawio.svg> -o <out.png|out.svg> [--scale 2]
|
|
25
|
+
Render a preview image.
|
|
26
|
+
linework check <file.drawio.svg>
|
|
27
|
+
Lint layout: overlaps, edges through nodes, labels that don't fit.
|
|
28
|
+
linework tidy <file.drawio.svg>
|
|
29
|
+
Reroute edges that check flags as broken (e.g. after moving nodes by hand).
|
|
30
|
+
Nothing else in the file changes.
|
|
31
|
+
linework edit <file.drawio.svg> [--no-open] [--port N]
|
|
32
|
+
Open the diagram in draw.io (bundled, offline) in your browser. Saving in
|
|
33
|
+
the editor writes the file. Stops when you exit the editor or close the tab.
|
|
34
|
+
linework import <file.mmd|doc.md|-> [--block N] [-o out.drawio.svg] [--png p.png]
|
|
35
|
+
Convert a Mermaid diagram (flowchart, state, sequence, ER) to an linework spec.
|
|
36
|
+
Prints the spec, or builds it straight away with -o.
|
|
37
|
+
linework icons <aws|gcp|k8s|azure> [filter]
|
|
38
|
+
List icon names, for "icon: aws.lambda" on architecture nodes.
|
|
39
|
+
linework publish <file.drawio.svg...> [--to DIR] [--pull] [--force]
|
|
40
|
+
Copy diagrams into your Google Drive folder (Google Drive for desktop) so
|
|
41
|
+
draw.io's Google Docs add-on can insert them and refresh them later.
|
|
42
|
+
--pull copies back a diagram someone edited from Docs or Drive.
|
|
43
|
+
linework skill [install <dir>]
|
|
44
|
+
Print where the bundled agent skill is, or copy it to <dir>/linework
|
|
45
|
+
(e.g. .claude/skills, ~/.agents/skills).
|
|
46
|
+
linework mcp [--root DIR]
|
|
47
|
+
Run as an MCP server over stdio, for chat apps and agents without a shell.
|
|
48
|
+
Relative paths resolve against --root (default: the current folder).
|
|
49
|
+
linework git-setup
|
|
50
|
+
Make git diff show .drawio.svg changes as spec changes (this repo only).
|
|
51
|
+
linework kinds [architecture|flow|state]
|
|
52
|
+
List node kinds for each diagram type.
|
|
53
|
+
linework schema
|
|
54
|
+
Print the spec's JSON Schema.
|
|
55
|
+
|
|
56
|
+
linework daemon [status|stop]
|
|
57
|
+
Builds after the first reuse a background process that keeps the browser
|
|
58
|
+
running, and take about a fifth of the time. It exits after 5 idle minutes.
|
|
59
|
+
Turn it off with LINEWORK_NO_DAEMON=1 or --no-daemon.
|
|
60
|
+
|
|
61
|
+
linework install-browser
|
|
62
|
+
Download the headless Chromium build that matches this version. Needed only
|
|
63
|
+
when no Chrome or Edge is installed, or to run inside an agent's sandbox.
|
|
64
|
+
`;
|
|
65
|
+
export const processIO = {
|
|
66
|
+
out: (t) => void process.stdout.write(t),
|
|
67
|
+
err: (t) => void process.stderr.write(t),
|
|
68
|
+
stdin: () => fs.readFileSync(0, 'utf8'),
|
|
69
|
+
get code() {
|
|
70
|
+
return Number(process.exitCode ?? 0);
|
|
71
|
+
},
|
|
72
|
+
set code(c) {
|
|
73
|
+
process.exitCode = c;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
/** A usage error: print the message and exit with this code. */
|
|
77
|
+
export class Exit extends Error {
|
|
78
|
+
code;
|
|
79
|
+
constructor(message, code) {
|
|
80
|
+
super(message);
|
|
81
|
+
this.code = code;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Print an error the way the CLI does and return the exit code for it. */
|
|
85
|
+
export function report(e, io = processIO) {
|
|
86
|
+
if (e instanceof Exit) {
|
|
87
|
+
io.err(e.message + '\n');
|
|
88
|
+
return e.code;
|
|
89
|
+
}
|
|
90
|
+
if (e?.constructor?.name === 'PatchError') {
|
|
91
|
+
io.err(`error: ${e.message}\n`);
|
|
92
|
+
return 2;
|
|
93
|
+
}
|
|
94
|
+
if (e?.name === 'SpecError') {
|
|
95
|
+
io.err('spec error:\n');
|
|
96
|
+
for (const i of e.issues)
|
|
97
|
+
io.err(` ${i.path ? i.path + ': ' : ''}${i.message}\n`);
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
io.err(`error: ${e?.message ?? e}\n`);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
/** Commands that draw, and so need Chromium. */
|
|
104
|
+
export function needsBrowser(cmd, values) {
|
|
105
|
+
return ['build', 'set', 'add', 'rm', 'render', 'check', 'tidy', 'icons'].includes(cmd) || (cmd === 'import' && !!values.out);
|
|
106
|
+
}
|
|
107
|
+
/** A small spec with a group, labels and a cycle: enough to exercise ELK's main paths. */
|
|
108
|
+
const WARM_SPEC = 'type: architecture\ngroups: { g: G }\nnodes:\n a: { in: g }\n b: { in: g }\n c: {}\nedges:\n - a -> b: x\n - b -> c: y\n - c -> a\n';
|
|
109
|
+
export async function warmUp() {
|
|
110
|
+
const [{ parseSpecText }, { layout }] = await Promise.all([import('./spec/parse.js'), import('./layout/layout.js')]);
|
|
111
|
+
await layout(parseSpecText(WARM_SPEC).diagram);
|
|
112
|
+
}
|
|
113
|
+
export const OPTIONS = {
|
|
114
|
+
out: { type: 'string', short: 'o' },
|
|
115
|
+
png: { type: 'string' },
|
|
116
|
+
force: { type: 'boolean', default: false },
|
|
117
|
+
relayout: { type: 'boolean', default: false },
|
|
118
|
+
geometry: { type: 'boolean', default: false },
|
|
119
|
+
quiet: { type: 'boolean', short: 'q', default: false },
|
|
120
|
+
block: { type: 'string' },
|
|
121
|
+
scale: { type: 'string' },
|
|
122
|
+
'no-open': { type: 'boolean', default: false },
|
|
123
|
+
port: { type: 'string' },
|
|
124
|
+
'no-daemon': { type: 'boolean', default: false },
|
|
125
|
+
root: { type: 'string' },
|
|
126
|
+
to: { type: 'string' },
|
|
127
|
+
pull: { type: 'boolean', default: false },
|
|
128
|
+
help: { type: 'boolean', short: 'h' },
|
|
129
|
+
};
|
|
130
|
+
export async function run(cmd, values, positionals, launching, io = processIO) {
|
|
131
|
+
const console = { log: (m = '') => io.out(m + '\n'), error: (m = '') => io.err(m + '\n') };
|
|
132
|
+
const fail = (msg) => {
|
|
133
|
+
throw new Exit(msg, 2);
|
|
134
|
+
};
|
|
135
|
+
const printCheck = async (res) => {
|
|
136
|
+
const { checkLines } = await import('./report.js');
|
|
137
|
+
for (const line of checkLines(res))
|
|
138
|
+
console.log(line);
|
|
139
|
+
};
|
|
140
|
+
const browser = async () => {
|
|
141
|
+
// ELK compiles its code on first use. Laying out a tiny graph while Chromium
|
|
142
|
+
// is still starting takes that cost off the real layout.
|
|
143
|
+
if (launching && !io.warm && ['build', 'import', 'set', 'add', 'rm'].includes(cmd))
|
|
144
|
+
await warmUp();
|
|
145
|
+
return launching;
|
|
146
|
+
};
|
|
147
|
+
const { buildLines } = await import('./report.js');
|
|
148
|
+
switch (cmd) {
|
|
149
|
+
case 'build': {
|
|
150
|
+
const src = positionals[0];
|
|
151
|
+
if (!src || !values.out)
|
|
152
|
+
fail('usage: linework build <spec.yaml|-> -o <out.drawio.svg>');
|
|
153
|
+
const text = src === '-' ? io.stdin() : fs.readFileSync(src, 'utf8');
|
|
154
|
+
const { build } = await import('./api.js');
|
|
155
|
+
// Spec errors first: they shouldn't wait on, or hide behind, a browser problem.
|
|
156
|
+
(await import('./spec/parse.js')).parseSpecText(text);
|
|
157
|
+
const res = await build(text, { out: values.out, png: values.png, force: values.force, relayout: values.relayout, browser: await browser() });
|
|
158
|
+
for (const line of buildLines(res, values.png))
|
|
159
|
+
console.log(line);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
case 'set':
|
|
163
|
+
case 'add':
|
|
164
|
+
case 'rm': {
|
|
165
|
+
const [file, ...args] = positionals;
|
|
166
|
+
if (!file || !args.length)
|
|
167
|
+
fail(`usage: linework ${cmd} <file.drawio.svg> ${cmd === 'set' ? 'id.field=value...' : cmd === 'add' ? '"id: { label: .. }" | "a -> b: label"...' : 'id | "a -> b"...'}`);
|
|
168
|
+
const { patchFile, patchSpec } = await import('./patch.js');
|
|
169
|
+
const { show } = await import('./api.js');
|
|
170
|
+
// Bad arguments first: they shouldn't wait on, or hide behind, a browser problem.
|
|
171
|
+
(await import('./spec/parse.js')).parseSpecText(patchSpec(show(file).yaml, [{ op: cmd, args }]));
|
|
172
|
+
const res = await patchFile(file, [{ op: cmd, args }], { png: values.png, browser: await browser() });
|
|
173
|
+
for (const line of buildLines(res, values.png))
|
|
174
|
+
console.log(line);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
case 'show': {
|
|
178
|
+
const file = positionals[0] ?? fail('usage: linework show <file.drawio.svg>');
|
|
179
|
+
const { show } = await import('./api.js');
|
|
180
|
+
const res = show(file, { geometry: values.geometry });
|
|
181
|
+
io.out(res.yaml);
|
|
182
|
+
if (values.quiet)
|
|
183
|
+
return;
|
|
184
|
+
for (const n of res.notes)
|
|
185
|
+
console.error(`note: ${n}`);
|
|
186
|
+
if (res.edits.length) {
|
|
187
|
+
const moved = res.edits.filter((e) => e.what !== 'added').length;
|
|
188
|
+
const added = res.edits.filter((e) => e.what === 'added').length;
|
|
189
|
+
console.error(`note: edited by hand since the last build (${moved} moved/resized, ${added} added). build keeps those positions and merges your changes in.`);
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
case 'render': {
|
|
194
|
+
const file = positionals[0];
|
|
195
|
+
if (!file || !values.out)
|
|
196
|
+
fail('usage: linework render <file.drawio.svg> -o <out.png>');
|
|
197
|
+
const { render } = await import('./api.js');
|
|
198
|
+
await render(file, values.out, await browser());
|
|
199
|
+
console.log(`wrote ${values.out}`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
case 'check': {
|
|
203
|
+
const file = positionals[0] ?? fail('usage: linework check <file.drawio.svg>');
|
|
204
|
+
const { checkFile } = await import('./api.js');
|
|
205
|
+
const res = await checkFile(file, await browser());
|
|
206
|
+
await printCheck(res);
|
|
207
|
+
if (res.findings.some((f) => f.level === 'error'))
|
|
208
|
+
io.code = 1;
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
case 'tidy': {
|
|
212
|
+
const file = positionals[0] ?? fail('usage: linework tidy <file.drawio.svg>');
|
|
213
|
+
const { tidy } = await import('./api.js');
|
|
214
|
+
const res = await tidy(file, await browser());
|
|
215
|
+
const did = [res.fixed ? `rerouted ${res.fixed} edge${res.fixed === 1 ? '' : 's'}` : '', res.labels ? `moved ${res.labels} label${res.labels === 1 ? '' : 's'}` : ''].filter(Boolean);
|
|
216
|
+
console.log(did.length ? `${did.join(', ')} in ${file}` : 'nothing to fix');
|
|
217
|
+
await printCheck(res.check);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
case 'edit': {
|
|
221
|
+
const file = positionals[0] ?? fail('usage: linework edit <file.drawio.svg>');
|
|
222
|
+
const { startEditor, openInBrowser } = await import('./edit/server.js');
|
|
223
|
+
const session = await startEditor(file, { port: values.port ? Number(values.port) : undefined });
|
|
224
|
+
const opened = !values['no-open'] && openInBrowser(session.url);
|
|
225
|
+
console.log(`${opened ? 'opened' : 'open'} ${session.url}`);
|
|
226
|
+
console.log(`editing ${file}. Save in the editor (Ctrl/Cmd+S) to write the file. Exit the editor, close the tab or press Ctrl+C to stop.`);
|
|
227
|
+
process.once('SIGINT', () => void session.close());
|
|
228
|
+
const res = await session.done;
|
|
229
|
+
console.log(res.saves ? `saved ${file} ${res.saves - res.conflicts.length} time(s)` : 'closed without saving');
|
|
230
|
+
for (const c of res.conflicts)
|
|
231
|
+
console.log(`note: ${file} changed on disk while it was open, so one save went to ${c}`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
case 'import': {
|
|
235
|
+
const src = positionals[0] ?? fail('usage: linework import <file.mmd|doc.md|-> [-o out.drawio.svg]');
|
|
236
|
+
const { importMermaid, mermaidBlocks } = await import('./import/mermaid.js');
|
|
237
|
+
const { toYaml } = await import('./drawio/show.js');
|
|
238
|
+
let text = src === '-' ? io.stdin() : fs.readFileSync(src, 'utf8');
|
|
239
|
+
if (/\.(md|markdown|mdx)$/i.test(src)) {
|
|
240
|
+
const blocks = mermaidBlocks(text);
|
|
241
|
+
if (!blocks.length)
|
|
242
|
+
fail(`${src} has no \`\`\`mermaid blocks`);
|
|
243
|
+
const n = values.block ? Number(values.block) : blocks.length === 1 ? 1 : 0;
|
|
244
|
+
if (!n || !blocks[n - 1]) {
|
|
245
|
+
const first = blocks.map((b, i) => ` ${i + 1}: ${b.split('\n')[0]}`).join('\n');
|
|
246
|
+
fail(`${src} has ${blocks.length} mermaid blocks; pick one with --block N:\n${first}`);
|
|
247
|
+
}
|
|
248
|
+
text = blocks[n - 1];
|
|
249
|
+
}
|
|
250
|
+
const res = importMermaid(text);
|
|
251
|
+
for (const w of res.warnings)
|
|
252
|
+
console.error(`import: ${w}`);
|
|
253
|
+
const yaml = toYaml(res.diagram, 'imported from Mermaid');
|
|
254
|
+
if (!values.out)
|
|
255
|
+
return void io.out(yaml);
|
|
256
|
+
const { build } = await import('./api.js');
|
|
257
|
+
const built = await build(yaml, { out: values.out, png: values.png, relayout: values.relayout, force: values.force, browser: await browser() });
|
|
258
|
+
console.log(`wrote ${built.out} (${built.check.stats.nodes} nodes, ${built.check.stats.edges} edges, ${built.direction})`);
|
|
259
|
+
await printCheck(built.check);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
case 'icons': {
|
|
263
|
+
const provider = positionals[0];
|
|
264
|
+
if (!provider || !['aws', 'gcp', 'k8s', 'azure'].includes(provider))
|
|
265
|
+
fail('usage: linework icons <aws|gcp|k8s|azure> [filter]');
|
|
266
|
+
const { listIcons } = await import('./api.js');
|
|
267
|
+
const names = await listIcons(provider, positionals[1], await browser());
|
|
268
|
+
console.log(names.map((n) => `${provider}.${n}`).join('\n') || 'no matches');
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
case 'publish': {
|
|
272
|
+
if (!positionals.length)
|
|
273
|
+
fail('usage: linework publish <file.drawio.svg...> [--to DIR]');
|
|
274
|
+
const { publish } = await import('./publish.js');
|
|
275
|
+
const items = publish(positionals, { to: values.to, pull: values.pull, force: values.force });
|
|
276
|
+
for (const i of items) {
|
|
277
|
+
if (i.action === 'published')
|
|
278
|
+
console.log(`published ${path.relative(process.cwd(), i.file)} -> ${i.dest}`);
|
|
279
|
+
else if (i.action === 'unchanged')
|
|
280
|
+
console.log(`unchanged ${i.dest}`);
|
|
281
|
+
else if (i.action === 'pulled')
|
|
282
|
+
console.log(`pulled ${i.dest} -> ${path.relative(process.cwd(), i.file)}`);
|
|
283
|
+
else
|
|
284
|
+
console.log(`skipped ${path.relative(process.cwd(), i.file)}: the Drive copy was edited after the last publish. Run with --pull to take that version, or --force to overwrite it.`);
|
|
285
|
+
}
|
|
286
|
+
if (items.some((i) => i.action === 'published') && !values.pull) {
|
|
287
|
+
console.log('\nNot in the doc yet? In Google Docs: Extensions > draw.io > Insert diagrams, then pick the file.');
|
|
288
|
+
console.log('Already in the doc? After Drive syncs: Extensions > draw.io > Update diagrams.');
|
|
289
|
+
}
|
|
290
|
+
if (items.some((i) => i.action === 'conflict'))
|
|
291
|
+
io.code = 1;
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
case 'skill': {
|
|
295
|
+
const { fileURLToPath } = await import('node:url');
|
|
296
|
+
const src = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'skills', 'linework');
|
|
297
|
+
if (positionals[0] !== 'install')
|
|
298
|
+
return void console.log(src);
|
|
299
|
+
const dir = positionals[1] ?? fail('usage: linework skill install <skills-dir> e.g. .claude/skills');
|
|
300
|
+
const dest = path.join(dir.replace(/^~(?=$|\/)/, process.env.HOME ?? '~'), 'linework');
|
|
301
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
302
|
+
fs.cpSync(src, dest, { recursive: true });
|
|
303
|
+
console.log(`installed the linework skill to ${dest}`);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
case 'mcp': {
|
|
307
|
+
const { runStdio } = await import('./mcp/server.js');
|
|
308
|
+
await runStdio({ root: path.resolve(values.root ?? process.env.LINEWORK_ROOT ?? process.cwd()) });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
case 'git-setup': {
|
|
312
|
+
const { gitSetup } = await import('./git.js');
|
|
313
|
+
for (const line of gitSetup())
|
|
314
|
+
console.log(line);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
case 'kinds': {
|
|
318
|
+
const { KINDS, DIAGRAM_TYPES } = await import('./spec/model.js');
|
|
319
|
+
const types = (positionals.length ? positionals : DIAGRAM_TYPES);
|
|
320
|
+
for (const t of types) {
|
|
321
|
+
if (!KINDS[t])
|
|
322
|
+
fail(`unknown type ${t}`);
|
|
323
|
+
console.log(`${t}:`);
|
|
324
|
+
for (const [k, d] of Object.entries(KINDS[t]))
|
|
325
|
+
console.log(` ${k.padEnd(10)} ${d.description}`);
|
|
326
|
+
}
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
case 'schema': {
|
|
330
|
+
const { jsonSchema } = await import('./spec/parse.js');
|
|
331
|
+
console.log(JSON.stringify(jsonSchema(), null, 2));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
case 'install-browser': {
|
|
335
|
+
const { browserInstallCommand } = await import('./render/browser.js');
|
|
336
|
+
const { spawnSync } = await import('node:child_process');
|
|
337
|
+
const [bin, ...args] = browserInstallCommand();
|
|
338
|
+
console.log('downloading headless Chromium for linework...');
|
|
339
|
+
const r = spawnSync(bin, args, { stdio: 'inherit' });
|
|
340
|
+
io.code = r.status ?? 1;
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
default:
|
|
344
|
+
fail(`unknown command "${cmd}". Run "linework help" for the list.`);
|
|
345
|
+
}
|
|
346
|
+
}
|
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// A background process that keeps Chromium, draw.io and ELK warm between builds.
|
|
2
|
+
// Starting those is most of a build: about 0.8s of a 1s run. The first drawing
|
|
3
|
+
// command starts this process as it exits; later ones hand their work to it and
|
|
4
|
+
// finish in a fraction of the time. It exits after a few idle minutes.
|
|
5
|
+
//
|
|
6
|
+
// It listens on a Unix socket in a folder only the user can open, and only runs
|
|
7
|
+
// the same commands the CLI runs. LINEWORK_NO_DAEMON=1 turns it off.
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import net from 'node:net';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
const IDLE_MS = Number(process.env.LINEWORK_DAEMON_IDLE ?? 300) * 1000;
|
|
16
|
+
const cliPath = () => path.join(path.dirname(fileURLToPath(import.meta.url)), 'cli.js');
|
|
17
|
+
/** Off on Windows (no Unix sockets with file permissions), in CI, and on request. */
|
|
18
|
+
export function daemonAllowed() {
|
|
19
|
+
return process.platform !== 'win32' && !process.env.LINEWORK_NO_DAEMON && !process.env.CI && typeof process.getuid === 'function';
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The socket for this exact build of linework and this browser setting, so an
|
|
23
|
+
* upgrade or a different LINEWORK_CHROME never talks to a stale process.
|
|
24
|
+
* Undefined when no private folder can be had.
|
|
25
|
+
*/
|
|
26
|
+
export function socketPath() {
|
|
27
|
+
const uid = process.getuid();
|
|
28
|
+
const dir = process.env.XDG_RUNTIME_DIR ? path.join(process.env.XDG_RUNTIME_DIR, 'linework') : path.join(os.tmpdir(), `linework-${uid}`);
|
|
29
|
+
try {
|
|
30
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
31
|
+
const st = fs.lstatSync(dir);
|
|
32
|
+
// Someone else's folder, or one others can write to, could hold a fake socket.
|
|
33
|
+
if (!st.isDirectory() || st.uid !== uid || (st.mode & 0o077) !== 0)
|
|
34
|
+
return undefined;
|
|
35
|
+
let stamp = '';
|
|
36
|
+
try {
|
|
37
|
+
stamp = String(fs.statSync(cliPath()).mtimeMs);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// running from source
|
|
41
|
+
}
|
|
42
|
+
const key = crypto.createHash('sha256').update([cliPath(), stamp, process.version, process.env.LINEWORK_CHROME ?? '', process.env.LINEWORK_NO_CACHE ?? ''].join('\0')).digest('hex').slice(0, 12);
|
|
43
|
+
return path.join(dir, `${key}.sock`);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Run a command in the background process. Resolves with its exit code, or
|
|
51
|
+
* undefined when no such process is running (the caller then runs it itself).
|
|
52
|
+
*/
|
|
53
|
+
export function viaDaemon(req, out, err) {
|
|
54
|
+
const sock = daemonAllowed() ? socketPath() : undefined;
|
|
55
|
+
if (!sock || !fs.existsSync(sock))
|
|
56
|
+
return Promise.resolve(undefined);
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
const c = net.createConnection(sock);
|
|
59
|
+
let buf = '';
|
|
60
|
+
let started = false;
|
|
61
|
+
let code;
|
|
62
|
+
// A process that died without cleaning up leaves a socket nobody answers.
|
|
63
|
+
c.once('error', () => {
|
|
64
|
+
if (!started)
|
|
65
|
+
fs.rmSync(sock, { force: true });
|
|
66
|
+
resolve(started ? 1 : undefined);
|
|
67
|
+
});
|
|
68
|
+
c.once('connect', () => c.write(JSON.stringify(req) + '\n'));
|
|
69
|
+
c.setEncoding('utf8');
|
|
70
|
+
c.on('data', (chunk) => {
|
|
71
|
+
started = true;
|
|
72
|
+
buf += chunk;
|
|
73
|
+
for (let i = buf.indexOf('\n'); i >= 0; i = buf.indexOf('\n')) {
|
|
74
|
+
const msg = JSON.parse(buf.slice(0, i));
|
|
75
|
+
buf = buf.slice(i + 1);
|
|
76
|
+
if (msg.t === 'out')
|
|
77
|
+
out(msg.d);
|
|
78
|
+
else if (msg.t === 'err')
|
|
79
|
+
err(msg.d);
|
|
80
|
+
else if (msg.t === 'exit')
|
|
81
|
+
code = msg.code;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
c.once('close', () => resolve(code ?? (started ? 1 : undefined)));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** Start the background process, detached, unless one is already listening. */
|
|
88
|
+
export function startDaemon() {
|
|
89
|
+
if (!daemonAllowed())
|
|
90
|
+
return;
|
|
91
|
+
const sock = socketPath();
|
|
92
|
+
if (!sock || fs.existsSync(sock))
|
|
93
|
+
return;
|
|
94
|
+
try {
|
|
95
|
+
spawn(process.execPath, [cliPath(), 'daemon', 'run'], { detached: true, stdio: 'ignore', cwd: os.homedir() }).unref();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// No background process only costs speed.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Ask a running background process to stop. Returns whether one was running. */
|
|
102
|
+
export async function stopDaemon() {
|
|
103
|
+
const code = await viaDaemon({ argv: ['daemon', 'stop'], cwd: process.cwd() }, () => { }, () => { });
|
|
104
|
+
return code === 0;
|
|
105
|
+
}
|
|
106
|
+
/** The background process itself. Returns when it has shut down. */
|
|
107
|
+
export async function serve() {
|
|
108
|
+
const sock = daemonAllowed() ? socketPath() : undefined;
|
|
109
|
+
if (!sock)
|
|
110
|
+
return;
|
|
111
|
+
// One per socket: if another answers, leave it to it.
|
|
112
|
+
if (fs.existsSync(sock)) {
|
|
113
|
+
const alive = await new Promise((r) => {
|
|
114
|
+
const c = net.createConnection(sock);
|
|
115
|
+
c.once('connect', () => (c.destroy(), r(true)));
|
|
116
|
+
c.once('error', () => r(false));
|
|
117
|
+
});
|
|
118
|
+
if (alive)
|
|
119
|
+
return;
|
|
120
|
+
fs.rmSync(sock, { force: true });
|
|
121
|
+
}
|
|
122
|
+
const { DrawioBrowser } = await import('./render/browser.js');
|
|
123
|
+
const { run, warmUp, report, OPTIONS } = await import('./commands.js');
|
|
124
|
+
const { parseArgs } = await import('node:util');
|
|
125
|
+
const browser = await DrawioBrowser.launch({ scale: 2 });
|
|
126
|
+
await warmUp();
|
|
127
|
+
let idle;
|
|
128
|
+
let queue = Promise.resolve();
|
|
129
|
+
const server = net.createServer({ allowHalfOpen: true });
|
|
130
|
+
const shutdown = async () => {
|
|
131
|
+
clearTimeout(idle);
|
|
132
|
+
server.close();
|
|
133
|
+
fs.rmSync(sock, { force: true });
|
|
134
|
+
await browser.close().catch(() => { });
|
|
135
|
+
process.exit(0);
|
|
136
|
+
};
|
|
137
|
+
const arm = () => {
|
|
138
|
+
clearTimeout(idle);
|
|
139
|
+
idle = setTimeout(() => void shutdown(), IDLE_MS);
|
|
140
|
+
};
|
|
141
|
+
server.on('connection', (c) => {
|
|
142
|
+
clearTimeout(idle);
|
|
143
|
+
c.setEncoding('utf8');
|
|
144
|
+
c.on('error', () => { });
|
|
145
|
+
let buf = '';
|
|
146
|
+
c.on('data', (chunk) => {
|
|
147
|
+
buf += chunk;
|
|
148
|
+
const nl = buf.indexOf('\n');
|
|
149
|
+
if (nl < 0)
|
|
150
|
+
return;
|
|
151
|
+
const line = buf.slice(0, nl);
|
|
152
|
+
buf = '';
|
|
153
|
+
const send = (m) => void (c.writable && c.write(JSON.stringify(m) + '\n'));
|
|
154
|
+
// One command at a time: they share the page and the working directory.
|
|
155
|
+
queue = queue.then(async () => {
|
|
156
|
+
let code = 0;
|
|
157
|
+
try {
|
|
158
|
+
const req = JSON.parse(line);
|
|
159
|
+
if (req.argv[0] === 'daemon' && req.argv[1] === 'stop') {
|
|
160
|
+
send({ t: 'exit', code: 0 });
|
|
161
|
+
c.end();
|
|
162
|
+
return void shutdown();
|
|
163
|
+
}
|
|
164
|
+
const io = { out: (d) => send({ t: 'out', d }), err: (d) => send({ t: 'err', d }), stdin: () => req.stdin ?? '', code: 0, warm: true };
|
|
165
|
+
try {
|
|
166
|
+
process.chdir(req.cwd);
|
|
167
|
+
const [cmd, ...rest] = req.argv;
|
|
168
|
+
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: OPTIONS });
|
|
169
|
+
await run(cmd, values, positionals, Promise.resolve(browser), io);
|
|
170
|
+
code = io.code;
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
code = report(e, io);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
code = 1;
|
|
178
|
+
}
|
|
179
|
+
send({ t: 'exit', code });
|
|
180
|
+
c.end();
|
|
181
|
+
arm();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
// The socket is created with the folder's protection already in place.
|
|
186
|
+
const mask = process.umask(0o077);
|
|
187
|
+
await new Promise((resolve, reject) => server.once('error', reject).listen(sock, resolve));
|
|
188
|
+
process.umask(mask);
|
|
189
|
+
arm();
|
|
190
|
+
for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP'])
|
|
191
|
+
process.once(sig, () => void shutdown());
|
|
192
|
+
await new Promise(() => { });
|
|
193
|
+
}
|
package/dist/drawio/merge.js
CHANGED
|
@@ -33,7 +33,7 @@ export function merge(d, old, fresh, opts = {}) {
|
|
|
33
33
|
const isTable = (n) => kindDef(d.type, n.kind).shape === 'table';
|
|
34
34
|
const abs = new Map(); // final absolute boxes for nodes
|
|
35
35
|
const changed = new Set(); // nodes whose box differs from the old file
|
|
36
|
-
const stats = { kept: 0, handMoved: 0, placed: [], grown: [], removed: [], rerouted: 0, carried: 0, keptUnknown: [] };
|
|
36
|
+
const stats = { kept: 0, handMoved: 0, placed: [], grown: [], shrunk: [], removed: [], rerouted: 0, carried: 0, keptUnknown: [] };
|
|
37
37
|
// 1. Existing nodes stay put.
|
|
38
38
|
for (const n of d.nodes) {
|
|
39
39
|
const c = cellOf.get(n.id);
|
|
@@ -96,6 +96,7 @@ export function merge(d, old, fresh, opts = {}) {
|
|
|
96
96
|
const boxy = !['start', 'end', 'decision'].includes(n.kind);
|
|
97
97
|
return { w: boxy ? Math.max(nat.w, commonW) : nat.w, h: nat.h };
|
|
98
98
|
};
|
|
99
|
+
const memberOf = (nodeId, gid) => ancestors(d.nodes.find((x) => x.id === nodeId)?.parent).has(gid);
|
|
99
100
|
const pending = d.nodes.filter((n) => !abs.has(n.id));
|
|
100
101
|
while (pending.length) {
|
|
101
102
|
let i = pending.findIndex((n) => [...preds(n.id), ...succs(n.id)].some((m) => abs.has(m)));
|
|
@@ -112,7 +113,7 @@ export function merge(d, old, fresh, opts = {}) {
|
|
|
112
113
|
else {
|
|
113
114
|
cand = fallbackSpot(n, f, abs, fresh, oldGroupBox(n.parent ?? ''));
|
|
114
115
|
}
|
|
115
|
-
abs.set(n.id, freeSpot(cand, n, abs, d, ancestors(n.parent), oldGroupBox));
|
|
116
|
+
abs.set(n.id, freeSpot(cand, n, abs, d, ancestors(n.parent), oldGroupBox, memberOf));
|
|
116
117
|
changed.add(n.id);
|
|
117
118
|
stats.placed.push(n.id);
|
|
118
119
|
}
|
|
@@ -125,9 +126,28 @@ export function merge(d, old, fresh, opts = {}) {
|
|
|
125
126
|
...d.groups.filter((x) => x.parent === g.id).map((x) => gabs.get(x.id)),
|
|
126
127
|
].filter(Boolean);
|
|
127
128
|
const need = kids.length ? padded(union(kids)) : undefined;
|
|
128
|
-
|
|
129
|
+
let prev = oldGroupBox(g.id);
|
|
130
|
+
// A group nobody sized by hand closes up when members leave it. Each side
|
|
131
|
+
// moves in by as much as its outermost member did, so the padding stays.
|
|
132
|
+
const gc = cellOf.get(g.id);
|
|
133
|
+
if (prev && kids.length && gc && !handEdits.has(gc.id)) {
|
|
134
|
+
const was = [
|
|
135
|
+
...shown.diagram.nodes.filter((n) => n.parent === g.id).map((n) => absBox(old, cellOf.get(n.id).id)),
|
|
136
|
+
...shown.diagram.groups.filter((x) => x.parent === g.id).map((x) => oldGroupBox(x.id)),
|
|
137
|
+
].filter((b) => !!b);
|
|
138
|
+
if (was.length) {
|
|
139
|
+
const a = union(was), z = union(kids);
|
|
140
|
+
const l = Math.max(0, z.x - a.x), t = Math.max(0, z.y - a.y);
|
|
141
|
+
const r = Math.max(0, a.x + a.w - (z.x + z.w)), bt = Math.max(0, a.y + a.h - (z.y + z.h));
|
|
142
|
+
if (l + t + r + bt > 1) {
|
|
143
|
+
prev = { x: prev.x + l, y: prev.y + t, w: prev.w - l - r, h: prev.h - t - bt };
|
|
144
|
+
stats.shrunk.push(g.id);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
129
148
|
const box = prev ? (need ? union([prev, need]) : prev) : need ?? { x: 0, y: 0, w: 200, h: 120 };
|
|
130
|
-
|
|
149
|
+
const before = oldGroupBox(g.id);
|
|
150
|
+
if (before && (box.w > before.w + 1 || box.h > before.h + 1) && !stats.shrunk.includes(g.id))
|
|
131
151
|
stats.grown.push(g.id);
|
|
132
152
|
gabs.set(g.id, box);
|
|
133
153
|
}
|
|
@@ -184,7 +204,13 @@ export function merge(d, old, fresh, opts = {}) {
|
|
|
184
204
|
});
|
|
185
205
|
const routeUntouched = !oc?.attrs.aiviz_auto || oc.attrs.aiviz_auto === routeAttr(oc.geom?.points ?? []);
|
|
186
206
|
const stale = endMovedByHand && routeUntouched;
|
|
187
|
-
|
|
207
|
+
let keepParent = oc?.parent && (oc.parent === '1' || outIds.has(oc.parent)) ? oc.parent : undefined;
|
|
208
|
+
// A route stored relative to a group is stale once that group's corner moves.
|
|
209
|
+
if (keepParent && keepParent !== '1') {
|
|
210
|
+
const was = oldGroupBox(keepParent), now = gabs.get(keepParent);
|
|
211
|
+
if (!was || !now || Math.abs(was.x - now.x) > 0.5 || Math.abs(was.y - now.y) > 0.5)
|
|
212
|
+
keepParent = undefined;
|
|
213
|
+
}
|
|
188
214
|
const attrs = { ...(oc ? ownAttrs(oc) : {}), ...edgeAttrs(e) };
|
|
189
215
|
if (oc && (prev?.label ?? '') === (e.label ?? '') && oc.attrs.label != null)
|
|
190
216
|
attrs.label = oc.attrs.label;
|
|
@@ -246,7 +272,8 @@ export function merge(d, old, fresh, opts = {}) {
|
|
|
246
272
|
return {
|
|
247
273
|
scene: {
|
|
248
274
|
name: d.title ?? old.name,
|
|
249
|
-
|
|
275
|
+
// 'manual' marks a layout a person arranged; a merge into a machine layout doesn't.
|
|
276
|
+
meta: { ...withoutTheme(oldMeta), ...diagramMeta(d, opts.touched === false ? {} : { aiviz_layout: 'manual' }) },
|
|
250
277
|
background: palette(d.theme).canvas,
|
|
251
278
|
vertices,
|
|
252
279
|
edges,
|
|
@@ -273,7 +300,7 @@ function fallbackSpot(n, f, abs, fresh, groupBox) {
|
|
|
273
300
|
return { x: area.x, y: area.y + area.h + GAP * 2, w: f.w, h: f.h };
|
|
274
301
|
}
|
|
275
302
|
/** Nearest spot to `cand` on a 8px grid that doesn't collide with placed nodes or foreign groups. */
|
|
276
|
-
function freeSpot(cand, n, abs, d, myGroups, groupBox) {
|
|
303
|
+
function freeSpot(cand, n, abs, d, myGroups, groupBox, memberOf) {
|
|
277
304
|
const obstacles = [];
|
|
278
305
|
for (const [id, b] of abs)
|
|
279
306
|
if (id !== n.id)
|
|
@@ -285,7 +312,19 @@ function freeSpot(cand, n, abs, d, myGroups, groupBox) {
|
|
|
285
312
|
if (b)
|
|
286
313
|
obstacles.push(b);
|
|
287
314
|
}
|
|
288
|
-
|
|
315
|
+
// Its own groups grow to hold it. That growth must not swallow a box that
|
|
316
|
+
// isn't in the group, or the picture says something the spec doesn't.
|
|
317
|
+
const mine = [...myGroups].map((g) => ({ g, box: groupBox(g) })).filter((x) => !!x.box);
|
|
318
|
+
const swallows = (b) => mine.some(({ g, box }) => {
|
|
319
|
+
const grown = union([box, padded(b)]);
|
|
320
|
+
if (grown.w <= box.w + 1 && grown.h <= box.h + 1)
|
|
321
|
+
return false;
|
|
322
|
+
for (const [id, o] of abs)
|
|
323
|
+
if (id !== n.id && !memberOf(id, g) && overlaps(grown, o) && !overlaps(box, o))
|
|
324
|
+
return true;
|
|
325
|
+
return false;
|
|
326
|
+
});
|
|
327
|
+
const ok = (b) => obstacles.every((o) => !overlaps(b, o, GAP)) && !swallows(b);
|
|
289
328
|
const snap = (v) => Math.round(v / 8) * 8;
|
|
290
329
|
const start = { ...cand, x: snap(cand.x), y: snap(cand.y) };
|
|
291
330
|
if (ok(start))
|