clearotron 0.3.2-beta.10 → 0.3.2-beta.12

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.
Files changed (74) hide show
  1. package/.env.example +1 -1
  2. package/CONTRIBUTING.md +1 -0
  3. package/INSTALL.md +49 -46
  4. package/README.md +8 -7
  5. package/bin/clearotron.mjs +14 -0
  6. package/bin/connect.mjs +68 -3
  7. package/bin/key.mjs +6 -1
  8. package/bin/onboard.mjs +51 -14
  9. package/bin/passphrase.mjs +4 -2
  10. package/bin/start.mjs +43 -9
  11. package/build-info.json +2 -2
  12. package/docs/architecture/05-config-governance.md +1 -1
  13. package/driver/CHANGELOG.md +416 -388
  14. package/driver/contract-vocabulary.mjs +5 -5
  15. package/driver/engine/mcp/recording-server.mjs +21 -1
  16. package/driver/engine/mcp/supplemental.mjs +22 -5
  17. package/driver/knockout-next-step.mjs +72 -0
  18. package/driver/named-band.mjs +1 -1
  19. package/driver/package.json +1 -1
  20. package/driver/phase0.mjs +16 -7
  21. package/driver/pipeline-knockout.mjs +26 -0
  22. package/driver/pipeline.mjs +48 -1
  23. package/driver/portal-local-auth.mjs +14 -4
  24. package/driver/portal-report.mjs +21 -2
  25. package/driver/portal-service.mjs +20 -11
  26. package/driver/publish/attr.mjs +36 -0
  27. package/driver/publish/index.mjs +10 -10
  28. package/driver/publish/parse.mjs +1 -1
  29. package/driver/publish/render-knockout.mjs +14 -9
  30. package/driver/publish/render.mjs +9 -9
  31. package/driver/publish/xlsx.mjs +7 -1
  32. package/driver/queue-watch-verdict.mjs +1 -1
  33. package/driver/register-availability.mjs +1 -1
  34. package/driver/register-plan.mjs +81 -11
  35. package/driver/result-noun-fields.mjs +3 -1
  36. package/driver/stages-knockout.mjs +1 -1
  37. package/driver/suite-census.json +104 -38
  38. package/driver/unit-inventory.mjs +7 -7
  39. package/driver/verify-knockout.mjs +0 -27
  40. package/mcp-server/CHANGELOG.md +27 -19
  41. package/mcp-server/lib/audit-view.mjs +4 -4
  42. package/mcp-server/lib/trace.mjs +1 -1
  43. package/mcp-server/package.json +1 -1
  44. package/package.json +2 -2
  45. package/portal-ui/dist/assets/{index-CtvwLCti.css → index-7Lq-dXDV.css} +12 -9
  46. package/portal-ui/dist/assets/{index-DXSRxPV_.js → index-w8GFZftk.js} +110 -69
  47. package/portal-ui/dist/index.html +2 -2
  48. package/portal-ui/package.json +1 -1
  49. package/providers/jx/README.md +2 -2
  50. package/providers/jx-subclass/README.md +1 -1
  51. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  52. package/providers/oauth-mcp-bridge/package.json +1 -1
  53. package/scripts/README.md +5 -10
  54. package/scripts/ask-ai-render-check.mjs +26 -1
  55. package/scripts/citation-line-check.mjs +2 -2
  56. package/scripts/dead-names.mjs +17 -16
  57. package/scripts/e2e.mjs +8 -8
  58. package/scripts/env-audit.mjs +6 -0
  59. package/scripts/generated-files-are-current.mjs +35 -4
  60. package/scripts/live-surface-check.mjs +17 -17
  61. package/scripts/release-code-scanning-check.mjs +114 -0
  62. package/scripts/release-visible-check.mjs +117 -0
  63. package/scripts/repo-writes.mjs +42 -0
  64. package/scripts/report-sections-render-check.mjs +245 -0
  65. package/scripts/report-theme-render-check.mjs +31 -3
  66. package/scripts/settings-render-check.mjs +10 -7
  67. package/scripts/test-run.mjs +63 -4
  68. package/shared/client-door.mjs +20 -0
  69. package/shared/invocation.mjs +2 -2
  70. package/shared/parent-watch.mjs +33 -0
  71. package/shared/register-selection.mjs +4 -1
  72. package/shared/root-doc-commands.mjs +10 -4
  73. package/shared/running-start.mjs +14 -3
  74. package/shared/scope.mjs +24 -5
