tablewalk 0.0.1 → 0.0.3
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 +76 -55
- package/dist/server/demo.js +405 -0
- package/dist/server/index.js +76 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,32 +1,35 @@
|
|
|
1
1
|
# tablewalk
|
|
2
2
|
|
|
3
|
-
**Follow the data
|
|
3
|
+
**[Follow the data.](https://tablewalk.pages.dev)** — [tablewalk.pages.dev](https://tablewalk.pages.dev)
|
|
4
4
|
|
|
5
5
|
Most database tools are SQL clients that happen to render a grid. tablewalk
|
|
6
|
-
starts somewhere else: a foreign key is a place you can go.
|
|
6
|
+
starts somewhere else: a foreign key is a place you can go. Open a row and it
|
|
7
|
+
shows you every row elsewhere that points at it — the orders on a customer, the
|
|
8
|
+
customer on an order — so you follow the data instead of writing joins to reach
|
|
9
|
+
it. The same machinery answers over **MCP**, so a coding agent sees what you
|
|
10
|
+
see ([jump to setup](#for-coding-agents-mcp)).
|
|
7
11
|
|
|
8
12
|
```bash
|
|
9
|
-
npx tablewalk
|
|
10
|
-
npx tablewalk mydata.sqlite
|
|
11
|
-
npx tablewalk
|
|
12
|
-
npx tablewalk mydata.sqlite --export md >> CLAUDE.md # the schema, as a committed brief
|
|
13
|
-
npx tablewalk postgres://… --lint --fail-on high # what the shape costs, for CI
|
|
14
|
-
npx tablewalk --config ./tablewalk.json --diff staging # is staging the same shape?
|
|
13
|
+
npx tablewalk --demo # build a sample database and open it — an instant try
|
|
14
|
+
npx tablewalk mydata.sqlite # or your own: SQLite, Postgres, MySQL
|
|
15
|
+
npx tablewalk # or nothing, and the page asks for a connection
|
|
15
16
|
```
|
|
16
17
|
|
|
17
|
-
No config
|
|
18
|
-
|
|
18
|
+
No config, no build step, no schema to define first — it reads the catalog on
|
|
19
|
+
connect. **Read-only** until you turn editing on, per connection. Runs from
|
|
20
|
+
`npx`, a [Docker image](#docker-and-a-desktop-app), or a desktop app.
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
keychain rather than into the file.
|
|
22
|
+
`npx tablewalk` with no target opens a page that asks for a connection: a form
|
|
23
|
+
that takes a URL or its parts, tests it before keeping it, and reads the
|
|
24
|
+
password from an environment variable or the keychain rather than the string.
|
|
25
|
+
The print-and-exit commands need a target, though — there is nothing for
|
|
26
|
+
`--export`, `--lint`, `--diff` or `--mcp` to read without one:
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
```bash
|
|
29
|
+
npx tablewalk mydata.sqlite --export md >> CLAUDE.md # the schema, as a committed brief
|
|
30
|
+
npx tablewalk postgres://… --lint --fail-on high # what the shape costs, for CI
|
|
31
|
+
npx tablewalk --config ./tablewalk.json --diff staging # is staging the same shape?
|
|
32
|
+
```
|
|
30
33
|
|
|
31
34
|
## The idea
|
|
32
35
|
|
|
@@ -195,39 +198,45 @@ npx tablewalk --mcp --config ./tablewalk.json # speaks MCP on stdio
|
|
|
195
198
|
{ "mcpServers": { "tablewalk": { "command": "npx", "args": ["tablewalk", "--mcp", "--config", "./tablewalk.json"] } } }
|
|
196
199
|
```
|
|
197
200
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
`
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
`
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
`
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
`
|
|
201
|
+
Or point it at a database instead of a config file: `"args": ["tablewalk",
|
|
202
|
+
"--mcp", "--db", "postgres://localhost/app"]`. **Twenty-three tools** —
|
|
203
|
+
seventeen read-only, and six writes offered only when a connection is
|
|
204
|
+
`"writable": true`. Every answer carries a deep link the human beside the
|
|
205
|
+
agent can open in the UI, and says what it cost in rows and milliseconds.
|
|
206
|
+
|
|
207
|
+
**Reading**
|
|
208
|
+
|
|
209
|
+
| tool | what it answers |
|
|
210
|
+
|---|---|
|
|
211
|
+
| `schema_summary` | orientation in a few hundred tokens — hubs, event tables, the deepest reference chain |
|
|
212
|
+
| `tables` / `table` | the list; then a table's columns, DDL, and foreign keys in **both** directions |
|
|
213
|
+
| `find` | where a name lives, across every table and column |
|
|
214
|
+
| `query` | the query language; its errors carry suggestions and the table's real columns, so a mistake is fixed in one retry |
|
|
215
|
+
| `breakdown` | counts and sums grouped by something — `invoice count by month invoice_date` |
|
|
216
|
+
| `record` | one row, its human name, and everything that points at it |
|
|
217
|
+
| `sql` | read-only, for whatever the language cannot say |
|
|
218
|
+
| `explain` | the planner's account of a statement without running it — feed a `query` answer's `sql` back |
|
|
219
|
+
| `profile` | what a column actually holds: null share, distinct count, range, the values that repeat |
|
|
220
|
+
| `lint` | what the shape will cost: keyless tables, unindexed references, naive timestamps |
|
|
221
|
+
| `change_impact` | what breaks if you change a table or column — asked *before* the migration is written |
|
|
222
|
+
| `diff` | whether two connections are still the same shape |
|
|
223
|
+
| `order` | what has to exist before what, and the reverse for teardown |
|
|
224
|
+
| `fixture` | a real row and the graph it belongs to, ready to replay into a test database |
|
|
225
|
+
| `refresh` | re-read the catalog after a migration |
|
|
226
|
+
| `connections` | list them; the call to start with |
|
|
227
|
+
|
|
228
|
+
**Writing** — offered only when a connection is marked `"writable": true`; a
|
|
229
|
+
read-only server does not list tools it would refuse.
|
|
230
|
+
|
|
231
|
+
| tool | what it does |
|
|
232
|
+
|---|---|
|
|
233
|
+
| `insert` | one row; answers with what the database stored, defaults filled and key assigned |
|
|
234
|
+
| `update` | one row, by its primary key |
|
|
235
|
+
| `delete` | reports its impact first — every table pointing at the row, counts, `ON DELETE` rules — and removes nothing until called again with `confirm: true` |
|
|
236
|
+
| `insert_graph` / `delete_graph` | several related rows in one all-or-nothing transaction |
|
|
237
|
+
| `revert` | undo the last write, behind the same two-call `confirm` gate; a raw `sql` write is recorded but marked unrevertible, since it has no general inverse |
|
|
238
|
+
|
|
239
|
+
Write answers carry `rowsInserted` / `rowsWritten` / `rowsDeleted`.
|
|
231
240
|
|
|
232
241
|
A fixture is a graph, so `insert_graph` takes one: several related rows in one
|
|
233
242
|
transaction, where either all of them land or none does. A row that cannot
|
|
@@ -487,10 +496,22 @@ is your own machine; bind wider and it is whoever else can reach you.
|
|
|
487
496
|
|
|
488
497
|
## Docker, and a desktop app
|
|
489
498
|
|
|
490
|
-
|
|
491
|
-
|
|
499
|
+
**Container** — `ghcr.io/rbaljinder/tablewalk`. It reads its connections from a
|
|
500
|
+
mounted `tablewalk.json` and holds no configuration of its own:
|
|
501
|
+
|
|
502
|
+
```bash
|
|
503
|
+
docker run --rm -p 127.0.0.1:4111:4111 -v "$PWD:/config:ro" ghcr.io/rbaljinder/tablewalk
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
[DEPLOY.md](DEPLOY.md) covers the two things a container changes about the
|
|
492
507
|
security model — binding `0.0.0.0` inside while publishing only to the host's
|
|
493
|
-
loopback, and `--allowed-host` for reaching it by
|
|
508
|
+
loopback, and `--allowed-host` for reaching it by a name other than
|
|
509
|
+
`localhost`.
|
|
510
|
+
|
|
511
|
+
**Desktop** — a native app (macOS, Windows, Linux) is attached to each
|
|
512
|
+
[GitHub release](https://github.com/rbaljinder/tablewalk/releases). The builds
|
|
513
|
+
are currently unsigned, so the first open needs an "open anyway" past the OS
|
|
514
|
+
warning; `npx tablewalk` and the container need no such thing.
|
|
494
515
|
|
|
495
516
|
## Keyboard
|
|
496
517
|
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the demo database.
|
|
3
|
+
*
|
|
4
|
+
* Lives in `src/` (not `test/`) because it ships: `tablewalk --demo` builds
|
|
5
|
+
* one of these at runtime so a new install has something to walk without
|
|
6
|
+
* hunting for a database first. `test/make-demo-db.ts` re-exports it, so the
|
|
7
|
+
* suite and `npm run demo` keep the path they have always used.
|
|
8
|
+
*
|
|
9
|
+
* A demo that ships as a binary file is a demo nobody can review, so this
|
|
10
|
+
* writes one from source instead. The shape is chosen to exercise the parts
|
|
11
|
+
* of the tool that are easy to get wrong: a self-referencing key, a composite
|
|
12
|
+
* key, a view, a nullable reference, and one table that several others point
|
|
13
|
+
* at so the reverse-reference panel has something to say.
|
|
14
|
+
*/
|
|
15
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
16
|
+
import { mkdirSync, rmSync } from 'node:fs';
|
|
17
|
+
import { dirname } from 'node:path';
|
|
18
|
+
const SCHEMA = `
|
|
19
|
+
CREATE TABLE country (
|
|
20
|
+
code TEXT PRIMARY KEY,
|
|
21
|
+
name TEXT NOT NULL
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
CREATE TABLE customer (
|
|
25
|
+
id INTEGER PRIMARY KEY,
|
|
26
|
+
name TEXT NOT NULL,
|
|
27
|
+
email TEXT,
|
|
28
|
+
country_code TEXT REFERENCES country(code),
|
|
29
|
+
credit_limit REAL DEFAULT 0,
|
|
30
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
31
|
+
signed_up DATE
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
-- Self-referencing: an employee reports to an employee. The graph view has to
|
|
35
|
+
-- handle an edge whose two ends are the same table without looping forever.
|
|
36
|
+
CREATE TABLE employee (
|
|
37
|
+
id INTEGER PRIMARY KEY,
|
|
38
|
+
first_name TEXT NOT NULL,
|
|
39
|
+
last_name TEXT NOT NULL,
|
|
40
|
+
title TEXT,
|
|
41
|
+
manager_id INTEGER REFERENCES employee(id),
|
|
42
|
+
hired_on DATE
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
CREATE TABLE invoice (
|
|
46
|
+
id INTEGER PRIMARY KEY,
|
|
47
|
+
customer_id INTEGER NOT NULL REFERENCES customer(id),
|
|
48
|
+
sold_by INTEGER REFERENCES employee(id),
|
|
49
|
+
invoice_date DATE NOT NULL,
|
|
50
|
+
total REAL NOT NULL DEFAULT 0,
|
|
51
|
+
-- A CHECK, not a lookup table: this is how most schemas in the wild spell
|
|
52
|
+
-- an enum, and to anything reading the catalog it is an untyped TEXT column
|
|
53
|
+
-- unless the constraint is read too.
|
|
54
|
+
status TEXT NOT NULL DEFAULT 'issued'
|
|
55
|
+
CHECK (status IN ('draft','issued','paid','void'))
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
CREATE TABLE product (
|
|
59
|
+
sku TEXT PRIMARY KEY,
|
|
60
|
+
name TEXT NOT NULL,
|
|
61
|
+
unit_price REAL NOT NULL
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
-- Composite primary key AND a composite-capable child, so PRAGMA collapsing
|
|
65
|
+
-- and multi-column reverse lookups both get exercised.
|
|
66
|
+
CREATE TABLE invoice_line (
|
|
67
|
+
invoice_id INTEGER NOT NULL REFERENCES invoice(id),
|
|
68
|
+
line_no INTEGER NOT NULL,
|
|
69
|
+
sku TEXT NOT NULL REFERENCES product(sku),
|
|
70
|
+
quantity INTEGER NOT NULL DEFAULT 1,
|
|
71
|
+
PRIMARY KEY (invoice_id, line_no)
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
CREATE TABLE shipment (
|
|
75
|
+
id INTEGER PRIMARY KEY,
|
|
76
|
+
invoice_id INTEGER NOT NULL,
|
|
77
|
+
line_no INTEGER NOT NULL,
|
|
78
|
+
shipped_on DATE,
|
|
79
|
+
FOREIGN KEY (invoice_id, line_no) REFERENCES invoice_line(invoice_id, line_no)
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
CREATE VIEW customer_totals AS
|
|
83
|
+
SELECT c.id, c.name, COUNT(i.id) AS invoices, COALESCE(SUM(i.total), 0) AS lifetime
|
|
84
|
+
FROM customer c LEFT JOIN invoice i ON i.customer_id = c.id
|
|
85
|
+
GROUP BY c.id, c.name;
|
|
86
|
+
`;
|
|
87
|
+
const DATA = `
|
|
88
|
+
INSERT INTO country VALUES ('AU','Australia'), ('NZ','New Zealand'), ('GB','United Kingdom');
|
|
89
|
+
|
|
90
|
+
INSERT INTO customer (id, name, email, country_code, credit_limit, active, signed_up) VALUES
|
|
91
|
+
(1,'Harbour Freight','ops@harbourfreight.example','AU',5000,1,'2023-02-11'),
|
|
92
|
+
(2,'Kereru Coffee','hello@kereru.example','NZ',1200,1,'2024-06-30'),
|
|
93
|
+
(3,'Pennine Tools','accounts@pennine.example','GB',0,0,'2022-11-05'),
|
|
94
|
+
(4,'Dry Creek Vineyards',NULL,'AU',9000,1,'2025-01-19'),
|
|
95
|
+
(5,'Southerly Logistics','ap@southerly.example',NULL,2500,1,'2025-09-02');
|
|
96
|
+
|
|
97
|
+
INSERT INTO employee (id, first_name, last_name, title, manager_id, hired_on) VALUES
|
|
98
|
+
(1,'Ada','Fraser','Managing Director',NULL,'2019-03-01'),
|
|
99
|
+
(2,'Miles','Okonkwo','Sales Manager',1,'2020-07-15'),
|
|
100
|
+
(3,'Rina','Petrova','Account Executive',2,'2022-01-10'),
|
|
101
|
+
(4,'Tom','Whitfield','Account Executive',2,'2023-08-22'),
|
|
102
|
+
(5,'Joan','Alvarez','Support Lead',1,'2021-05-04');
|
|
103
|
+
|
|
104
|
+
INSERT INTO product VALUES
|
|
105
|
+
('SKU-100','Bench Vice 150mm',249.00),
|
|
106
|
+
('SKU-200','Torque Wrench',389.50),
|
|
107
|
+
('SKU-300','Impact Driver',179.95),
|
|
108
|
+
('SKU-400','Socket Set 42pc',129.00);
|
|
109
|
+
|
|
110
|
+
INSERT INTO invoice (id, customer_id, sold_by, invoice_date, total) VALUES
|
|
111
|
+
(1001,1,3,'2025-03-14',627.95),
|
|
112
|
+
(1002,1,3,'2025-07-02',389.50),
|
|
113
|
+
(1003,2,4,'2025-08-19',258.00),
|
|
114
|
+
(1004,4,3,'2026-01-08',1247.40),
|
|
115
|
+
(1005,4,4,'2026-05-21',179.95),
|
|
116
|
+
(1006,5,NULL,'2026-07-30',908.00),
|
|
117
|
+
-- Deliberately has no lines. An aggregate over no children must count 0
|
|
118
|
+
-- and sum to null, and without a childless parent in the fixture that
|
|
119
|
+
-- distinction cannot be tested at all.
|
|
120
|
+
(1007,3,4,'2026-08-02',0);
|
|
121
|
+
|
|
122
|
+
INSERT INTO invoice_line VALUES
|
|
123
|
+
(1001,1,'SKU-100',1),(1001,2,'SKU-300',2),
|
|
124
|
+
(1002,1,'SKU-200',1),
|
|
125
|
+
(1003,1,'SKU-400',2),
|
|
126
|
+
(1004,1,'SKU-200',2),(1004,2,'SKU-300',1),(1004,3,'SKU-400',2),
|
|
127
|
+
(1005,1,'SKU-300',1),
|
|
128
|
+
(1006,1,'SKU-100',2),(1006,2,'SKU-200',1);
|
|
129
|
+
|
|
130
|
+
INSERT INTO shipment (id, invoice_id, line_no, shipped_on) VALUES
|
|
131
|
+
(1,1001,1,'2025-03-16'),(2,1001,2,'2025-03-16'),
|
|
132
|
+
(3,1002,1,'2025-07-04'),(4,1004,1,'2026-01-10'),(5,1004,2,NULL);
|
|
133
|
+
`;
|
|
134
|
+
export function makeDemoDatabase(file) {
|
|
135
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
136
|
+
rmSync(file, { force: true });
|
|
137
|
+
const db = new DatabaseSync(file);
|
|
138
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
139
|
+
db.exec(SCHEMA);
|
|
140
|
+
db.exec(DATA);
|
|
141
|
+
db.close();
|
|
142
|
+
}
|
|
143
|
+
/* ---------- bulk seeding ----------
|
|
144
|
+
|
|
145
|
+
Opt-in, and deterministic. The small fixture above is what the test suite
|
|
146
|
+
asserts against — `customer` has exactly five rows and several tests say so
|
|
147
|
+
— so bulk data is layered on top by request rather than by default.
|
|
148
|
+
|
|
149
|
+
Determinism comes from a seeded generator rather than Math.random: the same
|
|
150
|
+
scale always produces the same database, so a screenshot, a row count or a
|
|
151
|
+
reported bug can be reproduced exactly. */
|
|
152
|
+
/** mulberry32 — small, fast, and good enough for plausible-looking data. */
|
|
153
|
+
function rng(seed) {
|
|
154
|
+
let a = seed >>> 0;
|
|
155
|
+
return () => {
|
|
156
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
157
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
158
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
159
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const FIRST = ['Ada', 'Miles', 'Rina', 'Tom', 'Joan', 'Priya', 'Hugo', 'Wren', 'Kofi', 'Ines', 'Bo', 'Yuki', 'Omar', 'Lena', 'Fitz', 'Nadia', 'Ravi', 'Signe', 'Cormac', 'Tess'];
|
|
163
|
+
const LAST = ['Fraser', 'Okonkwo', 'Petrova', 'Whitfield', 'Alvarez', 'Nair', 'Lindqvist', 'Baptiste', 'Mensah', 'Duarte', 'Kirby', 'Tanaka', 'Haddad', 'Vogel', 'Moss', 'Rahman', 'Iyer', 'Bergen', 'Doyle', 'Whelan'];
|
|
164
|
+
const NOUN = ['Freight', 'Coffee', 'Tools', 'Vineyards', 'Logistics', 'Foundry', 'Provisions', 'Marine', 'Timber', 'Electrics', 'Joinery', 'Bakery', 'Optics', 'Fabrication', 'Roasters', 'Surveying', 'Plumbing', 'Glassworks'];
|
|
165
|
+
const PLACE = ['Harbour', 'Pennine', 'Dry Creek', 'Southerly', 'Kereru', 'Otway', 'Blackwood', 'Fitzroy', 'Kaimai', 'Torrens', 'Mallee', 'Waikato', 'Bendigo', 'Arthurs', 'Nullarbor', 'Kimberley'];
|
|
166
|
+
const TITLES = ['Account Executive', 'Sales Manager', 'Support Lead', 'Field Technician', 'Operations Analyst', 'Warehouse Lead', 'Credit Controller', 'Regional Manager'];
|
|
167
|
+
/* The countries a seeded demo spreads its customers over, beyond the three the
|
|
168
|
+
fixture declares. Real places with real names, because a column of `CC-07`
|
|
169
|
+
teaches nothing about what a grouped answer looks like. */
|
|
170
|
+
const MORE_COUNTRIES = [
|
|
171
|
+
['IE', 'Ireland'], ['CA', 'Canada'], ['US', 'United States'], ['DE', 'Germany'],
|
|
172
|
+
['FR', 'France'], ['ES', 'Spain'], ['IT', 'Italy'], ['NL', 'Netherlands'],
|
|
173
|
+
['SE', 'Sweden'], ['NO', 'Norway'], ['JP', 'Japan'], ['SG', 'Singapore'],
|
|
174
|
+
['ZA', 'South Africa'], ['BR', 'Brazil'], ['IN', 'India'],
|
|
175
|
+
];
|
|
176
|
+
const GEAR = ['Bench Vice', 'Torque Wrench', 'Impact Driver', 'Socket Set', 'Angle Grinder', 'Circular Saw', 'Air Compressor', 'Bandsaw', 'Drill Press', 'Welder', 'Lathe Chuck', 'Caliper Set'];
|
|
177
|
+
/* A deliberately awkward table, created only when seeding.
|
|
178
|
+
|
|
179
|
+
Everything here is something that is easy to get wrong and easy to miss:
|
|
180
|
+
twenty-seven columns so the grid has to scroll and the column chooser has
|
|
181
|
+
to be usable; *two* self-references, because code that handles one often
|
|
182
|
+
assumes there is only one; eight outgoing keys and two incoming, so the
|
|
183
|
+
neighbourhood diagram has to lay out both directions at once; and three
|
|
184
|
+
separate references to the same table, which a naive reverse-reference
|
|
185
|
+
count collapses into one.
|
|
186
|
+
|
|
187
|
+
It is kept out of the base fixture on purpose — several tests assert the
|
|
188
|
+
exact table list, and that assertion is worth keeping honest. */
|
|
189
|
+
const WIDE_SCHEMA = `
|
|
190
|
+
CREATE TABLE work_order (
|
|
191
|
+
id INTEGER PRIMARY KEY,
|
|
192
|
+
reference TEXT NOT NULL,
|
|
193
|
+
parent_id INTEGER REFERENCES work_order(id),
|
|
194
|
+
supersedes_id INTEGER REFERENCES work_order(id),
|
|
195
|
+
customer_id INTEGER NOT NULL REFERENCES customer(id),
|
|
196
|
+
raised_by INTEGER REFERENCES employee(id),
|
|
197
|
+
assigned_to INTEGER REFERENCES employee(id),
|
|
198
|
+
approved_by INTEGER REFERENCES employee(id),
|
|
199
|
+
country_code TEXT REFERENCES country(code),
|
|
200
|
+
product_sku TEXT REFERENCES product(sku),
|
|
201
|
+
invoice_id INTEGER REFERENCES invoice(id),
|
|
202
|
+
-- A CHECK rather than a bare TEXT column, because that is how most SQLite
|
|
203
|
+
-- and Postgres schemas actually spell an enum -- and it is the vocabulary
|
|
204
|
+
-- the table tool reports and the insert form offers.
|
|
205
|
+
status TEXT NOT NULL DEFAULT 'open'
|
|
206
|
+
CHECK (status IN ('open','scheduled','in_progress','awaiting_parts','on_hold','completed','cancelled')),
|
|
207
|
+
priority INTEGER NOT NULL DEFAULT 3,
|
|
208
|
+
is_urgent INTEGER NOT NULL DEFAULT 0,
|
|
209
|
+
is_billable INTEGER NOT NULL DEFAULT 1,
|
|
210
|
+
opened_on DATE NOT NULL,
|
|
211
|
+
due_on DATE,
|
|
212
|
+
closed_on DATE,
|
|
213
|
+
estimated_hours REAL,
|
|
214
|
+
actual_hours REAL,
|
|
215
|
+
labour_cost REAL NOT NULL DEFAULT 0,
|
|
216
|
+
parts_cost REAL NOT NULL DEFAULT 0,
|
|
217
|
+
total_cost REAL NOT NULL DEFAULT 0,
|
|
218
|
+
site_address TEXT,
|
|
219
|
+
external_ref TEXT,
|
|
220
|
+
notes TEXT,
|
|
221
|
+
sys_updated_at TIMESTAMP NOT NULL
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
CREATE INDEX work_order_customer_idx ON work_order (customer_id, status);
|
|
225
|
+
CREATE INDEX work_order_opened_idx ON work_order (opened_on);
|
|
226
|
+
|
|
227
|
+
CREATE TABLE work_order_note (
|
|
228
|
+
id INTEGER PRIMARY KEY,
|
|
229
|
+
work_order_id INTEGER NOT NULL REFERENCES work_order(id),
|
|
230
|
+
author_id INTEGER REFERENCES employee(id),
|
|
231
|
+
written_on DATE NOT NULL,
|
|
232
|
+
body TEXT NOT NULL
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
CREATE TABLE work_order_part (
|
|
236
|
+
work_order_id INTEGER NOT NULL REFERENCES work_order(id),
|
|
237
|
+
line_no INTEGER NOT NULL,
|
|
238
|
+
sku TEXT NOT NULL REFERENCES product(sku),
|
|
239
|
+
quantity INTEGER NOT NULL DEFAULT 1,
|
|
240
|
+
PRIMARY KEY (work_order_id, line_no)
|
|
241
|
+
);
|
|
242
|
+
`;
|
|
243
|
+
const STATUSES = ['open', 'scheduled', 'in_progress', 'awaiting_parts', 'on_hold', 'completed', 'cancelled'];
|
|
244
|
+
const STREETS = ['Wharf Rd', 'Kembla St', 'Beach Pde', 'Rundle Ml', 'Flinders Ln', 'Cuba St', 'Vulcan Ln'];
|
|
245
|
+
const NOTE_TEXT = [
|
|
246
|
+
'Attended site, isolated the unit and confirmed the fault reported by the customer.',
|
|
247
|
+
'Parts ordered from the supplier. Lead time quoted as five working days.',
|
|
248
|
+
'Customer requested the visit be rescheduled to the following week.',
|
|
249
|
+
'Work completed and signed off on site. No further action required.',
|
|
250
|
+
];
|
|
251
|
+
export function seedDemoDatabase(file, { scale = 1000, seed = 20260822 } = {}) {
|
|
252
|
+
const db = new DatabaseSync(file);
|
|
253
|
+
const random = rng(seed);
|
|
254
|
+
const pick = (list) => list[Math.floor(random() * list.length)];
|
|
255
|
+
const between = (lo, hi) => lo + Math.floor(random() * (hi - lo + 1));
|
|
256
|
+
/* More countries, but only when seeding.
|
|
257
|
+
|
|
258
|
+
The small fixture has three, which is right for it — several tests count
|
|
259
|
+
that table. It is wrong for a demo: "which countries have the most
|
|
260
|
+
customers" is one of the questions `breakdown` exists to answer and one
|
|
261
|
+
of the examples in the README, and three groups does not show what a
|
|
262
|
+
breakdown is for. Inserted here rather than in the fixture so the tests
|
|
263
|
+
that assert three keep asserting three.
|
|
264
|
+
|
|
265
|
+
`INSERT OR IGNORE`, so seeding a database twice is not an error. */
|
|
266
|
+
const insertCountry = db.prepare('INSERT OR IGNORE INTO country (code, name) VALUES (?, ?)');
|
|
267
|
+
for (const [code, name] of MORE_COUNTRIES)
|
|
268
|
+
insertCountry.run(code, name);
|
|
269
|
+
const countries = db.prepare('SELECT code FROM country').all()
|
|
270
|
+
.map((r) => r.code);
|
|
271
|
+
/* One transaction for the whole load. Without it SQLite commits per
|
|
272
|
+
statement and a hundred thousand rows takes minutes instead of seconds. */
|
|
273
|
+
db.exec('BEGIN');
|
|
274
|
+
try {
|
|
275
|
+
const employees = Math.max(8, Math.round(scale / 12));
|
|
276
|
+
const insertEmployee = db.prepare('INSERT INTO employee (first_name, last_name, title, manager_id, hired_on) VALUES (?, ?, ?, ?, ?)');
|
|
277
|
+
for (let i = 0; i < employees; i++) {
|
|
278
|
+
// Managers are drawn from employees already inserted, so the
|
|
279
|
+
// self-reference always points backwards and never dangles.
|
|
280
|
+
const managerId = i < 3 ? null : between(1, 3 + Math.floor(i / 4));
|
|
281
|
+
insertEmployee.run(pick(FIRST), pick(LAST), pick(TITLES), managerId, `${between(2018, 2026)}-${String(between(1, 12)).padStart(2, '0')}-${String(between(1, 28)).padStart(2, '0')}`);
|
|
282
|
+
}
|
|
283
|
+
const employeeCount = Number(db.prepare('SELECT COUNT(*) AS n FROM employee').get().n);
|
|
284
|
+
const insertProduct = db.prepare('INSERT INTO product (sku, name, unit_price) VALUES (?, ?, ?)');
|
|
285
|
+
const products = Math.max(20, Math.round(scale / 4));
|
|
286
|
+
const skus = [];
|
|
287
|
+
for (let i = 0; i < products; i++) {
|
|
288
|
+
const sku = `SKU-${String(1000 + i)}`;
|
|
289
|
+
skus.push(sku);
|
|
290
|
+
insertProduct.run(sku, `${pick(GEAR)} ${between(50, 900)}mm`, Math.round(random() * 90000) / 100);
|
|
291
|
+
}
|
|
292
|
+
const insertCustomer = db.prepare('INSERT INTO customer (name, email, country_code, credit_limit, active, signed_up) VALUES (?, ?, ?, ?, ?, ?)');
|
|
293
|
+
for (let i = 0; i < scale; i++) {
|
|
294
|
+
const name = `${pick(PLACE)} ${pick(NOUN)}`;
|
|
295
|
+
// Roughly one in nine has no email and one in eleven no country, so the
|
|
296
|
+
// null rendering and `is empty` filters have something to find.
|
|
297
|
+
const email = random() < 0.11 ? null : `${name.toLowerCase().replace(/[^a-z]+/g, '.')}@example.com`;
|
|
298
|
+
insertCustomer.run(name, email, random() < 0.09 ? null : pick(countries), between(0, 40) * 250, random() < 0.18 ? 0 : 1, `${between(2021, 2026)}-${String(between(1, 12)).padStart(2, '0')}-${String(between(1, 28)).padStart(2, '0')}`);
|
|
299
|
+
}
|
|
300
|
+
const customerCount = Number(db.prepare('SELECT COUNT(*) AS n FROM customer').get().n);
|
|
301
|
+
const insertInvoice = db.prepare('INSERT INTO invoice (customer_id, sold_by, invoice_date, total, status) VALUES (?, ?, ?, ?, ?)');
|
|
302
|
+
/* A spread of statuses, weighted the way a ledger actually looks: mostly
|
|
303
|
+
settled, a working set outstanding, a few never finished.
|
|
304
|
+
|
|
305
|
+
Without this every seeded invoice took the column default and the demo
|
|
306
|
+
answered `invoice count by status` — the first example in the README —
|
|
307
|
+
with a single group of eighteen thousand. The CHECK constraint on the
|
|
308
|
+
column lists four values; a demo that only ever shows one of them
|
|
309
|
+
teaches nothing about the column, the constraint, or the breakdown. */
|
|
310
|
+
const STATUSES = [['paid', 0.62], ['issued', 0.24], ['draft', 0.11], ['void', 0.03]];
|
|
311
|
+
const statusFor = () => {
|
|
312
|
+
let r = random();
|
|
313
|
+
for (const [name, share] of STATUSES) {
|
|
314
|
+
if (r < share)
|
|
315
|
+
return name;
|
|
316
|
+
r -= share;
|
|
317
|
+
}
|
|
318
|
+
return 'issued';
|
|
319
|
+
};
|
|
320
|
+
const insertLine = db.prepare('INSERT INTO invoice_line (invoice_id, line_no, sku, quantity) VALUES (?, ?, ?, ?)');
|
|
321
|
+
const insertShipment = db.prepare('INSERT INTO shipment (invoice_id, line_no, shipped_on) VALUES (?, ?, ?)');
|
|
322
|
+
const invoices = scale * 6;
|
|
323
|
+
let nextInvoiceId = Number(db.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS n FROM invoice').get().n);
|
|
324
|
+
for (let i = 0; i < invoices; i++) {
|
|
325
|
+
const id = nextInvoiceId++;
|
|
326
|
+
const date = `${between(2023, 2026)}-${String(between(1, 12)).padStart(2, '0')}-${String(between(1, 28)).padStart(2, '0')}`;
|
|
327
|
+
let total = 0;
|
|
328
|
+
const lines = between(1, 6);
|
|
329
|
+
insertInvoice.run(between(1, customerCount), random() < 0.08 ? null : between(1, employeeCount), date, 0, statusFor());
|
|
330
|
+
for (let line = 1; line <= lines; line++) {
|
|
331
|
+
const quantity = between(1, 12);
|
|
332
|
+
insertLine.run(id, line, pick(skus), quantity);
|
|
333
|
+
total += quantity * between(50, 900);
|
|
334
|
+
// Most lines ship; some are pending, and a few shipped rows carry a
|
|
335
|
+
// null date so the date column has nulls to render.
|
|
336
|
+
if (random() < 0.72) {
|
|
337
|
+
insertShipment.run(id, line, random() < 0.1 ? null : date);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
db.prepare('UPDATE invoice SET total = ? WHERE id = ?').run(total, id);
|
|
341
|
+
}
|
|
342
|
+
db.exec('COMMIT');
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
db.exec('ROLLBACK');
|
|
346
|
+
db.close();
|
|
347
|
+
throw err;
|
|
348
|
+
}
|
|
349
|
+
seedWorkOrders(db, random, pick, between, scale);
|
|
350
|
+
/* ANALYZE so the planner has statistics, and so anything reading row
|
|
351
|
+
estimates sees real numbers rather than zeros. */
|
|
352
|
+
db.exec('ANALYZE');
|
|
353
|
+
db.close();
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* The wide table, its notes and its parts.
|
|
357
|
+
*
|
|
358
|
+
* Split out because it is a different shape of data from the invoice chain
|
|
359
|
+
* above, and because the interesting part is the reference pattern rather
|
|
360
|
+
* than the volume.
|
|
361
|
+
*/
|
|
362
|
+
function seedWorkOrders(db, random, pick, between, scale) {
|
|
363
|
+
db.exec(WIDE_SCHEMA);
|
|
364
|
+
const customers = Number(db.prepare('SELECT COUNT(*) AS n FROM customer').get().n);
|
|
365
|
+
const employees = Number(db.prepare('SELECT COUNT(*) AS n FROM employee').get().n);
|
|
366
|
+
const invoices = Number(db.prepare('SELECT MAX(id) AS n FROM invoice').get().n);
|
|
367
|
+
const skus = db.prepare('SELECT sku FROM product').all().map((r) => r.sku);
|
|
368
|
+
const countries = db.prepare('SELECT code FROM country').all().map((r) => r.code);
|
|
369
|
+
const orders = Math.max(50, scale * 3);
|
|
370
|
+
const date = (fromYear) => `${between(fromYear, 2026)}-${String(between(1, 12)).padStart(2, '0')}-${String(between(1, 28)).padStart(2, '0')}`;
|
|
371
|
+
db.exec('BEGIN');
|
|
372
|
+
const insert = db.prepare(`
|
|
373
|
+
INSERT INTO work_order (
|
|
374
|
+
reference, parent_id, supersedes_id, customer_id, raised_by, assigned_to, approved_by,
|
|
375
|
+
country_code, product_sku, invoice_id, status, priority, is_urgent, is_billable,
|
|
376
|
+
opened_on, due_on, closed_on, estimated_hours, actual_hours,
|
|
377
|
+
labour_cost, parts_cost, total_cost, site_address, external_ref, notes, sys_updated_at
|
|
378
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`);
|
|
379
|
+
for (let i = 1; i <= orders; i++) {
|
|
380
|
+
const status = pick(STATUSES);
|
|
381
|
+
const closed = status === 'completed' || status === 'cancelled';
|
|
382
|
+
const estimated = Math.round(random() * 400) / 10;
|
|
383
|
+
const actual = closed ? Math.round(estimated * (0.6 + random()) * 10) / 10 : null;
|
|
384
|
+
const labour = Math.round((actual ?? 0) * between(80, 190) * 100) / 100;
|
|
385
|
+
const parts = Math.round(random() * 250000) / 100;
|
|
386
|
+
/* Parents and predecessors point only at rows already inserted, so both
|
|
387
|
+
self-references stay acyclic — a cycle here would be legal SQL and a
|
|
388
|
+
trap for anything that walks the graph. */
|
|
389
|
+
const parent = i > 10 && random() < 0.22 ? between(1, i - 1) : null;
|
|
390
|
+
const supersedes = i > 20 && random() < 0.1 ? between(1, i - 1) : null;
|
|
391
|
+
insert.run(`WO-${String(100000 + i)}`, parent, supersedes, between(1, customers), random() < 0.05 ? null : between(1, employees), random() < 0.15 ? null : between(1, employees), closed && random() < 0.8 ? between(1, employees) : null, random() < 0.12 ? null : pick(countries), random() < 0.3 ? null : pick(skus), random() < 0.55 && invoices ? between(1001, invoices) : null, status, between(1, 5), random() < 0.18 ? 1 : 0, random() < 0.85 ? 1 : 0, date(2023), random() < 0.9 ? date(2024) : null, closed ? date(2024) : null, estimated, actual, labour, parts, Math.round((labour + parts) * 100) / 100, `${between(1, 400)} ${pick(STREETS)}`, random() < 0.4 ? `EXT-${between(10000, 99999)}` : null, random() < 0.5 ? pick(NOTE_TEXT) : null, `${date(2025)}T${String(between(0, 23)).padStart(2, '0')}:${String(between(0, 59)).padStart(2, '0')}:00`);
|
|
392
|
+
}
|
|
393
|
+
const insertNote = db.prepare('INSERT INTO work_order_note (work_order_id, author_id, written_on, body) VALUES (?,?,?,?)');
|
|
394
|
+
const insertPart = db.prepare('INSERT INTO work_order_part (work_order_id, line_no, sku, quantity) VALUES (?,?,?,?)');
|
|
395
|
+
for (let id = 1; id <= orders; id++) {
|
|
396
|
+
for (let n = 0; n < between(0, 4); n++) {
|
|
397
|
+
insertNote.run(id, random() < 0.1 ? null : between(1, employees), date(2024), pick(NOTE_TEXT));
|
|
398
|
+
}
|
|
399
|
+
const parts = between(0, 5);
|
|
400
|
+
for (let line = 1; line <= parts; line++) {
|
|
401
|
+
insertPart.run(id, line, pick(skus), between(1, 20));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
db.exec('COMMIT');
|
|
405
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
12
12
|
import { extname, join, normalize } from 'node:path';
|
|
13
13
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
14
14
|
import { realpathSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
15
16
|
import { ANSI_STYLE, ESTIMATE_ABOVE, MYSQL_STYLE, POSTGRES_STYLE, clampLimit, clampOffset, } from '../adapters/adapter.js';
|
|
16
17
|
import { Registry, loadConfig, redact, configLocations, saveConfig } from './connections.js';
|
|
17
18
|
import { Refusal } from '../adapters/adapter.js';
|
|
@@ -42,14 +43,18 @@ function parseArgs(argv) {
|
|
|
42
43
|
const opts = {
|
|
43
44
|
target: '', port: 4111, host: '127.0.0.1', schemas: [], open: false, noConfig: false,
|
|
44
45
|
allowedHosts: [], mcp: false, outputSchema: false, tools: '',
|
|
45
|
-
help: false, version: false,
|
|
46
|
+
help: false, version: false, demo: false, portGiven: false,
|
|
46
47
|
};
|
|
47
48
|
for (let i = 0; i < argv.length; i++) {
|
|
48
49
|
const arg = argv[i];
|
|
49
50
|
if (arg === '--db' || arg === '-d')
|
|
50
51
|
opts.target = argv[++i] ?? '';
|
|
51
|
-
else if (arg === '--port' || arg === '-p')
|
|
52
|
+
else if (arg === '--port' || arg === '-p') {
|
|
52
53
|
opts.port = Number(argv[++i] ?? 4111);
|
|
54
|
+
opts.portGiven = true;
|
|
55
|
+
}
|
|
56
|
+
else if (arg === '--demo')
|
|
57
|
+
opts.demo = true;
|
|
53
58
|
else if (arg === '--host')
|
|
54
59
|
opts.host = argv[++i] ?? '127.0.0.1';
|
|
55
60
|
else if (arg === '--allowed-host')
|
|
@@ -128,6 +133,8 @@ Options:
|
|
|
128
133
|
--schema <name> Postgres only; repeatable. Defaults to all user schemas.
|
|
129
134
|
--config <path> Read connections from this file
|
|
130
135
|
--no-config Ignore any config file
|
|
136
|
+
--demo Build a throwaway sample database and open it. Nothing of
|
|
137
|
+
yours is touched; it lives in the temp directory.
|
|
131
138
|
--open Open a browser once the server is up
|
|
132
139
|
--mcp Speak MCP on stdio for coding agents, instead of HTTP
|
|
133
140
|
--tools <name> Offer one job's worth of tools instead of all of them:
|
|
@@ -258,9 +265,33 @@ async function main() {
|
|
|
258
265
|
process.exit(1);
|
|
259
266
|
}
|
|
260
267
|
}
|
|
268
|
+
/* `--demo`: a throwaway database, built on the fly, so a fresh install has
|
|
269
|
+
something to walk without a database of its own. It is written into the
|
|
270
|
+
OS temp directory, small and deterministic, and rebuilt each run (a few
|
|
271
|
+
tens of milliseconds) so it never drifts from the schema this version
|
|
272
|
+
knows. Naming a real target alongside it is a contradiction, so the target
|
|
273
|
+
wins and the flag is ignored with a word rather than silently. */
|
|
274
|
+
if (opts.demo) {
|
|
275
|
+
if (opts.target) {
|
|
276
|
+
console.log(`tablewalk: ignoring --demo because a database was named (${redact(opts.target)}).`);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
const { makeDemoDatabase, seedDemoDatabase } = await import('./demo.js');
|
|
280
|
+
const file = join(tmpdir(), 'tablewalk-demo.db');
|
|
281
|
+
makeDemoDatabase(file);
|
|
282
|
+
seedDemoDatabase(file, { scale: 150 });
|
|
283
|
+
opts.target = file;
|
|
284
|
+
/* Serving a demo without opening it would be handing someone a URL and
|
|
285
|
+
hoping they paste it. The one mode where that is wrong is a
|
|
286
|
+
print-and-exit or MCP run, which never opens anything. */
|
|
287
|
+
if (!opts.mcp && !opts.export && !opts.lint && !opts.diff)
|
|
288
|
+
opts.open = true;
|
|
289
|
+
console.log('tablewalk: built a demo database — 11 tables to walk. Nothing of yours is touched.');
|
|
290
|
+
}
|
|
291
|
+
}
|
|
261
292
|
if (opts.target) {
|
|
262
293
|
const added = registry.add({
|
|
263
|
-
name: redact(opts.target),
|
|
294
|
+
name: opts.demo ? 'demo' : redact(opts.target),
|
|
264
295
|
url: opts.target,
|
|
265
296
|
schemas: opts.schemas,
|
|
266
297
|
source: 'argument',
|
|
@@ -414,7 +445,31 @@ async function main() {
|
|
|
414
445
|
layouts: configLayouts,
|
|
415
446
|
bound: { host: opts.host, port: opts.port, allowedHosts: opts.allowedHosts },
|
|
416
447
|
});
|
|
417
|
-
|
|
448
|
+
/* A busy port is a normal thing, not a stack trace.
|
|
449
|
+
|
|
450
|
+
`server.listen` with no error handler throws EADDRINUSE and takes the
|
|
451
|
+
process down with a Node-internal trace — which is what happened when a
|
|
452
|
+
second `tablewalk` met the first on 4111. So: if the port was the default
|
|
453
|
+
and something is already there, roll on to the next one and say so; if it
|
|
454
|
+
was named explicitly with --port, respect that and fail with one clear
|
|
455
|
+
line rather than a guess the caller did not ask for. */
|
|
456
|
+
const FIRST_PORT = opts.port;
|
|
457
|
+
server.on('error', (err) => {
|
|
458
|
+
if (err.code === 'EADDRINUSE') {
|
|
459
|
+
if (!opts.portGiven && opts.port - FIRST_PORT < 20) {
|
|
460
|
+
console.log(`tablewalk: port ${opts.port} is in use, trying ${opts.port + 1}…`);
|
|
461
|
+
opts.port += 1;
|
|
462
|
+
setImmediate(() => server.listen(opts.port, opts.host));
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
console.error(`tablewalk: port ${opts.port} is already in use.`
|
|
466
|
+
+ (opts.portGiven ? ' Pick another with --port <n>.' : ' Give it a free one with --port <n>.'));
|
|
467
|
+
process.exit(1);
|
|
468
|
+
}
|
|
469
|
+
console.error(`tablewalk: ${err.message}`);
|
|
470
|
+
process.exit(1);
|
|
471
|
+
});
|
|
472
|
+
server.on('listening', () => {
|
|
418
473
|
const shown = opts.host === '127.0.0.1' ? 'localhost' : opts.host;
|
|
419
474
|
console.log(`Listening on http://${shown}:${opts.port}`);
|
|
420
475
|
if (nothingToOpen) {
|
|
@@ -438,6 +493,7 @@ async function main() {
|
|
|
438
493
|
if (opts.open)
|
|
439
494
|
void openBrowser(`http://${shown}:${opts.port}`);
|
|
440
495
|
});
|
|
496
|
+
server.listen(opts.port, opts.host);
|
|
441
497
|
const shutdown = async () => {
|
|
442
498
|
server.close();
|
|
443
499
|
await registry.closeAll();
|
|
@@ -2114,7 +2170,22 @@ async function readJson(req) {
|
|
|
2114
2170
|
async function openBrowser(url) {
|
|
2115
2171
|
const { spawn } = await import('node:child_process');
|
|
2116
2172
|
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
2117
|
-
|
|
2173
|
+
try {
|
|
2174
|
+
const child = spawn(cmd, [url], { detached: true, stdio: 'ignore' });
|
|
2175
|
+
/* A headless box — a container, a server, an SSH session — has no
|
|
2176
|
+
`xdg-open`, and `spawn` reports that as an `error` event, not a throw.
|
|
2177
|
+
With no handler Node re-raises it as an uncaught exception and takes the
|
|
2178
|
+
whole process down — so `--open`, and `--demo` which implies it, crashed
|
|
2179
|
+
exactly where it was most useful: the container serving the demo. A
|
|
2180
|
+
missing opener is not fatal. The server is already listening and the URL
|
|
2181
|
+
is already printed; opening it is a convenience, not a requirement. */
|
|
2182
|
+
child.once('error', () => { });
|
|
2183
|
+
child.unref();
|
|
2184
|
+
}
|
|
2185
|
+
catch {
|
|
2186
|
+
/* Some platforms throw synchronously instead. Same non-answer: the server
|
|
2187
|
+
stays up, the URL is on screen, the reader opens it themselves. */
|
|
2188
|
+
}
|
|
2118
2189
|
}
|
|
2119
2190
|
/* Started only when run, not when imported.
|
|
2120
2191
|
|