haltija 1.3.4 → 1.4.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/CHANGELOG.md +64 -0
- package/README.md +49 -1
- package/apps/desktop/main.js +75 -24
- package/apps/desktop/package.json +5 -5
- package/apps/desktop/resources/component.js +1 -1
- package/bin/cli-subcommand.mjs +71 -2
- package/bin/hj.mjs +130 -18
- package/bin/semver.mjs +71 -0
- package/bin/tosijs-dev.mjs +58 -15
- package/bin/version.mjs +6 -0
- package/dist/component.d.ts +1 -1
- package/dist/component.esm.js +1 -1
- package/dist/component.js +1 -1
- package/dist/hj-install.d.ts +112 -0
- package/dist/hj.js +137 -27
- package/dist/index.js +673 -191
- package/dist/legacy-servers.d.ts +88 -0
- package/dist/machine-log.d.ts +46 -0
- package/dist/port-pid.d.ts +33 -0
- package/dist/semver.d.ts +43 -0
- package/dist/server.js +673 -191
- package/dist/sessions.d.ts +60 -6
- package/dist/version.d.ts +1 -1
- package/llms.txt +11 -2
- package/package.json +2 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.4.0
|
|
4
|
+
|
|
5
|
+
**`hj` now routes to the server that owns your current directory.** If you run more than one
|
|
6
|
+
project, this changes where your commands go — for the better, but read the first section.
|
|
7
|
+
|
|
8
|
+
### Fixed: `hj` drove the wrong browser across projects
|
|
9
|
+
|
|
10
|
+
`hj` never looked at your working directory. Every invocation, in every project, fell back to
|
|
11
|
+
port 8700 and drove whatever browser was focused there — silently, with no error. The only way
|
|
12
|
+
to target a project's own server was per-shell environment variables that agents spawning fresh
|
|
13
|
+
shells routinely lose.
|
|
14
|
+
|
|
15
|
+
Servers now record the directory they were started in, and `hj` picks the live server whose
|
|
16
|
+
directory is the nearest ancestor of your cwd. Inside a project with its own server, plain
|
|
17
|
+
`hj tree` just works — no flags, no env vars.
|
|
18
|
+
|
|
19
|
+
- Falling back to the shared default port while other servers are running now **warns** on
|
|
20
|
+
stderr instead of quietly misrouting.
|
|
21
|
+
- **`hj where`** tells you which port you're targeting, *why* it was chosen, and what's alive
|
|
22
|
+
there. Reach for it first when a command seems to hit the wrong page.
|
|
23
|
+
- Precedence is unchanged and still wins: `--port` > `--name`/`HALTIJA_NAME` > `HALTIJA_PORT` >
|
|
24
|
+
`DEV_CHANNEL_PORT` > cwd match > port 8700.
|
|
25
|
+
|
|
26
|
+
### Fixed: a stale server could hand every project an old `hj`
|
|
27
|
+
|
|
28
|
+
`hj` is a single binary on your `PATH`, and every haltija server used to overwrite it on
|
|
29
|
+
startup — so the last server to boot decided which `hj` *every project on the machine* ran. One
|
|
30
|
+
forgotten `bunx haltija@beta` could silently downgrade the CLI for an unrelated, up-to-date
|
|
31
|
+
project.
|
|
32
|
+
|
|
33
|
+
- A **symlinked `hj` is never touched.** Point it at your own build and it stays put.
|
|
34
|
+
- Servers **only bootstrap or repair** `hj` — they write it when nothing is there, or when
|
|
35
|
+
what's there is strictly older. They never downgrade it, and never rewrite it just because
|
|
36
|
+
the bytes differ. To find out what's installed they ask it (`hj --version`).
|
|
37
|
+
- **`hj --version`**, and `hj` now warns when its version differs from the server it's driving.
|
|
38
|
+
- `HALTIJA_NO_INSTALL=1` opts out of the install entirely.
|
|
39
|
+
|
|
40
|
+
### New: pre-1.4.0 servers are retired on startup
|
|
41
|
+
|
|
42
|
+
Older servers have none of the guards above and cannot be fixed in code that already shipped, so
|
|
43
|
+
a 1.4.0+ server **asks** any haltija server **below 1.4.0** to stop when it starts, and says what
|
|
44
|
+
it did. Retirement is `POST /shutdown` — an endpoint every haltija has understood since 0.1.7 —
|
|
45
|
+
so it needs no process IDs and does no killing.
|
|
46
|
+
|
|
47
|
+
This is deliberately narrow: it never stops a peer (1.4.0 and 1.4.1 coexist, and once 1.3.x is
|
|
48
|
+
gone it never fires again), never touches a running desktop app, and never touches anything it
|
|
49
|
+
cannot identify as haltija. When it can't stop a server, it complains rather than failing
|
|
50
|
+
silently.
|
|
51
|
+
|
|
52
|
+
`HALTIJA_NO_RETIRE=1` opts out. See "Housekeeping" in the README.
|
|
53
|
+
|
|
54
|
+
### Also
|
|
55
|
+
|
|
56
|
+
- HTTPS-only servers no longer advertise an HTTP port they aren't listening on.
|
|
57
|
+
- Every REST response carries `X-Haltija-Version`.
|
|
58
|
+
- **`hj` exits non-zero when an operation fails** (`success:false`) — not just with `--json`, but on action commands too (`hj click`, `navigate`, `key`, …). A click that didn't land, or any command with no browser connected, now exits 1 instead of 0, so an agent checking the exit code can't read a failed step as success. (Commands with their own human formatting were already this way; this closes the gap for the rest.)
|
|
59
|
+
- `HALTIJA_REGISTRY_DIR` overrides the instance-registry location.
|
|
60
|
+
- `hj` no longer auto-spawns a server against an **explicitly targeted** port (`--port`/`--name`/`HALTIJA_PORT`) or under `--no-launch` — a read-only command against a server you manage will not start a colliding one; it errors instead. Auto-spawn remains only for the bare default port.
|
|
61
|
+
|
|
62
|
+
### Platform
|
|
63
|
+
|
|
64
|
+
macOS and Linux. Native Windows is not supported — use WSL, where all of this works unmodified.
|
package/README.md
CHANGED
|
@@ -249,7 +249,11 @@ export HALTIJA_PORT=9123
|
|
|
249
249
|
hj tree
|
|
250
250
|
```
|
|
251
251
|
|
|
252
|
-
If you don't pass `--port`, haltija tries 8700 first and falls back to a kernel-assigned ephemeral port
|
|
252
|
+
If you don't pass `--port`, haltija tries 8700 first and falls back to a kernel-assigned ephemeral port.
|
|
253
|
+
|
|
254
|
+
**You usually don't need `--name` at all.** A server records the directory it was started in, so **plain `hj` inside a project reaches that project's server** — no flags, no environment variables. `--name` and `--port` are overrides for when you want to address a server from *outside* its directory. `hj where` shows which server a shell is targeting and why.
|
|
255
|
+
|
|
256
|
+
Haltija does keep a little state outside your project — a shared `hj` on your PATH and a registry of running servers. That's deliberate, and it's all logged: see [Housekeeping](#housekeeping--what-haltija-does-to-your-machine) below.
|
|
253
257
|
|
|
254
258
|
**Production embedding.** When haltija is reachable beyond loopback, gate it with a shared-secret token:
|
|
255
259
|
|
|
@@ -302,6 +306,50 @@ app also exists but isn't required for either.
|
|
|
302
306
|
|
|
303
307
|
---
|
|
304
308
|
|
|
309
|
+
## Housekeeping — what Haltija does to your machine
|
|
310
|
+
|
|
311
|
+
Haltija acts at **machine** scope on purpose. "Which `hj` does every shell on this box
|
|
312
|
+
run?" is not a question a per-project fix can answer — so a few things live outside your
|
|
313
|
+
project directory. All of it is opt-out, and **all of it leaves a receipt.**
|
|
314
|
+
|
|
315
|
+
**Everything Haltija does outside its own project is logged to
|
|
316
|
+
`~/.haltija/machine-actions.log`** (timestamped, with the version and the *directory of the
|
|
317
|
+
project that triggered it*) and announced on **stderr**. That matters because Haltija is
|
|
318
|
+
often a transitive dependency: if some other project's `test-browser` script spawned it,
|
|
319
|
+
you may not know what Haltija even is. The receipt tells you what happened, when, and which
|
|
320
|
+
project caused it.
|
|
321
|
+
|
|
322
|
+
**It installs `hj` into `~/.local/bin`.** One CLI, shared by every project. It will
|
|
323
|
+
**never overwrite a symlink** (if you point `hj` at your own build, that's yours) and
|
|
324
|
+
**never downgrade** a newer `hj` than the one it carries. `HALTIJA_NO_INSTALL=1` disables it.
|
|
325
|
+
|
|
326
|
+
**It registers itself in `~/.haltija/servers/`,** recording the directory it was started
|
|
327
|
+
in. That's what lets plain `hj` inside a project reach *that project's* server instead of
|
|
328
|
+
whichever browser happens to be focused somewhere else. The entry is removed on shutdown.
|
|
329
|
+
`hj where` shows what your shell is targeting and why.
|
|
330
|
+
|
|
331
|
+
**It stops haltija servers older than 1.4.0.** Those versions overwrite the shared
|
|
332
|
+
`~/.local/bin/hj` on every boot, so one stale server left running quietly hands every
|
|
333
|
+
project on your machine an old CLI. A 1.4.0+ server **asks** them to stop on startup
|
|
334
|
+
(`POST /shutdown`, which every haltija has understood since 0.1.7) and says so. It does
|
|
335
|
+
not go hunting for processes to kill. It is deliberately narrow:
|
|
336
|
+
|
|
337
|
+
- It only stops servers **below 1.4.0** — never a peer. Two projects on 1.4.0 and 1.4.1
|
|
338
|
+
coexist; nothing kills anything once 1.3.x is gone.
|
|
339
|
+
- It **will not touch a running desktop app** (that would orphan a window you can see) —
|
|
340
|
+
it tells you to quit and update it instead.
|
|
341
|
+
- It **asks rather than kills.** Retirement is an HTTP request to a server that already
|
|
342
|
+
told us what it is — no process IDs, no `kill`. (An earlier draft resolved a port to a
|
|
343
|
+
pid with `lsof` and signalled it; `lsof -i :PORT` also matches connected *clients*, so
|
|
344
|
+
that would have killed your **browser**. Asking makes that whole class of mistake
|
|
345
|
+
impossible.)
|
|
346
|
+
- On the rare fallback paths that must free a port without waiting, it signals only
|
|
347
|
+
**listeners** that `ps` confirms are haltija — never something it cannot identify.
|
|
348
|
+
|
|
349
|
+
`HALTIJA_NO_RETIRE=1` disables it.
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
305
353
|
## Use Cases
|
|
306
354
|
|
|
307
355
|
- **AI pair programming** — Agent sees your actual app, not a description of it
|
package/apps/desktop/main.js
CHANGED
|
@@ -1292,39 +1292,90 @@ async function startEmbeddedServer() {
|
|
|
1292
1292
|
/**
|
|
1293
1293
|
* Ensure Haltija server is available
|
|
1294
1294
|
*/
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1295
|
+
/** Is anything listening on this port? Listeners only — never connected clients. */
|
|
1296
|
+
function listenerPidsOnPort(port) {
|
|
1298
1297
|
const { execSync } = require('child_process')
|
|
1298
|
+
try {
|
|
1299
|
+
// `-sTCP:LISTEN` is load-bearing. `lsof -i :PORT` matches sockets whose local OR
|
|
1300
|
+
// REMOTE port is PORT, so without it this also returns every connected CLIENT —
|
|
1301
|
+
// i.e. the user's browser, holding a WebSocket to this very server. Browsers open
|
|
1302
|
+
// since login have the lower pid and sort first, so killing what this returns used
|
|
1303
|
+
// to kill the browser and leave the server running.
|
|
1304
|
+
const out = execSync(`lsof -ti:${port} -sTCP:LISTEN 2>/dev/null`, { encoding: 'utf-8' }).trim()
|
|
1305
|
+
if (!out) return []
|
|
1306
|
+
return out.split('\n').filter(Boolean).map(Number)
|
|
1307
|
+
.filter(pid => Number.isFinite(pid) && pid !== process.pid)
|
|
1308
|
+
} catch {
|
|
1309
|
+
return []
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1299
1312
|
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1313
|
+
/** Only signal something we can positively identify as haltija. */
|
|
1314
|
+
function isHaltijaProcess(pid) {
|
|
1315
|
+
const { execSync } = require('child_process')
|
|
1316
|
+
try {
|
|
1317
|
+
const cmd = execSync(`ps -p ${pid} -o command= 2>/dev/null`, { encoding: 'utf-8' }).trim()
|
|
1318
|
+
return !!cmd && /haltija|tosijs-dev/i.test(cmd)
|
|
1319
|
+
} catch {
|
|
1320
|
+
return false
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1309
1323
|
|
|
1310
|
-
|
|
1311
|
-
|
|
1324
|
+
/**
|
|
1325
|
+
* Free a port held by an old haltija server.
|
|
1326
|
+
*
|
|
1327
|
+
* ASK FIRST. `POST /shutdown` has shipped since 0.1.7, so any haltija server on this
|
|
1328
|
+
* port can stop itself — cleanly, and with no pid involved. That matters: this used to
|
|
1329
|
+
* run `lsof -ti:PORT | xargs kill -9`, which kills every pid matching the port
|
|
1330
|
+
* INCLUDING connected clients, i.e. the user's browser. It ran on the default launch
|
|
1331
|
+
* path, whenever a server was already up — exactly when browsers are attached.
|
|
1332
|
+
*
|
|
1333
|
+
* Signalling is now the fallback only, restricted to LISTENERS that `ps` confirms are
|
|
1334
|
+
* haltija, and it never escalates to -9 against something it could not identify.
|
|
1335
|
+
*/
|
|
1336
|
+
async function killZombieOnPort(port) {
|
|
1337
|
+
if (os.platform() === 'win32') return true
|
|
1312
1338
|
|
|
1313
|
-
|
|
1339
|
+
if (listenerPidsOnPort(port).length === 0) {
|
|
1340
|
+
console.log(`[Haltija Desktop] Port ${port} is free`)
|
|
1341
|
+
return true
|
|
1342
|
+
}
|
|
1314
1343
|
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1344
|
+
// 1. Ask.
|
|
1345
|
+
try {
|
|
1346
|
+
await fetch(`http://localhost:${port}/shutdown`, {
|
|
1347
|
+
method: 'POST',
|
|
1348
|
+
signal: AbortSignal.timeout(1500),
|
|
1349
|
+
})
|
|
1350
|
+
} catch {
|
|
1351
|
+
// Dying mid-response is a success; the liveness check below decides.
|
|
1352
|
+
}
|
|
1353
|
+
for (let i = 0; i < 20; i++) {
|
|
1354
|
+
if (listenerPidsOnPort(port).length === 0) {
|
|
1355
|
+
console.log(`[Haltija Desktop] Server on port ${port} shut down cleanly`)
|
|
1323
1356
|
return true
|
|
1324
1357
|
}
|
|
1358
|
+
await new Promise(r => setTimeout(r, 100))
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// 2. It didn't answer. Signal only what we can identify.
|
|
1362
|
+
for (const pid of listenerPidsOnPort(port)) {
|
|
1363
|
+
if (!isHaltijaProcess(pid)) {
|
|
1364
|
+
console.error(`[Haltija Desktop] Port ${port} is held by pid ${pid}, which is not a haltija server — leaving it alone`)
|
|
1365
|
+
continue
|
|
1366
|
+
}
|
|
1367
|
+
try {
|
|
1368
|
+
process.kill(pid, 'SIGTERM')
|
|
1369
|
+
console.log(`[Haltija Desktop] Stopped unresponsive haltija (pid ${pid}) on port ${port}`)
|
|
1370
|
+
} catch {}
|
|
1325
1371
|
}
|
|
1372
|
+
await new Promise(r => setTimeout(r, 500))
|
|
1326
1373
|
|
|
1327
|
-
|
|
1374
|
+
if (listenerPidsOnPort(port).length === 0) {
|
|
1375
|
+
console.log(`[Haltija Desktop] Port ${port} freed`)
|
|
1376
|
+
return true
|
|
1377
|
+
}
|
|
1378
|
+
console.error(`[Haltija Desktop] Warning: could not free port ${port}`)
|
|
1328
1379
|
return false
|
|
1329
1380
|
}
|
|
1330
1381
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "haltija-desktop",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Haltija Desktop - God Mode Browser for AI Agents",
|
|
6
6
|
"homepage": "https://github.com/tonioloewald/haltija",
|
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
},
|
|
11
11
|
"main": "main.js",
|
|
12
12
|
"scripts": {
|
|
13
|
-
"start": "cd ../.. && bun run build && cd apps/desktop && electron .",
|
|
13
|
+
"start": "cd ../.. && bun run build && rm -f apps/desktop/resources/haltija-server-* && cd apps/desktop && electron .",
|
|
14
14
|
"start:quick": "electron .",
|
|
15
15
|
"icons": "electron scripts/generate-icons.js",
|
|
16
16
|
"sync-version": "node -e \"const root = require('../../package.json'); const pkg = require('./package.json'); pkg.version = root.version; require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\\n')\"",
|
|
17
17
|
"prebuild": "npm run sync-version && npm run icons && npm run compile:server",
|
|
18
|
-
"compile:server": "cd ../.. && bun run build && bun build --compile --target=bun-darwin-arm64 src/server.ts --outfile apps/desktop/resources/haltija-server-arm64 && bun build --compile --target=bun-darwin-x64 src/server.ts --outfile apps/desktop/resources/haltija-server-x64 &&
|
|
18
|
+
"compile:server": "cd ../.. && bun run build && bun build --compile --target=bun-darwin-arm64 src/server.ts --outfile apps/desktop/resources/haltija-server-arm64 && bun build --compile --target=bun-darwin-x64 src/server.ts --outfile apps/desktop/resources/haltija-server-x64 && cp dist/component.js apps/desktop/resources/component.js && cp haltija-icon.svg apps/desktop/resources/icon.svg",
|
|
19
19
|
"compile:server:win": "cd ../.. && bun build --compile --target=bun-windows-x64 src/server.ts --outfile apps/desktop/resources/haltija-server-x64.exe",
|
|
20
20
|
"compile:server:linux": "cd ../.. && bun build --compile --target=bun-linux-x64 src/server.ts --outfile apps/desktop/resources/haltija-server-x64 && bun build --compile --target=bun-linux-arm64 src/server.ts --outfile apps/desktop/resources/haltija-server-arm64",
|
|
21
21
|
"build": "electron-builder",
|
|
@@ -94,8 +94,8 @@
|
|
|
94
94
|
"to": "haltija-server-${arch}"
|
|
95
95
|
},
|
|
96
96
|
{
|
|
97
|
-
"from": "resources/hj
|
|
98
|
-
"to": "hj
|
|
97
|
+
"from": "resources/hj.mjs",
|
|
98
|
+
"to": "hj.mjs"
|
|
99
99
|
},
|
|
100
100
|
{
|
|
101
101
|
"from": "resources/component.js",
|
package/bin/cli-subcommand.mjs
CHANGED
|
@@ -26,6 +26,8 @@ import { formatEvents } from './format-events.mjs'
|
|
|
26
26
|
import { formatTestResult, formatSuiteResult } from './format-test.mjs'
|
|
27
27
|
import { formatNetwork, formatNetworkStats } from './format-network.mjs'
|
|
28
28
|
import { substituteGeneratedVars } from './test-data.mjs'
|
|
29
|
+
import { HJ_VERSION } from './version.mjs'
|
|
30
|
+
import { differsBeyondPatch } from './semver.mjs'
|
|
29
31
|
|
|
30
32
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
31
33
|
|
|
@@ -34,6 +36,37 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
|
34
36
|
const hintsPath = join(__dirname, 'hints.json')
|
|
35
37
|
export const COMMAND_HINTS = existsSync(hintsPath) ? JSON.parse(readFileSync(hintsPath, 'utf-8')) : {}
|
|
36
38
|
|
|
39
|
+
let warnedAboutSkew = false
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Warn when this hj is meaningfully out of step with the server it just talked to.
|
|
43
|
+
*
|
|
44
|
+
* `hj` is ONE global binary driving MANY per-project servers, so some version skew is
|
|
45
|
+
* the normal steady state, not a fault: a project pinned to haltija 1.4.0 while the
|
|
46
|
+
* CLI is 1.4.2 is fine, and no action the user takes can make those numbers match.
|
|
47
|
+
*
|
|
48
|
+
* So warn only when the versions differ by more than a patch — that's when hj can
|
|
49
|
+
* actually lack a resolution rule or response shape the server assumes. Warning on
|
|
50
|
+
* exact mismatch would fire forever, on every command, with a remedy
|
|
51
|
+
* (`bun install -g haltija@latest`) that cannot fix it. And a warning that always
|
|
52
|
+
* fires is one that gets ignored — including the times it's real. SKILL.md tells
|
|
53
|
+
* agents to *trust* this warning, so it has to be worth trusting.
|
|
54
|
+
*
|
|
55
|
+
* Once per process, on stderr (so `--json` stdout stays machine-readable).
|
|
56
|
+
* `HALTIJA_NO_SKEW_WARN=1` silences it.
|
|
57
|
+
*/
|
|
58
|
+
function warnOnVersionSkew(resp) {
|
|
59
|
+
if (warnedAboutSkew) return
|
|
60
|
+
if (process.env.HALTIJA_NO_SKEW_WARN === '1') return
|
|
61
|
+
const serverVersion = resp.headers?.get?.('X-Haltija-Version')
|
|
62
|
+
if (!serverVersion) return
|
|
63
|
+
if (!differsBeyondPatch(serverVersion, HJ_VERSION)) return
|
|
64
|
+
warnedAboutSkew = true
|
|
65
|
+
console.error(`hj: warning — hj ${HJ_VERSION} is driving haltija server ${serverVersion}.`)
|
|
66
|
+
console.error(`hj: that gap is wide enough to route or format wrongly. This hj is ${process.argv[1]}`)
|
|
67
|
+
console.error(`hj: silence with HALTIJA_NO_SKEW_WARN=1`)
|
|
68
|
+
}
|
|
69
|
+
|
|
37
70
|
// Endpoints that use GET (everything else is POST)
|
|
38
71
|
export const GET_ENDPOINTS = new Set([
|
|
39
72
|
'location', 'events', 'console', 'windows', 'recordings',
|
|
@@ -778,14 +811,38 @@ export async function runSubcommand(subcommand, subArgs, port = '8700', options
|
|
|
778
811
|
warnUnknownFlags(subcommand, filteredArgs)
|
|
779
812
|
}
|
|
780
813
|
|
|
781
|
-
// Check if server is
|
|
814
|
+
// Check if a server is answering; auto-start one only if it's safe to.
|
|
782
815
|
if (!(await isServerRunning(port))) {
|
|
816
|
+
// NEVER auto-spawn a server against a port the shell EXPLICITLY targeted (--port /
|
|
817
|
+
// --name / HALTIJA_PORT / …), and never under --no-launch.
|
|
818
|
+
//
|
|
819
|
+
// Spawning a server is a machine-topology mutation, and doing it from a read-only-ish
|
|
820
|
+
// command (`hj eval`, `hj tree`) against a targeted port is the sharpest edge in the CLI:
|
|
821
|
+
// the target is a server the USER manages — very often their dev server, embedded on a
|
|
822
|
+
// port they chose. If it's momentarily not answering (mid-restart, or HTTPS-only so the
|
|
823
|
+
// HTTP /status probe fails), spawning a generic server that BINDS that port collides with
|
|
824
|
+
// their setup and knocks it offline. That's a bug report, and it was: `hj --no-launch
|
|
825
|
+
// --port 8700 eval` bound its own listener on 8700 and took the dev channel down.
|
|
826
|
+
//
|
|
827
|
+
// So auto-spawn is only for the bare, unconfigured 8700 default — the zero-config "I just
|
|
828
|
+
// want it to work" path. This mirrors the Electron auto-launch rule (hj.mjs), which the
|
|
829
|
+
// server spawn had drifted out of sync with. An explicit target that isn't answering is an
|
|
830
|
+
// actionable error, not license to spawn.
|
|
831
|
+
if (noLaunch || explicitTarget) {
|
|
832
|
+
console.error(`Error: nothing is answering on the haltija server you targeted (port ${port}).`)
|
|
833
|
+
console.error(explicitTarget
|
|
834
|
+
? 'That port is yours to manage — haltija will not spawn a server against a target you named.'
|
|
835
|
+
: 'Start it yourself: `haltija --server` (or drop --no-launch to let hj start one on the default port).')
|
|
836
|
+
console.error('`hj where` shows what a shell is targeting and why.')
|
|
837
|
+
process.exit(1)
|
|
838
|
+
}
|
|
839
|
+
|
|
783
840
|
// Respect "user manually quit Haltija" before we try to spawn anything.
|
|
784
841
|
// The marker is dropped by the desktop app on will-quit and cleared on
|
|
785
842
|
// its next launch — agent calls in between should not bring it back.
|
|
786
843
|
try {
|
|
787
844
|
const quitMarker = join(homedir(), '.haltija', 'last-quit')
|
|
788
|
-
if (
|
|
845
|
+
if (existsSync(quitMarker)) {
|
|
789
846
|
console.error('Haltija was quit by user; not auto-launching.')
|
|
790
847
|
console.error('Open Haltija manually to resume — or run `hj --no-launch` to bypass this check.')
|
|
791
848
|
process.exit(1)
|
|
@@ -875,6 +932,7 @@ async function doRequest(url, method, body, context = {}) {
|
|
|
875
932
|
}
|
|
876
933
|
|
|
877
934
|
const resp = await fetch(url, opts)
|
|
935
|
+
warnOnVersionSkew(resp)
|
|
878
936
|
const contentType = resp.headers.get('content-type') || ''
|
|
879
937
|
|
|
880
938
|
if (contentType.includes('application/json')) {
|
|
@@ -925,7 +983,18 @@ async function doRequest(url, method, body, context = {}) {
|
|
|
925
983
|
console.log(JSON.stringify(result, null, 2))
|
|
926
984
|
}
|
|
927
985
|
} else {
|
|
986
|
+
// The fall-through for BOTH `--json` and plain action commands with no special
|
|
987
|
+
// formatter (hj click / navigate / key / type / scroll …): print the envelope,
|
|
988
|
+
// then EXIT NON-ZERO IF IT SAYS FAILURE.
|
|
989
|
+
//
|
|
990
|
+
// This used to print `{"success": false, "error": "No browser connected…"}` (or an
|
|
991
|
+
// element-not-found) and exit 0. An agent that checks the exit code — which is how a
|
|
992
|
+
// harness decides whether a step worked — saw success while the payload said failure.
|
|
993
|
+
// That is the instrument lying, and for a debugging tool a lying instrument is worse
|
|
994
|
+
// than none: you can't tell "the page is broken" from "my probe is broken". A click
|
|
995
|
+
// that didn't click, or a probe with no browser, is a failure and now exits 1.
|
|
928
996
|
console.log(JSON.stringify(json, null, 2))
|
|
997
|
+
if (json && json.success === false) process.exit(1)
|
|
929
998
|
}
|
|
930
999
|
} else {
|
|
931
1000
|
const text = await resp.text()
|
package/bin/hj.mjs
CHANGED
|
@@ -11,12 +11,22 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { runSubcommand, isSubcommand, getSuggestion, listSubcommands, COMMAND_HINTS } from './cli-subcommand.mjs'
|
|
14
|
+
import { HJ_VERSION } from './version.mjs'
|
|
15
|
+
import { differsBeyondPatch } from './semver.mjs'
|
|
14
16
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
15
17
|
import { homedir } from 'node:os'
|
|
16
18
|
import { join } from 'node:path'
|
|
17
19
|
|
|
18
20
|
const args = process.argv.slice(2)
|
|
19
21
|
|
|
22
|
+
/** Where instance entries live. Mirrors DEFAULT_REGISTRY_DIR in src/sessions.ts. */
|
|
23
|
+
const REGISTRY_DIR = process.env.HALTIJA_REGISTRY_DIR || join(homedir(), '.haltija', 'servers')
|
|
24
|
+
|
|
25
|
+
if (args[0] === '--version' || args[0] === '-v') {
|
|
26
|
+
console.log(HJ_VERSION)
|
|
27
|
+
process.exit(0)
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
/**
|
|
21
31
|
* Print what server this shell is currently targeting and what's alive
|
|
22
32
|
* there. Reports the resolved port, the source of the resolution (flag,
|
|
@@ -45,7 +55,7 @@ async function runWhere(port, portSource, jsonOutput) {
|
|
|
45
55
|
// an entry pointing at this port.
|
|
46
56
|
let instanceName = null
|
|
47
57
|
try {
|
|
48
|
-
const dir =
|
|
58
|
+
const dir = REGISTRY_DIR
|
|
49
59
|
if (existsSync(dir)) {
|
|
50
60
|
for (const file of readdirSync(dir)) {
|
|
51
61
|
if (!file.endsWith('.json')) continue
|
|
@@ -68,6 +78,9 @@ async function runWhere(port, portSource, jsonOutput) {
|
|
|
68
78
|
portSource,
|
|
69
79
|
reachable: !!serverInfo,
|
|
70
80
|
error: serverError,
|
|
81
|
+
client: HJ_VERSION,
|
|
82
|
+
// Same policy as the human output: patch drift is not "skew", it's normal.
|
|
83
|
+
versionSkew: serverInfo ? differsBeyondPatch(serverInfo.serverVersion || '', HJ_VERSION) : null,
|
|
71
84
|
server: serverInfo ? {
|
|
72
85
|
version: serverInfo.serverVersion,
|
|
73
86
|
instanceName,
|
|
@@ -95,11 +108,21 @@ async function runWhere(port, portSource, jsonOutput) {
|
|
|
95
108
|
serverInfo.agents > 0 ? `${serverInfo.agents} agent${serverInfo.agents === 1 ? '' : 's'}` : null,
|
|
96
109
|
].filter(Boolean).join(', ')
|
|
97
110
|
console.log(`${bold('server:')} ${desc}`)
|
|
111
|
+
console.log(`${bold('client:')} hj ${HJ_VERSION}`)
|
|
98
112
|
if (focused) {
|
|
99
113
|
console.log(`${bold('focused:')} ${focused.title || dim('(no title)')} ${dim(`— ${focused.url}`)}`)
|
|
100
114
|
} else if (tabs === 0) {
|
|
101
115
|
console.log(`${bold('focused:')} ${dim('no tabs connected')}`)
|
|
102
116
|
}
|
|
117
|
+
// `hj where` is the diagnostic command, so it always SHOWS both versions above.
|
|
118
|
+
// It only escalates to a warning when the gap is wide enough to matter — patch
|
|
119
|
+
// skew between one global hj and many pinned per-project servers is normal and
|
|
120
|
+
// unfixable, and a warning that always fires is one nobody reads.
|
|
121
|
+
if (serverInfo.serverVersion && differsBeyondPatch(serverInfo.serverVersion, HJ_VERSION)) {
|
|
122
|
+
console.log(`\n${bold('warning:')} hj ${HJ_VERSION} is driving server ${serverInfo.serverVersion}.`)
|
|
123
|
+
console.log(dim(` That gap is wide enough to route or format wrongly.`))
|
|
124
|
+
console.log(dim(` This hj is ${process.argv[1]}`))
|
|
125
|
+
}
|
|
103
126
|
}
|
|
104
127
|
|
|
105
128
|
/**
|
|
@@ -108,7 +131,7 @@ async function runWhere(port, portSource, jsonOutput) {
|
|
|
108
131
|
* malformed, or the recorded pid is no longer alive.
|
|
109
132
|
*/
|
|
110
133
|
function lookupNamedInstance(name) {
|
|
111
|
-
const path = join(
|
|
134
|
+
const path = join(REGISTRY_DIR, `${name}.json`)
|
|
112
135
|
if (!existsSync(path)) return null
|
|
113
136
|
let entry
|
|
114
137
|
try {
|
|
@@ -122,6 +145,47 @@ function lookupNamedInstance(name) {
|
|
|
122
145
|
return entry
|
|
123
146
|
}
|
|
124
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Every live entry in ~/.haltija/servers/. Mirrors `list()` in src/sessions.ts —
|
|
150
|
+
* duplicated rather than imported because this file is plain .mjs bundled
|
|
151
|
+
* standalone into dist/hj.js, with no access to the compiled TS.
|
|
152
|
+
*/
|
|
153
|
+
function listLiveInstances() {
|
|
154
|
+
const dir = REGISTRY_DIR
|
|
155
|
+
if (!existsSync(dir)) return []
|
|
156
|
+
const out = []
|
|
157
|
+
for (const file of readdirSync(dir)) {
|
|
158
|
+
if (!file.endsWith('.json')) continue
|
|
159
|
+
const entry = lookupNamedInstance(file.slice(0, -'.json'.length))
|
|
160
|
+
if (entry) out.push(entry)
|
|
161
|
+
}
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** True if `dir` is `p` or one of its ancestors (segment-wise, not raw prefix). */
|
|
166
|
+
function isAncestorOf(dir, p) {
|
|
167
|
+
if (!dir || !p) return false
|
|
168
|
+
if (dir === p) return true
|
|
169
|
+
return p.startsWith(dir.endsWith('/') ? dir : dir + '/')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Find the live server that owns `cwd` — the one whose recorded directory is
|
|
174
|
+
* the nearest ancestor of it. This is what makes plain `hj` inside a project
|
|
175
|
+
* reach *that project's* server instead of the global default port.
|
|
176
|
+
*
|
|
177
|
+
* `/` and the home directory are ancestors of everything, so servers started
|
|
178
|
+
* there can't win a match; otherwise they'd capture every project on the box.
|
|
179
|
+
*/
|
|
180
|
+
function resolveByCwd(cwd, instances) {
|
|
181
|
+
const candidates = instances.filter(
|
|
182
|
+
(e) => e.cwd && e.cwd !== '/' && e.cwd !== homedir() && isAncestorOf(e.cwd, cwd),
|
|
183
|
+
)
|
|
184
|
+
if (!candidates.length) return null
|
|
185
|
+
candidates.sort((a, b) => b.cwd.length - a.cwd.length || (b.startedAt || 0) - (a.startedAt || 0))
|
|
186
|
+
return candidates[0]
|
|
187
|
+
}
|
|
188
|
+
|
|
125
189
|
if (!args.length || args.includes('--help') || args.includes('-h')) {
|
|
126
190
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`
|
|
127
191
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`
|
|
@@ -130,7 +194,14 @@ ${bold('hj')} - Haltija command-line interface
|
|
|
130
194
|
|
|
131
195
|
Usage: hj <command> [args...]
|
|
132
196
|
|
|
133
|
-
${dim('
|
|
197
|
+
${dim('Which server does hj talk to?')}
|
|
198
|
+
${dim('By default, the one that owns the directory you are in: a haltija server')}
|
|
199
|
+
${dim('records where it was started, and hj picks the one whose directory is the')}
|
|
200
|
+
${dim('nearest ancestor of your cwd. So inside a project with its own server,')}
|
|
201
|
+
${dim('plain `hj tree` just works. Otherwise it falls back to port 8700.')}
|
|
202
|
+
${dim('Run `hj where` to see the port, WHY it was chosen, and what is alive there.')}
|
|
203
|
+
|
|
204
|
+
${dim('Overriding that (per-shell):')}
|
|
134
205
|
${dim('haltija --name api --server')} # in another shell: register as "api"
|
|
135
206
|
${dim('export HALTIJA_NAME=api')} # all hj calls in this shell talk to "api"
|
|
136
207
|
${dim('hj --name api tree')} # one-off name override
|
|
@@ -138,6 +209,7 @@ ${dim('Targeting a specific haltija server (per-shell):')}
|
|
|
138
209
|
${dim('hj --port 9123 tree')} # one-off port override
|
|
139
210
|
${dim('export HALTIJA_TOKEN=secret')} # required when server was started with HALTIJA_TOKEN
|
|
140
211
|
${dim('hj --token secret tree')} # one-off token override
|
|
212
|
+
${dim('hj --version')} # which hj is this?
|
|
141
213
|
${listSubcommands()}
|
|
142
214
|
Run ${dim('hj --help')} for this help.
|
|
143
215
|
Run ${dim('haltija --help')} for server/app options.
|
|
@@ -156,29 +228,69 @@ if (nameIdx !== -1 && args[nameIdx + 1]) {
|
|
|
156
228
|
args.splice(nameIdx, 2)
|
|
157
229
|
}
|
|
158
230
|
|
|
231
|
+
// Parse --port up front. It must be consumed BEFORE resolution runs: the
|
|
232
|
+
// fallback branch below warns about landing on the default port, and if --port
|
|
233
|
+
// were still unparsed at that moment, `hj --port 9999` would warn "you're on
|
|
234
|
+
// 8700, use --port" and then correctly use 9999 — a single run contradicting
|
|
235
|
+
// itself, telling the user to reach for the flag they just used.
|
|
236
|
+
let portFlag = ''
|
|
237
|
+
const portIdx = args.indexOf('--port')
|
|
238
|
+
if (portIdx !== -1 && args[portIdx + 1]) {
|
|
239
|
+
portFlag = args[portIdx + 1]
|
|
240
|
+
args.splice(portIdx, 2)
|
|
241
|
+
}
|
|
242
|
+
|
|
159
243
|
// Port resolution priority:
|
|
160
244
|
// --port flag > --name/HALTIJA_NAME registry lookup > HALTIJA_PORT env
|
|
161
|
-
// > DEV_CHANNEL_PORT env > 8700 default
|
|
245
|
+
// > DEV_CHANNEL_PORT env > cwd match against the registry > 8700 default
|
|
246
|
+
//
|
|
247
|
+
// The cwd step is what keeps projects from stepping on each other: a server
|
|
248
|
+
// started inside a project records its directory, so plain `hj` run anywhere
|
|
249
|
+
// under that directory routes to it. Without it, every `hj` in every project
|
|
250
|
+
// lands on 8700 and drives whatever browser is focused there — silently.
|
|
251
|
+
//
|
|
252
|
+
// Resolved highest-precedence-first and short-circuited, so each source is
|
|
253
|
+
// consulted only when nothing above it decided. Only the final, losing branch
|
|
254
|
+
// warns.
|
|
162
255
|
let port, portSource
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (resolvedName) {
|
|
256
|
+
if (portFlag) {
|
|
257
|
+
port = portFlag
|
|
258
|
+
portSource = '--port flag'
|
|
259
|
+
} else if (resolvedName) {
|
|
167
260
|
const entry = lookupNamedInstance(resolvedName)
|
|
168
|
-
if (entry) {
|
|
169
|
-
port = String(entry.port)
|
|
170
|
-
portSource = `name "${resolvedName}" via ${nameSource}`
|
|
171
|
-
} else {
|
|
261
|
+
if (!entry) {
|
|
172
262
|
console.error(`hj: no live haltija instance named "${resolvedName}".`)
|
|
173
263
|
console.error(`Start one with: haltija --name ${resolvedName} --server`)
|
|
174
264
|
process.exit(1)
|
|
175
265
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (
|
|
179
|
-
port =
|
|
180
|
-
portSource = '
|
|
181
|
-
|
|
266
|
+
port = String(entry.port)
|
|
267
|
+
portSource = `name "${resolvedName}" via ${nameSource}`
|
|
268
|
+
} else if (process.env.HALTIJA_PORT) {
|
|
269
|
+
port = process.env.HALTIJA_PORT
|
|
270
|
+
portSource = 'HALTIJA_PORT env'
|
|
271
|
+
} else if (process.env.DEV_CHANNEL_PORT) {
|
|
272
|
+
port = process.env.DEV_CHANNEL_PORT
|
|
273
|
+
portSource = 'DEV_CHANNEL_PORT env (legacy)'
|
|
274
|
+
} else {
|
|
275
|
+
const live = listLiveInstances()
|
|
276
|
+
const cwdMatch = resolveByCwd(process.cwd(), live)
|
|
277
|
+
if (cwdMatch) {
|
|
278
|
+
port = String(cwdMatch.port)
|
|
279
|
+
portSource = `cwd match: ${cwdMatch.name}`
|
|
280
|
+
} else {
|
|
281
|
+
port = '8700'
|
|
282
|
+
portSource = '8700 (default)'
|
|
283
|
+
// Falling back to the shared default while project servers are running is
|
|
284
|
+
// the classic misroute — you think you're driving this project's browser
|
|
285
|
+
// and you're driving someone else's. Say so rather than doing it quietly.
|
|
286
|
+
// Reached only when nothing else selected a port, so it can't contradict an
|
|
287
|
+
// explicit choice.
|
|
288
|
+
if (live.length) {
|
|
289
|
+
const names = live.map((e) => `${e.name} (${e.cwd})`).join(', ')
|
|
290
|
+
console.error(`hj: warning — targeting the default port 8700, but these haltija servers are running: ${names}`)
|
|
291
|
+
console.error(`hj: if you meant one of them, cd into its directory, or use --name/--port. See \`hj where\`.`)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
182
294
|
}
|
|
183
295
|
|
|
184
296
|
// Parse --token option (sets HALTIJA_TOKEN env so cli-subcommand.mjs picks it up).
|