package/bin/onboard.mjs CHANGED
@@ -1030,6 +1030,31 @@ export function installSizeLine(eng) {
1030
1030
  return `${Number.isFinite(eng?.installMB) ? `It takes about ${eng.installMB} MB. ` : ""}To remove it, delete that folder.`;
1031
1031
  }
1032
1032
 
1033
+ /**
1034
+ * Whether a login shell will put `dir` on the PATH: some profile this user's shells read names it.
1035
+ *
1036
+ * Setup promised "open a new login shell, and plain `clearotron start` works". That is Ubuntu's stock
1037
+ * `~/.profile` for an ordinary user, which adds `~/.local/bin` once it exists. It is not root's in a fresh
1038
+ * container, measured on ubuntu:24.04 by an outside install, and it is not macOS's default zsh. So the
1039
+ * promise is made only when a profile names the directory. A profile that adds it some other way reads as
1040
+ * "no", which costs a reader one line of advice and never sends them to a shell that will not find it.
1041
+ */
1042
+ const LOGIN_PROFILES = [".profile", ".bash_profile", ".bash_login", ".bashrc", ".zprofile", ".zshenv", ".zshrc", ".zlogin"];
1043
+ export function aLoginShellAdds(dir, { home = homedir(), read = (p) => readFileSync(p, "utf8") } = {}) {
1044
+ const d = String(dir ?? "");
1045
+ if (!d) return false;
1046
+ const rel = home && d.startsWith(`${home}/`) ? d.slice(home.length + 1) : null;
1047
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1048
+ const names = new RegExp(`(^|[^\\w.-])(${esc(d)}${rel ? `|(\\$HOME|\\$\\{HOME\\}|~)/${esc(rel)}` : ""})(?![\\w.-])`);
1049
+ for (const f of LOGIN_PROFILES) {
1050
+ let text;
1051
+ try { text = read(join(home, f)); } catch { continue; }
1052
+ // A COMMENTED-OUT LINE ADDS NOTHING, and a disabled PATH line is an ordinary state for a profile.
1053
+ if (String(text ?? "").split("\n").some((l) => !/^\s*#/.test(l) && names.test(l))) return true;
1054
+ }
1055
+ return false;
1056
+ }
1057
+
1033
1058
  /**
1034
1059
  * The engine menu, built from the driver's registry so the wizard cannot offer an adapter that does not
1035
1060
  * exist — or hide one that does. Same guarantee the register-provider list has.
@@ -1997,7 +2022,7 @@ export async function runCheck() {
1997
2022
  info(`this install's own is ${form.shim} — the commands below name it in full, so they reach THIS install`);
1998
2023
  } else if (form.form === "shim-path") {
1999
2024
  warn(`${form.dir} is not on this shell's PATH, so the bare \`clearotron\` will not resolve here`);
2000
- info(`add it with: export PATH="${form.dir}:$PATH" — or open a new login shell`);
2025
+ info(`add it with: export PATH="${form.dir}:$PATH"${aLoginShellAdds(form.dir) ? " — or open a new login shell" : ""}`);
2001
2026
  } else if (form.form === "npx-pinned") {
2002
2027
  info(`this is running from npm's npx cache, so the commands below name this version through npx (\`${form.prefix.trim()}\`), `
2003
2028
  + `which works from any directory and after npm cleans its cache; \`${invoke("install")}\` puts \`clearotron\` on your PATH`);
@@ -2793,6 +2818,9 @@ export async function runCheck() {
2793
2818
  info(`the units are installed but their environment could not be read (${unitEnv?.why ?? "no reason given"}) — `
2794
2819
  + "the door verdicts below are withheld rather than guessed, because a failure to look is not a finding");
2795
2820
  if (door.shape === "local") {
2821
+ // WHO THAT ONE USER IS, in the owner's words (2026-09-19), resolved as `start` resolves it.
2822
+ const oneUser = String(effectiveForService("PORTAL_LOCAL_USER")?.v || `${userInfo().username}@localhost`).trim().toLowerCase();
2823
+ say(` · Portal sign-in: one user, ${oneUser}, by passphrase.`);
2796
2824
  say(` · local passphrase door (${typed}) — one operator, one passphrase, no identity provider`);
2797
2825
  info(`a lost passphrase is recoverable: ${invocationPrefix()}clearotron passphrase --reset`);
2798
2826
  } else if (door.shape === "fronted") {
@@ -3169,7 +3197,10 @@ export async function runCheck() {
3169
3197
  try { const s = statSync(p); if (s.size > 0) { wrote = p; break; } } catch { /* not this one */ }
3170
3198
  }
