@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 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 that pass through nodes, change nothing else |
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
- const mode = old && !opts.relayout && (old.meta.aiviz_layout === 'manual' || edits.length > 0) ? 'merge' : 'fresh';
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 broken = brokenEdges(check(await b.inspect(xml)));
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 = await straightenDetours(b, toMxfile(diagram, l), detourEdges(diagram, l));
100
- xml = (await fixLabels(b, xml)).xml;
123
+ xml = xml;
124
+ const fixed = await fixLabels(b, xml);
125
+ xml = fixed.xml;
101
126
  xml = stampEdgeRoutes(xml);
102
- return finish(b, xml, { mode, warnings, direction: mode === 'merge' ? (old.meta.aiviz_direction ?? l.direction) : l.direction, edits, old, stats });
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
- fs.writeFileSync(opts.out, kind === 'svg' ? await b.renderSvg(xml) : xml);
107
- if (opts.png)
108
- await b.renderPng(xml, opts.png);
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(await b.inspect(xml)),
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
- /** Reroute only the edges check flags as broken. Nothing else changes. */
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 fs from 'node:fs';
3
- import path from 'node:path';
2
+ import module from 'node:module';
4
3
  import { parseArgs } from 'node:util';
5
- import { build, checkFile, listIcons, render, show, tidy } from './api.js';
6
- import { SpecError, jsonSchema } from './spec/parse.js';
7
- import { KINDS, DIAGRAM_TYPES } from './spec/model.js';
8
- import { buildLines, checkLines } from './report.js';
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
- const { values, positionals } = parseArgs({
59
- args: rest,
60
- allowPositionals: true,
61
- options: {
62
- out: { type: 'string', short: 'o' },
63
- png: { type: 'string' },
64
- force: { type: 'boolean', default: false },
65
- relayout: { type: 'boolean', default: false },
66
- geometry: { type: 'boolean', default: false },
67
- quiet: { type: 'boolean', short: 'q', default: false },
68
- block: { type: 'string' },
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
- switch (cmd) {
81
- case 'build': {
82
- const src = positionals[0];
83
- if (!src || !values.out)
84
- fail('usage: linework build <spec.yaml|-> -o <out.drawio.svg>');
85
- const text = src === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(src, 'utf8');
86
- const res = await build(text, { out: values.out, png: values.png, force: values.force, relayout: values.relayout });
87
- for (const line of buildLines(res, values.png))
88
- console.log(line);
89
- return;
90
- }
91
- case 'show': {
92
- const file = positionals[0] ?? fail('usage: linework show <file.drawio.svg>');
93
- const res = show(file, { geometry: values.geometry });
94
- process.stdout.write(res.yaml);
95
- if (values.quiet)
96
- return;
97
- for (const n of res.notes)
98
- console.error(`note: ${n}`);
99
- if (res.edits.length) {
100
- const moved = res.edits.filter((e) => e.what !== 'added').length;
101
- const added = res.edits.filter((e) => e.what === 'added').length;
102
- console.error(`note: edited by hand since the last build (${moved} moved/resized, ${added} added). build keeps those positions and merges your changes in.`);
103
- }
104
- return;
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 printCheck(res) {
259
- for (const line of checkLines(res))
260
- console.log(line);
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 fail(msg) {
263
- console.error(msg);
264
- process.exit(2);
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
- if (e instanceof SpecError) {
268
- console.error('spec error:');
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
  });