clearotron 0.3.0-beta.1 → 0.3.0-beta.2
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/.env.example +13 -2
- package/CONTRIBUTING.md +1 -1
- package/INSTALL.md +12 -10
- package/README.md +3 -2
- package/bin/brandowner.mjs +94 -1
- package/bin/onboard.mjs +156 -55
- package/bin/passphrase.mjs +23 -4
- package/bin/start.mjs +177 -38
- package/build-info.json +2 -2
- package/docs/architecture/04-configuration-reference.md +5 -2
- package/docs/architecture/05-config-governance.md +3 -2
- package/driver/CHANGELOG.md +95 -0
- package/driver/demo-posture.mjs +59 -8
- package/driver/driver.config.mjs +36 -7
- package/driver/engine/child-record.mjs +93 -0
- package/driver/findings-model.mjs +6 -1
- package/driver/knockout-assess-record.mjs +6 -3
- package/driver/package.json +1 -1
- package/driver/portal-local-auth.mjs +98 -3
- package/driver/portal-service.mjs +65 -29
- package/driver/publish/knockout.mjs +12 -1
- package/driver/publish/office-record-links.mjs +61 -10
- package/driver/publish/render-knockout.mjs +31 -11
- package/driver/publish/xlsx.mjs +33 -1
- package/driver/register-records.mjs +6 -0
- package/driver/run-requirements.mjs +24 -1
- package/driver/runner.mjs +10 -5
- package/driver/skills/knockout-assess/SKILL.md +1 -1
- package/driver/suite-census.json +174 -30
- package/driver/unit-inventory.mjs +109 -5
- package/driver/updater-identity.mjs +178 -0
- package/driver/usage-ledger.mjs +5 -5
- package/mcp-server/CHANGELOG.md +6 -0
- package/mcp-server/http-server.mjs +16 -13
- package/mcp-server/lib/driver.mjs +7 -0
- package/mcp-server/lib/knockout.mjs +14 -2
- package/mcp-server/lib/ops.mjs +38 -16
- package/mcp-server/package.json +2 -2
- package/mcp-server/server.mjs +1 -1
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-CWTHP0sH.js → index-CcFjgM78.js} +32 -17
- package/portal-ui/dist/assets/{index-KpytsmNH.css → index-CsCuPshD.css} +7 -2
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +3 -3
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +2 -2
- package/scripts/changelog-plain-language.mjs +7 -30
- package/scripts/e2e.mjs +13 -2
- package/scripts/env-audit.mjs +10 -3
- package/scripts/env-classify.mjs +205 -12
- package/scripts/live-surface-check.mjs +74 -2
- package/scripts/plain-language-rules.mjs +103 -0
- package/scripts/release-note-required.mjs +118 -24
- package/scripts/release-notes-lint.mjs +22 -43
- package/scripts/release-version.mjs +59 -0
- package/scripts/revisit-render-check.mjs +6 -2
- package/scripts/text-difference.mjs +22 -0
- package/shared/env-local.mjs +25 -2
- package/shared/names-in-force.mjs +1 -0
- package/shared/reap-on-exit.mjs +27 -14
- package/shared/store-in-repo.mjs +147 -0
- package/shared/withheld-paths-access.mjs +6 -6
- package/shared/yes-no-echo.mjs +35 -0
package/.env.example
CHANGED
|
@@ -388,6 +388,17 @@ CLEAROTRON_SUITE_TELEMETRY_DIR=
|
|
|
388
388
|
# effect: deployment
|
|
389
389
|
CLEAROTRON_DEMO=
|
|
390
390
|
|
|
391
|
+
# How the reader typed this command, handed from the dispatcher to each verb it runs. `clearotron`
|
|
392
|
+
# runs every verb as a process of its own, so a verb's own argv[1] is `bin/<verb>.mjs` however the
|
|
393
|
+
# reader started it. The dispatcher passes its own argv[1] down in this name, and every command a verb
|
|
394
|
+
# prints for the reader to type next is spelled from it: `clearotron …` after a global install,
|
|
395
|
+
# `npx clearotron …` otherwise. Unset, a verb reads its own argv[1].
|
|
396
|
+
# Set by bin/clearotron.mjs on every verb it runs, never by an operator, so the row is left commented.
|
|
397
|
+
# It is listed for the reason CLEAROTRON_DEMO is: the catalogue is about what an operator can FIND.
|
|
398
|
+
# Read by shared/invocation.mjs.
|
|
399
|
+
# effect: deployment
|
|
400
|
+
# CLEAROTRON_INVOKED_AS=
|
|
401
|
+
|
|
391
402
|
# How many client what-ifs the runner drains at once (default 1). A what-if re-runs ONE stage of a run
|
|
392
403
|
# that already exists, in a sandbox, at a client's request — the owner opened it to clients on
|
|
393
404
|
# 2026-08-27 and ruled SPEND controls out with it ("ignore the call spend"). This is not one.
|
|
@@ -538,8 +549,8 @@ CLEAROTRON_DEMO_PROFILES=
|
|
|
538
549
|
|
|
539
550
|
# ── The organisation setup names ──────────────────────────────────────────────────────────────────
|
|
540
551
|
#
|
|
541
|
-
# Your organisation's name. `npm run setup` asks for it
|
|
542
|
-
# here, quoted, so a name holding a `#` survives. The first `clearotron start` files it as the first
|
|
552
|
+
# Your organisation's name. `npm run setup` asks for it (and, on a local install, not for a sign-in
|
|
553
|
+
# address) and writes it here, quoted, so a name holding a `#` survives. The first `clearotron start` files it as the first
|
|
543
554
|
# organisation in the grants file (CLEAROTRON_ACCESS_FILE) when that file holds none; from then on the
|
|
544
555
|
# grants file holds the name, renaming is an edit there, and this is not read. Unset or empty, no
|
|
545
556
|
# organisation is invented. `clearotron start --organisation <name>` supplies it for one start, and a
|
package/CONTRIBUTING.md
CHANGED
|
@@ -12,7 +12,7 @@ the rest — with the file that owns each.
|
|
|
12
12
|
|
|
13
13
|
## What you can run, with nothing but a clone
|
|
14
14
|
|
|
15
|
-
**Node 22 is a hard floor.** `package.json` declares it, `.nvmrc` pins it, and the free US register
|
|
15
|
+
**Node 22.13 is a hard floor.** `package.json` declares it, `.nvmrc` pins it, and the free US register
|
|
16
16
|
runs on `node:sqlite`. Node 20 will fail in ways that look like your change.
|
|
17
17
|
|
|
18
18
|
```bash
|
package/INSTALL.md
CHANGED
|
@@ -48,7 +48,8 @@ run is [mcp-server/CONNECT.md](mcp-server/CONNECT.md), and why something is the
|
|
|
48
48
|
- **macOS, Linux, or native Windows for the demo; WSL2 for a clearance.** `npx clearotron
|
|
49
49
|
demo` runs anywhere Node does, native Windows included. A real clearance does not: the engine resolves
|
|
50
50
|
the reasoning CLI the POSIX way, so a native-Windows clearance refuses at preflight even with the CLI
|
|
51
|
-
on `PATH`.
|
|
51
|
+
on `PATH`. Native Windows clearances are planned for a later release. Until then, on Windows,
|
|
52
|
+
`wsl --install -d Ubuntu`, then `wsl -d Ubuntu`, and work through this page
|
|
52
53
|
from **inside** that distribution. Name it: plain `wsl` can open a minimal image with no apt, no
|
|
53
54
|
curl and no bash, and everything below assumes Ubuntu. A fresh Ubuntu has no Node at all, and
|
|
54
55
|
apt's package is below the floor above, so `npx` answers "not found" before anything of ours runs.
|
|
@@ -732,9 +733,10 @@ Use the demo to see what this system produces. Use `npx clearotron start` to run
|
|
|
732
733
|
- Creates `~/trademark/` — `pool/`, `workspace/`, `queue/`, `outbox/`, `locks/`, an empty grants file,
|
|
733
734
|
and a small git repository for saved searches. Same base directory `npx clearotron install` uses, so whichever
|
|
734
735
|
of the two you ran first, the other finds the same install. Move it with `npx clearotron start --base <dir>`.
|
|
735
|
-
- Mints your sign-in passphrase and **prints it once**. Write it down. It is stored as a scrypt digest
|
|
736
|
-
nothing can read it back, and no later start reprints it. To
|
|
737
|
-
|
|
736
|
+
- Mints your sign-in passphrase and **prints it once**. Write it down. It is stored as a scrypt digest in
|
|
737
|
+
`~/trademark/portal-local-credential.json`, nothing can read it back, and no later start reprints it. To
|
|
738
|
+
get a new one, run `clearotron passphrase --reset`. An install that has been signing in with
|
|
739
|
+
`~/.cordillera/portal-local-credential.json`, which earlier versions shared between installs, keeps it.
|
|
738
740
|
|
|
739
741
|
### Who you are
|
|
740
742
|
|
|
@@ -744,12 +746,12 @@ You sign in as `<your-username>@localhost` unless you say otherwise:
|
|
|
744
746
|
npx clearotron start --user you@example.com
|
|
745
747
|
```
|
|
746
748
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
instance.
|
|
749
|
+
Setup does not ask for the address: it writes the local-account form to `.env` and shows it once, in
|
|
750
|
+
its summary, as the address that signs in. An address already in `.env` is kept. It is also the first
|
|
751
|
+
person on this install: the first start writes it into the grants file (`CLEAROTRON_ACCESS_FILE`, §8)
|
|
752
|
+
with access to everything and both permissions, Run clearances and Manage. Setup asks for your
|
|
753
|
+
organisation's name, and the same start files it there as your first organisation. The address admits
|
|
754
|
+
nobody else at its domain; enrolling anyone else is that same file, exactly as on a hosted instance.
|
|
753
755
|
|
|
754
756
|
**No authentication is switched off to make this work, and none can be.** Both doors prove who the
|
|
755
757
|
caller is — the portal by passphrase and a signed session cookie, the engine door by a mandatory
|
package/README.md
CHANGED
|
@@ -36,8 +36,9 @@ npm install -g clearotron
|
|
|
36
36
|
```
|
|
37
37
|
|
|
38
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
|
-
|
|
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.
|
|
41
42
|
|
|
42
43
|
That command gives you the **stable** release — the one that has run a real clearance end to end before it
|
|
43
44
|
was published. If you want the newest code instead, a beta is published whenever there is something worth
|
package/bin/brandowner.mjs
CHANGED
|
@@ -119,6 +119,16 @@ const USAGE = `
|
|
|
119
119
|
--context a file whose contents become this owner's context pack
|
|
120
120
|
--dry-run say exactly what would be written, and write nothing
|
|
121
121
|
|
|
122
|
+
clearotron brandowner framework <key> <path>
|
|
123
|
+
|
|
124
|
+
Point an existing company at a risk framework, as skills/prelim-search/<file>.md.
|
|
125
|
+
The deck is checked before anything is written: a path that does not resolve, or a
|
|
126
|
+
manifest that will not load, is refused and the company is left exactly as it was.
|
|
127
|
+
|
|
128
|
+
This is the route a company created in the browser gets a framework by. The browser
|
|
129
|
+
deliberately sets no framework — frameworkPath is code-owned and the profile screen
|
|
130
|
+
shows it read-only — so without this verb there was no supported route at all.
|
|
131
|
+
|
|
122
132
|
Exit codes: 0 written and recorded · 1 refused, nothing written · 2 usage
|
|
123
133
|
3 written but NOT recorded — the bundle is written and the store has no record of it
|
|
124
134
|
`;
|
|
@@ -266,11 +276,94 @@ export async function add(argv, {
|
|
|
266
276
|
return { written: true, store, profile, framework, commit, commitError };
|
|
267
277
|
}
|
|
268
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Point an existing company at a risk framework.
|
|
281
|
+
*
|
|
282
|
+
* THE LINT IS `resolveFramework`, NOT A SECOND OPINION. That function already refuses a path of the
|
|
283
|
+
* wrong shape, one that does not resolve on this install, and one whose manifest will not load — in the
|
|
284
|
+
* words the create path uses. A second check here would be a second set of messages to keep in step, and
|
|
285
|
+
* the one this file could write is the one that would go stale.
|
|
286
|
+
*
|
|
287
|
+
* IT REFUSES BEFORE IT WRITES. A framework somebody chose that does not resolve is a mistake, not an
|
|
288
|
+
* absence, and the company is left exactly as it was — which matters more here than on a create, because
|
|
289
|
+
* there is an existing company whose rating this would change.
|
|
290
|
+
*
|
|
291
|
+
* THE WHOLE PROFILE IS REWRITTEN, one field changed. `defaultWriteProfile` is the one writer, so this
|
|
292
|
+
* cannot invent a second shape of profile file; the read-modify-write is deliberate and the alternative
|
|
293
|
+
* — patching a key in place — is how two writers come to disagree about what a bundle contains.
|
|
294
|
+
*/
|
|
295
|
+
export async function framework(argv, {
|
|
296
|
+
resolution = profileStoreResolution(),
|
|
297
|
+
out = console.log,
|
|
298
|
+
} = {}) {
|
|
299
|
+
const [key, path] = argv;
|
|
300
|
+
if (!key) throw new Refusal(`this command needs a company key.${USAGE}`);
|
|
301
|
+
if (!path) throw new Refusal(`this command needs a framework path, as skills/prelim-search/<file>.md.${USAGE}`);
|
|
302
|
+
try { assertProfileKey(key); }
|
|
303
|
+
catch (e) { throw new Refusal(e?.message ?? String(e)); }
|
|
304
|
+
|
|
305
|
+
const store = storeForAdd(resolution);
|
|
306
|
+
const file = join(store, `${key}.json`);
|
|
307
|
+
// AN ABSENT COMPANY IS NAMED, not created. `add` is the verb that creates; a typo here would
|
|
308
|
+
// otherwise write a bundle carrying nothing but a framework path.
|
|
309
|
+
if (!existsSync(file))
|
|
310
|
+
throw new Refusal(`no company "${key}" in ${store} — "brandowner add ${key} --name ..." creates one.`);
|
|
311
|
+
|
|
312
|
+
// Refuses here, before anything is read for writing.
|
|
313
|
+
const resolved = resolveFramework(path);
|
|
314
|
+
|
|
315
|
+
const profile = JSON.parse(readFileSync(file, "utf8"));
|
|
316
|
+
const was = profile.frameworkPath ?? null;
|
|
317
|
+
if (was === resolved.path) {
|
|
318
|
+
out(` ${key} already rates under ${resolved.path} — nothing to change.`);
|
|
319
|
+
return { written: false, store, framework: resolved };
|
|
320
|
+
}
|
|
321
|
+
// THE CONTEXT PACK IS READ AND HANDED BACK, because omitting it is not "leave it alone".
|
|
322
|
+
// `defaultWriteProfile` reads an absent pack as "this company has none" and REMOVES the sibling file.
|
|
323
|
+
// `add` never meets that branch: it always passes the pack it was given. This verb is the first caller
|
|
324
|
+
// that rewrites a company which already exists, so it is the first one that can reach it — and setting
|
|
325
|
+
// a framework would have deleted the company's context pack, and committed the deletion under a
|
|
326
|
+
// message about the framework. Found in review, driven before the fix: a store holding acme.json and
|
|
327
|
+
// acme.context.md kept only acme.json.
|
|
328
|
+
//
|
|
329
|
+
// Re-writing the pack is deliberate rather than clever. It lands in the commit's file list, git sees
|
|
330
|
+
// no change in a pack that was already stored the way this writer stores it, and the commit still
|
|
331
|
+
// carries only the profile. NOT byte-for-byte in every case: `defaultWriteProfile` trims and ends
|
|
332
|
+
// with one newline, so a pack hand-edited with blank lines around its text comes back without them.
|
|
333
|
+
// The words are untouched, which is what the arm below checks. A pack holding nothing but whitespace
|
|
334
|
+
// is still removed, which is what every other reader of this store already means by an empty pack.
|
|
335
|
+
const packPath = join(store, CONTEXT_PACK_FILE(key));
|
|
336
|
+
const contextPack = existsSync(packPath) ? readFileSync(packPath, "utf8") : "";
|
|
337
|
+
const { files } = defaultWriteProfile({
|
|
338
|
+
profileDir: store, key, profile: { ...profile, frameworkPath: resolved.path }, contextPack,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const repoRoot = resolveStoreRepoRoot({ names: ["CLEAROTRON_CUSTOMERS_DIR"], fallback: store }).root;
|
|
342
|
+
const audit = makeCommittableAudit({ auditPath: join(store, "audit.jsonl"), repoRoot });
|
|
343
|
+
const gitCommit = makeStoreCommit({ repoRoot, what: "customers" });
|
|
344
|
+
let by = "unknown";
|
|
345
|
+
try { by = userInfo().username || "unknown"; } catch { /* no passwd entry — the row still gets written */ }
|
|
346
|
+
const { commit, commitError } = commitWithAuditRow({
|
|
347
|
+
audit, gitCommit, files, by,
|
|
348
|
+
message: `company ${key} rates under ${resolved.path}${was ? ` (was ${was})` : ""}`,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
out(` ${key} now rates under ${resolved.path}${was ? ` — was ${was}` : " — it had none of its own"}.`);
|
|
352
|
+
if (commit) out(` recorded ${commit}`);
|
|
353
|
+
if (commitError) out(` WROTE THE CHANGE BUT DID NOT RECORD IT: ${commitError} — the audit line is on disk; fix the store's git state`);
|
|
354
|
+
return { written: true, store, framework: resolved, was, commit, commitError };
|
|
355
|
+
}
|
|
356
|
+
|
|
269
357
|
async function main() {
|
|
270
358
|
const [sub, ...rest] = process.argv.slice(2);
|
|
271
359
|
if (!sub || sub === "--help" || sub === "-h" || sub === "help") { console.log(USAGE); process.exit(sub ? 0 : 1); }
|
|
272
|
-
if (sub !== "add") { console.error(`brandowner: no such action "${sub}". One of: add`); process.exit(2); }
|
|
360
|
+
if (sub !== "add" && sub !== "framework") { console.error(`brandowner: no such action "${sub}". One of: add, framework`); process.exit(2); }
|
|
273
361
|
try {
|
|
362
|
+
if (sub === "framework") {
|
|
363
|
+
const r = await framework(rest);
|
|
364
|
+
if (r?.commitError) process.exit(3);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
274
367
|
// WROTE-BUT-DID-NOT-RECORD IS NOT SUCCESS. The write stands — rolling a written bundle back
|
|
275
368
|
// would lose the operator's work over a git fault they can fix — but a scripted onboarding
|
|
276
369
|
// reading exit 0 concludes the store recorded it, and the store did not. Exit 3 says both
|
package/bin/onboard.mjs
CHANGED
|
@@ -72,11 +72,11 @@ import { styleFor, banner } from "../shared/tty-style.mjs"; // — weight wher
|
|
|
72
72
|
import { bracketAsciiCells, BRAND } from "../shared/brand.mjs"; // F18 — the mark, from the geometry the SVG already uses
|
|
73
73
|
// THE REFUSALS ABOUT THE SIGN-IN ADDRESS ITSELF, shared with `bin/start.mjs`. Two copies would be a
|
|
74
74
|
// wizard that accepts an address the launcher then sends back.
|
|
75
|
-
import { addressRefusal } from "../shared/staff-domain.mjs";
|
|
76
75
|
import { join, dirname, isAbsolute, resolve } from "node:path";
|
|
77
76
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
78
77
|
import { delimiter } from "node:path";
|
|
79
78
|
import { Writable } from "node:stream";
|
|
79
|
+
import { looksLikeAKey, yesNoEchoOf } from "../shared/yes-no-echo.mjs"; // a yes/no question echoes only a yes/no answer
|
|
80
80
|
|
|
81
81
|
import { envLocalPath, activeEnvPath, loadEnvLocal, LEGACY_ENV_LOCAL_LOCATION } from "../shared/env-local.mjs";
|
|
82
82
|
import { writeSecretFile } from "../shared/secret-file.mjs"; // one atomic write for every file holding credentials
|
|
@@ -97,6 +97,7 @@ import { processTable } from "../shared/process-table.mjs"; // — /proc is no
|
|
|
97
97
|
import { programsFromAnotherCheckout } from "../shared/checkout-move.mjs";
|
|
98
98
|
import { entrypointOf } from "../driver/systemd/install-census.mjs"; // one ExecStart parser
|
|
99
99
|
import { overlayReport, renderOverlayReport } from "../shared/doctrine-overlay.mjs"; // — the doctor reports the overlay
|
|
100
|
+
import { whereSavesGo } from "../shared/store-in-repo.mjs"; // — doctor says where a portal save goes once it is committed
|
|
100
101
|
import { engineInventory, engineMode, ENGINE_MODES } from "../driver/config-inventory.mjs"; //
|
|
101
102
|
import { probeEngineTurn, probeFailureText, PROBE_MODEL, PROBE_TIMEOUT_SEC, engineEnvKeys } from "../driver/engine/probe.mjs";
|
|
102
103
|
import { runRequiredNames, missingRequirements, REGISTER_ENV, ENGINE_ENV } from "../driver/run-requirements.mjs"; // the order-time gate's own question, asked here rather than restated
|
|
@@ -594,13 +595,17 @@ const present = (v) => typeof v === "string" && v.trim() !== "";
|
|
|
594
595
|
/**
|
|
595
596
|
* Ask who signs in, and what their organisation is called.
|
|
596
597
|
*
|
|
597
|
-
* ──
|
|
598
|
+
* ── THE ADDRESS IS NOT ASKED (ruling, 2026-09-10) ──────────────────────────────────────────────────
|
|
598
599
|
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
600
|
+
* It was, for a while. `clearotron start` took an address from `--user`, from `PORTAL_LOCAL_USER`, or
|
|
601
|
+
* from the local account as `<account>@localhost`, and an outside install reached a state nobody chose
|
|
602
|
+
* that way: an assistant filled a documentation address into the environment file on the operator's
|
|
603
|
+
* behalf. On a local install the question buys nothing the local-account form does not give, so it is
|
|
604
|
+
* gone: the address is `<account>@localhost`, used without a question and shown once, in the summary's
|
|
605
|
+
* "signs in as" line. Anyone behind a login system changes it in the grants file.
|
|
606
|
+
*
|
|
607
|
+
* AN ADDRESS THE SETTINGS FILE ALREADY HOLDS IS KEPT. A re-run of setup must not sign out the person an
|
|
608
|
+
* earlier run enrolled: their passphrase and their grants entry are both keyed on that address.
|
|
604
609
|
*
|
|
605
610
|
* WHAT IT NO LONGER DECIDES. The part after its `@` used to decide who was an administrator: everyone at
|
|
606
611
|
* that domain was staff. That rule is deleted, with the question about granting the domain and the
|
|
@@ -608,47 +613,28 @@ const present = (v) => typeof v === "string" && v.trim() !== "";
|
|
|
608
613
|
* written as their own entry in the grants file by `clearotron start` (`installerGrants`), and it admits
|
|
609
614
|
* nobody else whatever its domain.
|
|
610
615
|
*
|
|
611
|
-
* TWO REFUSALS ABOUT THE ADDRESS ITSELF
|
|
612
|
-
*
|
|
616
|
+
* THE TWO REFUSALS ABOUT THE ADDRESS ITSELF are `clearotron start`'s (shared/staff-domain.mjs): not a
|
|
617
|
+
* single email address, and a public or reserved domain. The wizard no longer takes an address to refuse.
|
|
613
618
|
*
|
|
614
|
-
* THE ORGANISATION'S NAME,
|
|
619
|
+
* THE ORGANISATION'S NAME, the one question this step asks, and required. It is the organisation the install
|
|
615
620
|
* starts with and the label the portal shows for it; `clearotron start` files it in the grants file the
|
|
616
621
|
* first time that file holds none, and it is renamed there. WRITTEN QUOTED, because the loader reads
|
|
617
622
|
* `.env` with Node's `parseEnv`, which ends an unquoted value at `#` and trims it — measured: "Café #1
|
|
618
623
|
* Sàrl" written bare comes back "Café". Quoted it survives whole, except a double quote, which ends the
|
|
619
624
|
* value, so a name holding one is sent back.
|
|
620
625
|
*
|
|
621
|
-
* THE
|
|
622
|
-
* variable
|
|
623
|
-
* cannot name a second person.
|
|
626
|
+
* THE ADDRESS IS THE LOCAL ACCOUNT AND NOTHING ELSE — not the git author, not the hostname, not a shell
|
|
627
|
+
* variable: the one source that cannot name a second person.
|
|
624
628
|
*
|
|
625
629
|
* `io` IS INJECTED for the reason `offerUsptoSync`'s is: the branches that matter are the answers that
|
|
626
630
|
* must be sent back, and closed over a terminal they are asserted nowhere. Returns the `.env` keys to
|
|
627
631
|
* write and nothing else.
|
|
628
632
|
*/
|
|
629
|
-
export async function askSignIn(io, { localAccount = "user" } = {}) {
|
|
633
|
+
export async function askSignIn(io, { localAccount = "user", existing = null } = {}) {
|
|
630
634
|
const { askValue, ok = () => {}, problem = () => {} } = io;
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
"local-account form, which is this machine and nobody else.");
|
|
635
|
-
let address;
|
|
636
|
-
for (;;) {
|
|
637
|
-
const typed = String(await askValue("Sign-in address:", { def: localDefault })).trim().toLowerCase();
|
|
638
|
-
if (!typed.includes("@") || typed.indexOf("@") !== typed.lastIndexOf("@")) {
|
|
639
|
-
problem(`"${typed}" is not a single email address. The portal refuses a multi-@ identity outright, `
|
|
640
|
-
+ "so this would sign in and then be denied at the door.");
|
|
641
|
-
continue;
|
|
642
|
-
}
|
|
643
|
-
// The refusal's own sentence, never a second copy: `clearotron start` prints these same words when it
|
|
644
|
-
// meets the same address, and two wordings of one refusal is how a reader comes to believe they have
|
|
645
|
-
// met two different problems.
|
|
646
|
-
const refusal = addressRefusal(typed);
|
|
647
|
-
if (refusal) { problem(refusal); continue; }
|
|
648
|
-
address = typed;
|
|
649
|
-
break;
|
|
650
|
-
}
|
|
651
|
-
ok(`${address} — the first person on this install, with access to everything.`);
|
|
635
|
+
// Returned rather than left to the writer to carry, so the address in force is written whichever file
|
|
636
|
+
// it was read from.
|
|
637
|
+
const address = String(existing ?? "").trim() || `${localAccount}@localhost`;
|
|
652
638
|
for (;;) {
|
|
653
639
|
const name = String(await askValue("Your organisation's name:") ?? "").trim();
|
|
654
640
|
if (!name) {
|
|
@@ -1770,6 +1756,47 @@ export async function runCheck() {
|
|
|
1770
1756
|
} catch (e) {
|
|
1771
1757
|
info(`could not resolve the profile store from here (${String(e?.message ?? e).slice(0, 80)}) — read a run's \`profile-store\` line instead`);
|
|
1772
1758
|
}
|
|
1759
|
+
|
|
1760
|
+
// ── AND WHERE A SAVE GOES ONCE IT IS COMMITTED ─────────────────────────────────────────────────────
|
|
1761
|
+
//
|
|
1762
|
+
// A save through the portal commits into the store's repository and stops there. Whether it then
|
|
1763
|
+
// leaves this machine is a property of that repository, answered by `whereSavesGo` beside the
|
|
1764
|
+
// committer, which carries the measurement: a store inside a checkout that tracks a remote branch has
|
|
1765
|
+
// its saves published by the next person who syncs or pushes that checkout.
|
|
1766
|
+
//
|
|
1767
|
+
// `warn`, NEVER `problem`, and the exit contract near the top of this file is why. rc 1 means
|
|
1768
|
+
// something the operator set is not doing what they think it is. A store in a repository they push is
|
|
1769
|
+
// doing exactly what it was set up to do; the danger is somebody else's ordinary git work in the same
|
|
1770
|
+
// checkout, which this command cannot see. So the line says it, says when it is fine, and leaves the
|
|
1771
|
+
// exit status alone.
|
|
1772
|
+
//
|
|
1773
|
+
// THE SERVICES' STORE on a hosted box, for the reason the roster lines above give. When their
|
|
1774
|
+
// environment could not be read nothing is judged here, and the lines above already said so.
|
|
1775
|
+
{
|
|
1776
|
+
const storeSet = hosted ? (serviceKnown ? effectiveForService("CLEAROTRON_CUSTOMERS_DIR") : null) : effective("CLEAROTRON_CUSTOMERS_DIR");
|
|
1777
|
+
const storeDir = storeSet?.v || null;
|
|
1778
|
+
if (storeDir && existsSync(storeDir)) {
|
|
1779
|
+
const w = whereSavesGo(storeDir);
|
|
1780
|
+
if (w.state === "publishes") {
|
|
1781
|
+
const waiting = w.ahead > 0 ? `, and ${w.ahead} commit(s) made there have not been pushed yet` : "";
|
|
1782
|
+
const how = w.via && w.via !== "tracking"
|
|
1783
|
+
? `whose branch ${w.branch} tracks no remote branch, but a bare \`git push\` there sends it to ${w.upstream} (${w.via})`
|
|
1784
|
+
: `whose branch ${w.branch} tracks ${w.upstream}`;
|
|
1785
|
+
warn(`saves to ${storeDir} are committed in ${w.root}, ${how} — `
|
|
1786
|
+
+ `whoever next syncs or pushes that checkout publishes them${waiting}. Fine if you publish this store `
|
|
1787
|
+
+ "on purpose; if other work happens in that checkout, give the store a repository of its own with no remote");
|
|
1788
|
+
} else if (w.state === "stays-here") {
|
|
1789
|
+
ok(`saves to ${storeDir} are committed in ${w.root}, `
|
|
1790
|
+
+ (w.branch ? `whose branch ${w.branch} tracks no remote branch` : "which is not on a branch")
|
|
1791
|
+
+ " — nothing carries them off this machine unless someone pushes them by name");
|
|
1792
|
+
} else if (w.state === "not-a-repository") {
|
|
1793
|
+
warn(`${storeDir} is not inside a git repository, so a save through the portal is refused rather than `
|
|
1794
|
+
+ "recorded until it is");
|
|
1795
|
+
} else {
|
|
1796
|
+
info(`could not ask git where saves to ${storeDir} go (${w.why}) — a failure to look, not a finding`);
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1773
1800
|
}
|
|
1774
1801
|
|
|
1775
1802
|
// — WHICH DOCTRINE FILES THIS INSTALL OVERRIDES, AND WHETHER OURS HAVE MOVED UNDER THEM.
|
|
@@ -2259,12 +2286,34 @@ export async function runCheck() {
|
|
|
2259
2286
|
// the provider's own capability rather than a number typed here.
|
|
2260
2287
|
if (PROBE_PROVIDERS) {
|
|
2261
2288
|
say("\n Register lane — proven, not inferred");
|
|
2289
|
+
// THE PROBE ASKS WITH WHAT THE SECTION ABOVE PRINTED. That section reads the environment first and
|
|
2290
|
+
// this install's env file behind it, and says which: `signa (.env)`. This process does not apply the
|
|
2291
|
+
// file — `doctor` is not a CLI entry in shared/env-local.mjs, because reading is not applying — so the
|
|
2292
|
+
// probe took its provider from a module constant fixed from the shell alone, and its key from the
|
|
2293
|
+
// shell at call time. A provider or key kept only in the file, which is where setup writes them,
|
|
2294
|
+
// came back "not set" or "absent" directly under the line that had just printed it. Measured
|
|
2295
|
+
// 2026-09-10 on a fresh install.
|
|
2296
|
+
//
|
|
2297
|
+
// So the provider is resolved from the same reading, and the provider's own variables, the ones the
|
|
2298
|
+
// section above lists, are placed in this process for the probe and put back after it: the adapter
|
|
2299
|
+
// reads them from the environment when it is called. Nothing else moves.
|
|
2300
|
+
const placed = [];
|
|
2262
2301
|
try {
|
|
2263
2302
|
const [{ activeProvider }, { makeLaneProbe, probeSpend, DEFAULT_CONTROLS, loadProviderCapabilities }] = await Promise.all([
|
|
2264
2303
|
import(pathToFileURL(join(REPO, "driver", "driver.config.mjs")).href),
|
|
2265
2304
|
import(pathToFileURL(join(REPO, "providers", "_shared", "lane-probe.mjs")).href),
|
|
2266
2305
|
]);
|
|
2267
|
-
const
|
|
2306
|
+
const chosen = effective("CLEAROTRON_DATABASE");
|
|
2307
|
+
const adapter = activeProvider(chosen ? { CLEAROTRON_DATABASE: chosen.v } : null);
|
|
2308
|
+
const spec = PROVIDERS.find((p) => p.id === adapter?.id);
|
|
2309
|
+
const names = spec ? [...spec.credentials, ...(spec.optionalCredentials ?? [])]
|
|
2310
|
+
: [adapter?.credEnv, ...(adapter?.credEnvAlso ?? [])].filter(Boolean);
|
|
2311
|
+
for (const k of ["CLEAROTRON_DATABASE", ...names]) {
|
|
2312
|
+
const e = effective(k);
|
|
2313
|
+
if (e?.from !== ".env") continue; // unset, or the environment already carries it
|
|
2314
|
+
placed.push([k, Object.hasOwn(process.env, k), process.env[k]]);
|
|
2315
|
+
process.env[k] = e.v;
|
|
2316
|
+
}
|
|
2268
2317
|
// NOT `adapter.capabilities` — that is null on every adapter, and reading it announced a LOCAL
|
|
2269
2318
|
// index as a billable search. The declaration lives in the provider's own module. (issue 1871)
|
|
2270
2319
|
const caps = await loadProviderCapabilities(REPO, adapter?.id);
|
|
@@ -2293,6 +2342,10 @@ export async function runCheck() {
|
|
|
2293
2342
|
} catch (e) {
|
|
2294
2343
|
// A probe that could not run says so. It is not evidence about the credential either way.
|
|
2295
2344
|
info(`the register lane could not be probed: ${String(e?.message ?? e)}`);
|
|
2345
|
+
} finally {
|
|
2346
|
+
// EVERY ONE PUT BACK, including those that were absent: the sections below read this process's
|
|
2347
|
+
// environment, and must not report a value from the file as though the shell had set it.
|
|
2348
|
+
for (const [k, had, v] of placed) { if (had) process.env[k] = v; else delete process.env[k]; }
|
|
2296
2349
|
}
|
|
2297
2350
|
|
|
2298
2351
|
const pxKey = effective("PERPLEXITY_API_KEY");
|
|
@@ -2766,6 +2819,12 @@ export async function runCheck() {
|
|
|
2766
2819
|
// ── CLI gate ─────────────────────────────────────────────────────────────────────────────────────────
|
|
2767
2820
|
// Everything above is importable: the helpers are what the tests exercise directly, and importing this
|
|
2768
2821
|
// file must not start a wizard. Same gate driver/dev-portal.mjs uses.
|
|
2822
|
+
// SETUP'S ONE READLINE KEEPS NO HISTORY. Readline remembers recent answers for the up-arrow, and a key
|
|
2823
|
+
// typed at a masked prompt, or pasted at a yes/no one, is an answer like any other: an up-arrow at the
|
|
2824
|
+
// next question brought it back in full, showed it and submitted it. Setup never needs an earlier answer
|
|
2825
|
+
// back. Exported so the test drives the options this file uses; above the call below, which reads it.
|
|
2826
|
+
export const SETUP_READLINE = Object.freeze({ terminal: true, historySize: 0 });
|
|
2827
|
+
|
|
2769
2828
|
if (!isEntrypoint(import.meta.url)) {
|
|
2770
2829
|
// imported, not run — do nothing else
|
|
2771
2830
|
} else {
|
|
@@ -2795,26 +2854,50 @@ if (!input.isTTY) {
|
|
|
2795
2854
|
// in whatever the reader pastes into a bug report. So the echo is muted while a secret is being typed:
|
|
2796
2855
|
// the output stream readline writes through drops everything while `muted` is set.
|
|
2797
2856
|
let muted = false;
|
|
2857
|
+
// AND WHILE A YES/NO QUESTION IS OPEN, only what can still become its answer is echoed
|
|
2858
|
+
// (shared/yes-no-echo.mjs), so a key pasted there is never shown. `rl` is read only while one is open.
|
|
2859
|
+
let yesNo = false;
|
|
2798
2860
|
const maskedOutput = new Writable({
|
|
2799
|
-
write(chunk, enc, cb) {
|
|
2861
|
+
write(chunk, enc, cb) {
|
|
2862
|
+
if (!muted) {
|
|
2863
|
+
const out = yesNo ? yesNoEchoOf(String(chunk), { line: rl.line, prompt: rl.getPrompt() }) : chunk;
|
|
2864
|
+
if (out.length) output.write(out, yesNo ? undefined : enc);
|
|
2865
|
+
}
|
|
2866
|
+
cb();
|
|
2867
|
+
},
|
|
2800
2868
|
});
|
|
2801
|
-
const rl = createInterface({ input, output: maskedOutput,
|
|
2869
|
+
const rl = createInterface({ input, output: maskedOutput, ...SETUP_READLINE });
|
|
2802
2870
|
const askRaw = async (q) => (await rl.question(q)).trim();
|
|
2803
2871
|
const askSecretRaw = async (q) => {
|
|
2804
2872
|
output.write(q);
|
|
2805
2873
|
muted = true;
|
|
2806
2874
|
try { return (await rl.question("")).trim(); } finally { muted = false; output.write("\n"); }
|
|
2807
2875
|
};
|
|
2808
|
-
|
|
2809
|
-
const
|
|
2876
|
+
// A YES/NO READ: the echo is filtered for exactly as long as the question is open (see maskedOutput).
|
|
2877
|
+
const askYesNo = async (q) => { yesNo = true; try { return (await rl.question(q)).trim(); } finally { yesNo = false; } };
|
|
2878
|
+
/**
|
|
2879
|
+
* Yes/no with an explicit default. Enter takes the default; nothing else is guessed at, except a key.
|
|
2880
|
+
*
|
|
2881
|
+
* A question that offers to take a key (`key: true`) accepts one pasted as the answer. The echo has
|
|
2882
|
+
* already kept it off the screen, and asking for it a second time would only invite a second paste.
|
|
2883
|
+
* Every other yes/no question still says "Please answer y or n." to anything that is not one.
|
|
2884
|
+
*/
|
|
2885
|
+
const confirmOrKey = async (q, def = true, { key = true, what = "key" } = {}) => {
|
|
2810
2886
|
for (;;) {
|
|
2811
|
-
const
|
|
2812
|
-
|
|
2813
|
-
if (
|
|
2814
|
-
if (["
|
|
2887
|
+
const raw = await askYesNo(` ${q} ${def ? "[Y/n]" : "[y/N]"} `);
|
|
2888
|
+
const a = raw.toLowerCase();
|
|
2889
|
+
if (a === "") return { yes: def, value: null };
|
|
2890
|
+
if (["y", "yes"].includes(a)) return { yes: true, value: null };
|
|
2891
|
+
if (["n", "no"].includes(a)) return { yes: false, value: null };
|
|
2892
|
+
if (key && looksLikeAKey(raw)) {
|
|
2893
|
+
info(`that looks like the ${what} itself, so it is taken as the answer. It was not shown.`);
|
|
2894
|
+
info(`received — ${raw.length} characters, ending …${raw.slice(-4)}`);
|
|
2895
|
+
return { yes: true, value: raw };
|
|
2896
|
+
}
|
|
2815
2897
|
say(" Please answer y or n.");
|
|
2816
2898
|
}
|
|
2817
2899
|
};
|
|
2900
|
+
const confirm = async (q, def = true) => (await confirmOrKey(q, def, { key: false })).yes;
|
|
2818
2901
|
/**
|
|
2819
2902
|
* — `skippable` IS THE WHOLE ANSWER TO "WHICH PROMPTS MAY BE LEFT EMPTY", DECIDED ONCE.
|
|
2820
2903
|
*
|
|
@@ -3197,8 +3280,11 @@ try {
|
|
|
3197
3280
|
// named, and the re-probe is the proof either way.
|
|
3198
3281
|
if (eng.headless) {
|
|
3199
3282
|
info(`on a box with no browser: run \`${eng.headless.cmd}\`${eng.headless.tokenEnv ? " (from any machine you can sign in on)" : " here"}.`);
|
|
3200
|
-
if (eng.headless.tokenEnv
|
|
3201
|
-
|
|
3283
|
+
if (eng.headless.tokenEnv) {
|
|
3284
|
+
// A TOKEN PASTED AT THE YES/NO IS THE ANSWER (confirmOrKey): taken, never shown, not asked for twice.
|
|
3285
|
+
const answer = await confirmOrKey(`Did that give you a token to paste? Capture it into ${eng.headless.tokenEnv} now`, false, { what: "token" });
|
|
3286
|
+
const tok = answer.value ?? (answer.yes
|
|
3287
|
+
? await askValue(`${eng.headless.tokenEnv}:`, { secret: true, skippable: true, skipped: "Nothing captured." }) : null);
|
|
3202
3288
|
if (tok !== null) {
|
|
3203
3289
|
candidate[eng.headless.tokenEnv] = tok;
|
|
3204
3290
|
authEnv[eng.headless.tokenEnv] = tok; // the re-probe below must prove the lane WITH it
|
|
@@ -3292,7 +3378,10 @@ try {
|
|
|
3292
3378
|
if (present(candidate[k])) { ok(`${k} already adopted from your environment`); continue; }
|
|
3293
3379
|
info(`${k} is optional. Without it, the offices it serves are DISCLOSED as deferred coverage rather`);
|
|
3294
3380
|
info(" than searched — the run still works and still tells the truth about what it did not reach.");
|
|
3295
|
-
|
|
3381
|
+
// A VALUE PASTED AT THE YES/NO IS THE ANSWER (confirmOrKey), as at every question that leads to a secret.
|
|
3382
|
+
const answer = await confirmOrKey(`Set ${k} now?`, false);
|
|
3383
|
+
if (answer.value) candidate[k] = answer.value;
|
|
3384
|
+
else if (answer.yes) candidate[k] = await askValue(`${k}:`, { secret: true });
|
|
3296
3385
|
else info(`Skipped. Build the index later with: ${invocationPrefix()}clearotron sync`);
|
|
3297
3386
|
}
|
|
3298
3387
|
if (registerSelected) {
|
|
@@ -3363,7 +3452,10 @@ try {
|
|
|
3363
3452
|
if (adapter.obtain) say(` Where to get one: ${adapter.obtain}.`);
|
|
3364
3453
|
if (!present(candidate[name])) {
|
|
3365
3454
|
const skippedLine = `Skipped. You can add ${name} later; until then: ${adapter.absentMeans ?? "the lane stays off"}.`;
|
|
3366
|
-
|
|
3455
|
+
// A KEY PASTED AT THE YES/NO IS THE ANSWER (confirmOrKey): taken, never shown, not asked for twice.
|
|
3456
|
+
const answer = await confirmOrKey(`Enter a ${adapter.label} API key now?`, true);
|
|
3457
|
+
if (answer.value) candidate[name] = answer.value;
|
|
3458
|
+
else if (answer.yes) {
|
|
3367
3459
|
const v = await askValue(`${adapter.label} key:`, { secret: true, skippable: true, skipped: skippedLine });
|
|
3368
3460
|
if (v !== null) candidate[name] = v;
|
|
3369
3461
|
} else info(skippedLine);
|
|
@@ -3586,18 +3678,20 @@ try {
|
|
|
3586
3678
|
say(" fallback — falls through by name, so an empty store is a working install. Your own customers");
|
|
3587
3679
|
say(" are added here by name; the bundled demo customers never show through into your roster.");
|
|
3588
3680
|
|
|
3589
|
-
// 7c ──
|
|
3681
|
+
// 7c ── YOUR ORGANISATION, AND WHO SIGNS IN
|
|
3590
3682
|
//
|
|
3591
|
-
// The
|
|
3683
|
+
// The name is the install's first organisation. The address is not asked: it is the local account, or
|
|
3684
|
+
// the one the settings file in force already holds, and the summary below shows it once. The whole
|
|
3592
3685
|
// reasoning, including the domain rule this step no longer writes, is on `askSignIn` above.
|
|
3593
|
-
section("
|
|
3686
|
+
section("Your organisation");
|
|
3594
3687
|
const localAccount = (() => {
|
|
3595
3688
|
try { return userInfo().username || "user"; } catch { return "user"; }
|
|
3596
3689
|
})();
|
|
3597
|
-
|
|
3598
|
-
//
|
|
3599
|
-
//
|
|
3600
|
-
|
|
3690
|
+
const signInKept = readEnvFile(READ_ENV_PATH()).PORTAL_LOCAL_USER ?? null;
|
|
3691
|
+
// ONE CALL SITE. The loop itself lives in `askSignIn` so that the branches that matter — a name that is
|
|
3692
|
+
// missing, or one the settings file cannot keep — are reachable without a terminal. The same seam and
|
|
3693
|
+
// the same reason as `offerUsptoSync`.
|
|
3694
|
+
Object.assign(candidate, await askSignIn({ askValue, ok, problem }, { localAccount, existing: signInKept }));
|
|
3601
3695
|
|
|
3602
3696
|
// 8 ── the engine's own preflight over the whole candidate
|
|
3603
3697
|
//
|
|
@@ -3653,6 +3747,11 @@ try {
|
|
|
3653
3747
|
// rather than fall back, so a permissions fault can never silently swap a customer's framework for the
|
|
3654
3748
|
// Generic default). Nothing is written INTO them — see the note at step 7b.
|
|
3655
3749
|
for (const k of ["CLEAROTRON_CUSTOMERS_DIR"]) mkdirSync(candidate[k], { recursive: true });
|
|
3750
|
+
// AND THE SKILLS FOLDER THE NOTE AT 7b TELLS THE READER TO USE, created and NOT configured.
|
|
3751
|
+
// CLEAROTRON_INSTRUCTIONS_DIR stays unset, so the engine uses the product's own instructions, and the
|
|
3752
|
+
// portal reads an empty folder the same way: it overrides nothing. It is here so that the place an
|
|
3753
|
+
// override goes exists, and so the line below is true of both folders it describes.
|
|
3754
|
+
mkdirSync(join(cfg, "skills"), { recursive: true });
|
|
3656
3755
|
ok(`configuration directories created under ${cfg} (empty — the bundled instructions and the generic fallback show through; customers are yours to add)`);
|
|
3657
3756
|
|
|
3658
3757
|
// 9a ── PUT THE VERB ON THIS OPERATOR'S PATH
|
|
@@ -3723,6 +3822,8 @@ try {
|
|
|
3723
3822
|
say(`\n ${style.bold("Setup finished.")} Nothing here needs running again.\n`);
|
|
3724
3823
|
say(" What this box has now:");
|
|
3725
3824
|
for (const line of [
|
|
3825
|
+
// THE ONE PLACE THE ADDRESS IS SHOWN (ruling, 2026-09-10): setup no longer asks for it.
|
|
3826
|
+
stateLine("signs in as", candidate.PORTAL_LOCAL_USER, "the local account"),
|
|
3726
3827
|
stateLine("register", candidate.CLEAROTRON_DATABASE, "no register, so every search refuses until one is chosen"),
|
|
3727
3828
|
stateLine("web research", present(candidate.PERPLEXITY_API_KEY) ? "a key" : "", "a clearance stops before it starts, and names the missing key; a knockout search runs and discloses the half it skipped"),
|
|
3728
3829
|
stateLine("data directory", candidate.CLEAROTRON_REPORTS_DIR, "unset"),
|