clearotron 0.3.0-beta.7 → 0.3.0-beta.9
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 +28 -21
- package/bin/example.mjs +14 -2
- package/bin/key.mjs +23 -5
- package/bin/onboard.mjs +52 -16
- package/bin/start.mjs +95 -35
- package/bin/status.mjs +23 -3
- package/bin/stop.mjs +23 -8
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +20 -0
- package/driver/compose-read.mjs +21 -2
- package/driver/engine/jx-turn.mjs +4 -1
- package/driver/package.json +1 -1
- package/driver/portal-service.mjs +12 -5
- package/driver/suite-census.json +41 -5
- package/driver/systemd/render-units.mjs +7 -13
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-DBUVdQT-.js → index-qes7gLpM.js} +2 -1
- package/portal-ui/dist/index.html +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/shared/access-audience.mjs +7 -6
- package/shared/client-door.mjs +39 -0
- package/shared/env-file-merge.mjs +24 -0
- package/shared/invocation.mjs +49 -2
- package/shared/permanent-install.mjs +34 -0
- package/shared/running-start.mjs +73 -0
package/README.md
CHANGED
|
@@ -19,33 +19,41 @@ no gateway, no platform, and nothing about your matters reaches us.
|
|
|
19
19
|
|
|
20
20
|
## Install
|
|
21
21
|
|
|
22
|
-
**See it work first, with nothing
|
|
22
|
+
**See it work first, with nothing installed.**
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
25
|
npx clearotron demo
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
That fetches the published package — it will ask once before downloading — then replays
|
|
29
|
-
|
|
30
|
-
calls to us.
|
|
28
|
+
That fetches the published package — it will ask once before downloading — then replays finished
|
|
29
|
+
clearances into a local portal and prints the portal's address and the passphrase to sign in with. Open
|
|
30
|
+
the address in your browser. No account, no credentials, no network calls to us.
|
|
31
31
|
|
|
32
32
|
**Then install it.**
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
|
-
|
|
35
|
+
npx clearotron install
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
Node 22.13 or newer, on macOS or Linux.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
Node 22.13 or newer, on macOS or Linux. It needs no root: it puts the program under `~/.local`, with the
|
|
39
|
+
`clearotron` command in `~/.local/bin`, then asks one question at a time and checks each credential
|
|
40
|
+
before it saves it. With `~/.local/bin` on your `PATH`, every command below works in the short form;
|
|
41
|
+
otherwise use the full path `install` prints at the end. **On Windows the demo above runs natively; a
|
|
42
|
+
real clearance needs WSL2.** Native Windows clearances are planned for a later release. Until then the
|
|
43
|
+
engine does not run on native Windows: it resolves the reasoning CLI the POSIX way, and a clearance
|
|
44
|
+
started there refuses at preflight.
|
|
45
|
+
|
|
46
|
+
`npm install -g clearotron` also works where npm's global directory is yours to write. On a Linux Node
|
|
47
|
+
from the distribution or NodeSource that directory is `/usr`, owned by root, and npm refuses with
|
|
48
|
+
`EACCES`. Do not answer that with `sudo`; install under your home instead:
|
|
49
|
+
`npm install -g clearotron --prefix ~/.local`.
|
|
42
50
|
|
|
43
|
-
|
|
44
|
-
was published. If you want the newest code instead, a beta is published whenever there is
|
|
45
|
-
testing — deliberately, days apart, not on every merge:
|
|
51
|
+
`npx clearotron install` gives you the **stable** release — the one that has run a real clearance end to
|
|
52
|
+
end before it was published. If you want the newest code instead, a beta is published whenever there is
|
|
53
|
+
something worth testing — deliberately, days apart, not on every merge:
|
|
46
54
|
|
|
47
55
|
```bash
|
|
48
|
-
|
|
56
|
+
npx clearotron@beta install
|
|
49
57
|
```
|
|
50
58
|
|
|
51
59
|
What each channel promises, and when a stable is cut: [docs/RELEASES.md](docs/RELEASES.md). If you are not
|
|
@@ -53,17 +61,17 @@ sure, the first command is the one you want.
|
|
|
53
61
|
|
|
54
62
|
## Quick start
|
|
55
63
|
|
|
56
|
-
|
|
57
|
-
names whatever is still missing:
|
|
64
|
+
With it installed, check what it found before it does anything. `doctor` only reads — it writes nothing,
|
|
65
|
+
calls nobody, and names whatever is still missing:
|
|
58
66
|
|
|
59
67
|
```bash
|
|
60
|
-
|
|
68
|
+
clearotron doctor
|
|
61
69
|
```
|
|
62
70
|
|
|
63
|
-
Then start the product and open the portal it prints:
|
|
71
|
+
Then start the product and open the portal address it prints:
|
|
64
72
|
|
|
65
73
|
```bash
|
|
66
|
-
|
|
74
|
+
clearotron start
|
|
67
75
|
```
|
|
68
76
|
|
|
69
77
|
That is the portal a brand owner uses. Ordering a clearance is the same screen — describe it in a
|
|
@@ -82,11 +90,10 @@ rights-holders behind them by jurisdiction:
|
|
|
82
90
|
|
|
83
91
|

