clearotron 0.3.0-beta.7 → 0.3.0-beta.8

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/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
@@ -1548,9 +1558,9 @@ export async function runCheck() {
1548
1558
  {
1549
1559
  const inCheckout = (p) => isInsideCheckout(p, REPO);
1550
1560
  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"],
1561
+ ["CLEAROTRON_CUSTOMERS_DIR", `customers resolve to the bundled demo roster IN ${THIS_TREE}`],
1562
+ ["CLEAROTRON_INSTRUCTIONS_DIR", `doctrine resolves to the bundled files IN ${THIS_TREE}`],
1563
+ ["PROFILE_REPO_ROOT", `the portal's profile editor commits INTO ${THIS_TREE}`],
1554
1564
  ]) {
1555
1565
  // — WHICH NAME THE READER IS TOLD IS NOT ONE QUESTION, IT IS TWO.
1556
1566
  //
@@ -1891,7 +1901,8 @@ export async function runCheck() {
1891
1901
  info(`the units are installed but their environment could not be read (${unitEnv?.why ?? "no reason given"}) — `
1892
1902
  + "whether the services read an overlay is not judged here");
1893
1903
  }
1894
- if (report.ok && report.overlayConfigured) say("\n Full detail: npm run doctrine-report");
1904
+ if (report.ok && report.overlayConfigured)
1905
+ say(`\n Full detail: ${PACKAGED ? `node ${join(REPO, "scripts", "doctrine-report.mjs")}` : "npm run doctrine-report"}`);
1895
1906
  } catch (e) {
1896
1907
  // An unreadable overlay THROWS by design (config.resolveSkillPath refuses rather than falling back
1897
1908
  // to the product's copy). Surfaced here rather than allowed to abort the whole check: the doctor's
@@ -2224,15 +2235,26 @@ export async function runCheck() {
2224
2235
  say("\n Portal sign-in");
2225
2236
  {
2226
2237
  const { authView } = await import("../driver/portal-config-view.mjs");
2238
+ // A BOX WITH NO UNITS RUNS THE PORTAL `clearotron start` LAUNCHES, as the line below says, and start
2239
+ // hands that portal `PORTAL_AUTH_MODE=local` and refuses any other declared mode (bin/start.mjs). So on
2240
+ // such a box the door is start's, not the bare service's fronted default. Reading the bare default told
2241
+ // a fresh home that the portal would refuse to start and that nobody could use it; `start` then came
2242
+ // up on the local sign-in and created the grants file (measured on a published beta, 2026-09-11).
2243
+ const startLaunched = !hosted;
2244
+ const declaredAuth = String(effectiveForService("PORTAL_AUTH_MODE")?.v ?? "").trim();
2227
2245
  const door = authView({
2228
- mode: effectiveForService("PORTAL_AUTH_MODE")?.v ?? "",
2246
+ mode: startLaunched ? "local" : declaredAuth,
2229
2247
  oidcIssuer: effectiveForService("PORTAL_OIDC_ISSUER")?.v ?? "",
2230
2248
  team: effectiveForService("CF_ACCESS_TEAM")?.v ?? "",
2231
2249
  jwksUrl: effectiveForService("PORTAL_JWKS_URL")?.v ?? "",
2232
2250
  emailClaim: effectiveForService("PORTAL_EMAIL_CLAIM")?.v ?? "",
2233
2251
  authHeader: effectiveForService("PORTAL_AUTH_HEADER")?.v ?? "",
2234
2252
  });
2235
- const typed = door.declared ? `PORTAL_AUTH_MODE=${door.declared}` : "PORTAL_AUTH_MODE is unset";
2253
+ const typed = declaredAuth ? `PORTAL_AUTH_MODE=${declaredAuth}`
2254
+ : startLaunched ? "PORTAL_AUTH_MODE is unset, and `start` runs the local sign-in" : "PORTAL_AUTH_MODE is unset";
2255
+ if (startLaunched && declaredAuth && declaredAuth.toLowerCase() !== "local")
2256
+ problem(`${typed}: \`${reachableCommand("start")}\` refuses it, because start is the local install `
2257
+ + "and runs the local sign-in; that mode belongs to a hosted deployment's units");
2236
2258
  // NOT A TICK, AND THAT IS THE POINT. This reads the environment THIS command is
2237
2259
  // typed in. `bin/start.mjs` INJECTS `PORTAL_AUTH_MODE: "local"` into the portal's own environment,
2238
2260
  // and a systemd unit's EnvironmentFile can name a third thing — so a green tick here was a
@@ -2297,7 +2319,13 @@ export async function runCheck() {
2297
2319
  const { makePrincipal } = await import("../driver/portal-access.mjs");
2298
2320
  // ASKED OF THE SERVICE'S OWN ENVIRONMENT. Reading this command's file here is what produced a hard
2299
2321
  // ✗ claiming nobody could use a portal that was admitting its operator on every request.
2300
- const grantsFile = effectiveForService("CLEAROTRON_ACCESS_FILE")?.v ?? "";
2322
+ // ON A BOX `start` RUNS, an unset grants file is the one start creates on its first run, admitting
2323
+ // the person who started it (`paths.grants` in bin/start.mjs). Before that run it does not exist,
2324
+ // which is a first start still to come, not a portal nobody can use.
2325
+ const namedGrants = effectiveForService("CLEAROTRON_ACCESS_FILE")?.v ?? "";
2326
+ const startGrants = startLaunched && !namedGrants ? startPaths({ env: {} }).grants : null;
2327
+ const beforeFirstStart = Boolean(startGrants) && !existsSync(startGrants);
2328
+ const grantsFile = namedGrants || (beforeFirstStart ? "" : startGrants ?? "");
2301
2329
  let grants = null, unreadable = null;
2302
2330
  if (grantsFile) {
2303
2331
  try { grants = JSON.parse(readFileSync(grantsFile, "utf8")); }
@@ -2310,7 +2338,10 @@ export async function runCheck() {
2310
2338
  // A FAILURE TO LOOK IS NOT A LOCKOUT. On a hosted box whose unit environment
2311
2339
  // could not be read, every name above resolves empty — which is indistinguishable from a box that
2312
2340
  // has genuinely configured nothing, and would print the loudest ✗ in this command on no evidence.
2313
- if (!serviceKnown) {
2341
+ if (beforeFirstStart) {
2342
+ info(`no grants file yet: the first \`${reachableCommand("start")}\` creates ${startGrants} and gives the person `
2343
+ + "who runs it access to everything");
2344
+ } else if (!serviceKnown) {
2314
2345
  info("who may use this portal is not judged here: the units' environment could not be read, so a "
2315
2346
  + "grants file configured there would be invisible to this check");
2316
2347
  } else if (unreadable) {
@@ -3945,11 +3976,13 @@ try {
3945
3976
 
3946
3977
  say(`\n ${style.bold("Start here:")}\n`);
3947
3978
  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");
3979
+ say(" Starts the portal and the engine door and prints one address to open in your browser. That");
3980
+ say(" address is the product: you order a clearance from it and read the report there.\n");
3950
3981
  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`);
3982
+ say(` ${style.dim(`\`${invocationPrefix()}clearotron run --job ${/\s/.test(EXAMPLE_JOB) ? `"${EXAMPLE_JOB}"` : EXAMPLE_JOB}\` runs a first real clearance on the EU register.`)}`);
3983
+ // THE OLD WAY IS A CHECKOUT'S. A package has no npm scripts where its reader stands.
3984
+ if (!PACKAGED) say(` ${style.dim("Each still works the old way too — `npm start`, `npm run example`, `node driver/pipeline.mjs`.")}`);
3985
+ say("");
3953
3986
 
3954
3987
  // WHY THOSE LINES LOOK THE WAY THEY DO, when they are not the bare verb.
3955
3988
  //
package/bin/start.mjs CHANGED
@@ -111,7 +111,7 @@ 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
@@ -123,7 +123,7 @@ 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
126
+ import { invocationPrefix, invoke, reachableCommand } from "../shared/invocation.mjs"; // — the banner names the verb
127
127
  import { unitEnvPath } from "../shared/env-local.mjs"; // — the file the units read, named once
128
128
  import { homedir, userInfo } from "node:os";
129
129
  import { dirname, join } from "node:path";
@@ -1253,7 +1253,7 @@ if (isMain) {
1253
1253
  say(` grants ${paths.grants} named nobody, so ${user} was added: Run, Manage, access to everything`);
1254
1254
  if (seeded.changed.includes("organisation")) say(` organisation "${organisation}", filed there — rename it there too`);
1255
1255
  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`);
1256
+ say(` no organisation named yet — \`${reachableCommand('start --organisation "<name>"')}\` files the first one`);
1257
1257
  // REPORTED, NOT REPAIRED. A file that names other people is somebody's decision, and adding this
1258
1258
  // address to it would give access to everything to an address nobody enrolled.
1259
1259
  if (seeded.unadmitted)
@@ -1464,25 +1464,9 @@ if (isMain) {
1464
1464
  // The same version goes into `<base>/program` (shared/permanent-install.mjs), BEFORE the services start
1465
1465
  // so the portal's connect rows name it too, and only when it is not there already. It never stops the
1466
1466
  // 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
- }
1467
+ // `clearotron demo` makes the copy before it starts this file, and starts this file FROM the copy, so
1468
+ // there this finds nothing to do; it acts for a `start --demo` typed at npx directly.
1469
+ const demoProgramRoot = DEMO ? ensureDemoProgram({ base: paths.base, say }) : null;
1486
1470
 
1487
1471
  const envs = childEnv({ ports, paths, user, portalSecret, tokenSecret, opsToken,
1488
1472
  localWorker: wantWorker, demo: DEMO, clientFence: declaredFence || null,
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "263794e3f7267c8ed0c25d9906efbb1694883674",
3
- "version": "0.3.0-beta.7"
2
+ "commit": "6c0b09af72bcc7b6e57f5aa268ffb29361d1eaab",
3
+ "version": "0.3.0-beta.8"
4
4
  }
@@ -1,5 +1,15 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.3.0-beta.8
4
+
5
+ ### Patch Changes
6
+
7
+ - 9181d6a: Fixed: Commands the install, start and the New company screen print now run as printed, from any directory and after npm cleans its cache.
8
+ - 0d75b4a: Fixed: A demo started with npx prints commands that keep working after npm cleans its cache.
9
+ - 9181d6a: Fixed: Doctor, run before the first start, describes the local sign-in that start brings up, and drops checkout-only warnings on a packaged install.
10
+ - 91888b2: Fixed: The package no longer names the hosted service's own hostnames; a deployment names its own.
11
+ - 9181d6a: Fixed: The README installs with `npx clearotron install`, which needs no root, and no longer says the demo opens a browser.
12
+
3
13
  ## 0.3.0-beta.7
4
14
 
5
15
  ### Patch Changes
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.0-beta.7",
5
+ "version": "0.3.0-beta.8",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {
@@ -38,10 +38,9 @@ import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
38
38
  import { storeInRepo, storeOutsideRepoMessage, makeCommittableAudit, resolveStoreRepoRoot, makeStoreCommit } from "../shared/store-in-repo.mjs"; //,
39
39
  import { customerStoreDir, customerStoreLine } from "../shared/customer-store.mjs"; // — one store for the surface and the runs
40
40
  import { clientFailureNote } from "../shared/client-failure-note.mjs"; // — one sentence, three surfaces
41
- import { bareInvocation, invocationPrefix, installRoute } from "../shared/invocation.mjs"; // — and why this one surface is by NAME
41
+ import { bareInvocation, browserCommand, invocationPrefix, installRoute, npxVersionOf } from "../shared/invocation.mjs"; // — and why this one surface is by NAME
42
42
  import { stdioConnectOffer, stdioConnectFor, STDIO_SHAPES } from "../shared/stdio-connect.mjs"; // — ONE author for the connect route
43
43
  import { isWsl } from "../shared/wsl.mjs"; // — on WSL, the connect lines say where they run
44
- import { inNpxCache, ownVersion } from "../shared/permanent-install.mjs"; // — a demo from npx is reset through npx
45
44
  import { connectOffers, offersForWire } from "../shared/connect-clients.mjs"; // — ONE table, resolved server-side
46
45
 
47
46
  /**
@@ -1534,6 +1533,10 @@ export function makePortalService({
1534
1533
  // half its readers. A WORD, never a command line and never a prefix: `invocationForm` can
1535
1534
  // answer with this machine's absolute path, and this value is rendered in a browser.
1536
1535
  setupRoute: installRoute(),
1536
+ // THE COMMAND THAT FILES AN ORGANISATION, for the screen that cannot create a company without one.
1537
+ // `browserCommand`'s rule: no path on this machine, and from npx's cache the published version
1538
+ // rather than a bare name that reader does not have.
1539
+ organisationCommand: browserCommand('start --organisation "<name>"'),
1537
1540
  // — a button that always fails must not render as available. The reason is
1538
1541
  // operator-shaped and staff-only; a client reads the generic sentence the button carries.
1539
1542
  controls: { stop: { available: stopControl.available !== false,
@@ -4838,7 +4841,7 @@ const PORT = PORT_CHOICE.port;
4838
4841
  attempts: makeAttemptLimiter({ max: 10, windowMs: 5 * 60 * 1000 }),
4839
4842
  // The sign-in page's recovery line. It resets the file this portal reads, and names no path on this
4840
4843
  // machine, because the page is read before anyone has signed in (signInResetCommand says how).
4841
- resetCommand: signInResetCommand({ credentialPath, npxVersion: inNpxCache() ? ownVersion() : null }),
4844
+ resetCommand: signInResetCommand({ credentialPath, npxVersion: npxVersionOf() }),
4842
4845
  };
4843
4846
  })();
4844
4847
 
@@ -970,9 +970,9 @@
970
970
  "todos": 0
971
971
  },
972
972
  "an-install-from-npx-moves-out-of-the-cache.test.mjs": {
973
- "tests": 11,
974
- "asserts": 61,
975
- "skips": 6,
973
+ "tests": 13,
974
+ "asserts": 78,
975
+ "skips": 7,
976
976
  "todos": 0
977
977
  },
978
978
  "an-mcp-route-says-which-challenge-it-answers-with.test.mjs": {
@@ -2747,7 +2747,7 @@
2747
2747
  },
2748
2748
  "no-internal-hostname-ships-in-the-package.test.mjs": {
2749
2749
  "tests": 1,
2750
- "asserts": 3,
2750
+ "asserts": 4,
2751
2751
  "skips": 0,
2752
2752
  "todos": 0
2753
2753
  },
@@ -3095,7 +3095,7 @@
3095
3095
  },
3096
3096
  "portal-service.test.mjs": {
3097
3097
  "tests": 124,
3098
- "asserts": 670,
3098
+ "asserts": 671,
3099
3099
  "skips": 1,
3100
3100
  "todos": 0
3101
3101
  },
@@ -4587,6 +4587,12 @@
4587
4587
  "skips": 0,
4588
4588
  "todos": 0
4589
4589
  },
4590
+ "the-first-hour-reads-true.test.mjs": {
4591
+ "tests": 8,
4592
+ "asserts": 41,
4593
+ "skips": 0,
4594
+ "todos": 0
4595
+ },
4590
4596
  "the-fold-receipt-names-the-model.test.mjs": {
4591
4597
  "tests": 5,
4592
4598
  "asserts": 11,
@@ -1,5 +1,9 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.3.0-beta.8
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.3.0-beta.7
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.3.0-beta.7",
3
+ "version": "0.3.0-beta.8",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clearotron",
3
3
  "type": "module",
4
- "version": "0.3.0-beta.7",
4
+ "version": "0.3.0-beta.8",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -10508,6 +10508,7 @@ var api = {
10508
10508
  }),
10509
10509
  accountOrgs: Object.fromEntries(Object.entries(asRecord(b["accountOrgs"])).filter(([, v]) => typeof v === "string" && v)),
10510
10510
  genericOrgs: asArray(b["genericOrgs"]).filter((o) => typeof o === "string" && o.length > 0),
10511
+ organisationCommand: asString(b["organisationCommand"]) || "clearotron start --organisation \"<name>\"",
10511
10512
  email: asString(b["email"]) ?? "",
10512
10513
  accounts: asArray(b["accounts"]).filter((a) => typeof a === "string"),
10513
10514
  allAccounts: b["accounts"] === "*",
@@ -19106,7 +19107,7 @@ function NewCompany({ ctx }) {
19106
19107
  if (!raw.trim()) return [];
19107
19108
  return parseLines(raw, spec.commaSeparated ?? false).filter((e) => !spec.item.ok(e));
19108
19109
  });
19109
- const unmet = !typedName ? "Needs a name." : !key ? "Needs a key — type one below." : !orgChosen ? orgsHeld.length === 0 ? "No organisation is filed on this install yet. File one with: clearotron start --organisation \"<name>\"" : "Choose the organisation it belongs to." : refusedEntries.length ? `${refusedEntries.join(", ")} cannot be searched — fix or remove ${refusedEntries.length === 1 ? "it" : "them"}.` : null;
19110
+ const unmet = !typedName ? "Needs a name." : !key ? "Needs a key — type one below." : !orgChosen ? orgsHeld.length === 0 ? `No organisation is filed on this install yet. File one with: ${ctx.me.organisationCommand}` : "Choose the organisation it belongs to." : refusedEntries.length ? `${refusedEntries.join(", ")} cannot be searched — fix or remove ${refusedEntries.length === 1 ? "it" : "them"}.` : null;
19110
19111
  async function create() {
19111
19112
  if (unmet || busy) return;
19112
19113
  setBusy(true);
@@ -49,7 +49,7 @@
49
49
  -->
50
50
  <link rel="preconnect" href="https://api.fontshare.com" crossorigin />
51
51
  <link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700,900&display=swap" rel="stylesheet" />
52
- <script type="module" crossorigin src="/portal/assets/index-DBUVdQT-.js"></script>
52
+ <script type="module" crossorigin src="/portal/assets/index-qes7gLpM.js"></script>
53
53
  <link rel="stylesheet" crossorigin href="/portal/assets/index-Cv-E_agg.css">
54
54
  </head>
55
55
  <body>
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.0-beta.7",
5
+ "version": "0.3.0-beta.8",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
8
8
  "engines": {
@@ -1,5 +1,9 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.3.0-beta.8
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.3.0-beta.7
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.3.0-beta.7",
3
+ "version": "0.3.0-beta.8",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
@@ -135,13 +135,14 @@ export function readAudience({ location = "", status = null, error = null, wwwAu
135
135
  if (error) return { kind: "unreachable", why: String(error?.message ?? error).slice(0, 200) };
136
136
  // ── THREE DOORS, NOT ONE ────────────────────────────────────────────────────────────────────────
137
137
  //
138
- // This returned `not-fronted` for every response with no redirect, and measured against production's
139
- // four configured hostnames that one label covered three materially different states:
138
+ // This returned `not-fronted` for every response with no redirect, and measured against a production
139
+ // deployment's four configured hostnames that one label covered three materially different states
140
+ // (the hosts are written here as placeholders; a deployment names its own):
140
141
  //
141
- // trademark.cordillera.ch 302 redirect present → audience read, kid agrees
142
- // mcp.cordillera.ch/mcp 401 no redirect → not-fronted ← FALSE, it IS fronted
143
- // clients-mcp.cordillera.ch/mcp 401 no redirect → not-fronted ← FALSE, it IS fronted
144
- // agent-mcp.cordillera.ch/mcp 502 no redirect → not-fronted ← an origin fault
142
+ // portal.example.com 302 redirect present → audience read, kid agrees
143
+ // mcp.example.com/mcp 401 no redirect → not-fronted ← FALSE, it IS fronted
144
+ // clients-mcp.example.com/mcp 401 no redirect → not-fronted ← FALSE, it IS fronted
145
+ // agent-mcp.example.com/mcp 502 no redirect → not-fronted ← an origin fault
145
146
  //
146
147
  // None of these was a false pass — every one returned ok:false, which is the property that matters
147
148
  // most and is untouched. It was a WRONG DIAGNOSIS on a safe failure, and the cost is a reader's hour
@@ -36,8 +36,8 @@
36
36
  // below is not a resolver check: the shim is read and must NAME THIS INSTALL, and a `clearotron` found
37
37
  // earlier on PATH demotes the bare form rather than confirming it. Identity, not availability. A later
38
38
  // sweep tempted to simplify this into `command -v` would be reintroducing the defect, not tidying it.
39
- import { existsSync } from "node:fs";
40
- import { basename, sep } from "node:path";
39
+ import { existsSync, readFileSync } from "node:fs";
40
+ import { basename, join, sep } from "node:path";
41
41
  import { INSTALL_DIR, inspectShim, pathPosition, shimDir, shimPath } from "./verb-shim.mjs";
42
42
  import { chdirPrefix } from "./os-advice.mjs";
43
43
 
@@ -272,6 +272,25 @@ export function invocationPrefix(argv1 = process.argv[1] ?? "", env = process.en
272
272
  export const invoke = (verb, argv1 = process.argv[1] ?? "", env = process.env, io = FS, installDir = INSTALL_DIR) =>
273
273
  `${invocationPrefix(argv1, env, io, installDir)}clearotron ${verb}`;
274
274
 
275
+ /** This install's version when it runs from npx's cache, read off its own manifest; `null` anywhere else. */
276
+ export function npxVersionOf(installDir = INSTALL_DIR, read = readFileSync) {
277
+ if (!/[\\/]_npx[\\/]/.test(String(installDir ?? ""))) return null;
278
+ try { return JSON.parse(read(join(installDir, "package.json"), "utf8")).version ?? null; } catch { return null; }
279
+ }
280
+
281
+ /**
282
+ * A command the reader can type from any directory that still runs after npm cleans its cache.
283
+ *
284
+ * `invoke` answers how this install is reached NOW. From npx's cache that is `cd <the cache> && npx
285
+ * clearotron …`, which stops working once npm cleans the cache, and a remedy is typed later, often after
286
+ * that. So from npx's cache this names the published version, `npx clearotron@<version> …`, which npm
287
+ * fetches again wherever it is typed. Everywhere else it is `invoke`'s answer.
288
+ */
289
+ export function reachableCommand(verb, { argv1 = process.argv[1] ?? "", env = process.env, io = FS, installDir = INSTALL_DIR, read = readFileSync } = {}) {
290
+ const version = npxVersionOf(installDir, read);
291
+ return version ? `npx clearotron@${version} ${verb}` : invoke(verb, argv1, env, io, installDir);
292
+ }
293
+
275
294
  /**
276
295
  * The verb by NAME ONLY — no prefix, no path, no `npx`.
277
296
  *
@@ -292,6 +311,16 @@ export const invoke = (verb, argv1 = process.argv[1] ?? "", env = process.env, i
292
311
  */
293
312
  export const bareInvocation = (verb) => `clearotron ${verb}`;
294
313
 
314
+ /**
315
+ * A command for a page rendered in a browser: `bareInvocation`'s rule, never a path on this machine, with
316
+ * one exception. Run from npx's cache the bare name is one the reader does not have, so the published
317
+ * version is named instead, `npx clearotron@<version> …`, which runs anywhere and names no path.
318
+ */
319
+ export const browserCommand = (verb, installDir = INSTALL_DIR, read = readFileSync) => {
320
+ const version = npxVersionOf(installDir, read);
321
+ return version ? `npx clearotron@${version} ${verb}` : bareInvocation(verb);
322
+ };
323
+
295
324
  /**
296
325
  * Refuse a prompt when there is nobody to answer it.
297
326
  *
@@ -201,3 +201,37 @@ export function demoProgramPlan({ base, installDir = INSTALL_DIR, platform = pro
201
201
  npmArgs: ["install", "--global", "--prefix", prefix, "--prefer-offline", "--no-fund", "--no-audit", `clearotron@${version}`],
202
202
  };
203
203
  }
204
+
205
+ /**
206
+ * Lays the demo's copy down when it is missing or another version, and returns its root; `null` when there
207
+ * is no copy to use (not run from npx, or a copy that could not be made). It never stops the demo: if npm
208
+ * fails, the demo runs from npx's cache as before and says what that costs. `demoProgramPlan` decides;
209
+ * this is the one place that acts on it, for `demo` and for a `start --demo` run from npx.
210
+ */
211
+ export function ensureDemoProgram({ base, say, env = process.env, run = spawnSync, exists = existsSync, plan = demoProgramPlan({ base }) }) {
212
+ if (plan?.current) return plan.root;
213
+ if (!plan || plan.skip) return null;
214
+ say(` program copying clearotron ${plan.version} into ${plan.prefix}, so the commands below and your assistant's connection survive npm cleaning its cache`);
215
+ // The npm that launched this, when npm says which: no second npm is guessed at.
216
+ const npmCli = env.npm_execpath;
217
+ const opts = { stdio: ["ignore", "ignore", "pipe"], encoding: "utf8", timeout: 180_000 };
218
+ const r = npmCli && exists(npmCli) ? run(process.execPath, [npmCli, ...plan.npmArgs], opts) : run("npm", plan.npmArgs, opts);
219
+ if (r.status === 0 && exists(join(plan.root, "mcp-server", "server.mjs"))) return plan.root;
220
+ const why = r.error ? r.error.message : String(r.stderr ?? "").trim().split("\n").pop() || `npm exited ${r.status}`;
221
+ say(` program could not be copied (${why}). The commands and the connect line below run from npm's temporary`);
222
+ say(" cache, so they stop working when npm cleans it; start the demo again to retry.");
223
+ return null;
224
+ }
225
+
226
+ /**
227
+ * The environment a demo's services get when they run from the demo's copy. What makes their printed
228
+ * commands name the copy is running from it: `invocationPrefix` answers from the running install. npm's
229
+ * marks of an npx arrival (`npm_command=exec` among them) and the npx path the dispatcher hands down
230
+ * describe a process npm started, which the copy is not, so they are taken off, as `install` takes them
231
+ * off when it moves out of the cache.
232
+ */
233
+ export function demoProgramEnv(env = process.env) {
234
+ const out = { ...env };
235
+ for (const k of ["npm_command", "npm_lifecycle_event", "npm_execpath", "CLEAROTRON_INVOKED_AS"]) delete out[k];
236
+ return out;
237
+ }