haltija 1.5.4 → 1.6.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/CHANGELOG.md +109 -0
- package/apps/desktop/main.js +33 -2
- package/apps/desktop/package.json +1 -1
- package/apps/desktop/resources/component.js +83 -19
- package/bin/cli-subcommand.mjs +14 -2
- package/bin/hj.mjs +242 -0
- package/bin/tosijs-dev.mjs +12 -1
- package/bin/version.mjs +1 -1
- package/dist/api-handlers.d.ts +2 -0
- package/dist/component.d.ts +22 -1
- package/dist/component.esm.js +83 -19
- package/dist/component.js +83 -19
- package/dist/hj.js +189 -4
- package/dist/index.js +190 -48
- package/dist/server.js +190 -48
- package/dist/types.d.ts +6 -0
- package/dist/version.d.ts +1 -1
- package/docs/CI-INTEGRATION.md +32 -15
- package/llms.txt +8 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,114 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.6.1
|
|
4
|
+
|
|
5
|
+
Makes haltija's detection reachable by automation ([#8](https://github.com/tonioloewald/haltija/issues/8),
|
|
6
|
+
[#11](https://github.com/tonioloewald/haltija/issues/11)).
|
|
7
|
+
|
|
8
|
+
haltija already *detects* the situations that wreck a test lane — the wrong project's browser, a
|
|
9
|
+
hidden tab returning stale results, a server with nothing to drive. It only ever **warned on
|
|
10
|
+
stderr**, so scripts consumed plausible-but-wrong results and failed much later, pointing at the
|
|
11
|
+
caller's own code.
|
|
12
|
+
|
|
13
|
+
### New: `ready` — "server is up" is not "server is drivable"
|
|
14
|
+
|
|
15
|
+
`/status` and `/windows` now return **`ready`**: true when at least one top-level tab is connected.
|
|
16
|
+
A server running with *zero* windows answers `/status` 200, so an adopter's reuse probe skipped
|
|
17
|
+
starting its own browser and then had nothing to navigate. Gate a lane on `ready`, not on the 200.
|
|
18
|
+
|
|
19
|
+
### New: `hj doctor`
|
|
20
|
+
|
|
21
|
+
One-command preflight that **exits non-zero**: server reachable → a tab is connected → the target
|
|
22
|
+
isn't ambiguous → tabs aren't all hidden → versions aligned. `--json` for machine-readable output.
|
|
23
|
+
Use it as the wait-loop condition in CI.
|
|
24
|
+
|
|
25
|
+
### New: `hj --strict` / `HALTIJA_STRICT=1`
|
|
26
|
+
|
|
27
|
+
Turns the advisory warnings into **non-zero exits**, and refuses to print a suspect result to
|
|
28
|
+
stdout at all — a script must not consume a value that may be wrong. A warning is the right default
|
|
29
|
+
for a human at a prompt and the wrong one for a lane.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- **Warning de-duplication silently defeated strict mode.** The server withheld a repeated warning
|
|
34
|
+
entirely, so the first command in a lane failed and every later one within the cooldown passed.
|
|
35
|
+
The server now always reports the condition and marks repeats (`warningRepeated`); de-dup is a
|
|
36
|
+
presentation concern, so `hj` stays quiet on a repeat while `--strict` fails on any warning.
|
|
37
|
+
|
|
38
|
+
## 1.6.0
|
|
39
|
+
|
|
40
|
+
Consolidating release: rolls up everything from 1.5.2–1.5.7 (the last npm-published version was
|
|
41
|
+
1.5.4) and adds the fixes from a full nine-lens pre-release review of the whole span.
|
|
42
|
+
|
|
43
|
+
**Highlights since 1.5.4** (see the per-version entries below for detail):
|
|
44
|
+
|
|
45
|
+
- **`--private --app` teardown** (1.5.5, #7) — private Electron no longer orphans: skips the
|
|
46
|
+
single-instance lock, self-terminates when its spawner dies, `hj shutdown` tears it all down.
|
|
47
|
+
- **Console capture** (1.5.6) — uncaught exceptions, unhandled rejections, and `console.error(Error)`
|
|
48
|
+
messages/stacks are now captured (all were previously dropped).
|
|
49
|
+
- **Server picker** (1.5.7) — `hj servers` lists every live server; the desktop app is reachable as
|
|
50
|
+
`hj --name desktop`.
|
|
51
|
+
|
|
52
|
+
**Review-driven fixes in this release:**
|
|
53
|
+
|
|
54
|
+
- **`hj shutdown` no longer orphans a running desktop app.** It refuses (with a clear message —
|
|
55
|
+
quit the app from its window) instead of killing just the embedded server and leaving the window
|
|
56
|
+
on screen with dead tabs. `--private` instances still tear down fully.
|
|
57
|
+
- **`--name desktop` is reserved** — a normal server can no longer claim it and clobber the desktop
|
|
58
|
+
app's registry entry.
|
|
59
|
+
- The uncaught-error listeners are removed on widget disconnect (no leak across re-injection).
|
|
60
|
+
- `hj ls` is now an alias for `hj servers` (was `hj tree`); the old alias is gone.
|
|
61
|
+
- Added tabs-focus regression tests and `hj servers` / `hj --name desktop` / `hj shutdown` to the
|
|
62
|
+
generated docs and the agent skill.
|
|
63
|
+
|
|
64
|
+
## 1.5.7
|
|
65
|
+
|
|
66
|
+
Pick between coexisting servers.
|
|
67
|
+
|
|
68
|
+
When a normal haltija server and the Electron desktop app both ran, there was no way to see them or
|
|
69
|
+
target the app — it was unregistered, invisible to `hj where` and the registry.
|
|
70
|
+
|
|
71
|
+
- **`hj servers`** (alias `hj ls`) lists every live server: registry entries, the probed defaults
|
|
72
|
+
8700/8701, and this shell's target — with port, name, version, tab count, whether it's the
|
|
73
|
+
desktop app, and a `▸` on the one `hj` would drive.
|
|
74
|
+
- **The desktop app is now reachable as `hj --name desktop`.** Its public server registers under
|
|
75
|
+
the reserved name `desktop`, but *cwd-less* — so it's nameable without ever hijacking cwd routing.
|
|
76
|
+
(The internal chrome server stays unregistered; a `--private` app registers nothing.)
|
|
77
|
+
|
|
78
|
+
## 1.5.6
|
|
79
|
+
|
|
80
|
+
`hj console` now captures the errors that actually matter.
|
|
81
|
+
|
|
82
|
+
It intercepted `console.*` calls, but the most important errors slipped through: an **uncaught
|
|
83
|
+
exception** (`throw`) and an **unhandled promise rejection** were never captured (no `window`
|
|
84
|
+
error / `unhandledrejection` listener), and `console.error(new Error(...))` recorded `{}` because
|
|
85
|
+
`JSON.stringify` drops an Error's message and stack. A page could be throwing on every action while
|
|
86
|
+
`hj console` showed it clean.
|
|
87
|
+
|
|
88
|
+
- Uncaught exceptions and unhandled rejections are now captured as `error` entries, with the real
|
|
89
|
+
stack. The `error` listener uses the capture phase, so failed resource loads (img/script/…) are
|
|
90
|
+
seen too.
|
|
91
|
+
- Error objects serialize to `{name, message, stack}` (at any depth), so the message survives.
|
|
92
|
+
- Note: capture begins when the widget is injected, so errors thrown *before* injection are only
|
|
93
|
+
caught by the desktop app (which injects at document-start).
|
|
94
|
+
|
|
95
|
+
## 1.5.5
|
|
96
|
+
|
|
97
|
+
`--private` now really is "torn down with the run" ([#7](https://github.com/tonioloewald/haltija/issues/7)).
|
|
98
|
+
|
|
99
|
+
The private Electron instance used to survive teardown — holding Electron's single-instance lock (so
|
|
100
|
+
the **next** `--private` run failed with "Another instance is already running") and leaking a process
|
|
101
|
+
per run. Fixed three ways, all verified with real Electron:
|
|
102
|
+
|
|
103
|
+
- **A private run never takes the single-instance lock.** Private instances are isolated on ephemeral
|
|
104
|
+
ports and meant to run concurrently / back-to-back, so an orphan can no longer block the next run,
|
|
105
|
+
and two private runs coexist.
|
|
106
|
+
- **The private Electron self-terminates** when its spawner dies (even via SIGKILL) or on
|
|
107
|
+
SIGTERM/SIGINT. It reparents to launchd, so it watches the launcher's pid (`HALTIJA_SPAWNER_PID`)
|
|
108
|
+
and calls `app.quit()` — which reaps its own helper processes, unlike an external tree-kill.
|
|
109
|
+
- **`hj shutdown` / `hj quit`** (and `POST /shutdown`) on a private-desktop instance tears down the
|
|
110
|
+
whole thing — Electron and its servers — for a deterministic end-of-run teardown.
|
|
111
|
+
|
|
3
112
|
## 1.5.4
|
|
4
113
|
|
|
5
114
|
The `hj tabs open` client-less-tab trap now explains itself ([#5](https://github.com/tonioloewald/haltija/issues/5)).
|
package/apps/desktop/main.js
CHANGED
|
@@ -1212,6 +1212,10 @@ function spawnHaltijaServer({ port, role, serverPath, useCompiledBinary, compone
|
|
|
1212
1212
|
HALTIJA_PORT: port.toString(), // what src/server.ts actually reads
|
|
1213
1213
|
DEV_CHANNEL_PORT: port.toString(),
|
|
1214
1214
|
HALTIJA_DESKTOP: '1',
|
|
1215
|
+
// Only the PUBLIC server (the one agents drive) registers under the reserved 'desktop' name so
|
|
1216
|
+
// `hj --name desktop` / `hj servers` can find it; the internal chrome server stays unregistered.
|
|
1217
|
+
// See src/server.ts REGISTRY_NAME. (Ignored for a private run — those never register.)
|
|
1218
|
+
HALTIJA_DESKTOP_PUBLIC: role === 'public' ? '1' : '0',
|
|
1215
1219
|
}
|
|
1216
1220
|
if (IS_PRIVATE) {
|
|
1217
1221
|
// Isolated instance: this child binds an EPHEMERAL port (HALTIJA_PRIVATE forces PORT=0) and
|
|
@@ -1532,8 +1536,12 @@ async function ensureServer() {
|
|
|
1532
1536
|
}
|
|
1533
1537
|
}
|
|
1534
1538
|
|
|
1535
|
-
// Single-instance lock — prevent multiple Electron windows from launching
|
|
1536
|
-
|
|
1539
|
+
// Single-instance lock — prevent multiple Electron windows from launching.
|
|
1540
|
+
// A PRIVATE run must NOT contend for it (issue #7): private instances are isolated on ephemeral
|
|
1541
|
+
// ports and are meant to run many at once / back-to-back. Taking the shared lock means an orphaned
|
|
1542
|
+
// private Electron blocks the NEXT private run ("Another instance is already running"), and two
|
|
1543
|
+
// concurrent private runs collide. So a private run never requests and never holds the lock.
|
|
1544
|
+
const gotTheLock = IS_PRIVATE ? true : app.requestSingleInstanceLock()
|
|
1537
1545
|
|
|
1538
1546
|
if (!gotTheLock) {
|
|
1539
1547
|
console.log('[Haltija Desktop] Another instance is already running. Focusing existing window.')
|
|
@@ -1548,6 +1556,29 @@ if (!gotTheLock) {
|
|
|
1548
1556
|
}
|
|
1549
1557
|
})
|
|
1550
1558
|
|
|
1559
|
+
// "Torn down with the run": a PRIVATE Electron must not outlive its spawner (issue #7). An
|
|
1560
|
+
// orphaned private Electron holds nothing shared anymore (we skip the lock above), but it still
|
|
1561
|
+
// leaks a process + its servers. Electron reparents to launchd shortly after startup, so
|
|
1562
|
+
// process.ppid is useless — the launcher passes its own pid as HALTIJA_SPAWNER_PID and we poll
|
|
1563
|
+
// it. On the spawner's death, or a SIGTERM/SIGINT, quit via app.quit() → 'will-quit' kills the
|
|
1564
|
+
// child servers AND Electron reaps its own helper processes (which an EXTERNAL kill notoriously
|
|
1565
|
+
// fails to do — the reason we self-terminate instead of asking the consumer to hunt the tree).
|
|
1566
|
+
if (IS_PRIVATE) {
|
|
1567
|
+
let quitting = false
|
|
1568
|
+
const quitOnce = () => { if (quitting) return; quitting = true; try { app.quit() } catch {} }
|
|
1569
|
+
process.on('SIGTERM', quitOnce)
|
|
1570
|
+
process.on('SIGINT', quitOnce)
|
|
1571
|
+
const spawnerPid = parseInt(process.env.HALTIJA_SPAWNER_PID || '', 10)
|
|
1572
|
+
if (Number.isFinite(spawnerPid)) {
|
|
1573
|
+
const iv = setInterval(() => {
|
|
1574
|
+
let alive = true
|
|
1575
|
+
try { process.kill(spawnerPid, 0) } catch { alive = false } // signal 0 = existence check
|
|
1576
|
+
if (!alive) { clearInterval(iv); console.log('[Haltija Desktop] spawner gone — tearing down private instance'); quitOnce() }
|
|
1577
|
+
}, 1000)
|
|
1578
|
+
if (iv.unref) iv.unref()
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1551
1582
|
// App lifecycle
|
|
1552
1583
|
app.whenReady().then(async () => {
|
|
1553
1584
|
console.log('[Haltija Desktop] App ready, starting initialization...')
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
// src/version.ts
|
|
49
|
-
var VERSION = "1.
|
|
49
|
+
var VERSION = "1.6.1";
|
|
50
50
|
|
|
51
51
|
// src/text-selector.ts
|
|
52
52
|
var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
|
|
@@ -1738,6 +1738,7 @@
|
|
|
1738
1738
|
ws = null;
|
|
1739
1739
|
state = "disconnected";
|
|
1740
1740
|
consoleBuffer = [];
|
|
1741
|
+
errorCaptureInstalled = false;
|
|
1741
1742
|
eventWatchers = new Map;
|
|
1742
1743
|
mutationObserver = null;
|
|
1743
1744
|
shadowObservers = new Map;
|
|
@@ -1969,6 +1970,7 @@
|
|
|
1969
1970
|
this.killed = true;
|
|
1970
1971
|
this.disconnect();
|
|
1971
1972
|
this.restoreConsole();
|
|
1973
|
+
this.uninstallErrorCapture();
|
|
1972
1974
|
this.restoreDialogs();
|
|
1973
1975
|
this.clearEventWatchers();
|
|
1974
1976
|
this.stopMutationWatch();
|
|
@@ -7426,6 +7428,26 @@ ${elementSummary}${moreText}`;
|
|
|
7426
7428
|
window.prompt = this.originalDialogs.prompt;
|
|
7427
7429
|
this.originalDialogs = {};
|
|
7428
7430
|
}
|
|
7431
|
+
serializeConsoleArg(arg) {
|
|
7432
|
+
try {
|
|
7433
|
+
return JSON.parse(JSON.stringify(arg, (_k, v) => v instanceof Error ? { name: v.name, message: v.message, stack: v.stack } : v));
|
|
7434
|
+
} catch {
|
|
7435
|
+
return String(arg);
|
|
7436
|
+
}
|
|
7437
|
+
}
|
|
7438
|
+
recordConsoleEntry(entry) {
|
|
7439
|
+
try {
|
|
7440
|
+
this.consoleBuffer.push(entry);
|
|
7441
|
+
if (this.consoleBuffer.length > 1000) {
|
|
7442
|
+
this.consoleBuffer = this.consoleBuffer.slice(-500);
|
|
7443
|
+
}
|
|
7444
|
+
if (entry.level === "error") {
|
|
7445
|
+
if (this.state === "connected")
|
|
7446
|
+
this.send("console", "error", entry);
|
|
7447
|
+
this.updateUI();
|
|
7448
|
+
}
|
|
7449
|
+
} catch {}
|
|
7450
|
+
}
|
|
7429
7451
|
interceptConsole() {
|
|
7430
7452
|
const levels = [
|
|
7431
7453
|
"log",
|
|
@@ -7441,31 +7463,73 @@ ${elementSummary}${moreText}`;
|
|
|
7441
7463
|
try {
|
|
7442
7464
|
const entry = {
|
|
7443
7465
|
level,
|
|
7444
|
-
args: args.map((arg) =>
|
|
7445
|
-
try {
|
|
7446
|
-
return JSON.parse(JSON.stringify(arg));
|
|
7447
|
-
} catch {
|
|
7448
|
-
return String(arg);
|
|
7449
|
-
}
|
|
7450
|
-
}),
|
|
7466
|
+
args: args.map((arg) => this.serializeConsoleArg(arg)),
|
|
7451
7467
|
timestamp: Date.now()
|
|
7452
7468
|
};
|
|
7453
7469
|
if (level === "error") {
|
|
7454
|
-
|
|
7455
|
-
|
|
7456
|
-
this.consoleBuffer.push(entry);
|
|
7457
|
-
if (this.consoleBuffer.length > 1000) {
|
|
7458
|
-
this.consoleBuffer = this.consoleBuffer.slice(-500);
|
|
7459
|
-
}
|
|
7460
|
-
if (level === "error") {
|
|
7461
|
-
if (this.state === "connected") {
|
|
7462
|
-
this.send("console", level, entry);
|
|
7463
|
-
}
|
|
7464
|
-
this.updateUI();
|
|
7470
|
+
const errArg = args.find((a) => a instanceof Error);
|
|
7471
|
+
entry.stack = errArg?.stack || new Error().stack;
|
|
7465
7472
|
}
|
|
7473
|
+
this.recordConsoleEntry(entry);
|
|
7466
7474
|
} catch {}
|
|
7467
7475
|
};
|
|
7468
7476
|
}
|
|
7477
|
+
this.installErrorCapture();
|
|
7478
|
+
}
|
|
7479
|
+
onWindowError = null;
|
|
7480
|
+
onUnhandledRejection = null;
|
|
7481
|
+
installErrorCapture() {
|
|
7482
|
+
if (typeof window === "undefined" || this.errorCaptureInstalled)
|
|
7483
|
+
return;
|
|
7484
|
+
this.errorCaptureInstalled = true;
|
|
7485
|
+
this.onWindowError = (event) => {
|
|
7486
|
+
try {
|
|
7487
|
+
const err = event.error;
|
|
7488
|
+
let message;
|
|
7489
|
+
let stack;
|
|
7490
|
+
if (err instanceof Error) {
|
|
7491
|
+
message = `Uncaught ${err.name}: ${err.message}`;
|
|
7492
|
+
stack = err.stack;
|
|
7493
|
+
} else if (event.target?.tagName) {
|
|
7494
|
+
const el = event.target;
|
|
7495
|
+
message = `Resource failed to load: <${String(el.tagName).toLowerCase()}> ${el.src || el.href || ""}`.trim();
|
|
7496
|
+
} else {
|
|
7497
|
+
message = `Uncaught error: ${event.message || "unknown"}`;
|
|
7498
|
+
}
|
|
7499
|
+
const where = event.filename ? ` (${event.filename}:${event.lineno}:${event.colno})` : "";
|
|
7500
|
+
this.recordConsoleEntry({
|
|
7501
|
+
level: "error",
|
|
7502
|
+
args: [message + where],
|
|
7503
|
+
timestamp: Date.now(),
|
|
7504
|
+
stack
|
|
7505
|
+
});
|
|
7506
|
+
} catch {}
|
|
7507
|
+
};
|
|
7508
|
+
window.addEventListener("error", this.onWindowError, true);
|
|
7509
|
+
this.onUnhandledRejection = (event) => {
|
|
7510
|
+
try {
|
|
7511
|
+
const reason = event.reason;
|
|
7512
|
+
const args = reason instanceof Error ? [`Unhandled promise rejection: ${reason.name}: ${reason.message}`] : ["Unhandled promise rejection:", this.serializeConsoleArg(reason)];
|
|
7513
|
+
this.recordConsoleEntry({
|
|
7514
|
+
level: "error",
|
|
7515
|
+
args,
|
|
7516
|
+
timestamp: Date.now(),
|
|
7517
|
+
stack: reason instanceof Error ? reason.stack : undefined
|
|
7518
|
+
});
|
|
7519
|
+
} catch {}
|
|
7520
|
+
};
|
|
7521
|
+
window.addEventListener("unhandledrejection", this.onUnhandledRejection);
|
|
7522
|
+
}
|
|
7523
|
+
uninstallErrorCapture() {
|
|
7524
|
+
if (typeof window !== "undefined") {
|
|
7525
|
+
if (this.onWindowError)
|
|
7526
|
+
window.removeEventListener("error", this.onWindowError, true);
|
|
7527
|
+
if (this.onUnhandledRejection)
|
|
7528
|
+
window.removeEventListener("unhandledrejection", this.onUnhandledRejection);
|
|
7529
|
+
}
|
|
7530
|
+
this.onWindowError = null;
|
|
7531
|
+
this.onUnhandledRejection = null;
|
|
7532
|
+
this.errorCaptureInstalled = false;
|
|
7469
7533
|
}
|
|
7470
7534
|
restoreConsole() {
|
|
7471
7535
|
for (const [level, fn] of Object.entries(this.originalConsole)) {
|
package/bin/cli-subcommand.mjs
CHANGED
|
@@ -942,7 +942,19 @@ async function doRequest(url, method, body, context = {}) {
|
|
|
942
942
|
// content never mounted, so an empty selector means "not mounted", not "broken" (issue #3).
|
|
943
943
|
// Print it on stderr so it can't be mistaken for output, and so --json stdout stays clean.
|
|
944
944
|
if (json && typeof json.warning === 'string' && json.warning) {
|
|
945
|
-
|
|
945
|
+
if (process.env.HALTIJA_STRICT === '1') {
|
|
946
|
+
// Strict mode (issue #8): if the result may be wrong, a script must not consume it. Fail
|
|
947
|
+
// fast with the real reason instead of emitting a plausible-but-wrong value that makes the
|
|
948
|
+
// lane fail later, pointing at the caller's own code. stdout stays empty on purpose.
|
|
949
|
+
// NB: fails on `warningRepeated` too — the condition still holds, and suppressing repeats
|
|
950
|
+
// here would let every command after the first silently pass.
|
|
951
|
+
console.error(`hj: ERROR (strict) — ${json.warning}`)
|
|
952
|
+
console.error(`hj: refusing to return a result that may be wrong. Fix the condition above, or drop --strict/HALTIJA_STRICT to proceed anyway.`)
|
|
953
|
+
process.exit(1)
|
|
954
|
+
}
|
|
955
|
+
// Non-strict: stay quiet on a repeat within the cooldown, so a burst of commands doesn't
|
|
956
|
+
// re-print the same block and train the reader to ignore it.
|
|
957
|
+
if (!json.warningRepeated) console.error(`hj: warning — ${json.warning}`)
|
|
946
958
|
}
|
|
947
959
|
|
|
948
960
|
// Text format for supported subcommands (unless --json)
|
|
@@ -1073,7 +1085,7 @@ const COMMAND_ALIASES = {
|
|
|
1073
1085
|
'execute': 'eval',
|
|
1074
1086
|
'shot': 'screenshot',
|
|
1075
1087
|
'capture': 'screenshot',
|
|
1076
|
-
|
|
1088
|
+
// NB: `ls` is NOT an alias for `tree` — it's intercepted in hj.mjs as `hj servers` (list servers).
|
|
1077
1089
|
'list': 'tree',
|
|
1078
1090
|
'show': 'tree',
|
|
1079
1091
|
'help': '--help',
|
package/bin/hj.mjs
CHANGED
|
@@ -126,6 +126,169 @@ async function runWhere(port, portSource, jsonOutput) {
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* `hj servers` — enumerate every live haltija server so you can pick one when several coexist
|
|
131
|
+
* (e.g. a project server + the Electron desktop app). Sources: the registry, the well-known
|
|
132
|
+
* defaults 8700/8701 (to catch anything unregistered), and this shell's resolved target. Marks the
|
|
133
|
+
* one `hj` would drive. Pure probes + registry read; no side effects, never auto-launches.
|
|
134
|
+
*/
|
|
135
|
+
async function runServers(resolvedPort) {
|
|
136
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`
|
|
137
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`
|
|
138
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`
|
|
139
|
+
const token = process.env.HALTIJA_TOKEN
|
|
140
|
+
|
|
141
|
+
const byPort = new Map()
|
|
142
|
+
for (const e of listLiveInstances()) {
|
|
143
|
+
byPort.set(String(e.port), { port: String(e.port), name: e.name, cwd: e.cwd })
|
|
144
|
+
}
|
|
145
|
+
for (const p of ['8700', '8701', String(resolvedPort)]) {
|
|
146
|
+
if (!byPort.has(p)) byPort.set(p, { port: p, name: null, cwd: null })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const rows = await Promise.all(
|
|
150
|
+
[...byPort.values()].map(async (c) => {
|
|
151
|
+
try {
|
|
152
|
+
const resp = await fetch(`http://localhost:${c.port}/status`, {
|
|
153
|
+
headers: token ? { 'X-Haltija-Token': token } : {},
|
|
154
|
+
signal: AbortSignal.timeout(2000),
|
|
155
|
+
})
|
|
156
|
+
if (!resp.ok) return { ...c, up: false }
|
|
157
|
+
const s = await resp.json()
|
|
158
|
+
return {
|
|
159
|
+
...c,
|
|
160
|
+
up: true,
|
|
161
|
+
version: s.serverVersion || '?',
|
|
162
|
+
desktopApp: !!s.desktopApp,
|
|
163
|
+
tabs: Array.isArray(s.windows) ? s.windows.length : s.browsers ?? 0,
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
return { ...c, up: false }
|
|
167
|
+
}
|
|
168
|
+
}),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const up = rows.filter((r) => r.up).sort((a, b) => Number(a.port) - Number(b.port))
|
|
172
|
+
if (!up.length) {
|
|
173
|
+
console.log('No haltija servers are running.')
|
|
174
|
+
console.log(dim('Start one: bunx haltija --server (or the desktop app: bunx haltija)'))
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
console.log(bold('Live haltija servers') + dim(' (▸ = what this shell targets)'))
|
|
179
|
+
for (const r of up) {
|
|
180
|
+
const here = String(r.port) === String(resolvedPort) ? green('▸') : ' '
|
|
181
|
+
const name = r.desktopApp ? 'desktop' : r.name || '(unnamed)'
|
|
182
|
+
const tabs = `${r.tabs} tab${r.tabs === 1 ? '' : 's'}`
|
|
183
|
+
const kind = r.desktopApp ? 'desktop app' : r.cwd || ''
|
|
184
|
+
console.log(
|
|
185
|
+
` ${here} ${String(r.port).padEnd(6)} ${name.padEnd(14)} v${String(r.version).padEnd(8)} ${tabs.padEnd(9)} ${dim(kind)}`,
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
if (!up.some((r) => String(r.port) === String(resolvedPort))) {
|
|
189
|
+
console.log(dim(`\nThis shell targets :${resolvedPort}, but nothing is listening there.`))
|
|
190
|
+
}
|
|
191
|
+
console.log(dim('\nPick one: ') + `hj --port <n> <cmd>` + dim(' or ') + `hj --name <name> <cmd>`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* `hj doctor` — one-command preflight for a test lane: "is the thing I'm about to drive the thing
|
|
196
|
+
* I mean, and can it actually be driven?" (issues #8, #11). Exits NON-ZERO when it isn't, which is
|
|
197
|
+
* the whole point: "is a server up?" is a cheap probe that does NOT predict success, so adopters
|
|
198
|
+
* who used it skipped spawning their own browser and failed much later on a timeout.
|
|
199
|
+
*
|
|
200
|
+
* Checks, in the order they bite: server reachable → drivable (a tab is connected) → targeting is
|
|
201
|
+
* unambiguous (cwd matches, or the choice was explicit) → tabs visible → versions aligned.
|
|
202
|
+
*/
|
|
203
|
+
async function runDoctor(port, portSource, jsonOutput) {
|
|
204
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`
|
|
205
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`
|
|
206
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`
|
|
207
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`
|
|
208
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[0m`
|
|
209
|
+
const token = process.env.HALTIJA_TOKEN
|
|
210
|
+
|
|
211
|
+
const problems = [] // fatal → exit 1
|
|
212
|
+
const notes = [] // advisory
|
|
213
|
+
let status = null
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const resp = await fetch(`http://localhost:${port}/status`, {
|
|
217
|
+
headers: token ? { 'X-Haltija-Token': token } : {},
|
|
218
|
+
signal: AbortSignal.timeout(3000),
|
|
219
|
+
})
|
|
220
|
+
if (resp.ok) status = await resp.json()
|
|
221
|
+
else problems.push(`server on port ${port} returned HTTP ${resp.status}`)
|
|
222
|
+
} catch (err) {
|
|
223
|
+
const refused = err.code === 'ConnectionRefused' || err.cause?.code === 'ECONNREFUSED'
|
|
224
|
+
problems.push(
|
|
225
|
+
refused
|
|
226
|
+
? `no haltija server is listening on port ${port} — start one (bunx haltija) or check the target`
|
|
227
|
+
: `could not reach the server on port ${port}: ${err.message}`,
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (status) {
|
|
232
|
+
// The signal that actually predicts success. Older servers (<1.6.1) don't send `ready`; fall
|
|
233
|
+
// back to counting tabs rather than inventing a pass.
|
|
234
|
+
const tabs = Array.isArray(status.windows) ? status.windows : []
|
|
235
|
+
const ready = typeof status.ready === 'boolean' ? status.ready : tabs.length > 0
|
|
236
|
+
if (!ready) {
|
|
237
|
+
problems.push(
|
|
238
|
+
`the server on port ${port} is up but has NO connected browser tab — nothing to drive. ` +
|
|
239
|
+
`Open a tab in the desktop app, or inject the widget into a page. ` +
|
|
240
|
+
`("server is up" is not "server is drivable" — that's what this check exists for.)`,
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
const hidden = tabs.filter((w) => w.hidden)
|
|
244
|
+
if (ready && hidden.length === tabs.length) {
|
|
245
|
+
problems.push(
|
|
246
|
+
`every connected tab reports HIDDEN — results from a backgrounded tab can be ` +
|
|
247
|
+
`plausible-but-wrong (rAF/timers throttled). Bring one to the front.`,
|
|
248
|
+
)
|
|
249
|
+
} else if (hidden.length) {
|
|
250
|
+
notes.push(`${hidden.length} of ${tabs.length} tab(s) are hidden; commands targeting them may return stale results`)
|
|
251
|
+
}
|
|
252
|
+
if (status.serverVersion && differsBeyondPatch(HJ_VERSION, status.serverVersion)) {
|
|
253
|
+
notes.push(`hj ${HJ_VERSION} is driving server ${status.serverVersion} (version skew)`)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Ambiguous targeting: we fell back to the shared default while other projects' servers are live.
|
|
258
|
+
const live = listLiveInstances()
|
|
259
|
+
const ambiguous = portSource === '8700 (default)' && live.length > 0
|
|
260
|
+
if (ambiguous) {
|
|
261
|
+
problems.push(
|
|
262
|
+
`targeting the shared default port 8700, but ${live.length} other haltija server(s) are ` +
|
|
263
|
+
`running and none matches this directory (${process.cwd()}) — the target is ambiguous. ` +
|
|
264
|
+
`Pick one with --name/--port, or run from the project's directory.`,
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const ok = problems.length === 0
|
|
269
|
+
|
|
270
|
+
if (jsonOutput) {
|
|
271
|
+
console.log(JSON.stringify({
|
|
272
|
+
ok, port, portSource,
|
|
273
|
+
serverVersion: status?.serverVersion ?? null,
|
|
274
|
+
ready: status ? (typeof status.ready === 'boolean' ? status.ready : (status.windows?.length ?? 0) > 0) : false,
|
|
275
|
+
tabs: status?.windows?.length ?? 0,
|
|
276
|
+
problems, notes,
|
|
277
|
+
}, null, 2))
|
|
278
|
+
return ok
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
console.log(`${bold('target:')} port ${port} ${dim(`(${portSource})`)}`)
|
|
282
|
+
if (status) {
|
|
283
|
+
const tabCount = status.windows?.length ?? 0
|
|
284
|
+
console.log(`${bold('server:')} haltija ${status.serverVersion || '?'}${status.desktopApp ? dim(' (desktop app)') : ''}, ${tabCount} tab${tabCount === 1 ? '' : 's'}`)
|
|
285
|
+
}
|
|
286
|
+
for (const n of notes) console.log(`${yellow('!')} ${n}`)
|
|
287
|
+
for (const p of problems) console.log(`${red('✗')} ${p}`)
|
|
288
|
+
if (ok) console.log(`${green('✓')} ready to drive`)
|
|
289
|
+
return ok
|
|
290
|
+
}
|
|
291
|
+
|
|
129
292
|
/**
|
|
130
293
|
* Resolve a named haltija instance to its port by reading
|
|
131
294
|
* ~/.haltija/servers/<name>.json. Returns null if the file is missing,
|
|
@@ -211,6 +374,17 @@ ${dim('Overriding that (per-shell):')}
|
|
|
211
374
|
${dim('export HALTIJA_TOKEN=secret')} # required when server was started with HALTIJA_TOKEN
|
|
212
375
|
${dim('hj --token secret tree')} # one-off token override
|
|
213
376
|
${dim('hj --version')} # which hj is this?
|
|
377
|
+
|
|
378
|
+
${dim('Lifecycle:')}
|
|
379
|
+
${dim('hj where')} # which server this shell targets + what is alive there
|
|
380
|
+
${dim('hj servers')} # list ALL live servers (pick one with --port/--name)
|
|
381
|
+
${dim('hj doctor')} # preflight: drivable + unambiguous? EXITS 1 if not
|
|
382
|
+
${dim('hj shutdown')} # stop the targeted server (a private --app: Electron + all)
|
|
383
|
+
|
|
384
|
+
${dim('For scripts / CI:')}
|
|
385
|
+
${dim('hj --strict <cmd>')} # turn advisory warnings (wrong project, hidden tab)
|
|
386
|
+
${dim('HALTIJA_STRICT=1')} # into non-zero exits, so a lane fails fast on the
|
|
387
|
+
${dim('# real cause instead of a later timeout')}
|
|
214
388
|
${listSubcommands()}
|
|
215
389
|
Run ${dim('hj --help')} for this help.
|
|
216
390
|
Run ${dim('haltija --help')} for server/app options.
|
|
@@ -218,6 +392,23 @@ Run ${dim('haltija --help')} for server/app options.
|
|
|
218
392
|
process.exit(0)
|
|
219
393
|
}
|
|
220
394
|
|
|
395
|
+
// Parse --strict FIRST — before port resolution, which is itself one of the things strict mode
|
|
396
|
+
// turns from a warning into an error. (A check placed before the input it depends on is a
|
|
397
|
+
// recurring bug shape: parse the flag, then run the code that reads it.) Sets HALTIJA_STRICT so
|
|
398
|
+
// cli-subcommand.mjs sees it too.
|
|
399
|
+
//
|
|
400
|
+
// In strict mode the advisory warnings — cross-project targeting, hidden tab, focus ambiguity —
|
|
401
|
+
// become non-zero exits (issue #8). haltija already DETECTS these precisely; the gap was that
|
|
402
|
+
// detection never reached the exit code, so a lane consumed a plausible-but-wrong result and failed
|
|
403
|
+
// much later pointing at the caller's own code. A warning is right for a human at a prompt and
|
|
404
|
+
// wrong for a script.
|
|
405
|
+
const strictIdx = args.indexOf('--strict')
|
|
406
|
+
if (strictIdx !== -1) {
|
|
407
|
+
process.env.HALTIJA_STRICT = '1'
|
|
408
|
+
args.splice(strictIdx, 1)
|
|
409
|
+
}
|
|
410
|
+
const STRICT = process.env.HALTIJA_STRICT === '1'
|
|
411
|
+
|
|
221
412
|
// Parse --name option (or HALTIJA_NAME env): resolve to a port via
|
|
222
413
|
// ~/.haltija/servers/<name>.json, written by `haltija --name <foo>`.
|
|
223
414
|
let resolvedName = process.env.HALTIJA_NAME || ''
|
|
@@ -288,6 +479,13 @@ if (portFlag) {
|
|
|
288
479
|
// explicit choice.
|
|
289
480
|
if (live.length) {
|
|
290
481
|
const names = live.map((e) => `${e.name} (${e.cwd})`).join(', ')
|
|
482
|
+
if (STRICT) {
|
|
483
|
+
// A lane must not silently drive another project's browser (issue #8, case 1).
|
|
484
|
+
console.error(`hj: ERROR (strict) — refusing to fall back to the default port 8700 while other haltija servers are running: ${names}`)
|
|
485
|
+
console.error(`hj: this shell's cwd (${process.cwd()}) matches none of them, so the target is ambiguous.`)
|
|
486
|
+
console.error(`hj: pick one explicitly with --name/--port (or cd into its directory), or drop --strict to proceed anyway.`)
|
|
487
|
+
process.exit(1)
|
|
488
|
+
}
|
|
291
489
|
console.error(`hj: warning — targeting the default port 8700, but these haltija servers are running: ${names}`)
|
|
292
490
|
console.error(`hj: if you meant one of them, cd into its directory, or use --name/--port. See \`hj where\`.`)
|
|
293
491
|
}
|
|
@@ -367,6 +565,50 @@ if (subcommand === 'where') {
|
|
|
367
565
|
process.exit(0)
|
|
368
566
|
}
|
|
369
567
|
|
|
568
|
+
// `hj servers` / `hj ls` — list every live haltija server (registry + defaults + this shell's
|
|
569
|
+
// target), so you can pick one when several coexist. Diagnostic; never auto-launches.
|
|
570
|
+
if (subcommand === 'servers' || subcommand === 'ls') {
|
|
571
|
+
await runServers(port)
|
|
572
|
+
process.exit(0)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// `hj doctor` — preflight for a test lane. EXITS NON-ZERO when the target isn't drivable or is
|
|
576
|
+
// ambiguous, so a lane can fail fast with the real reason (issues #8, #11). Never auto-launches.
|
|
577
|
+
if (subcommand === 'doctor') {
|
|
578
|
+
const ok = await runDoctor(port, portSource, subArgs.includes('--json'))
|
|
579
|
+
process.exit(ok ? 0 : 1)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// `hj shutdown` / `hj quit` — cleanly stop the targeted server. For a private `--app` instance this
|
|
583
|
+
// tears down the WHOLE thing (Electron + its child servers); for a plain server it stops that
|
|
584
|
+
// server. Never auto-launches (it's a stop command), so it's handled here before the routing table.
|
|
585
|
+
if (subcommand === 'shutdown' || subcommand === 'quit') {
|
|
586
|
+
const token = process.env.HALTIJA_TOKEN
|
|
587
|
+
try {
|
|
588
|
+
const resp = await fetch(`http://localhost:${port}/shutdown`, {
|
|
589
|
+
method: 'POST',
|
|
590
|
+
headers: token ? { 'X-Haltija-Token': token } : {},
|
|
591
|
+
signal: AbortSignal.timeout(3000),
|
|
592
|
+
})
|
|
593
|
+
const j = await resp.json().catch(() => ({}))
|
|
594
|
+
if (resp.ok) {
|
|
595
|
+
console.log(j.message || `Shutdown requested on port ${port}.`)
|
|
596
|
+
process.exit(0)
|
|
597
|
+
}
|
|
598
|
+
// Surface the server's explanation (e.g. the desktop-app refusal), not a bare status code.
|
|
599
|
+
console.error(`hj ${subcommand}: ${j.error || `server on port ${port} returned HTTP ${resp.status}`}`)
|
|
600
|
+
process.exit(1)
|
|
601
|
+
} catch (err) {
|
|
602
|
+
// Nothing listening = already stopped; that's success for a stop command.
|
|
603
|
+
if (err.code === 'ConnectionRefused' || err.cause?.code === 'ECONNREFUSED') {
|
|
604
|
+
console.log(`No server listening on port ${port} (already stopped).`)
|
|
605
|
+
process.exit(0)
|
|
606
|
+
}
|
|
607
|
+
console.error(`hj ${subcommand}: ${err.message}`)
|
|
608
|
+
process.exit(1)
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
370
612
|
if (!isSubcommand(subcommand)) {
|
|
371
613
|
const suggestion = getSuggestion(subcommand)
|
|
372
614
|
if (suggestion === '--help') {
|
package/bin/tosijs-dev.mjs
CHANGED
|
@@ -580,7 +580,12 @@ function launchApp(desktopDir, port) {
|
|
|
580
580
|
const electronBinary = resolveElectronBinary()
|
|
581
581
|
// A private app run must NOT pin DEV_CHANNEL_PORT — it binds ephemeral ports and never touches
|
|
582
582
|
// 8700. `env` already carries HALTIJA_PRIVATE=1 / HALTIJA_PORT_FILE from the --private block.
|
|
583
|
-
|
|
583
|
+
// For a private run we also hand the app OUR pid (HALTIJA_SPAWNER_PID): the Electron process
|
|
584
|
+
// reparents to launchd shortly after startup, so it can't watch us via process.ppid — it polls
|
|
585
|
+
// this pid instead and quits when we're gone (issue #7, "torn down with the run").
|
|
586
|
+
const appEnv = privateMode
|
|
587
|
+
? { ...env, HALTIJA_SPAWNER_PID: String(process.pid) }
|
|
588
|
+
: { ...env, DEV_CHANNEL_PORT: String(port) }
|
|
584
589
|
const child = electronBinary
|
|
585
590
|
? spawn(electronBinary, [desktopDir], { env: appEnv, stdio: 'inherit' })
|
|
586
591
|
: spawn('npx', ['--yes', 'electron', desktopDir], { env: appEnv, stdio: 'inherit' })
|
|
@@ -595,6 +600,12 @@ function launchApp(desktopDir, port) {
|
|
|
595
600
|
child.on('exit', code => {
|
|
596
601
|
process.exit(code || 0)
|
|
597
602
|
})
|
|
603
|
+
|
|
604
|
+
// Belt-and-suspenders to the app's own spawner-pid poll: if WE get a signal, ask the app to quit
|
|
605
|
+
// before we exit. (If we're SIGKILLed we can't do this — that's exactly what the poll covers.)
|
|
606
|
+
const signalChild = () => { try { child.kill('SIGTERM') } catch {} ; process.exit(0) }
|
|
607
|
+
process.on('SIGTERM', signalChild)
|
|
608
|
+
process.on('SIGINT', signalChild)
|
|
598
609
|
|
|
599
610
|
// In CI/wait-ready mode, wait for server + browser to be ready
|
|
600
611
|
if (waitReady) {
|
package/bin/version.mjs
CHANGED
package/dist/api-handlers.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface DevResponse {
|
|
|
18
18
|
timestamp: number;
|
|
19
19
|
/** Hidden-tab / focus-ambiguity caveat attached by the server (see requestFromBrowser). */
|
|
20
20
|
warning?: string;
|
|
21
|
+
/** True when this exact warning was already reported within the cooldown (see types.ts). */
|
|
22
|
+
warningRepeated?: boolean;
|
|
21
23
|
}
|
|
22
24
|
/** Function to send request to browser widget */
|
|
23
25
|
export type RequestFromBrowserFn = (channel: string, action: string, payload: any, timeoutMs?: number, windowId?: string) => Promise<DevResponse>;
|