@itsthecsguy/linework 0.1.4 → 0.2.1
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 +38 -2
- 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/drawio/write.js +68 -4
- package/dist/layout/layout.js +48 -2
- package/dist/layout/polish.js +112 -22
- package/dist/lint/check.js +16 -0
- package/dist/mcp/server.js +6 -6
- package/dist/patch.js +167 -0
- package/dist/report.js +2 -0
- package/dist/spec/parse.js +2 -0
- package/package.json +2 -1
- package/skills/linework/SKILL.md +57 -200
- package/skills/linework/references/spec.md +1 -1
- package/skills/linework/references/style-guide.md +1 -1
- package/skills/linework/references/troubleshooting.md +11 -0
package/README.md
CHANGED
|
@@ -159,6 +159,8 @@ 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
|
+
|
|
162
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.
|
|
163
165
|
|
|
164
166
|
## Connect over MCP
|
|
@@ -202,6 +204,20 @@ npx -y @itsthecsguy/linework skill install .claude/skills # Claude Code
|
|
|
202
204
|
npx -y @itsthecsguy/linework skill install .grok/skills # Grok Build
|
|
203
205
|
```
|
|
204
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
|
+
|
|
205
221
|
## Editing by hand
|
|
206
222
|
|
|
207
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:
|
|
@@ -221,11 +237,14 @@ Docs can't hold an SVG, so the diagram lives in Google Drive and draw.io's Googl
|
|
|
221
237
|
| command | does |
|
|
222
238
|
|---|---|
|
|
223
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 |
|
|
224
243
|
| `linework show <file> [--geometry]` | print the spec of a diagram, including hand edits |
|
|
225
244
|
| `linework edit <file> [--no-open]` | edit in the bundled draw.io, in your browser; saves write the file |
|
|
226
245
|
| `linework check <file>` | lint: overlaps, edges through nodes, labels that don't fit, too wide |
|
|
227
246
|
| `linework render <file> -o out.png [--scale 2]` | preview image |
|
|
228
|
-
| `linework tidy <file>` | reroute edges
|
|
247
|
+
| `linework tidy <file>` | after hand edits: reroute stale edges and move colliding labels; boxes stay |
|
|
229
248
|
| `linework import <file.mmd\|doc.md> [-o out.drawio.svg]` | convert a Mermaid diagram |
|
|
230
249
|
| `linework publish <file...> [--to dir] [--pull]` | copy diagrams into Google Drive for the Docs add-on |
|
|
231
250
|
| `linework icons <aws\|gcp\|k8s\|azure> [filter]` | list icon names |
|
|
@@ -234,11 +253,12 @@ Docs can't hold an SVG, so the diagram lives in Google Drive and draw.io's Googl
|
|
|
234
253
|
| `linework mcp [--root dir]` | MCP server over stdio |
|
|
235
254
|
| `linework kinds [type]` | node kinds per diagram type |
|
|
236
255
|
| `linework schema` | JSON Schema of the spec |
|
|
256
|
+
| `linework daemon [status\|stop]` | the background process that keeps the browser running between builds |
|
|
237
257
|
| `linework install-browser` | download the headless Chromium build this version uses |
|
|
238
258
|
|
|
239
259
|
## Status
|
|
240
260
|
|
|
241
|
-
All
|
|
261
|
+
All five phases of [PLAN.md](PLAN.md) are built. Two paths haven't been tried yet, because they need a real account or app: the Google Docs add-on round trip, and saving from the VS Code Draw.io Integration extension. Both use the same file format and protocol that the tests cover with draw.io's own editor.
|
|
242
262
|
|
|
243
263
|
## Development
|
|
244
264
|
|
|
@@ -249,4 +269,20 @@ npm run build # compile to dist/
|
|
|
249
269
|
npm run dev -- build examples/checkout.yaml -o out/checkout.drawio.svg --png out/checkout.png
|
|
250
270
|
```
|
|
251
271
|
|
|
272
|
+
Every push to `main` and every pull request runs the typecheck and the tests on Node 22 and 24 in GitHub Actions. To run the suite with a browser that isn't in one of the usual places, set `LINEWORK_CHROME=/path/to/chromium`.
|
|
273
|
+
|
|
274
|
+
After a change to layout or rendering, rebuild the README gallery and look at what moved:
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
npm run build && node docs/gallery/build.mjs
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### Releasing
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
npm run release # patch; or: npm run release -- minor | major | 1.2.3
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
That bumps the version, commits it as `Release x.y.z`, tags `vx.y.z` and pushes both. The tag starts the release workflow, which runs the tests again, publishes to npm and opens a GitHub release with generated notes. Nobody logs in to npm: the workflow publishes through npm's [trusted publishing](https://docs.npmjs.com/trusted-publishers), which is set up once in the package's settings on npmjs.com (GitHub Actions, repository `itsthecsguy/linework`, workflow `release.yml`).
|
|
287
|
+
|
|
252
288
|
`assets/drawio` holds the parts of the draw.io web app that linework runs: the renderer it uses headless, and the editor behind `linework edit`. draw.io is Apache-2.0; see [NOTICE](NOTICE). To update it, check out a jgraph/drawio tag in `vendor/drawio` and run `npm run assets`.
|
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,26 +87,45 @@ 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 =
|
|
123
|
+
xml = xml;
|
|
100
124
|
const fixed = await fixLabels(b, xml);
|
|
101
125
|
xml = fixed.xml;
|
|
102
126
|
xml = stampEdgeRoutes(xml);
|
|
103
127
|
// Stamping routes changes attributes, not geometry: the cells fixLabels read still hold.
|
|
104
|
-
return finish(b, xml, { mode, warnings, direction: mode === 'merge' ? (old.meta.aiviz_direction ?? l.direction) : l.direction, edits, old, stats, cells: fixed.cells });
|
|
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 });
|
|
105
129
|
});
|
|
106
130
|
async function finish(b, xml, r) {
|
|
107
131
|
fs.mkdirSync(path.dirname(path.resolve(opts.out)), { recursive: true });
|
|
@@ -116,10 +140,33 @@ export async function build(specText, opts) {
|
|
|
116
140
|
direction: r.direction,
|
|
117
141
|
mode: r.mode,
|
|
118
142
|
merge: r.stats,
|
|
143
|
+
labelsMoved: r.labelsMoved ?? 0,
|
|
119
144
|
discardedLayout: !!(opts.relayout && r.old && (r.old.meta.aiviz_layout === 'manual' || r.edits.length > 0)),
|
|
120
145
|
};
|
|
121
146
|
}
|
|
122
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
|
+
}
|
|
123
170
|
/**
|
|
124
171
|
* Edges whose route is far longer than the gap between their ends. Layered layout
|
|
125
172
|
* draws one edge of every cycle backwards, so the reply in a call-and-callback pair
|
|
@@ -160,17 +207,27 @@ async function straightenDetours(b, xml, ids) {
|
|
|
160
207
|
async function rerouteInto(b, xml, edgeIds) {
|
|
161
208
|
return patchEdges(xml, await b.reroute(xml, edgeIds), edgeIds);
|
|
162
209
|
}
|
|
163
|
-
/**
|
|
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
|
+
*/
|
|
164
215
|
export async function tidy(file, browser) {
|
|
165
216
|
const text = fs.readFileSync(file, 'utf8');
|
|
166
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
|
+
}
|
|
167
224
|
return withBrowser(browser, async (b) => {
|
|
168
225
|
const broken = brokenEdges(check(await b.inspect(m.xml)));
|
|
169
226
|
if (!broken.length)
|
|
170
|
-
return { fixed: 0, check: check(await b.inspect(m.xml)) };
|
|
227
|
+
return { fixed: 0, labels: 0, check: check(await b.inspect(m.xml)) };
|
|
171
228
|
const xml = stampEdgeRoutes(await rerouteInto(b, m.xml, broken));
|
|
172
229
|
fs.writeFileSync(file, file.endsWith('.svg') ? await b.renderSvg(xml) : xml);
|
|
173
|
-
return { fixed: broken.length, check: check(await b.inspect(xml)) };
|
|
230
|
+
return { fixed: broken.length, labels: 0, check: check(await b.inspect(xml)) };
|
|
174
231
|
});
|
|
175
232
|
}
|
|
176
233
|
export function show(file, opts = {}) {
|
package/dist/cli.js
CHANGED
|
@@ -1,320 +1,87 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import fs from 'node:fs';
|
|
3
2
|
import module from 'node:module';
|
|
4
|
-
import path from 'node:path';
|
|
5
3
|
import { parseArgs } from 'node:util';
|
|
6
|
-
const USAGE = `linework: AI-written, hand-editable diagrams (YAML spec -> .drawio.svg)
|
|
7
|
-
|
|
8
|
-
Usage:
|
|
9
|
-
linework build <spec.yaml|-> -o <out.drawio.svg> [--png preview.png] [--relayout]
|
|
10
|
-
Lay out and write the diagram, then lint it. "-" reads the spec from stdin.
|
|
11
|
-
If the file exists and someone edited it by hand, the spec is merged in:
|
|
12
|
-
existing positions stay, new nodes are placed next to their neighbours.
|
|
13
|
-
--relayout throws the existing layout away (hand edits included).
|
|
14
|
-
linework show <file.drawio.svg> [--geometry]
|
|
15
|
-
Print the spec of an existing diagram (including hand edits) as YAML.
|
|
16
|
-
--geometry appends node positions as comments.
|
|
17
|
-
linework render <file.drawio.svg> -o <out.png|out.svg> [--scale 2]
|
|
18
|
-
Render a preview image.
|
|
19
|
-
linework check <file.drawio.svg>
|
|
20
|
-
Lint layout: overlaps, edges through nodes, labels that don't fit.
|
|
21
|
-
linework tidy <file.drawio.svg>
|
|
22
|
-
Reroute edges that check flags as broken (e.g. after moving nodes by hand).
|
|
23
|
-
Nothing else in the file changes.
|
|
24
|
-
linework edit <file.drawio.svg> [--no-open] [--port N]
|
|
25
|
-
Open the diagram in draw.io (bundled, offline) in your browser. Saving in
|
|
26
|
-
the editor writes the file. Stops when you exit the editor or close the tab.
|
|
27
|
-
linework import <file.mmd|doc.md|-> [--block N] [-o out.drawio.svg] [--png p.png]
|
|
28
|
-
Convert a Mermaid diagram (flowchart, state, sequence, ER) to an linework spec.
|
|
29
|
-
Prints the spec, or builds it straight away with -o.
|
|
30
|
-
linework icons <aws|gcp|k8s|azure> [filter]
|
|
31
|
-
List icon names, for "icon: aws.lambda" on architecture nodes.
|
|
32
|
-
linework publish <file.drawio.svg...> [--to DIR] [--pull] [--force]
|
|
33
|
-
Copy diagrams into your Google Drive folder (Google Drive for desktop) so
|
|
34
|
-
draw.io's Google Docs add-on can insert them and refresh them later.
|
|
35
|
-
--pull copies back a diagram someone edited from Docs or Drive.
|
|
36
|
-
linework skill [install <dir>]
|
|
37
|
-
Print where the bundled agent skill is, or copy it to <dir>/linework
|
|
38
|
-
(e.g. .claude/skills, ~/.agents/skills).
|
|
39
|
-
linework mcp [--root DIR]
|
|
40
|
-
Run as an MCP server over stdio, for chat apps and agents without a shell.
|
|
41
|
-
Relative paths resolve against --root (default: the current folder).
|
|
42
|
-
linework git-setup
|
|
43
|
-
Make git diff show .drawio.svg changes as spec changes (this repo only).
|
|
44
|
-
linework kinds [architecture|flow|state]
|
|
45
|
-
List node kinds for each diagram type.
|
|
46
|
-
linework schema
|
|
47
|
-
Print the spec's JSON Schema.
|
|
48
|
-
|
|
49
|
-
linework install-browser
|
|
50
|
-
Download the headless Chromium build that matches this version. Needed only
|
|
51
|
-
when no Chrome or Edge is installed, or to run inside an agent's sandbox.
|
|
52
|
-
`;
|
|
53
4
|
// Node 22.1+ can keep compiled bytecode for linework's modules on disk, which
|
|
54
5
|
// takes about a quarter off loading them on every run after the first. Older
|
|
55
6
|
// Node has no such call. NODE_DISABLE_COMPILE_CACHE=1 turns it off.
|
|
56
7
|
module.enableCompileCache?.();
|
|
57
8
|
async function main(argv) {
|
|
9
|
+
const { OPTIONS, USAGE, needsBrowser, run, report, processIO } = await import('./commands.js');
|
|
58
10
|
const [cmd, ...rest] = argv;
|
|
59
|
-
|
|
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;
|
|
60
22
|
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h' || values.help)
|
|
61
23
|
return void process.stdout.write(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
|
+
}
|
|
62
37
|
// Commands that draw start Chromium first and load the rest of linework while it
|
|
63
38
|
// boots and loads draw.io: the two take about the same time, so they overlap.
|
|
64
|
-
const
|
|
65
|
-
const launching = needsBrowser ? import('./render/browser.js').then((m) => m.DrawioBrowser.launch({ scale: cmd === 'render' && values.scale ? Number(values.scale) : 2 })) : undefined;
|
|
39
|
+
const launching = draws ? import('./render/browser.js').then((m) => m.DrawioBrowser.launch({ scale: cmd === 'render' && values.scale ? Number(values.scale) : 2 })) : undefined;
|
|
66
40
|
// A failure here surfaces where the browser is awaited; don't report it twice.
|
|
67
41
|
launching?.catch(() => { });
|
|
42
|
+
let failed = false;
|
|
68
43
|
try {
|
|
69
44
|
await run(cmd, values, positionals, launching);
|
|
70
45
|
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
failed = true;
|
|
48
|
+
process.exitCode = report(e);
|
|
49
|
+
}
|
|
71
50
|
finally {
|
|
72
51
|
if (launching)
|
|
73
52
|
await (await launching.catch(() => undefined))?.close();
|
|
74
53
|
}
|
|
54
|
+
// The next drawing command finds the browser already running.
|
|
55
|
+
if (shareable && !failed)
|
|
56
|
+
(await import('./daemon.js')).startDaemon();
|
|
75
57
|
// The font subsetter's WebAssembly keeps node alive for ~100ms after the work
|
|
76
58
|
// is done, with nothing left to run. A drawing command is finished here.
|
|
77
59
|
if (launching)
|
|
78
|
-
await exitWhenFlushed();
|
|
60
|
+
await exitWhenFlushed(Number(process.exitCode ?? 0));
|
|
79
61
|
}
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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;
|
|
85
75
|
}
|
|
86
|
-
function exitWhenFlushed() {
|
|
76
|
+
function exitWhenFlushed(code) {
|
|
87
77
|
return new Promise(() => {
|
|
88
78
|
let pending = 2;
|
|
89
|
-
const done = () => --pending === 0 && process.exit();
|
|
79
|
+
const done = () => --pending === 0 && process.exit(code);
|
|
90
80
|
process.stdout.write('', done);
|
|
91
81
|
process.stderr.write('', done);
|
|
92
82
|
});
|
|
93
83
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
force: { type: 'boolean', default: false },
|
|
98
|
-
relayout: { type: 'boolean', default: false },
|
|
99
|
-
geometry: { type: 'boolean', default: false },
|
|
100
|
-
quiet: { type: 'boolean', short: 'q', default: false },
|
|
101
|
-
block: { type: 'string' },
|
|
102
|
-
scale: { type: 'string' },
|
|
103
|
-
'no-open': { type: 'boolean', default: false },
|
|
104
|
-
port: { type: 'string' },
|
|
105
|
-
root: { type: 'string' },
|
|
106
|
-
to: { type: 'string' },
|
|
107
|
-
pull: { type: 'boolean', default: false },
|
|
108
|
-
help: { type: 'boolean', short: 'h' },
|
|
109
|
-
};
|
|
110
|
-
async function run(cmd, values, positionals, launching) {
|
|
111
|
-
const browser = async () => {
|
|
112
|
-
// ELK compiles its code on first use. Laying out a tiny graph while Chromium
|
|
113
|
-
// is still starting takes that cost off the real layout.
|
|
114
|
-
if (launching && ['build', 'import'].includes(cmd))
|
|
115
|
-
await warmUp();
|
|
116
|
-
return launching;
|
|
117
|
-
};
|
|
118
|
-
const { buildLines } = await import('./report.js');
|
|
119
|
-
switch (cmd) {
|
|
120
|
-
case 'build': {
|
|
121
|
-
const src = positionals[0];
|
|
122
|
-
if (!src || !values.out)
|
|
123
|
-
fail('usage: linework build <spec.yaml|-> -o <out.drawio.svg>');
|
|
124
|
-
const text = src === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(src, 'utf8');
|
|
125
|
-
const { build } = await import('./api.js');
|
|
126
|
-
// Spec errors first: they shouldn't wait on, or hide behind, a browser problem.
|
|
127
|
-
(await import('./spec/parse.js')).parseSpecText(text);
|
|
128
|
-
const res = await build(text, { out: values.out, png: values.png, force: values.force, relayout: values.relayout, browser: await browser() });
|
|
129
|
-
for (const line of buildLines(res, values.png))
|
|
130
|
-
console.log(line);
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
case 'show': {
|
|
134
|
-
const file = positionals[0] ?? fail('usage: linework show <file.drawio.svg>');
|
|
135
|
-
const { show } = await import('./api.js');
|
|
136
|
-
const res = show(file, { geometry: values.geometry });
|
|
137
|
-
process.stdout.write(res.yaml);
|
|
138
|
-
if (values.quiet)
|
|
139
|
-
return;
|
|
140
|
-
for (const n of res.notes)
|
|
141
|
-
console.error(`note: ${n}`);
|
|
142
|
-
if (res.edits.length) {
|
|
143
|
-
const moved = res.edits.filter((e) => e.what !== 'added').length;
|
|
144
|
-
const added = res.edits.filter((e) => e.what === 'added').length;
|
|
145
|
-
console.error(`note: edited by hand since the last build (${moved} moved/resized, ${added} added). build keeps those positions and merges your changes in.`);
|
|
146
|
-
}
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
case 'render': {
|
|
150
|
-
const file = positionals[0];
|
|
151
|
-
if (!file || !values.out)
|
|
152
|
-
fail('usage: linework render <file.drawio.svg> -o <out.png>');
|
|
153
|
-
const { render } = await import('./api.js');
|
|
154
|
-
await render(file, values.out, await browser());
|
|
155
|
-
console.log(`wrote ${values.out}`);
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
case 'check': {
|
|
159
|
-
const file = positionals[0] ?? fail('usage: linework check <file.drawio.svg>');
|
|
160
|
-
const { checkFile } = await import('./api.js');
|
|
161
|
-
const res = await checkFile(file, await browser());
|
|
162
|
-
await printCheck(res);
|
|
163
|
-
if (res.findings.some((f) => f.level === 'error'))
|
|
164
|
-
process.exitCode = 1;
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
case 'tidy': {
|
|
168
|
-
const file = positionals[0] ?? fail('usage: linework tidy <file.drawio.svg>');
|
|
169
|
-
const { tidy } = await import('./api.js');
|
|
170
|
-
const res = await tidy(file, await browser());
|
|
171
|
-
console.log(res.fixed ? `rerouted ${res.fixed} edges in ${file}` : 'nothing to fix');
|
|
172
|
-
await printCheck(res.check);
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
case 'edit': {
|
|
176
|
-
const file = positionals[0] ?? fail('usage: linework edit <file.drawio.svg>');
|
|
177
|
-
const { startEditor, openInBrowser } = await import('./edit/server.js');
|
|
178
|
-
const session = await startEditor(file, { port: values.port ? Number(values.port) : undefined });
|
|
179
|
-
const opened = !values['no-open'] && openInBrowser(session.url);
|
|
180
|
-
console.log(`${opened ? 'opened' : 'open'} ${session.url}`);
|
|
181
|
-
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.`);
|
|
182
|
-
process.once('SIGINT', () => void session.close());
|
|
183
|
-
const res = await session.done;
|
|
184
|
-
console.log(res.saves ? `saved ${file} ${res.saves - res.conflicts.length} time(s)` : 'closed without saving');
|
|
185
|
-
for (const c of res.conflicts)
|
|
186
|
-
console.log(`note: ${file} changed on disk while it was open, so one save went to ${c}`);
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
case 'import': {
|
|
190
|
-
const src = positionals[0] ?? fail('usage: linework import <file.mmd|doc.md|-> [-o out.drawio.svg]');
|
|
191
|
-
const { importMermaid, mermaidBlocks } = await import('./import/mermaid.js');
|
|
192
|
-
const { toYaml } = await import('./drawio/show.js');
|
|
193
|
-
let text = src === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(src, 'utf8');
|
|
194
|
-
if (/\.(md|markdown|mdx)$/i.test(src)) {
|
|
195
|
-
const blocks = mermaidBlocks(text);
|
|
196
|
-
if (!blocks.length)
|
|
197
|
-
fail(`${src} has no \`\`\`mermaid blocks`);
|
|
198
|
-
const n = values.block ? Number(values.block) : blocks.length === 1 ? 1 : 0;
|
|
199
|
-
if (!n || !blocks[n - 1]) {
|
|
200
|
-
const first = blocks.map((b, i) => ` ${i + 1}: ${b.split('\n')[0]}`).join('\n');
|
|
201
|
-
fail(`${src} has ${blocks.length} mermaid blocks; pick one with --block N:\n${first}`);
|
|
202
|
-
}
|
|
203
|
-
text = blocks[n - 1];
|
|
204
|
-
}
|
|
205
|
-
const res = importMermaid(text);
|
|
206
|
-
for (const w of res.warnings)
|
|
207
|
-
console.error(`import: ${w}`);
|
|
208
|
-
const yaml = toYaml(res.diagram, 'imported from Mermaid');
|
|
209
|
-
if (!values.out)
|
|
210
|
-
return void process.stdout.write(yaml);
|
|
211
|
-
const { build } = await import('./api.js');
|
|
212
|
-
const built = await build(yaml, { out: values.out, png: values.png, relayout: values.relayout, force: values.force, browser: await browser() });
|
|
213
|
-
console.log(`wrote ${built.out} (${built.check.stats.nodes} nodes, ${built.check.stats.edges} edges, ${built.direction})`);
|
|
214
|
-
await printCheck(built.check);
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
case 'icons': {
|
|
218
|
-
const provider = positionals[0];
|
|
219
|
-
if (!provider || !['aws', 'gcp', 'k8s', 'azure'].includes(provider))
|
|
220
|
-
fail('usage: linework icons <aws|gcp|k8s|azure> [filter]');
|
|
221
|
-
const { listIcons } = await import('./api.js');
|
|
222
|
-
const names = await listIcons(provider, positionals[1], await browser());
|
|
223
|
-
console.log(names.map((n) => `${provider}.${n}`).join('\n') || 'no matches');
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
case 'publish': {
|
|
227
|
-
if (!positionals.length)
|
|
228
|
-
fail('usage: linework publish <file.drawio.svg...> [--to DIR]');
|
|
229
|
-
const { publish } = await import('./publish.js');
|
|
230
|
-
const items = publish(positionals, { to: values.to, pull: values.pull, force: values.force });
|
|
231
|
-
for (const i of items) {
|
|
232
|
-
if (i.action === 'published')
|
|
233
|
-
console.log(`published ${path.relative(process.cwd(), i.file)} -> ${i.dest}`);
|
|
234
|
-
else if (i.action === 'unchanged')
|
|
235
|
-
console.log(`unchanged ${i.dest}`);
|
|
236
|
-
else if (i.action === 'pulled')
|
|
237
|
-
console.log(`pulled ${i.dest} -> ${path.relative(process.cwd(), i.file)}`);
|
|
238
|
-
else
|
|
239
|
-
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.`);
|
|
240
|
-
}
|
|
241
|
-
if (items.some((i) => i.action === 'published') && !values.pull) {
|
|
242
|
-
console.log('\nNot in the doc yet? In Google Docs: Extensions > draw.io > Insert diagrams, then pick the file.');
|
|
243
|
-
console.log('Already in the doc? After Drive syncs: Extensions > draw.io > Update diagrams.');
|
|
244
|
-
}
|
|
245
|
-
if (items.some((i) => i.action === 'conflict'))
|
|
246
|
-
process.exitCode = 1;
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
case 'skill': {
|
|
250
|
-
const { fileURLToPath } = await import('node:url');
|
|
251
|
-
const src = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'skills', 'linework');
|
|
252
|
-
if (positionals[0] !== 'install')
|
|
253
|
-
return void console.log(src);
|
|
254
|
-
const dir = positionals[1] ?? fail('usage: linework skill install <skills-dir> e.g. .claude/skills');
|
|
255
|
-
const dest = path.join(dir.replace(/^~(?=$|\/)/, process.env.HOME ?? '~'), 'linework');
|
|
256
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
257
|
-
fs.cpSync(src, dest, { recursive: true });
|
|
258
|
-
console.log(`installed the linework skill to ${dest}`);
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
case 'mcp': {
|
|
262
|
-
const { runStdio } = await import('./mcp/server.js');
|
|
263
|
-
await runStdio({ root: path.resolve(values.root ?? process.env.LINEWORK_ROOT ?? process.cwd()) });
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
case 'git-setup': {
|
|
267
|
-
const { gitSetup } = await import('./git.js');
|
|
268
|
-
for (const line of gitSetup())
|
|
269
|
-
console.log(line);
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
case 'kinds': {
|
|
273
|
-
const { KINDS, DIAGRAM_TYPES } = await import('./spec/model.js');
|
|
274
|
-
const types = (positionals.length ? positionals : DIAGRAM_TYPES);
|
|
275
|
-
for (const t of types) {
|
|
276
|
-
if (!KINDS[t])
|
|
277
|
-
fail(`unknown type ${t}`);
|
|
278
|
-
console.log(`${t}:`);
|
|
279
|
-
for (const [k, d] of Object.entries(KINDS[t]))
|
|
280
|
-
console.log(` ${k.padEnd(10)} ${d.description}`);
|
|
281
|
-
}
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
case 'schema': {
|
|
285
|
-
const { jsonSchema } = await import('./spec/parse.js');
|
|
286
|
-
console.log(JSON.stringify(jsonSchema(), null, 2));
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
case 'install-browser': {
|
|
290
|
-
const { browserInstallCommand } = await import('./render/browser.js');
|
|
291
|
-
const { spawnSync } = await import('node:child_process');
|
|
292
|
-
const [bin, ...args] = browserInstallCommand();
|
|
293
|
-
console.log('downloading headless Chromium for linework...');
|
|
294
|
-
const r = spawnSync(bin, args, { stdio: 'inherit' });
|
|
295
|
-
process.exitCode = r.status ?? 1;
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
default:
|
|
299
|
-
fail(`unknown command "${cmd}"\n\n${USAGE}`);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
async function printCheck(res) {
|
|
303
|
-
const { checkLines } = await import('./report.js');
|
|
304
|
-
for (const line of checkLines(res))
|
|
305
|
-
console.log(line);
|
|
306
|
-
}
|
|
307
|
-
function fail(msg) {
|
|
308
|
-
console.error(msg);
|
|
309
|
-
process.exit(2);
|
|
310
|
-
}
|
|
311
|
-
main(process.argv.slice(2)).catch((e) => {
|
|
312
|
-
if (e?.name === 'SpecError') {
|
|
313
|
-
console.error('spec error:');
|
|
314
|
-
for (const i of e.issues)
|
|
315
|
-
console.error(` ${i.path ? i.path + ': ' : ''}${i.message}`);
|
|
316
|
-
process.exit(1);
|
|
317
|
-
}
|
|
318
|
-
console.error(`error: ${e.message}`);
|
|
319
|
-
process.exit(1);
|
|
84
|
+
main(process.argv.slice(2)).catch(async (e) => {
|
|
85
|
+
const { report } = await import('./commands.js');
|
|
86
|
+
process.exit(report(e));
|
|
320
87
|
});
|