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 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 checked out.**
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 a finished
29
- clearance into a local pool and opens the report in your browser. No account, no credentials, no network
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
- npm install -g clearotron
35
+ npx clearotron install
36
36
  ```
37
37
 
38
- Node 22.13 or newer, on macOS or Linux. That puts `clearotron` on your `PATH`; every command below
39
- works in that short form. **On Windows the demo above runs natively; a real clearance needs WSL2.**
40
- Native Windows clearances are planned for a later release. Until then the engine does not run on native
41
- Windows: it resolves the reasoning CLI the POSIX way, and a clearance started there refuses at preflight.
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
- That command gives you the **stable** release — the one that has run a real clearance end to end before it
44
- was published. If you want the newest code instead, a beta is published whenever there is something worth
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
- npm install -g clearotron@beta
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
- Check the install before it does anything. `doctor` only reads — it writes nothing, calls nobody, and
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
- npx clearotron doctor
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
- npx clearotron start
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
  ![The conflict landscape, with rights-holders grouped by jurisdiction](docs/assets/portal-conflict-landscape.jpg)
84
92
 
85
- Then run your own. `install` asks one question at a time and checks each credential before it saves it:
93
+ Then run your own: order it in the portal, or hand the engine a job file:
86
94
 
87
95
  ```bash
88
- npx clearotron install
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
- const child = spawn(process.execPath, [join(REPO, "bin", "start.mjs"), ...startArgs], {
373
- cwd: REPO, stdio: ["ignore", "inherit", "inherit"],
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 tenants = roster?.tenants && typeof roster.tenants === "object" ? roster.tenants : {};
102
- const listed = Object.values(tenants).some((t) => t?.users && Object.prototype.hasOwnProperty.call(t.users, email));
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 on no tenant 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>`);
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
- warn(`could not tell whether running programs are on the current tree: ${running.detail}`);
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
- warn(`could not tell whether running programs are on a different checkout: ${elsewhere.detail}`);
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
- + "and the MCP connection are live right now; `npm run example` needs no engine.");
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", "customers resolve to the bundled demo roster IN this checkout"],
1552
- ["CLEAROTRON_INSTRUCTIONS_DIR", "doctrine resolves to the bundled files IN this checkout"],
1553
- ["PROFILE_REPO_ROOT", "the portal's profile editor commits INTO this checkout"],
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) say("\n Full detail: npm run doctrine-report");
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: effectiveForService("PORTAL_AUTH_MODE")?.v ?? "",
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 = door.declared ? `PORTAL_AUTH_MODE=${door.declared}` : "PORTAL_AUTH_MODE is unset";
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
- const grantsFile = effectiveForService("CLEAROTRON_ACCESS_FILE")?.v ?? "";
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 (!serviceKnown) {
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, prints one address, and opens it. That address is");
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 examples/job.euipo.json\` runs a first real clearance on the EU register.`)}`);
3952
- say(` ${style.dim("Each still works the old way too `npm start`, `npm run example`, `node driver/pipeline.mjs`.")}\n`);
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 { demoProgramPlan } from "../shared/permanent-install.mjs"; // — a demo from npx keeps its own copy
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
- const tokenSecret = secretFor("TRADEMARK_MCP_TOKEN_SECRET");
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 — \`${invocationPrefix()}clearotron start --organisation "<name>"\` files the first one`);
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
- let demoProgramRoot = null;
1468
- if (DEMO) {
1469
- const plan = demoProgramPlan({ base: paths.base });
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()}clearotron key issue <email>`);
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 = backgroundManager();
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 — this box runs the product only while a terminal holds");
68
- say(` \`clearotron start\`. \`clearotron start --background\` is the form that survives the terminal.`);
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