3171
3199
  if (wrote) {
3172
- ok(`the client door's access log is being written: ${wrote}`);
3200
+ // WHAT WAS READ, AND NOTHING MORE. This said "is being written" from any non-empty file — a claim
3201
+ // about activity made from a file's size, printed over a log whose only line was a test's
3202
+ // (measured 2026-09-19). Reworded as the owner ruled, 2026-09-19.
3203
+ ok(`the client door's access log has entries: ${wrote}`);
3173
3204
  } else {
3174
3205
  // NOT-YET-WRITTEN IS NOT UNWRITABLE. A door nobody has called through has no log, and saying it
3175
3206
  // is broken would be the same lie as calling an unreachable unit inactive.
@@ -4029,20 +4060,25 @@ try {
4029
4060
  say("\n Register provider");
4030
4061
  say(" This decides which register gets searched, and which vendor gets billed. There is no default in");
4031
4062
  say(" the engine — it refuses to guess.");
4032
- const provider = await choose("Which register?", PROVIDERS.map((p) => ({ label: `${p.label} — ${p.cost}`, spec: p })), 0);
4063
+ // THE LAST ROW IS NOT A REGISTER, as on the AI question, and with the same words. Leaving the register
4064
+ // unset was reachable only by picking a vendor and skipping its credential, so a reader who meant "none
4065
+ // yet" had to pretend to choose one. Choosing it takes the branch a skipped credential reaches below.
4066
+ const provider = await choose("Which register?", [...PROVIDERS.map((p) => ({ label: `${p.label} — ${p.cost}`, spec: p })), { label: "None for now", spec: null }], 0);
4033
4067
  const spec = provider.spec;
4034
- say(`\n ${spec.label}: covers ${spec.covers}`);
4035
- for (const w of spec.warnings ?? []) warn(w);
4036
- if (spec.signup && !spec.credentials.every((k) => present(candidate[k]))) {
4037
- say("\n How to get a credential:");
4038
- for (const l of spec.signup) say(` ${l}`);
4039
- say("");
4068
+ if (spec) {
4069
+ say(`\n ${spec.label}: covers ${spec.covers}`);
4070
+ for (const w of spec.warnings ?? []) warn(w);
4071
+ if (spec.signup && !spec.credentials.every((k) => present(candidate[k]))) {
4072
+ say("\n How to get a credential:");
4073
+ for (const l of spec.signup) say(` ${l}`);
4074
+ say("");
4075
+ }
4040
4076
  }
4041
4077
  // ── — INSTALL MAY FINISH WITH NO REGISTER. Ruling, 2026-08-26 ──────────────────────
4042
4078
  //
4043
4079
  // Every row of PROVIDERS declares required credentials, and this prompt had no way out, so a reader
4044
- // with no vendor account could not reach the closing screen at all — the menu offers no "none" row,
4045
- // which means they arrive here without ever having chosen to supply a key.
4080
+ // with no vendor account could not reach the closing screen at all. The menu's "None for now" row is
4081
+ // the chosen way here; a skipped credential is the other.
4046
4082
  //
4047
4083
  // A REGISTER IS ALL-OR-NOTHING, WHICH IS WHY ONE SKIP ABANDONS THE WHOLE SELECTION. Half a credential
4048
4084
  // pair is not a working register; writing CLEAROTRON_DATABASE beside it would name an adapter that
@@ -4053,14 +4089,14 @@ try {
4053
4089
  // is single-valued with no default, a run already refuses by name when it is unset
4054
4090
  // (driver.config.mjs), and the Installation settings page already says a register is needed.
4055
4091
  // One register per install, any one of them sufficient, none a precondition for another.
4056
- let registerSelected = true;
4092
+ let registerSelected = spec != null;
4057
4093
  // What THIS step collected, so abandoning the selection can take it back. Measured: a register with
4058
4094
  // two required credentials — `euipo`, `free-tier` — let a reader supply the first and skip the second,
4059
4095
  // and the first was written to the .env with no CLEAROTRON_DATABASE beside it. `candidate` is
4060
4096
  // serialised wholesale at the write step, so anything left in it ships. A credential for a register
4061
4097
  // nobody selected is a secret persisted for a decision that was reversed.
4062
4098
  const collectedHere = [];
4063
- for (const k of spec.credentials) {
4099
+ for (const k of spec?.credentials ?? []) {
4064
4100
  if (present(candidate[k])) { ok(`${k} already adopted from your environment`); continue; }
4065
4101
  const v = await askValue(`${k}:`, { secret: true, skippable: true,
4066
4102
  skipped: `${k} not set, so ${spec.id} cannot be configured — this install will have NO register selected.` });
@@ -4581,7 +4617,8 @@ try {
4581
4617
  say(` \`clearotron\` is now installed at ${form.shim}, and ${form.dir} is not on this shell's`);
4582
4618
  say(" PATH yet — most login profiles add it only if it existed when the shell started. So:\n");
4583
4619
  say(` export PATH="${form.dir}:$PATH" # this terminal, now`);
4584
- say(" …or open a new login shell, and plain `clearotron start` works from anywhere.\n");
4620
+ if (aLoginShellAdds(form.dir)) say(" …or open a new login shell, and plain `clearotron start` works from anywhere.\n");
4621
+ else say("");
4585
4622
  } else if (form.form === "in-place") {
4586
4623
  say(" There is no `clearotron` on your PATH, so the commands above name the directory to run them");
4587
4624
  say(" from. Re-run the install to put the verb on your PATH.\n");
@@ -32,6 +32,7 @@ import { defaultInstallBase, establishCredential, installCredential, readLocalCr
32
32
  // rather than hardcoded. A hardcoded `npx ` tells a global installer their install is somehow lesser;
33
33
  // a hardcoded bare name sends an npx reader to `command not found`.
34
34
  import { invocationPrefix } from "../shared/invocation.mjs";
35
+ import { BRAND } from "../shared/brand.mjs";
35
36
 
36
37
  const P = invocationPrefix();
37
38
  const USAGE = ` ${P}clearotron passphrase — report or reset the portal's local sign-in
@@ -117,8 +118,9 @@ catch (e) {
117
118
  process.exit(1);
118
119
  }
119
120
 
120
- console.log(`\n A NEW passphrase has been minted for ${email}.`);
121
- console.log(` The previous one no longer works.\n`);
121
+ // THE OWNER'S WORDS, 2026-09-19: what the value is for comes before it, and names the one user it signs in.
122
+ console.log(`\n New passphrase for this ${BRAND.name}'s one user, ${email}. Paste it on the portal's sign-in page:\n`);
122
123
  console.log(` PASSPHRASE: ${minted.passphrase}\n`);
124
+ console.log(` The previous one no longer works.`);
123
125
  console.log(` Write it down now. It is stored only as a digest, so this line is the only copy that will`);
124
126
  console.log(` ever exist — re-run this command if you lose it.\n`);
package/bin/start.mjs CHANGED
@@ -121,7 +121,7 @@ import { SERVER_INSTALL_SET, unitsToRestartOnRefresh, unitHealthVerdict } from "
121
121
  // — the door --background now INSTALLS, and the one authority for the settings
122
122
  // it refuses to start without. (Until 2026-09-03 this import read "the one unit --background may
123
123
  // tolerate and never manage"; settled point 2 superseded that.)
124
- 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
124
+ import { defaultDenylistPath, denylistPathFor, denylistFor, ensureDenylistFile, CLIENT_DOOR_UNIT, enablePlan, clientDoorPort, demoTokenSecret, demoTokenSecretPath, keyIssueCommand, demoConnectCommand } from "../shared/client-door.mjs"; // — one owner for the revocation list's path
125
125
  import { createServer } from "node:net";
126
126
  import { listenErrorMessage, nextFreePort } from "../shared/listen.mjs";
127
127
  import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
@@ -805,6 +805,10 @@ export function childEnv({ ports, paths, user, portalSecret, tokenSecret, opsTok
805
805
  // DNS-rebinding protection is keyed off this list being non-empty, so it is derived from the same
806
806
  // port the listener is given rather than left to whoever remembers.
807
807
  TRADEMARK_MCP_ALLOWED_HOSTS: `${host}:${ports.mcp},localhost:${ports.mcp}`,
808
+ // WHERE THE OTHER DOOR IS, so a key refused here is told where it belongs (shared/scope.mjs
809
+ // otherDoor). Read by nothing else in this process.
810
+ CLIENT_MCP_HTTP_HOST: host,
811
+ CLIENT_MCP_HTTP_PORT: String(ports.client),
808
812
  },
809
813
  // The worker needs the install's PATHS and nothing else — no ports, no secrets, no door config. It
810
814
  // talks to the queue and the pool, not to either listener.
@@ -830,6 +834,9 @@ export function childEnv({ ports, paths, user, portalSecret, tokenSecret, opsTok
830
834
  // written in one place and an allow-list in another drift into a door that starts and turns every
831
835
  // request away, which reads as a dead door rather than as a misconfiguration.
832
836
  CLIENT_MCP_ALLOWED_HOSTS: `${host}:${ports.client},localhost:${ports.client}`,
837
+ // The staff door's place, for the mirror refusal (shared/scope.mjs otherDoor). Read by nothing else here.
838
+ TRADEMARK_MCP_HTTP_HOST: host,
839
+ TRADEMARK_MCP_HTTP_PORT: String(ports.mcp),
833
840
  CLIENT_MCP_ACCOUNT_ACCESS: String(clientFence ?? "1"),
834
841
  // TOKEN-ONLY, AND WITHOUT IT THE DOOR DOES NOT START. Found by driving it rather than by reading
835
842
  // the diff: with this unset the door demands an OIDC audience plus a Cloudflare Access team or
@@ -2322,7 +2329,7 @@ if (isMain) {
2322
2329
  // Captured HERE, immediately before the spawn, rather than beside the sentence that reads it: the
2323
2330
  // check has to sit on the other side of the thing that mints, and the only way to keep that true is
2324
2331
  // for it to be adjacent to the spawn where a reader can see why.
2325
- const { credentialPathFor: credentialPathBeforeStart, newPassphrase, passphraseResetCommand, demoCredentialToReplace, laterStartLines, readLocalCredential } = await import("../driver/portal-local-auth.mjs");
2332
+ const { credentialPathFor: credentialPathBeforeStart, newPassphrase, passphraseResetCommand, passphraseWithheldLines, demoCredentialToReplace, laterStartLines, readLocalCredential } = await import("../driver/portal-local-auth.mjs");
2326
2333
  // ASKED ABOUT THE FILE THE PORTAL WILL ACTUALLY USE, not the shared default. `credentialPathFor`
2327
2334
  // reads `PORTAL_LOCAL_CREDENTIAL`, and a demo sets it to a file inside its own base — but this call
2328
2335
  // was made against THIS process's environment, which never carries it. So on any box that already had
@@ -2421,7 +2428,8 @@ if (isMain) {
2421
2428
  // The output used to name neither as a door, so an owner watching one of them start concluded MCP had
2422
2429
  // not come up. Both are printed with who each is for, because "MCP is running" is ambiguous on a box
2423
2430
  // that has two of them and the ambiguity is what cost the leg.
2424
- say(` Engine door http://${HOST}:${ports.mcp}/mcp — the portal's Start button calls this. Staff.`);
2431
+ // THE CLIENT DOOR LEADS (owner, 2026-09-19). It is the one an assistant uses, and with the engine door
2432
+ // printed first a demo's reader handed the staff address to their assistant, which refused its key.
2425
2433
  // THE SYNCHRONOUS TRUTH, NOT THE ASYNC FLAG ( — F26, review finding).
2426
2434
  //
2427
2435
  // `rec.alive` is flipped by the child's exit handler, which is async: reading it here asks "has the
@@ -2440,11 +2448,12 @@ if (isMain) {
2440
2448
  if (adoptedClientDoor)
2441
2449
  say(` Already running as ${CLIENT_DOOR_UNIT}; this start kept it, so existing keys still work.`);
2442
2450
  else
2443
- say(` It refuses every caller until a key is issued: ${keyIssueCommand({ prefix: invocationPrefix(), demo: DEMO, user, base: paths.base, defaultBase: join(homedir(), "trademark") })}`);
2451
+ say(` It refuses every caller until a key is issued: ${DEMO ? demoConnectCommand({ prefix: invocationPrefix(), base: paths.base }) : keyIssueCommand({ prefix: invocationPrefix(), demo: DEMO, user, base: paths.base, defaultBase: join(homedir(), "trademark") })}`);
2444
2452
  } else {
2445
2453
  say(` Client door NOT RUNNING on ${HOST}:${ports.client} — its output above says why. The portal and`);
2446
2454
  say(" the engine door are unaffected; a client assistant cannot connect until it is up.");
2447
2455
  }
2456
+ say(` Engine door http://${HOST}:${ports.mcp}/mcp — the portal's Start button calls this. Staff.`);
2448
2457
  say("");
2449
2458
  // ── WHAT `status` AND `stop` READ ABOUT THIS START ───────────────────────────────────────────────
2450
2459
  //
@@ -2486,16 +2495,25 @@ if (isMain) {
2486
2495
  // line is what a reader copies when they lose the passphrase — run as printed, the bare form resolved
2487
2496
  // the shared default and exited 1 saying no credential exists.
2488
2497
  const reset = passphraseResetCommand({ prefix: invocationPrefix(), credentialPath: envs.portal.PORTAL_LOCAL_CREDENTIAL ?? null });
2498
+ // ── ONLY A TERMINAL IS HANDED THE PASSPHRASE ──────────────────────────────────────────────────────
2499
+ //
2500
+ // The first-start box went to standard output whatever standard output was. Under a service manager,
2501
+ // `nohup` or `> start.log` that is a file, so the one value this product cannot read back landed in a
2502
+ // log, a moment after the portal's own line said it was being withheld for exactly that reason.
2503
+ // Measured on a published beta by an outside install. A redirected run gets the portal's own words
2504
+ // instead, from the composer the portal uses, and the way to mint one on a terminal.
2489
2505
  if (mintedPassphrase) {
2490
2506
  const rule = "─".repeat(66);
2491
2507
  say(` ┌${rule}┐`);
2492
2508
  say(` │ Open ${envs.url}`);
2493
2509
  say(` │ Sign in as ${user}`);
2494
- say(` │ Passphrase ${mintedPassphrase}`);
2495
- say(` │`);
2496
- say(` │ WRITE THE PASSPHRASE DOWN NOW. It is stored only as a digest, so`);
2497
- say(` │ nothing — not this product, not this terminal — can read it back.`);
2498
- say(` │ Lost it? ${reset}`);
2510
+ if (process.stdout.isTTY === true) {
2511
+ say(` │ Passphrase ${mintedPassphrase}`);
2512
+ say(` │`);
2513
+ say(` │ WRITE THE PASSPHRASE DOWN NOW. It is stored only as a digest, so`);
2514
+ say(` │ nothing — not this product, not this terminal — can read it back.`);
2515
+ say(` │ Lost it? ${reset}`);
2516
+ } else for (const line of passphraseWithheldLines({ stream: "stdout", resetCommand: reset }).flatMap((l) => (/^\s/.test(l) ? [l] : fitTo(64, l)))) say(` │ ${line}`);
2499
2517
  // THE HINT BELONGS IN THE BOX TOO, and this was the reader the whole sentence was written for. The
2500
2518
  // frame exists because a first-time reader skips the log wall and acts on it — so the one address
2501
2519
  // they copy was the one address with nothing beside it saying what to do when the page that opens
@@ -2604,6 +2622,22 @@ export function demoBaseIsTheReaders({ baseGiven = false, ownBase = false, base
2604
2622
  return !(ownBase && base && demoDefault && base === demoDefault); // handed over, and it IS the default
2605
2623
  }
2606
2624
 
2625
+ /**
2626
+ * A sentence broken at spaces into lines of at most `width` characters, for the framed box. The withheld
2627
+ * lines come from the portal's composer, written for a log line of any length; inside the frame they are
2628
+ * broken to the width of the lines they stand in for. A word longer than `width` keeps its own line. PURE.
2629
+ */
2630
+ export function fitTo(width, text) {
2631
+ const out = [];
2632
+ let line = "";
2633
+ for (const word of String(text ?? "").split(/\s+/).filter(Boolean)) {
2634
+ if (line && line.length + 1 + word.length > width) { out.push(line); line = word; }
2635
+ else line = line ? `${line} ${word}` : word;
2636
+ }
2637
+ if (line) out.push(line);
2638
+ return out;
2639
+ }
2640
+
2607
2641
  export function demoBaseResetTarget({ baseGiven = false, base = "", demoDefault = "" } = {}) {
2608
2642
  if (baseGiven) return null; // the reader chose it, so it is not the demo's to clear
2609
2643
  if (!base || !demoDefault) return null; // nothing to compare: say no
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "1c5ecbdefb9e379d1a7407aabfd3efc9384ffa44",
3
- "version": "0.3.2-beta.10"
2
+ "commit": "92dc979bd176adeedc0dbe2518fbf24388bc6072",
3
+ "version": "0.3.2-beta.12"
4
4
  }
@@ -542,7 +542,7 @@ change. The product's known mirror classes:
542
542
  | CF Access team / AUD / domain gates | edge (dashboard) + each fronted unit's inline env | staff or company lockout, service by service |
543
543
  | `TRADEMARK_MCP_TOKEN_SECRET` | EnvironmentFile + any integrator-hosted artifacts-MCP env block | run-bound `user` tokens fail verification |
544
544
  | Register-provider credentials | EnvironmentFile + integrator plugin config (when both consume the provider) | one consumer silently unauthenticated |
545
- | Profiles/skills store paths | EnvironmentFile + service unit files (+ integrator MCP env) | roster-mismatch class (the PR #14 incident) |
545
+ | Profiles/skills store paths | EnvironmentFile + service unit files (+ integrator MCP env) | roster-mismatch class: a door serving the bundled demo roster instead of the real one |
546
546
  | Pool root | code default + units + web-server file root (+ integrator MCP env) | reports publish where nothing serves |
547
547
  | Public hostnames | EnvironmentFile (rendered links) + router + edge | dead links / dead routes |
548
548