@itsthecsguy/linework 0.1.3 → 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 +23 -1
- package/dist/api.js +74 -14
- package/dist/cli.js +76 -264
- 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/lint/fixlabels.js +22 -14
- package/dist/mcp/server.js +4 -4
- package/dist/patch.js +167 -0
- package/dist/render/browser.js +96 -32
- package/dist/render/fonts.js +65 -1
- 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/README.md
CHANGED
|
@@ -159,6 +159,10 @@ Linework sizes every box from the real width of its text in the font it renders
|
|
|
159
159
|
|
|
160
160
|
Linework needs Node 20+ and a Chromium-based browser, which it uses headless to measure text and render. It tries Playwright's Chromium, then Chrome, then Edge. If none is found, run `linework install-browser`, or set `LINEWORK_CHROME` to a browser binary. If a browser is found but crashes on launch, an agent's sandbox is usually blocking it. Linework then retries Playwright's Chromium in single-process mode, which starts inside Codex's sandbox.
|
|
161
161
|
|
|
162
|
+
The first drawing command starts a background process that keeps Chromium running, and later commands hand their work to it. A build of the checkout example takes about 1.0s the first time and 0.16s after that. The process listens on a Unix socket in a folder only you can open, exits after 5 idle minutes, and is off on Windows and in CI. `LINEWORK_NO_DAEMON=1` or `--no-daemon` turns it off, and `linework daemon stop` ends it.
|
|
163
|
+
|
|
164
|
+
Linework keeps the font subsets it embeds in your cache folder (`~/Library/Caches/linework` on macOS, `~/.cache/linework` on Linux, `%LOCALAPPDATA%\linework` on Windows), so rebuilding a diagram doesn't compress the same fonts again. Set `LINEWORK_NO_CACHE=1` to turn that off.
|
|
165
|
+
|
|
162
166
|
## Connect over MCP
|
|
163
167
|
|
|
164
168
|
The MCP server is for chat apps and agents that can't run shell commands. Agents with a shell do better with the skill, since they can run the CLI and see its output directly.
|
|
@@ -200,6 +204,20 @@ npx -y @itsthecsguy/linework skill install .claude/skills # Claude Code
|
|
|
200
204
|
npx -y @itsthecsguy/linework skill install .grok/skills # Grok Build
|
|
201
205
|
```
|
|
202
206
|
|
|
207
|
+
## Small changes
|
|
208
|
+
|
|
209
|
+
Once a diagram exists, a change to it keeps everything else where it is. That holds whether a person arranged the layout in draw.io or nobody has touched it.
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
linework set docs/checkout.drawio.svg api.label="Checkout API v2" api.highlight=true
|
|
213
|
+
linework add docs/checkout.drawio.svg "fraud: { label: Fraud check, in: vpc }" "api -> fraud: score"
|
|
214
|
+
linework rm docs/checkout.drawio.svg cache "api -> stripe"
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
A renamed box grows in place. A new box goes next to the one it connects to, and its group grows to hold it without covering boxes outside the group. A removed box takes its edges with it, and its group closes up. `linework build` with an edited spec follows the same rules. It lays out from scratch only for a new file, a changed direction or theme on a layout nobody arranged, or `--relayout`.
|
|
218
|
+
|
|
219
|
+
After dragging boxes around in draw.io, `linework tidy <file>` reroutes the edges left behind and moves labels off whatever they landed on. Boxes stay where you put them.
|
|
220
|
+
|
|
203
221
|
## Editing by hand
|
|
204
222
|
|
|
205
223
|
Any draw.io editor works on these files: draw.io desktop, VS Code with the Draw.io Integration extension, or app.diagrams.net. Or:
|
|
@@ -219,11 +237,14 @@ Docs can't hold an SVG, so the diagram lives in Google Drive and draw.io's Googl
|
|
|
219
237
|
| command | does |
|
|
220
238
|
|---|---|
|
|
221
239
|
| `linework build <spec\|-> -o out.drawio.svg [--png p.png] [--relayout]` | layout (or merge into an existing file), write, lint |
|
|
240
|
+
| `linework set <file> id.field=value...` | change a label, kind, color, theme or title in place |
|
|
241
|
+
| `linework add <file> "id: {...}" "a -> b: label"...` | add boxes, groups and edges in place |
|
|
242
|
+
| `linework rm <file> id "a -> b"...` | remove boxes, groups and edges in place |
|
|
222
243
|
| `linework show <file> [--geometry]` | print the spec of a diagram, including hand edits |
|
|
223
244
|
| `linework edit <file> [--no-open]` | edit in the bundled draw.io, in your browser; saves write the file |
|
|
224
245
|
| `linework check <file>` | lint: overlaps, edges through nodes, labels that don't fit, too wide |
|
|
225
246
|
| `linework render <file> -o out.png [--scale 2]` | preview image |
|
|
226
|
-
| `linework tidy <file>` | reroute edges
|
|
247
|
+
| `linework tidy <file>` | after hand edits: reroute stale edges and move colliding labels; boxes stay |
|
|
227
248
|
| `linework import <file.mmd\|doc.md> [-o out.drawio.svg]` | convert a Mermaid diagram |
|
|
228
249
|
| `linework publish <file...> [--to dir] [--pull]` | copy diagrams into Google Drive for the Docs add-on |
|
|
229
250
|
| `linework icons <aws\|gcp\|k8s\|azure> [filter]` | list icon names |
|
|
@@ -232,6 +253,7 @@ Docs can't hold an SVG, so the diagram lives in Google Drive and draw.io's Googl
|
|
|
232
253
|
| `linework mcp [--root dir]` | MCP server over stdio |
|
|
233
254
|
| `linework kinds [type]` | node kinds per diagram type |
|
|
234
255
|
| `linework schema` | JSON Schema of the spec |
|
|
256
|
+
| `linework daemon [status\|stop]` | the background process that keeps the browser running between builds |
|
|
235
257
|
| `linework install-browser` | download the headless Chromium build this version uses |
|
|
236
258
|
|
|
237
259
|
## Status
|
package/dist/api.js
CHANGED
|
@@ -36,8 +36,13 @@ export async function build(specText, opts) {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
const edits = old ? manualEdits(old) : [];
|
|
39
|
-
// Once a person has touched the layout, every later build merges into it.
|
|
40
|
-
|
|
39
|
+
// Once a person has touched the layout, every later build merges into it. A
|
|
40
|
+
// layout nobody touched is also kept when the spec changed a little: a renamed
|
|
41
|
+
// label or one new box shouldn't move the rest.
|
|
42
|
+
const touched = !!old && (old.meta.aiviz_layout === 'manual' || edits.length > 0);
|
|
43
|
+
let mode = 'fresh';
|
|
44
|
+
if (old && !opts.relayout)
|
|
45
|
+
mode = touched || opts.keepLayout || smallChange(old, diagram) ? 'merge' : 'fresh';
|
|
41
46
|
return withBrowser(opts.browser, async (b) => {
|
|
42
47
|
const measure = (r) => b.measure(r);
|
|
43
48
|
for (const n of diagram.nodes) {
|
|
@@ -82,41 +87,86 @@ export async function build(specText, opts) {
|
|
|
82
87
|
const l = await layout(diagram, measure);
|
|
83
88
|
let xml;
|
|
84
89
|
let stats;
|
|
90
|
+
let freshXml;
|
|
85
91
|
if (mode === 'merge') {
|
|
86
|
-
const m = merge(diagram, old, l, { specIsCurrent: specBase(specText) === fingerprint(old) });
|
|
92
|
+
const m = merge(diagram, old, l, { specIsCurrent: specBase(specText) === fingerprint(old), touched });
|
|
87
93
|
stats = m.stats;
|
|
88
94
|
xml = serialize(m.scene);
|
|
89
95
|
if (m.reroute.length)
|
|
90
96
|
xml = await rerouteInto(b, xml, m.reroute);
|
|
91
97
|
// Kept routes can already be broken, e.g. stale waypoints after someone moved a node.
|
|
92
|
-
const
|
|
98
|
+
const after = check(await b.inspect(xml));
|
|
99
|
+
const broken = brokenEdges(after);
|
|
93
100
|
if (broken.length) {
|
|
94
101
|
xml = await rerouteInto(b, xml, broken);
|
|
95
102
|
stats.rerouted += broken.filter((id) => !m.reroute.includes(id)).length;
|
|
96
103
|
}
|
|
104
|
+
// Nobody arranged this layout, so there is nothing to protect: when the
|
|
105
|
+
// change doesn't fit into it cleanly, lay it out again.
|
|
106
|
+
if (!touched && !opts.keepLayout) {
|
|
107
|
+
const merged = check(await b.inspect(xml));
|
|
108
|
+
const bad = (c) => c.findings.filter((f) => f.level === 'error').length;
|
|
109
|
+
let redo = bad(merged) > 0;
|
|
110
|
+
if (!redo && (stats.placed.length || merged.stats.crossings)) {
|
|
111
|
+
freshXml = await straightenDetours(b, toMxfile(diagram, l), detourEdges(diagram, l));
|
|
112
|
+
redo = merged.stats.crossings > check(await b.inspect(freshXml)).stats.crossings + 1;
|
|
113
|
+
}
|
|
114
|
+
if (redo) {
|
|
115
|
+
mode = 'fresh';
|
|
116
|
+
stats = undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
97
119
|
}
|
|
120
|
+
if (mode === 'fresh')
|
|
121
|
+
xml = freshXml ?? (await straightenDetours(b, toMxfile(diagram, l), detourEdges(diagram, l)));
|
|
98
122
|
else
|
|
99
|
-
xml =
|
|
100
|
-
|
|
123
|
+
xml = xml;
|
|
124
|
+
const fixed = await fixLabels(b, xml);
|
|
125
|
+
xml = fixed.xml;
|
|
101
126
|
xml = stampEdgeRoutes(xml);
|
|
102
|
-
|
|
127
|
+
// Stamping routes changes attributes, not geometry: the cells fixLabels read still hold.
|
|
128
|
+
return finish(b, xml, { mode, warnings, direction: mode === 'merge' ? (old.meta.aiviz_direction ?? l.direction) : l.direction, edits, old, stats, cells: fixed.cells, labelsMoved: fixed.moved });
|
|
103
129
|
});
|
|
104
130
|
async function finish(b, xml, r) {
|
|
105
131
|
fs.mkdirSync(path.dirname(path.resolve(opts.out)), { recursive: true });
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
132
|
+
// Issued together: the browser renders the preview and lays out the lint pass
|
|
133
|
+
// while node subsets the fonts for the SVG.
|
|
134
|
+
const [file, , cells] = await Promise.all([kind === 'svg' ? b.renderSvg(xml) : xml, opts.png ? b.renderPng(xml, opts.png) : undefined, r.cells ?? b.inspect(xml)]);
|
|
135
|
+
fs.writeFileSync(opts.out, file);
|
|
109
136
|
return {
|
|
110
137
|
out: opts.out,
|
|
111
138
|
warnings: r.warnings,
|
|
112
|
-
check: check(
|
|
139
|
+
check: check(cells),
|
|
113
140
|
direction: r.direction,
|
|
114
141
|
mode: r.mode,
|
|
115
142
|
merge: r.stats,
|
|
143
|
+
labelsMoved: r.labelsMoved ?? 0,
|
|
116
144
|
discardedLayout: !!(opts.relayout && r.old && (r.old.meta.aiviz_layout === 'manual' || r.edits.length > 0)),
|
|
117
145
|
};
|
|
118
146
|
}
|
|
119
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Whether the new spec is close enough to an untouched file to keep its layout:
|
|
150
|
+
* same type, direction and theme, and only a few new boxes. Sequence diagrams
|
|
151
|
+
* always lay out again; their rows follow message order either way.
|
|
152
|
+
*/
|
|
153
|
+
function smallChange(old, d) {
|
|
154
|
+
if (d.type === 'sequence')
|
|
155
|
+
return false;
|
|
156
|
+
let prev;
|
|
157
|
+
try {
|
|
158
|
+
prev = toDiagram(old).diagram;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
if (prev.type !== d.type || prev.direction !== d.direction || prev.theme !== d.theme)
|
|
164
|
+
return false;
|
|
165
|
+
const had = new Set(prev.nodes.map((n) => n.id));
|
|
166
|
+
const added = d.nodes.filter((n) => !had.has(n.id)).length;
|
|
167
|
+
const kept = d.nodes.length - added;
|
|
168
|
+
return kept > 0 && added <= Math.max(2, Math.ceil(d.nodes.length / 4));
|
|
169
|
+
}
|
|
120
170
|
/**
|
|
121
171
|
* Edges whose route is far longer than the gap between their ends. Layered layout
|
|
122
172
|
* draws one edge of every cycle backwards, so the reply in a call-and-callback pair
|
|
@@ -157,17 +207,27 @@ async function straightenDetours(b, xml, ids) {
|
|
|
157
207
|
async function rerouteInto(b, xml, edgeIds) {
|
|
158
208
|
return patchEdges(xml, await b.reroute(xml, edgeIds), edgeIds);
|
|
159
209
|
}
|
|
160
|
-
/**
|
|
210
|
+
/**
|
|
211
|
+
* Repair a diagram after hand edits: reroute edges that are broken or were left
|
|
212
|
+
* behind when someone moved a box, and move labels off whatever they landed on.
|
|
213
|
+
* Boxes, groups and routes a person drew stay where they are.
|
|
214
|
+
*/
|
|
161
215
|
export async function tidy(file, browser) {
|
|
162
216
|
const text = fs.readFileSync(file, 'utf8');
|
|
163
217
|
const m = readDrawio(text);
|
|
218
|
+
if (m.meta.aiviz_type && m.meta.aiviz_type !== 'sequence') {
|
|
219
|
+
// The file's own spec merged into itself: positions stay, hand moves become
|
|
220
|
+
// part of the layout, and only stale routes and colliding labels change.
|
|
221
|
+
const res = await build(show(file).yaml, { out: file, keepLayout: true, browser });
|
|
222
|
+
return { fixed: res.merge?.rerouted ?? 0, labels: res.labelsMoved, check: res.check };
|
|
223
|
+
}
|
|
164
224
|
return withBrowser(browser, async (b) => {
|
|
165
225
|
const broken = brokenEdges(check(await b.inspect(m.xml)));
|
|
166
226
|
if (!broken.length)
|
|
167
|
-
return { fixed: 0, check: check(await b.inspect(m.xml)) };
|
|
227
|
+
return { fixed: 0, labels: 0, check: check(await b.inspect(m.xml)) };
|
|
168
228
|
const xml = stampEdgeRoutes(await rerouteInto(b, m.xml, broken));
|
|
169
229
|
fs.writeFileSync(file, file.endsWith('.svg') ? await b.renderSvg(xml) : xml);
|
|
170
|
-
return { fixed: broken.length, check: check(await b.inspect(xml)) };
|
|
230
|
+
return { fixed: broken.length, labels: 0, check: check(await b.inspect(xml)) };
|
|
171
231
|
});
|
|
172
232
|
}
|
|
173
233
|
export function show(file, opts = {}) {
|
package/dist/cli.js
CHANGED
|
@@ -1,275 +1,87 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
3
|
-
import path from 'node:path';
|
|
2
|
+
import module from 'node:module';
|
|
4
3
|
import { parseArgs } from 'node:util';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const USAGE = `linework: AI-written, hand-editable diagrams (YAML spec -> .drawio.svg)
|
|
10
|
-
|
|
11
|
-
Usage:
|
|
12
|
-
linework build <spec.yaml|-> -o <out.drawio.svg> [--png preview.png] [--relayout]
|
|
13
|
-
Lay out and write the diagram, then lint it. "-" reads the spec from stdin.
|
|
14
|
-
If the file exists and someone edited it by hand, the spec is merged in:
|
|
15
|
-
existing positions stay, new nodes are placed next to their neighbours.
|
|
16
|
-
--relayout throws the existing layout away (hand edits included).
|
|
17
|
-
linework show <file.drawio.svg> [--geometry]
|
|
18
|
-
Print the spec of an existing diagram (including hand edits) as YAML.
|
|
19
|
-
--geometry appends node positions as comments.
|
|
20
|
-
linework render <file.drawio.svg> -o <out.png|out.svg> [--scale 2]
|
|
21
|
-
Render a preview image.
|
|
22
|
-
linework check <file.drawio.svg>
|
|
23
|
-
Lint layout: overlaps, edges through nodes, labels that don't fit.
|
|
24
|
-
linework tidy <file.drawio.svg>
|
|
25
|
-
Reroute edges that check flags as broken (e.g. after moving nodes by hand).
|
|
26
|
-
Nothing else in the file changes.
|
|
27
|
-
linework edit <file.drawio.svg> [--no-open] [--port N]
|
|
28
|
-
Open the diagram in draw.io (bundled, offline) in your browser. Saving in
|
|
29
|
-
the editor writes the file. Stops when you exit the editor or close the tab.
|
|
30
|
-
linework import <file.mmd|doc.md|-> [--block N] [-o out.drawio.svg] [--png p.png]
|
|
31
|
-
Convert a Mermaid diagram (flowchart, state, sequence, ER) to an linework spec.
|
|
32
|
-
Prints the spec, or builds it straight away with -o.
|
|
33
|
-
linework icons <aws|gcp|k8s|azure> [filter]
|
|
34
|
-
List icon names, for "icon: aws.lambda" on architecture nodes.
|
|
35
|
-
linework publish <file.drawio.svg...> [--to DIR] [--pull] [--force]
|
|
36
|
-
Copy diagrams into your Google Drive folder (Google Drive for desktop) so
|
|
37
|
-
draw.io's Google Docs add-on can insert them and refresh them later.
|
|
38
|
-
--pull copies back a diagram someone edited from Docs or Drive.
|
|
39
|
-
linework skill [install <dir>]
|
|
40
|
-
Print where the bundled agent skill is, or copy it to <dir>/linework
|
|
41
|
-
(e.g. .claude/skills, ~/.agents/skills).
|
|
42
|
-
linework mcp [--root DIR]
|
|
43
|
-
Run as an MCP server over stdio, for chat apps and agents without a shell.
|
|
44
|
-
Relative paths resolve against --root (default: the current folder).
|
|
45
|
-
linework git-setup
|
|
46
|
-
Make git diff show .drawio.svg changes as spec changes (this repo only).
|
|
47
|
-
linework kinds [architecture|flow|state]
|
|
48
|
-
List node kinds for each diagram type.
|
|
49
|
-
linework schema
|
|
50
|
-
Print the spec's JSON Schema.
|
|
51
|
-
|
|
52
|
-
linework install-browser
|
|
53
|
-
Download the headless Chromium build that matches this version. Needed only
|
|
54
|
-
when no Chrome or Edge is installed, or to run inside an agent's sandbox.
|
|
55
|
-
`;
|
|
4
|
+
// Node 22.1+ can keep compiled bytecode for linework's modules on disk, which
|
|
5
|
+
// takes about a quarter off loading them on every run after the first. Older
|
|
6
|
+
// Node has no such call. NODE_DISABLE_COMPILE_CACHE=1 turns it off.
|
|
7
|
+
module.enableCompileCache?.();
|
|
56
8
|
async function main(argv) {
|
|
9
|
+
const { OPTIONS, USAGE, needsBrowser, run, report, processIO } = await import('./commands.js');
|
|
57
10
|
const [cmd, ...rest] = argv;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
scale: { type: 'string' },
|
|
70
|
-
'no-open': { type: 'boolean', default: false },
|
|
71
|
-
port: { type: 'string' },
|
|
72
|
-
root: { type: 'string' },
|
|
73
|
-
to: { type: 'string' },
|
|
74
|
-
pull: { type: 'boolean', default: false },
|
|
75
|
-
help: { type: 'boolean', short: 'h' },
|
|
76
|
-
},
|
|
77
|
-
});
|
|
11
|
+
if (cmd === 'daemon')
|
|
12
|
+
return daemon(rest[0]);
|
|
13
|
+
let parsed;
|
|
14
|
+
try {
|
|
15
|
+
parsed = parseArgs({ args: rest, allowPositionals: true, options: OPTIONS });
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
process.exitCode = report(e);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const { values, positionals } = parsed;
|
|
78
22
|
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h' || values.help)
|
|
79
23
|
return void process.stdout.write(USAGE);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
case 'render': {
|
|
107
|
-
const file = positionals[0];
|
|
108
|
-
if (!file || !values.out)
|
|
109
|
-
fail('usage: linework render <file.drawio.svg> -o <out.png>');
|
|
110
|
-
const { DrawioBrowser } = await import('./render/browser.js');
|
|
111
|
-
const b = await DrawioBrowser.launch({ scale: values.scale ? Number(values.scale) : 2 });
|
|
112
|
-
try {
|
|
113
|
-
await render(file, values.out, b);
|
|
114
|
-
}
|
|
115
|
-
finally {
|
|
116
|
-
await b.close();
|
|
117
|
-
}
|
|
118
|
-
console.log(`wrote ${values.out}`);
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
case 'check': {
|
|
122
|
-
const file = positionals[0] ?? fail('usage: linework check <file.drawio.svg>');
|
|
123
|
-
const res = await checkFile(file);
|
|
124
|
-
printCheck(res);
|
|
125
|
-
if (res.findings.some((f) => f.level === 'error'))
|
|
126
|
-
process.exitCode = 1;
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
case 'tidy': {
|
|
130
|
-
const file = positionals[0] ?? fail('usage: linework tidy <file.drawio.svg>');
|
|
131
|
-
const res = await tidy(file);
|
|
132
|
-
console.log(res.fixed ? `rerouted ${res.fixed} edges in ${file}` : 'nothing to fix');
|
|
133
|
-
printCheck(res.check);
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
case 'edit': {
|
|
137
|
-
const file = positionals[0] ?? fail('usage: linework edit <file.drawio.svg>');
|
|
138
|
-
const { startEditor, openInBrowser } = await import('./edit/server.js');
|
|
139
|
-
const session = await startEditor(file, { port: values.port ? Number(values.port) : undefined });
|
|
140
|
-
const opened = !values['no-open'] && openInBrowser(session.url);
|
|
141
|
-
console.log(`${opened ? 'opened' : 'open'} ${session.url}`);
|
|
142
|
-
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.`);
|
|
143
|
-
process.once('SIGINT', () => void session.close());
|
|
144
|
-
const res = await session.done;
|
|
145
|
-
console.log(res.saves ? `saved ${file} ${res.saves - res.conflicts.length} time(s)` : 'closed without saving');
|
|
146
|
-
for (const c of res.conflicts)
|
|
147
|
-
console.log(`note: ${file} changed on disk while it was open, so one save went to ${c}`);
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
case 'import': {
|
|
151
|
-
const src = positionals[0] ?? fail('usage: linework import <file.mmd|doc.md|-> [-o out.drawio.svg]');
|
|
152
|
-
const { importMermaid, mermaidBlocks } = await import('./import/mermaid.js');
|
|
153
|
-
const { toYaml } = await import('./drawio/show.js');
|
|
154
|
-
let text = src === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(src, 'utf8');
|
|
155
|
-
if (/\.(md|markdown|mdx)$/i.test(src)) {
|
|
156
|
-
const blocks = mermaidBlocks(text);
|
|
157
|
-
if (!blocks.length)
|
|
158
|
-
fail(`${src} has no \`\`\`mermaid blocks`);
|
|
159
|
-
const n = values.block ? Number(values.block) : blocks.length === 1 ? 1 : 0;
|
|
160
|
-
if (!n || !blocks[n - 1]) {
|
|
161
|
-
const first = blocks.map((b, i) => ` ${i + 1}: ${b.split('\n')[0]}`).join('\n');
|
|
162
|
-
fail(`${src} has ${blocks.length} mermaid blocks; pick one with --block N:\n${first}`);
|
|
163
|
-
}
|
|
164
|
-
text = blocks[n - 1];
|
|
165
|
-
}
|
|
166
|
-
const res = importMermaid(text);
|
|
167
|
-
for (const w of res.warnings)
|
|
168
|
-
console.error(`import: ${w}`);
|
|
169
|
-
const yaml = toYaml(res.diagram, 'imported from Mermaid');
|
|
170
|
-
if (!values.out)
|
|
171
|
-
return void process.stdout.write(yaml);
|
|
172
|
-
const built = await build(yaml, { out: values.out, png: values.png, relayout: values.relayout, force: values.force });
|
|
173
|
-
console.log(`wrote ${built.out} (${built.check.stats.nodes} nodes, ${built.check.stats.edges} edges, ${built.direction})`);
|
|
174
|
-
printCheck(built.check);
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
case 'icons': {
|
|
178
|
-
const provider = positionals[0];
|
|
179
|
-
if (!provider || !['aws', 'gcp', 'k8s', 'azure'].includes(provider))
|
|
180
|
-
fail('usage: linework icons <aws|gcp|k8s|azure> [filter]');
|
|
181
|
-
const names = await listIcons(provider, positionals[1]);
|
|
182
|
-
console.log(names.map((n) => `${provider}.${n}`).join('\n') || 'no matches');
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
case 'publish': {
|
|
186
|
-
if (!positionals.length)
|
|
187
|
-
fail('usage: linework publish <file.drawio.svg...> [--to DIR]');
|
|
188
|
-
const { publish } = await import('./publish.js');
|
|
189
|
-
const items = publish(positionals, { to: values.to, pull: values.pull, force: values.force });
|
|
190
|
-
for (const i of items) {
|
|
191
|
-
if (i.action === 'published')
|
|
192
|
-
console.log(`published ${path.relative(process.cwd(), i.file)} -> ${i.dest}`);
|
|
193
|
-
else if (i.action === 'unchanged')
|
|
194
|
-
console.log(`unchanged ${i.dest}`);
|
|
195
|
-
else if (i.action === 'pulled')
|
|
196
|
-
console.log(`pulled ${i.dest} -> ${path.relative(process.cwd(), i.file)}`);
|
|
197
|
-
else
|
|
198
|
-
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.`);
|
|
199
|
-
}
|
|
200
|
-
if (items.some((i) => i.action === 'published') && !values.pull) {
|
|
201
|
-
console.log('\nNot in the doc yet? In Google Docs: Extensions > draw.io > Insert diagrams, then pick the file.');
|
|
202
|
-
console.log('Already in the doc? After Drive syncs: Extensions > draw.io > Update diagrams.');
|
|
203
|
-
}
|
|
204
|
-
if (items.some((i) => i.action === 'conflict'))
|
|
205
|
-
process.exitCode = 1;
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
case 'skill': {
|
|
209
|
-
const { fileURLToPath } = await import('node:url');
|
|
210
|
-
const src = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'skills', 'linework');
|
|
211
|
-
if (positionals[0] !== 'install')
|
|
212
|
-
return void console.log(src);
|
|
213
|
-
const dir = positionals[1] ?? fail('usage: linework skill install <skills-dir> e.g. .claude/skills');
|
|
214
|
-
const dest = path.join(dir.replace(/^~(?=$|\/)/, process.env.HOME ?? '~'), 'linework');
|
|
215
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
216
|
-
fs.cpSync(src, dest, { recursive: true });
|
|
217
|
-
console.log(`installed the linework skill to ${dest}`);
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
case 'mcp': {
|
|
221
|
-
const { runStdio } = await import('./mcp/server.js');
|
|
222
|
-
await runStdio({ root: path.resolve(values.root ?? process.env.LINEWORK_ROOT ?? process.cwd()) });
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
case 'git-setup': {
|
|
226
|
-
const { gitSetup } = await import('./git.js');
|
|
227
|
-
for (const line of gitSetup())
|
|
228
|
-
console.log(line);
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
case 'kinds': {
|
|
232
|
-
const types = (positionals.length ? positionals : DIAGRAM_TYPES);
|
|
233
|
-
for (const t of types) {
|
|
234
|
-
if (!KINDS[t])
|
|
235
|
-
fail(`unknown type ${t}`);
|
|
236
|
-
console.log(`${t}:`);
|
|
237
|
-
for (const [k, d] of Object.entries(KINDS[t]))
|
|
238
|
-
console.log(` ${k.padEnd(10)} ${d.description}`);
|
|
239
|
-
}
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
case 'schema':
|
|
243
|
-
console.log(JSON.stringify(jsonSchema(), null, 2));
|
|
244
|
-
return;
|
|
245
|
-
case 'install-browser': {
|
|
246
|
-
const { browserInstallCommand } = await import('./render/browser.js');
|
|
247
|
-
const { spawnSync } = await import('node:child_process');
|
|
248
|
-
const [bin, ...args] = browserInstallCommand();
|
|
249
|
-
console.log('downloading headless Chromium for linework...');
|
|
250
|
-
const r = spawnSync(bin, args, { stdio: 'inherit' });
|
|
251
|
-
process.exitCode = r.status ?? 1;
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
default:
|
|
255
|
-
fail(`unknown command "${cmd}"\n\n${USAGE}`);
|
|
24
|
+
const draws = needsBrowser(cmd, values);
|
|
25
|
+
// A custom render scale needs its own page, so that one runs here.
|
|
26
|
+
const shareable = draws && !(cmd === 'render' && values.scale) && !values['no-daemon'];
|
|
27
|
+
if (shareable) {
|
|
28
|
+
// Chromium, draw.io and ELK may already be warm in the background process.
|
|
29
|
+
const { viaDaemon } = await import('./daemon.js');
|
|
30
|
+
const stdin = positionals[0] === '-' && (cmd === 'build' || cmd === 'import') ? processIO.stdin() : undefined;
|
|
31
|
+
const code = await viaDaemon({ argv, cwd: process.cwd(), stdin }, processIO.out, processIO.err);
|
|
32
|
+
if (code !== undefined)
|
|
33
|
+
return exitWhenFlushed(code);
|
|
34
|
+
if (stdin !== undefined)
|
|
35
|
+
processIO.stdin = () => stdin;
|
|
36
|
+
}
|
|
37
|
+
// Commands that draw start Chromium first and load the rest of linework while it
|
|
38
|
+
// boots and loads draw.io: the two take about the same time, so they overlap.
|
|
39
|
+
const launching = draws ? import('./render/browser.js').then((m) => m.DrawioBrowser.launch({ scale: cmd === 'render' && values.scale ? Number(values.scale) : 2 })) : undefined;
|
|
40
|
+
// A failure here surfaces where the browser is awaited; don't report it twice.
|
|
41
|
+
launching?.catch(() => { });
|
|
42
|
+
let failed = false;
|
|
43
|
+
try {
|
|
44
|
+
await run(cmd, values, positionals, launching);
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
failed = true;
|
|
48
|
+
process.exitCode = report(e);
|
|
256
49
|
}
|
|
50
|
+
finally {
|
|
51
|
+
if (launching)
|
|
52
|
+
await (await launching.catch(() => undefined))?.close();
|
|
53
|
+
}
|
|
54
|
+
// The next drawing command finds the browser already running.
|
|
55
|
+
if (shareable && !failed)
|
|
56
|
+
(await import('./daemon.js')).startDaemon();
|
|
57
|
+
// The font subsetter's WebAssembly keeps node alive for ~100ms after the work
|
|
58
|
+
// is done, with nothing left to run. A drawing command is finished here.
|
|
59
|
+
if (launching)
|
|
60
|
+
await exitWhenFlushed(Number(process.exitCode ?? 0));
|
|
257
61
|
}
|
|
258
|
-
function
|
|
259
|
-
|
|
260
|
-
|
|
62
|
+
async function daemon(sub) {
|
|
63
|
+
const d = await import('./daemon.js');
|
|
64
|
+
if (sub === 'run')
|
|
65
|
+
return d.serve();
|
|
66
|
+
if (sub === 'stop')
|
|
67
|
+
return void console.log((await d.stopDaemon()) ? 'stopped' : 'not running');
|
|
68
|
+
if (sub === 'status' || !sub) {
|
|
69
|
+
const sock = d.daemonAllowed() ? d.socketPath() : undefined;
|
|
70
|
+
const fs = await import('node:fs');
|
|
71
|
+
return void console.log(!sock ? 'off (LINEWORK_NO_DAEMON, CI or Windows)' : fs.existsSync(sock) ? `running at ${sock}` : 'not running; the next build starts it');
|
|
72
|
+
}
|
|
73
|
+
console.error('usage: linework daemon [status|stop]');
|
|
74
|
+
process.exitCode = 2;
|
|
261
75
|
}
|
|
262
|
-
function
|
|
263
|
-
|
|
264
|
-
|
|
76
|
+
function exitWhenFlushed(code) {
|
|
77
|
+
return new Promise(() => {
|
|
78
|
+
let pending = 2;
|
|
79
|
+
const done = () => --pending === 0 && process.exit(code);
|
|
80
|
+
process.stdout.write('', done);
|
|
81
|
+
process.stderr.write('', done);
|
|
82
|
+
});
|
|
265
83
|
}
|
|
266
|
-
main(process.argv.slice(2)).catch((e) => {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
for (const i of e.issues)
|
|
270
|
-
console.error(` ${i.path ? i.path + ': ' : ''}${i.message}`);
|
|
271
|
-
process.exit(1);
|
|
272
|
-
}
|
|
273
|
-
console.error(`error: ${e.message}`);
|
|
274
|
-
process.exit(1);
|
|
84
|
+
main(process.argv.slice(2)).catch(async (e) => {
|
|
85
|
+
const { report } = await import('./commands.js');
|
|
86
|
+
process.exit(report(e));
|
|
275
87
|
});
|