|
|
84
92
|
|
|
85
|
-
Then run your own
|
|
93
|
+
Then run your own: order it in the portal, or hand the engine a job file:
|
|
86
94
|
|
|
87
95
|
```bash
|
|
88
|
-
|
|
89
|
-
npx clearotron run --job my-job.json
|
|
96
|
+
clearotron run --job my-job.json
|
|
90
97
|
```
|
|
91
98
|
|
|
92
99
|
## How it fits together
|
package/bin/example.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import { spawn } from "node:child_process";
|
|
|
47
47
|
import { BRAND } from "../shared/brand.mjs"; // — the installer's own name, from the tenant seam
|
|
48
48
|
import { envFrom } from "../shared/env-aliases.mjs"; // — resolves EITHER spelling; names the retired one because that is the live-writable half
|
|
49
49
|
import { isFrozen, demoChildren, publishSource } from "../driver/demo-container.mjs"; // — one definition of what a frozen demo is, for the player AND the gate
|
|
50
|
+
import { ensureDemoProgram, demoProgramEnv } from "../shared/permanent-install.mjs";
|
|
50
51
|
|
|
51
52
|
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
52
53
|
|
|
@@ -369,8 +370,19 @@ console.log(` Removing this demo later is one directory: ${removeDirectory(dem
|
|
|
369
370
|
strayFromAnOlderDemo();
|
|
370
371
|
console.log("");
|
|
371
372
|
|
|
372
|
-
|
|
373
|
-
|
|
373
|
+
// ── RUN FROM NPX, THE SERVICES RUN FROM THE DEMO'S OWN COPY ─────────────────────────────────────────
|
|
374
|
+
//
|
|
375
|
+
// Started from npm's cache, the supervisor printed every command it gives the reader as `cd <npm's cache>
|
|
376
|
+
// && npx clearotron …`: the passphrase reset, the key, the free-port hint, `start --background`. Each one
|
|
377
|
+
// failed once npm cleaned that cache (measured on a published beta, 2026-09-11). So the copy the demo
|
|
378
|
+
// keeps in `<base>/program` is laid down first and the supervisor is started from it, which also puts the
|
|
379
|
+
// portal and the doors on a program that outlives the cache. Everywhere else, and when the copy cannot be
|
|
380
|
+
// made, it starts from here as before.
|
|
381
|
+
const programRoot = ensureDemoProgram({ base: demoBase, say: (line) => console.log(line) });
|
|
382
|
+
const startFrom = programRoot ?? REPO;
|
|
383
|
+
const child = spawn(process.execPath, [join(startFrom, "bin", "start.mjs"), ...startArgs], {
|
|
384
|
+
cwd: startFrom, stdio: ["ignore", "inherit", "inherit"],
|
|
385
|
+
env: programRoot ? demoProgramEnv(process.env) : process.env,
|
|
374
386
|
});
|
|
375
387
|
child.on("error", (e) => die(`demo: could not start the portal: ${String(e?.message ?? e)}`));
|
|
376
388
|
// Its exit code is the demo's. A supervisor that swallowed a child's refusal would report a demo that
|
package/bin/key.mjs
CHANGED
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
import "../shared/env-local.mjs"; // side effect: apply the install's .env — FIRST, before anything reads process.env
|
|
30
30
|
import { invocationPrefix } from "../shared/invocation.mjs";
|
|
31
31
|
import { existsSync, readFileSync } from "node:fs";
|
|
32
|
-
import { defaultGrantsPath } from "./start.mjs";
|
|
32
|
+
import { defaultGrantsPath, installPaths } from "./start.mjs";
|
|
33
|
+
import { demoTokenSecretPath } from "../shared/client-door.mjs";
|
|
34
|
+
import { resolvePerson } from "../shared/scope.mjs"; // the door's own reading of the guest list, so one answer serves both
|
|
33
35
|
import { mintFromOptions } from "../mcp-server/mint-token.mjs";
|
|
34
36
|
|
|
35
37
|
const argv = process.argv.slice(2);
|
|
@@ -44,6 +46,7 @@ const USAGE = `usage: ${p}clearotron key issue <email> [options]
|
|
|
44
46
|
|
|
45
47
|
--accounts a,b cap the key to these account keys as well (omit = whatever their grant allows)
|
|
46
48
|
--ttl-days <n> how long it is valid (default 90)
|
|
49
|
+
--base <dir> the install or demo to issue for, when it is not the one set up in ~/trademark
|
|
47
50
|
|
|
48
51
|
The token is printed ONCE on stdout and stored nowhere — possession is the credential. Everything
|
|
49
52
|
else goes to stderr, including the jti that revokes it.`;
|
|
@@ -65,6 +68,17 @@ const email = positional[0];
|
|
|
65
68
|
if (!email) die(`who is the key for? Give the person's email.\n\n${USAGE}`);
|
|
66
69
|
if (!email.includes("@")) die(`"${email}" is not an email address — the subject of an account key is the identity their assistant presents, and the grants file is keyed on it.`);
|
|
67
70
|
|
|
71
|
+
// `--base` NAMES THE INSTALL, as it does for `start` and `passphrase`: its guest list, and — for a demo,
|
|
72
|
+
// which keeps its signing secret in its own base rather than in any settings file — that secret. Without
|
|
73
|
+
// it the verb read the install's settings and refused to issue a key for a running demo.
|
|
74
|
+
const baseAt = rest.indexOf("--base");
|
|
75
|
+
const base = baseAt >= 0 ? rest[baseAt + 1] : null;
|
|
76
|
+
if (baseAt >= 0 && (!base || base.startsWith("--"))) die(`--base needs a directory.\n\n${USAGE}`);
|
|
77
|
+
if (base) {
|
|
78
|
+
const secretFile = demoTokenSecretPath(base);
|
|
79
|
+
if (existsSync(secretFile)) process.env.TRADEMARK_MCP_TOKEN_SECRET = readFileSync(secretFile, "utf8").trim();
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
const ttlDays = Number(flag("--ttl-days") ?? 90);
|
|
69
83
|
const accounts = flag("--accounts") ? flag("--accounts").split(",").map((s) => s.trim()).filter(Boolean) : null;
|
|
70
84
|
|
|
@@ -93,15 +107,19 @@ for (const line of minted.notes) console.error(line);
|
|
|
93
107
|
// inert. STDERR, so the token stays alone on stdout and `key issue ... > token.txt` keeps working —
|
|
94
108
|
// that split is deliberate and this must not undo it.
|
|
95
109
|
try {
|
|
96
|
-
const rosterPath = defaultGrantsPath();
|
|
110
|
+
const rosterPath = base ? installPaths(base).grants : defaultGrantsPath();
|
|
97
111
|
if (!existsSync(rosterPath)) {
|
|
98
112
|
console.error(`\nNOTE: no guest list at ${rosterPath} yet, so nothing grants ${email} anything and this key will be refused at the door. \`clearotron start\` writes the list; then: clearotron grant add ${email} --tenant <name> --accounts <brand-owner-key>`);
|
|
99
113
|
} else {
|
|
114
|
+
// THE DOOR'S OWN RESOLVER DECIDES, not a second reading of the file. This walked `tenants[].users`
|
|
115
|
+
// alone, and the demo's guest list grants its account through the top-level `people` map — so the
|
|
116
|
+
// demo's own key came with a note saying the door would refuse it, over a key the door accepts
|
|
117
|
+
// (driven 2026-09-11). One reader for one question.
|
|
100
118
|
const roster = JSON.parse(readFileSync(rosterPath, "utf8"));
|
|
101
|
-
const
|
|
102
|
-
const listed =
|
|
119
|
+
const person = resolvePerson(email, roster);
|
|
120
|
+
const listed = Boolean(person && (person.everything || person.accounts?.length || person.organisations?.length));
|
|
103
121
|
if (!listed)
|
|
104
|
-
console.error(`\nNOTE: ${email} is
|
|
122
|
+
console.error(`\nNOTE: ${email} is granted nothing in ${rosterPath}, so this key resolves to no accounts and the door will refuse it. Grant them access with: clearotron grant add ${email} --tenant <name> --accounts <brand-owner-key>`);
|
|
105
123
|
}
|
|
106
124
|
} catch (e) {
|
|
107
125
|
// A ROSTER THIS COMMAND CANNOT READ IS NOT A ROSTER SAYING THE SUBJECT IS ABSENT. Said as what it is,
|
package/bin/onboard.mjs
CHANGED
|
@@ -63,7 +63,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
63
63
|
import { stdin as input, stdout as output } from "node:process";
|
|
64
64
|
import { accessSync, constants, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, chmodSync } from "node:fs"; // read the process table here; moved that to shared/process-table.mjs
|
|
65
65
|
import { homedir, userInfo } from "node:os";
|
|
66
|
-
import { invocationPrefix } from "../shared/invocation.mjs"; // — one rule for how the reader invokes us
|
|
66
|
+
import { invocationPrefix, installRoute, reachableCommand } from "../shared/invocation.mjs"; // — one rule for how the reader invokes us
|
|
67
67
|
import { nodeFloorVerdict } from "../shared/node-floor.mjs"; // — the floor is package.json engines, not a constant here
|
|
68
68
|
import { invocationForm } from "../shared/invocation.mjs"; // — and WHY that form
|
|
69
69
|
import { standFrom } from "../shared/invocation.mjs"; // is this tree one npm replaces?
|
|
@@ -130,6 +130,12 @@ function readIfPresent(path) {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
133
|
+
// A PACKAGED INSTALL IS NOT A CHECKOUT. doctor says which one it is talking to, and names only commands
|
|
134
|
+
// that run there: npm's scripts and a checkout's tree are not where a package's reader stands.
|
|
135
|
+
const PACKAGED = installRoute(REPO) === "packaged";
|
|
136
|
+
const THIS_TREE = PACKAGED ? "the installed package" : "this checkout";
|
|
137
|
+
// The example job, by the path it has in THIS install, so the printed command runs from any directory.
|
|
138
|
+
const EXAMPLE_JOB = join(REPO, "examples", "job.euipo.json");
|
|
133
139
|
const ENV_PATH = envLocalPath({ repoRoot: REPO }); // resolved, never composed: one resolver, so moving this file later is one line
|
|
134
140
|
// WHAT IS READ IS NOT ALWAYS WHERE THE NEXT WRITE GOES. An install configured before the move still
|
|
135
141
|
// has its file at the old path, and the loader still reads it — so every READ here asks
|
|
@@ -1105,7 +1111,10 @@ export async function runCheck() {
|
|
|
1105
1111
|
say(" Restart whatever supervises them; this command does not, because which supervisor owns");
|
|
1106
1112
|
say(" them is a property of your deployment and not of this checkout.");
|
|
1107
1113
|
} else if (running.state === "unknown") {
|
|
1108
|
-
|
|
1114
|
+
// A PACKAGED INSTALL HAS NO TREE TO BE BEHIND. npm replaces the whole package on an update, so this
|
|
1115
|
+
// question belongs to a checkout, and on a package it is a `!` nothing can ever clear.
|
|
1116
|
+
if (PACKAGED) info("running programs against a checkout's tree: not applicable to a packaged install");
|
|
1117
|
+
else warn(`could not tell whether running programs are on the current tree: ${running.detail}`);
|
|
1109
1118
|
}
|
|
1110
1119
|
|
|
1111
1120
|
// ── AND THE MORE DANGEROUS ANSWER: A DIFFERENT TREE, NOT AN OLDER ONE ──────────────────────────────
|
|
@@ -1137,7 +1146,8 @@ export async function runCheck() {
|
|
|
1137
1146
|
if (elsewhere.programs.length > 6) say(` … and ${elsewhere.programs.length - 6} more`);
|
|
1138
1147
|
say(" Either point the install back at the tree they run from, or restart them onto this one.");
|
|
1139
1148
|
} else if (elsewhere.state === "unknown") {
|
|
1140
|
-
|
|
1149
|
+
if (PACKAGED && !configuredTree) info("running programs on a different checkout: not applicable to a packaged install");
|
|
1150
|
+
else warn(`could not tell whether running programs are on a different checkout: ${elsewhere.detail}`);
|
|
1141
1151
|
} else if (elsewhere.state === "unplaced") {
|
|
1142
1152
|
// PRINTED, because the alternative is silence that reads as a clean answer. Nothing here was placed
|
|
1143
1153
|
// on a tree, which is not the same as everything being on the right one, and the reader is the only
|
|
@@ -1343,7 +1353,7 @@ export async function runCheck() {
|
|
|
1343
1353
|
const installMode = engineMode(engineInventory(invEnv));
|
|
1344
1354
|
if (installMode === ENGINE_MODES.DEMO) {
|
|
1345
1355
|
info("MODE: demo — everything works except starting a NEW search. The example report, its audit trail "
|
|
1346
|
-
+
|
|
1356
|
+
+ `and the MCP connection are live right now; \`${reachableCommand("demo")}\` needs no engine.`);
|
|
1347
1357
|
// NAMES THE COMMAND, AND THE RESTART. The settings page says both now, and a doctor that named
|
|
1348
1358
|
// the program but not how to install it — or that left out the restart, which is what actually
|
|
1349
1359
|
// unsticks a reader who has just installed it — would be the third opinion this issue exists to
|
|
@@ -1493,6 +1503,9 @@ export async function runCheck() {
|
|
|
1493
1503
|
} else if (form.form === "shim-path") {
|
|
1494
1504
|
warn(`${form.dir} is not on this shell's PATH, so the bare \`clearotron\` will not resolve here`);
|
|
1495
1505
|
info(`add it with: export PATH="${form.dir}:$PATH" — or open a new login shell`);
|
|
1506
|
+
} else if (form.form === "npx-pinned") {
|
|
1507
|
+
info(`this is running from npm's npx cache, so the commands below name this version through npx (\`${form.prefix.trim()}\`), `
|
|
1508
|
+
+ `which works from any directory and after npm cleans its cache; \`${invoke("install")}\` puts \`clearotron\` on your PATH`);
|
|
1496
1509
|
} else if (form.shimKind === "ours-other-install") {
|
|
1497
1510
|
warn(`${form.shim} is a shim for a DIFFERENT install (${form.otherInstall})`);
|
|
1498
1511
|
info(`re-run \`${invoke("install")}\` to point the bare name at this one`);
|
|
@@ -1548,9 +1561,9 @@ export async function runCheck() {
|
|
|
1548
1561
|
{
|
|
1549
1562
|
const inCheckout = (p) => isInsideCheckout(p, REPO);
|
|
1550
1563
|
for (const [name, what] of [
|
|
1551
|
-
["CLEAROTRON_CUSTOMERS_DIR",
|
|
1552
|
-
["CLEAROTRON_INSTRUCTIONS_DIR",
|
|
1553
|
-
["PROFILE_REPO_ROOT",
|
|
1564
|
+
["CLEAROTRON_CUSTOMERS_DIR", `customers resolve to the bundled demo roster IN ${THIS_TREE}`],
|
|
1565
|
+
["CLEAROTRON_INSTRUCTIONS_DIR", `doctrine resolves to the bundled files IN ${THIS_TREE}`],
|
|
1566
|
+
["PROFILE_REPO_ROOT", `the portal's profile editor commits INTO ${THIS_TREE}`],
|
|
1554
1567
|
]) {
|
|
1555
1568
|
// — WHICH NAME THE READER IS TOLD IS NOT ONE QUESTION, IT IS TWO.
|
|
1556
1569
|
//
|
|
@@ -1891,7 +1904,8 @@ export async function runCheck() {
|
|
|
1891
1904
|
info(`the units are installed but their environment could not be read (${unitEnv?.why ?? "no reason given"}) — `
|
|
1892
1905
|
+ "whether the services read an overlay is not judged here");
|
|
1893
1906
|
}
|
|
1894
|
-
if (report.ok && report.overlayConfigured)
|
|
1907
|
+
if (report.ok && report.overlayConfigured)
|
|
1908
|
+
say(`\n Full detail: ${PACKAGED ? `node ${join(REPO, "scripts", "doctrine-report.mjs")}` : "npm run doctrine-report"}`);
|
|
1895
1909
|
} catch (e) {
|
|
1896
1910
|
// An unreadable overlay THROWS by design (config.resolveSkillPath refuses rather than falling back
|
|
1897
1911
|
// to the product's copy). Surfaced here rather than allowed to abort the whole check: the doctor's
|
|
@@ -2224,15 +2238,26 @@ export async function runCheck() {
|
|
|
2224
2238
|
say("\n Portal sign-in");
|
|
2225
2239
|
{
|
|
2226
2240
|
const { authView } = await import("../driver/portal-config-view.mjs");
|
|
2241
|
+
// A BOX WITH NO UNITS RUNS THE PORTAL `clearotron start` LAUNCHES, as the line below says, and start
|
|
2242
|
+
// hands that portal `PORTAL_AUTH_MODE=local` and refuses any other declared mode (bin/start.mjs). So on
|
|
2243
|
+
// such a box the door is start's, not the bare service's fronted default. Reading the bare default told
|
|
2244
|
+
// a fresh home that the portal would refuse to start and that nobody could use it; `start` then came
|
|
2245
|
+
// up on the local sign-in and created the grants file (measured on a published beta, 2026-09-11).
|
|
2246
|
+
const startLaunched = !hosted;
|
|
2247
|
+
const declaredAuth = String(effectiveForService("PORTAL_AUTH_MODE")?.v ?? "").trim();
|
|
2227
2248
|
const door = authView({
|
|
2228
|
-
mode:
|
|
2249
|
+
mode: startLaunched ? "local" : declaredAuth,
|
|
2229
2250
|
oidcIssuer: effectiveForService("PORTAL_OIDC_ISSUER")?.v ?? "",
|
|
2230
2251
|
team: effectiveForService("CF_ACCESS_TEAM")?.v ?? "",
|
|
2231
2252
|
jwksUrl: effectiveForService("PORTAL_JWKS_URL")?.v ?? "",
|
|
2232
2253
|
emailClaim: effectiveForService("PORTAL_EMAIL_CLAIM")?.v ?? "",
|
|
2233
2254
|
authHeader: effectiveForService("PORTAL_AUTH_HEADER")?.v ?? "",
|
|
2234
2255
|
});
|
|
2235
|
-
const typed =
|
|
2256
|
+
const typed = declaredAuth ? `PORTAL_AUTH_MODE=${declaredAuth}`
|
|
2257
|
+
: startLaunched ? "PORTAL_AUTH_MODE is unset, and `start` runs the local sign-in" : "PORTAL_AUTH_MODE is unset";
|
|
2258
|
+
if (startLaunched && declaredAuth && declaredAuth.toLowerCase() !== "local")
|
|
2259
|
+
problem(`${typed}: \`${reachableCommand("start")}\` refuses it, because start is the local install `
|
|
2260
|
+
+ "and runs the local sign-in; that mode belongs to a hosted deployment's units");
|
|
2236
2261
|
// NOT A TICK, AND THAT IS THE POINT. This reads the environment THIS command is
|
|
2237
2262
|
// typed in. `bin/start.mjs` INJECTS `PORTAL_AUTH_MODE: "local"` into the portal's own environment,
|
|
2238
2263
|
// and a systemd unit's EnvironmentFile can name a third thing — so a green tick here was a
|
|
@@ -2297,7 +2322,13 @@ export async function runCheck() {
|
|
|
2297
2322
|
const { makePrincipal } = await import("../driver/portal-access.mjs");
|
|
2298
2323
|
// ASKED OF THE SERVICE'S OWN ENVIRONMENT. Reading this command's file here is what produced a hard
|
|
2299
2324
|
// ✗ claiming nobody could use a portal that was admitting its operator on every request.
|
|
2300
|
-
|
|
2325
|
+
// ON A BOX `start` RUNS, an unset grants file is the one start creates on its first run, admitting
|
|
2326
|
+
// the person who started it (`paths.grants` in bin/start.mjs). Before that run it does not exist,
|
|
2327
|
+
// which is a first start still to come, not a portal nobody can use.
|
|
2328
|
+
const namedGrants = effectiveForService("CLEAROTRON_ACCESS_FILE")?.v ?? "";
|
|
2329
|
+
const startGrants = startLaunched && !namedGrants ? startPaths({ env: {} }).grants : null;
|
|
2330
|
+
const beforeFirstStart = Boolean(startGrants) && !existsSync(startGrants);
|
|
2331
|
+
const grantsFile = namedGrants || (beforeFirstStart ? "" : startGrants ?? "");
|
|
2301
2332
|
let grants = null, unreadable = null;
|
|
2302
2333
|
if (grantsFile) {
|
|
2303
2334
|
try { grants = JSON.parse(readFileSync(grantsFile, "utf8")); }
|
|
@@ -2310,7 +2341,10 @@ export async function runCheck() {
|
|
|
2310
2341
|
// A FAILURE TO LOOK IS NOT A LOCKOUT. On a hosted box whose unit environment
|
|
2311
2342
|
// could not be read, every name above resolves empty — which is indistinguishable from a box that
|
|
2312
2343
|
// has genuinely configured nothing, and would print the loudest ✗ in this command on no evidence.
|
|
2313
|
-
if (
|
|
2344
|
+
if (beforeFirstStart) {
|
|
2345
|
+
info(`no grants file yet: the first \`${reachableCommand("start")}\` creates ${startGrants} and gives the person `
|
|
2346
|
+
+ "who runs it access to everything");
|
|
2347
|
+
} else if (!serviceKnown) {
|
|
2314
2348
|
info("who may use this portal is not judged here: the units' environment could not be read, so a "
|
|
2315
2349
|
+ "grants file configured there would be invisible to this check");
|
|
2316
2350
|
} else if (unreadable) {
|
|
@@ -3945,11 +3979,13 @@ try {
|
|
|
3945
3979
|
|
|
3946
3980
|
say(`\n ${style.bold("Start here:")}\n`);
|
|
3947
3981
|
say(` ${invocationPrefix()}clearotron start\n`);
|
|
3948
|
-
say(" Starts the portal and the engine door
|
|
3949
|
-
say(" the product: you order a clearance from it and read the report there.\n");
|
|
3982
|
+
say(" Starts the portal and the engine door and prints one address to open in your browser. That");
|
|
3983
|
+
say(" address is the product: you order a clearance from it and read the report there.\n");
|
|
3950
3984
|
say(` ${style.dim(`Also: \`${invocationPrefix()}clearotron demo\` replays a finished report with no keys and no model calls;`)}`);
|
|
3951
|
-
say(` ${style.dim(`\`${invocationPrefix()}clearotron run --job
|
|
3952
|
-
|
|
3985
|
+
say(` ${style.dim(`\`${invocationPrefix()}clearotron run --job ${/\s/.test(EXAMPLE_JOB) ? `"${EXAMPLE_JOB}"` : EXAMPLE_JOB}\` runs a first real clearance on the EU register.`)}`);
|
|
3986
|
+
// THE OLD WAY IS A CHECKOUT'S. A package has no npm scripts where its reader stands.
|
|
3987
|
+
if (!PACKAGED) say(` ${style.dim("Each still works the old way too — `npm start`, `npm run example`, `node driver/pipeline.mjs`.")}`);
|
|
3988
|
+
say("");
|
|
3953
3989
|
|
|
3954
3990
|
// WHY THOSE LINES LOOK THE WAY THEY DO, when they are not the bare verb.
|
|
3955
3991
|
//
|
package/bin/start.mjs
CHANGED
|
@@ -111,22 +111,23 @@ async function runTables() {
|
|
|
111
111
|
import { spawn, spawnSync, execFileSync } from "node:child_process";
|
|
112
112
|
import { storeInRepo, storeOutsideRepoMessage, storeCommitRefusal } from "../shared/store-in-repo.mjs"; //
|
|
113
113
|
import { stdioConnectOffer } from "../shared/stdio-connect.mjs";
|
|
114
|
-
import {
|
|
114
|
+
import { ensureDemoProgram } from "../shared/permanent-install.mjs"; // — a demo from npx keeps its own copy
|
|
115
115
|
import { mergeEnvFile } from "../shared/env-file-merge.mjs";
|
|
116
116
|
import { mcpOriginFor } from "../shared/lane-address.mjs"; // — one author for the origin
|
|
117
117
|
import { SERVER_INSTALL_SET, unitsToRestartOnRefresh, unitHealthVerdict } from "../shared/server-units.mjs"; // — one authority, two callers
|
|
118
118
|
// — the door --background now INSTALLS, and the one authority for the settings
|
|
119
119
|
// it refuses to start without. (Until 2026-09-03 this import read "the one unit --background may
|
|
120
120
|
// tolerate and never manage"; settled point 2 superseded that.)
|
|
121
|
-
import { defaultDenylistPath, denylistPathFor, denylistFor, ensureDenylistFile, CLIENT_DOOR_UNIT, enablePlan, clientDoorPort } from "../shared/client-door.mjs"; // — one owner for the revocation list's path
|
|
121
|
+
import { defaultDenylistPath, denylistPathFor, denylistFor, ensureDenylistFile, CLIENT_DOOR_UNIT, enablePlan, clientDoorPort, demoTokenSecret, demoTokenSecretPath, keyIssueCommand } from "../shared/client-door.mjs"; // — one owner for the revocation list's path
|
|
122
122
|
import { createServer } from "node:net";
|
|
123
123
|
import { listenErrorMessage, nextFreePort } from "../shared/listen.mjs";
|
|
124
124
|
import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
125
125
|
import { randomBytes } from "node:crypto";
|
|
126
|
-
import { invocationPrefix, invoke } from "../shared/invocation.mjs"; // — the banner names the verb
|
|
127
|
-
import { unitEnvPath } from "../shared/env-local.mjs"; // — the file the units read, named once
|
|
126
|
+
import { invocationPrefix, invoke, reachableCommand } from "../shared/invocation.mjs"; // — the banner names the verb
|
|
127
|
+
import { unitEnvPath, activeEnvPath } from "../shared/env-local.mjs"; // — the file the units read, named once
|
|
128
|
+
import { parseEnvFile } from "../shared/env-file-merge.mjs"; // ONE KEY=value reader, in a leaf: render-units is a COMMAND, and importing it from here closed a cycle
|
|
128
129
|
import { homedir, userInfo } from "node:os";
|
|
129
|
-
import { dirname, join } from "node:path";
|
|
130
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
130
131
|
import { fileURLToPath } from "node:url";
|
|
131
132
|
import { usageBlock } from "../shared/usage-block.mjs";
|
|
132
133
|
import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-point test, all spellings
|
|
@@ -142,6 +143,7 @@ import { addressRefusal } from "../shared/staff-domain.mjs";
|
|
|
142
143
|
import { withPerson, withOrganisation, withCompany } from "../shared/grants-edit.mjs";
|
|
143
144
|
import { assertGrantsShape, resolvePerson } from "../shared/scope.mjs";
|
|
144
145
|
import { backgroundManager } from "../shared/os-advice.mjs";
|
|
146
|
+
import { recordRunning } from "../shared/running-start.mjs";
|
|
145
147
|
import { frontingVariablesSet } from "../shared/install-auth.mjs"; // — one owner for what counts as a proxy in front of a door
|
|
146
148
|
|
|
147
149
|
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -909,6 +911,11 @@ if (isMain) {
|
|
|
909
911
|
// Decided before any path is, because in a demo every path below is the demo's own. The posture
|
|
910
912
|
// itself is described at the DEMO block further down.
|
|
911
913
|
const DEMO = argv.includes("--demo");
|
|
914
|
+
// A DEMO HAS NO BACKGROUND FORM, and asking for one is refused before anything is written. The
|
|
915
|
+
// background path installs units that run the reader's own install, so `--demo --background` would set
|
|
916
|
+
// up an empty install in their home and call it the demo.
|
|
917
|
+
if (DEMO && wantBackground)
|
|
918
|
+
fatal("the demo has no background form — it runs as long as its terminal does. `--background` starts your own install as services, not the demo.");
|
|
912
919
|
// The same base `npm run setup` writes under, so whichever of the two a reader ran first, the other
|
|
913
920
|
// finds the same install rather than a second one beside it.
|
|
914
921
|
// Whatever the environment already says wins over the base-derived default, for every path — a reader
|
|
@@ -916,6 +923,35 @@ if (isMain) {
|
|
|
916
923
|
// which `startPaths` says why. One author, because `doctor` asks the same function what the services
|
|
917
924
|
// were handed.
|
|
918
925
|
const paths = startPaths({ env: process.env, base: flag("--base", join(homedir(), DEMO ? "trademark-demo" : "trademark")), demo: DEMO });
|
|
926
|
+
// ── NOTHING OF THE DEMO LANDS IN AN INSTALL, AND THAT IS CHECKED BEFORE ANYTHING IS WRITTEN ───────
|
|
927
|
+
//
|
|
928
|
+
// The demo keeps its signing secret in its base and `key issue --base` reads it from there, so a demo
|
|
929
|
+
// pointed at an install's directory would leave a secret where that install's own key command looks:
|
|
930
|
+
// every key issued afterwards would be signed with the demo's while the install's door verified with
|
|
931
|
+
// its own — a key that looks issued and is refused, with nothing said either way. The settings are
|
|
932
|
+
// READ here and never applied; a demo still takes nothing from them.
|
|
933
|
+
if (DEMO) {
|
|
934
|
+
const base = resolve(paths.base);
|
|
935
|
+
const inside = (p) => { const v = String(p ?? "").trim(); if (!v) return false; const r = resolve(v); return r === base || r.startsWith(base + sep); };
|
|
936
|
+
let settings = {};
|
|
937
|
+
try { settings = parseEnvFile(readFileSync(activeEnvPath(), "utf8")); } catch { /* no settings in force: nothing of an install to collide with */ }
|
|
938
|
+
const found = [];
|
|
939
|
+
if (inside(settings.CLEAROTRON_REPORTS_DIR)) found.push(`an install keeps its reports in ${settings.CLEAROTRON_REPORTS_DIR}`);
|
|
940
|
+
if (inside(settings.RECIPE_REPO_ROOT)) found.push(`an install keeps its saved searches in ${settings.RECIPE_REPO_ROOT}`);
|
|
941
|
+
if (inside(settings.CLEAROTRON_WORK_DIR)) found.push(`an install works in ${settings.CLEAROTRON_WORK_DIR}`);
|
|
942
|
+
if (existsSync(join(paths.base, ".env"))) found.push(`it holds a settings file, ${join(paths.base, ".env")}`);
|
|
943
|
+
if (!found.length && base === resolve(join(homedir(), "trademark"))) found.push("it is the directory an install is set up in by default");
|
|
944
|
+
// AN INSTALL NOBODY CONFIGURED still answers. `start --base <dir>` writes a guest list on every start
|
|
945
|
+
// and no settings at all, so an install somewhere of its own passes every check above. A guest list
|
|
946
|
+
// with no demo secret beside it is somebody's install; a demo's own base has both.
|
|
947
|
+
if (!found.length && existsSync(paths.grants) && !existsSync(demoTokenSecretPath(paths.base)))
|
|
948
|
+
found.push(`it holds a guest list, ${paths.grants}, and no demo of its own`);
|
|
949
|
+
if (found.length)
|
|
950
|
+
fatal(`--demo cannot run in ${paths.base}: ${found.join("; ")}.\n`
|
|
951
|
+
+ " The demo keeps its own data, and its own signing secret, in its base. Leaving those in an\n"
|
|
952
|
+
+ " install's directory would make keys issued for that install refuse at its door.\n"
|
|
953
|
+
+ " Run the demo without --base, or give it a directory of its own.");
|
|
954
|
+
}
|
|
919
955
|
// ── THIS INSTALL'S FIRST START, read before this start writes either file that answers it ────────────
|
|
920
956
|
//
|
|
921
957
|
// The grants file and the config store's repository are both written further down, on every start
|
|
@@ -1171,7 +1207,15 @@ if (isMain) {
|
|
|
1171
1207
|
return v;
|
|
1172
1208
|
};
|
|
1173
1209
|
const portalSecret = secretFor("PORTAL_SECRET");
|
|
1174
|
-
|
|
1210
|
+
// A DEMO'S SIGNING SECRET IS KEPT IN ITS OWN BASE, so the key command it prints can sign for it; see
|
|
1211
|
+
// `demoTokenSecret`. Every other secret a demo uses stays in memory.
|
|
1212
|
+
const tokenSecret = DEMO
|
|
1213
|
+
? demoTokenSecret(paths.base, {
|
|
1214
|
+
read: (f) => readFileSync(f, "utf8"),
|
|
1215
|
+
write: (f, text) => { mkdirSync(dirname(f), { recursive: true }); writeSecretFile(f, text); },
|
|
1216
|
+
mint: () => randomBytes(32).toString("base64url"),
|
|
1217
|
+
})
|
|
1218
|
+
: secretFor("TRADEMARK_MCP_TOKEN_SECRET");
|
|
1175
1219
|
if (!process.env.PORTAL_LOCAL_USER) generated.PORTAL_LOCAL_USER = user;
|
|
1176
1220
|
const stores = DEMO ? {} : storesForOtherReaders(paths);
|
|
1177
1221
|
|
|
@@ -1253,7 +1297,7 @@ if (isMain) {
|
|
|
1253
1297
|
say(` grants ${paths.grants} named nobody, so ${user} was added: Run, Manage, access to everything`);
|
|
1254
1298
|
if (seeded.changed.includes("organisation")) say(` organisation "${organisation}", filed there — rename it there too`);
|
|
1255
1299
|
else if (seeded.changed.includes("person") && !Object.keys(seeded.grants.tenants ?? {}).length)
|
|
1256
|
-
say(` no organisation named yet — \`${
|
|
1300
|
+
say(` no organisation named yet — \`${reachableCommand('start --organisation "<name>"')}\` files the first one`);
|
|
1257
1301
|
// REPORTED, NOT REPAIRED. A file that names other people is somebody's decision, and adding this
|
|
1258
1302
|
// address to it would give access to everything to an address nobody enrolled.
|
|
1259
1303
|
if (seeded.unadmitted)
|
|
@@ -1464,25 +1508,9 @@ if (isMain) {
|
|
|
1464
1508
|
// The same version goes into `<base>/program` (shared/permanent-install.mjs), BEFORE the services start
|
|
1465
1509
|
// so the portal's connect rows name it too, and only when it is not there already. It never stops the
|
|
1466
1510
|
// demo: if npm fails, the demo runs as before and says the line will not survive a cache clean.
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
if (plan?.current) demoProgramRoot = plan.root;
|
|
1471
|
-
else if (plan && !plan.skip) {
|
|
1472
|
-
say(` program copying clearotron ${plan.version} into ${plan.prefix}, so your assistant's connection survives npm cleaning its cache`);
|
|
1473
|
-
// The npm that launched this, when npm says which: no second npm is guessed at.
|
|
1474
|
-
const npmCli = process.env.npm_execpath;
|
|
1475
|
-
const r = npmCli && existsSync(npmCli)
|
|
1476
|
-
? spawnSync(process.execPath, [npmCli, ...plan.npmArgs], { stdio: ["ignore", "ignore", "pipe"], encoding: "utf8", timeout: 180_000 })
|
|
1477
|
-
: spawnSync("npm", plan.npmArgs, { stdio: ["ignore", "ignore", "pipe"], encoding: "utf8", timeout: 180_000 });
|
|
1478
|
-
if (r.status === 0 && existsSync(join(plan.root, "mcp-server", "server.mjs"))) demoProgramRoot = plan.root;
|
|
1479
|
-
else {
|
|
1480
|
-
const why = r.error ? r.error.message : String(r.stderr ?? "").trim().split("\n").pop() || `npm exited ${r.status}`;
|
|
1481
|
-
say(` program could not be copied (${why}). The connect line below runs from npm's temporary cache,`);
|
|
1482
|
-
say(" so it stops working when npm cleans that cache; start the demo again to retry.");
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1485
|
-
}
|
|
1511
|
+
// `clearotron demo` makes the copy before it starts this file, and starts this file FROM the copy, so
|
|
1512
|
+
// there this finds nothing to do; it acts for a `start --demo` typed at npx directly.
|
|
1513
|
+
const demoProgramRoot = DEMO ? ensureDemoProgram({ base: paths.base, say }) : null;
|
|
1486
1514
|
|
|
1487
1515
|
const envs = childEnv({ ports, paths, user, portalSecret, tokenSecret, opsToken,
|
|
1488
1516
|
localWorker: wantWorker, demo: DEMO, clientFence: declaredFence || null,
|
|
@@ -2192,12 +2220,26 @@ if (isMain) {
|
|
|
2192
2220
|
if (adoptedClientDoor)
|
|
2193
2221
|
say(` Already running as ${CLIENT_DOOR_UNIT}; this start kept it, so existing keys still work.`);
|
|
2194
2222
|
else
|
|
2195
|
-
say(` It refuses every caller until a key is issued: ${invocationPrefix()
|
|
2223
|
+
say(` It refuses every caller until a key is issued: ${keyIssueCommand({ prefix: invocationPrefix(), demo: DEMO, user, base: paths.base, defaultBase: join(homedir(), "trademark") })}`);
|
|
2196
2224
|
} else {
|
|
2197
2225
|
say(` Client door NOT RUNNING on ${HOST}:${ports.client} — its output above says why. The portal and`);
|
|
2198
2226
|
say(" the engine door are unaffected; a client assistant cannot connect until it is up.");
|
|
2199
2227
|
}
|
|
2200
2228
|
say("");
|
|
2229
|
+
// ── WHAT `status` AND `stop` READ ABOUT THIS START ───────────────────────────────────────────────
|
|
2230
|
+
//
|
|
2231
|
+
// Both verbs knew only about background units, so with this start serving, `status` described units
|
|
2232
|
+
// nobody installed and `stop` said nothing was running (measured on a published beta, 2026-09-11). The
|
|
2233
|
+
// record is the address this banner just printed. It goes on every exit — Ctrl-C, a fatal refusal, a
|
|
2234
|
+
// crash — and a start killed outright leaves a record whose process is gone, which the readers ignore.
|
|
2235
|
+
try {
|
|
2236
|
+
const forget = recordRunning({ pid: process.pid, demo: DEMO, base: paths.base, url: envs.url, host: HOST,
|
|
2237
|
+
ports: { portal: ports.portal, mcp: ports.mcp, client: doorRunning ? ports.client : null },
|
|
2238
|
+
startedAt: new Date().toISOString() });
|
|
2239
|
+
process.on("exit", forget);
|
|
2240
|
+
} catch (e) {
|
|
2241
|
+
say(` (\`status\` will not see this start: its record could not be written — ${String(e?.message ?? e)})`);
|
|
2242
|
+
}
|
|
2201
2243
|
// — THE BANNER TOLD THE SAME STORY ON EVERY START, and it was only true of the first.
|
|
2202
2244
|
//
|
|
2203
2245
|
// "printed once, above" describes what a FIRST start does. On every start after it the passphrase was
|
|
@@ -2289,17 +2331,35 @@ if (isMain) {
|
|
|
2289
2331
|
// cannot succeed and given a service manager that is not on the machine and cannot be put there.
|
|
2290
2332
|
// Reported from a real run. Same rule as the engine refusal above: do not name a route this platform
|
|
2291
2333
|
// does not have.
|
|
2292
|
-
const manager
|
|
2293
|
-
if (manager) {
|
|
2294
|
-
say(` To get your prompt back instead, stop this and run ${invoke("start")} --background`);
|
|
2295
|
-
say(` — same product, managed by ${manager}, and it survives logout.`);
|
|
2296
|
-
} else {
|
|
2297
|
-
say(" There is no background form on this platform: the product runs as long as this window does.");
|
|
2298
|
-
say(" Leave it open and use a second terminal for the commands above.");
|
|
2299
|
-
}
|
|
2334
|
+
for (const line of backgroundOfferLines({ demo: DEMO, manager: backgroundManager(), start: invoke("start") })) say(line);
|
|
2300
2335
|
say("");
|
|
2301
2336
|
}
|
|
2302
2337
|
|
|
2338
|
+
/**
|
|
2339
|
+
* The closing offer of a foreground start: how to get the prompt back, where there is a way.
|
|
2340
|
+
*
|
|
2341
|
+
* NOT IN A DEMO. The offer ran `start --background`, which set up a new, empty install in the reader's
|
|
2342
|
+
* home — not the demo, its samples or its sign-in — and then failed where the user's systemd was not
|
|
2343
|
+
* reachable (measured on a published beta, 2026-09-11). A demo has no background form, so it is not
|
|
2344
|
+
* offered one. Where the offer stands, it names what it needs BEFORE the reader stops what is running.
|
|
2345
|
+
* PURE.
|
|
2346
|
+
*/
|
|
2347
|
+
export function backgroundOfferLines({ demo = false, manager = null, start = "clearotron start" } = {}) {
|
|
2348
|
+
if (demo) return [
|
|
2349
|
+
" The demo has no background form: it runs as long as this window does. Leave it open and use a",
|
|
2350
|
+
" second terminal for the commands above.",
|
|
2351
|
+
];
|
|
2352
|
+
if (!manager) return [
|
|
2353
|
+
" There is no background form on this platform: the product runs as long as this window does.",
|
|
2354
|
+
" Leave it open and use a second terminal for the commands above.",
|
|
2355
|
+
];
|
|
2356
|
+
return [
|
|
2357
|
+
` To get your prompt back instead, stop this and run ${start} --background`,
|
|
2358
|
+
` — same product, managed by ${manager}, and it survives logout. It needs ${manager}'s user manager`,
|
|
2359
|
+
" reachable from this session; where it is not, that command says so and changes nothing you use.",
|
|
2360
|
+
];
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2303
2363
|
/**
|
|
2304
2364
|
* What a reader is told when a child this install cannot run without exits.
|
|
2305
2365
|
*
|
package/bin/status.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { BACKGROUND_UNITS, resolvePorts } from "./start.mjs";
|
|
|
18
18
|
import { parseEnvFile } from "../driver/systemd/render-units.mjs"; // ONE KEY=value reader — what systemd actually reads
|
|
19
19
|
import { CLIENT_DOOR_UNIT, clientDoorPort } from "../shared/client-door.mjs";
|
|
20
20
|
import { invoke } from "../shared/invocation.mjs";
|
|
21
|
+
import { readRunning, probe } from "../shared/running-start.mjs";
|
|
21
22
|
import { configStaleness, stalenessWarning, parseSystemdTimestamp } from "../driver/config-staleness.mjs"; // — F48
|
|
22
23
|
|
|
23
24
|
const UNIT_DIR = join(homedir(), ".config", "systemd", "user");
|
|
@@ -57,15 +58,34 @@ const startedEpochMs = (u) => {
|
|
|
57
58
|
return parseSystemdTimestamp(line?.slice("ActiveEnterTimestamp=".length));
|
|
58
59
|
} catch { return null; }
|
|
59
60
|
};say("");
|
|
61
|
+
// ── A FOREGROUND START ANSWERS HERE TOO ─────────────────────────────────────────────────────────────
|
|
62
|
+
//
|
|
63
|
+
// The README starts the product in a terminal, and this verb answered only for background units: with
|
|
64
|
+
// everything up it printed a sentence about units the reader never installed (measured on a published
|
|
65
|
+
// beta, 2026-09-11). The record gives the address `start` printed; the portal itself says whether it is up.
|
|
66
|
+
const running = readRunning();
|
|
67
|
+
for (const r of running) {
|
|
68
|
+
const what = r.demo ? "The demo" : "The product";
|
|
69
|
+
// The portal's liveness route, which answers without a session: the address itself asks for a sign-in
|
|
70
|
+
// and read as "not answering" over a portal that was up (driven 2026-09-11).
|
|
71
|
+
if (await probe(new URL("/portal/health", r.url).href)) say(` ${what} is up, in the foreground — a terminal holds \`start\` (pid ${r.pid}); Ctrl-C there stops it.`);
|
|
72
|
+
else say(` ${what} was started in the foreground (pid ${r.pid}), but its portal at ${r.url} is not answering.`);
|
|
73
|
+
say(` Open ${r.url}`);
|
|
74
|
+
say(` Engine door http://${r.host}:${r.ports.mcp}/mcp`);
|
|
75
|
+
if (r.ports.client) say(` Client door http://${r.host}:${r.ports.client}/mcp`);
|
|
76
|
+
say("");
|
|
77
|
+
}
|
|
60
78
|
let installed = 0;
|
|
61
79
|
for (const u of BACKGROUND_UNITS) {
|
|
62
80
|
if (!existsSync(join(UNIT_DIR, u))) continue;
|
|
63
81
|
installed++;
|
|
64
82
|
say(` ${u.padEnd(34)} ${state(u)}`);
|
|
65
83
|
}
|
|
66
|
-
if (!installed) {
|
|
67
|
-
say(" No background units are installed
|
|
68
|
-
|
|
84
|
+
if (!installed && running.length) {
|
|
85
|
+
say(" No background units are installed.");
|
|
86
|
+
} else if (!installed) {
|
|
87
|
+
say(" The product is not running: no terminal holds `start`, and no background units are installed.");
|
|
88
|
+
say(` \`${invoke("start")}\` runs it in a terminal; \`${invoke("start")} --background\` runs it as services that survive logout.`);
|
|
69
89
|
} else {
|
|
70
90
|
// The ports come from the same resolver start uses, RESOLVED OVER THE UNITS' OWN FILE: the running
|
|
71
91
|
// services read %h/.env via systemd, and this CLI's shell env may know nothing of it — the first
|