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/shared/store-in-repo.mjs
CHANGED
|
@@ -362,3 +362,150 @@ export function resolveStoreRepoRoot({ names, fallback = null, env = process.env
|
|
|
362
362
|
if (fallback) return { root: fallback, from: "module-relative fallback", tried };
|
|
363
363
|
return { root: null, from: null, tried };
|
|
364
364
|
}
|
|
365
|
+
|
|
366
|
+
// ── WHERE A SAVE GOES AFTER IT IS COMMITTED ──────────────────────────────────────────────────────────
|
|
367
|
+
//
|
|
368
|
+
// Everything above ends at a commit. Nothing in this file pushes, and nothing should: what happens to a
|
|
369
|
+
// commit next is a property of the repository the store sits in, not of the save. When that repository's
|
|
370
|
+
// branch tracks a remote one, the next person who syncs or pushes the checkout publishes every save made
|
|
371
|
+
// there, and that person is usually not the one who made them.
|
|
372
|
+
//
|
|
373
|
+
// Measured on a test instance, 2026-09-10: its store sat inside a checkout that people also synced for
|
|
374
|
+
// unrelated work. A company created in the portal was committed there as designed, waited eleven hours
|
|
375
|
+
// as a local commit, and went out with an ordinary pull-then-push. It had happened the evening before,
|
|
376
|
+
// and the only thing that stopped that one was somebody noticing.
|
|
377
|
+
//
|
|
378
|
+
// THIS IS A REPORT, NEVER A REFUSAL. Keeping a store in a repository you push on purpose is a
|
|
379
|
+
// reasonable deployment, and the harm needs a second person doing ordinary git work in the same checkout,
|
|
380
|
+
// which no service can see. So the answer is for whoever reads it (doctor), and no save waits on it.
|
|
381
|
+
//
|
|
382
|
+
// READ FROM THE BRANCH'S TRACKING CONFIGURATION, NOT FROM `@{u}`. The clone of an empty remote has its
|
|
383
|
+
// tracking configured and nothing to resolve `@{u}` to until the first commit, so `@{u}` answers with a
|
|
384
|
+
// usage error for a store whose first save a bare push WOULD publish. The configuration answers the
|
|
385
|
+
// question actually being asked: will a push or pull with no arguments carry this branch anywhere?
|
|
386
|
+
//
|
|
387
|
+
// NO FETCH. A doctor that reaches the network hangs on a box without a route, and the count of commits
|
|
388
|
+
// waiting is measured against the last fetch, which is enough: a commit made here is ahead of any
|
|
389
|
+
// version of the remote branch until somebody pushes it.
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Where does a save committed in `dir`'s repository go next? Read-only.
|
|
393
|
+
*
|
|
394
|
+
* @param {string} dir the store directory — any directory inside the repository; git finds the root
|
|
395
|
+
* @param {{ env?: object, gitVersion?: string }} [opts] the environment git runs in — a caller can bound the
|
|
396
|
+
* search upwards — and, for a test, the `git version` answer to reason from instead of asking git
|
|
397
|
+
* @returns one of:
|
|
398
|
+
* `{ state: "publishes", root, branch, upstream, ahead, via }` — a push with no arguments carries the store's
|
|
399
|
+
* commits out: the branch tracks a remote branch (`via: "tracking"`), or it tracks none and git's push
|
|
400
|
+
* settings send it anyway (`via` names the setting). `ahead` counts the commits waiting, or is null when
|
|
401
|
+
* git cannot count them (nothing to count against yet).
|
|
402
|
+
* `{ state: "stays-here", root, branch, remotes }` — no tracking branch (or no branch at all), and no push
|
|
403
|
+
* setting that sends it anyway: nothing leaves unless someone pushes it by name. `remotes` lists the remotes such a push could name.
|
|
404
|
+
* `{ state: "not-a-repository", why }` — a save through a door is refused until the store is in one.
|
|
405
|
+
* `{ state: "could-not-look", why }` — git would not answer: a missing directory, a repository owned
|
|
406
|
+
* by another account, no git on the path. Never read as either answer above.
|
|
407
|
+
*/
|
|
408
|
+
export function whereSavesGo(dir, { env = process.env, gitVersion = null } = {}) {
|
|
409
|
+
const ask = (...args) => {
|
|
410
|
+
try {
|
|
411
|
+
return { ok: true, out: execFileSync("git", ["-C", String(dir ?? ""), ...args],
|
|
412
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env }).trim() };
|
|
413
|
+
} catch (e) {
|
|
414
|
+
return { ok: false, status: e?.status ?? null,
|
|
415
|
+
why: String(e?.stderr || e?.message || e).trim().split("\n")[0].slice(0, 200) };
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
const top = ask("rev-parse", "--show-toplevel");
|
|
419
|
+
if (!top.ok) {
|
|
420
|
+
return /not a git repository/i.test(top.why)
|
|
421
|
+
? { state: "not-a-repository", why: top.why }
|
|
422
|
+
: { state: "could-not-look", why: top.why };
|
|
423
|
+
}
|
|
424
|
+
const root = top.out;
|
|
425
|
+
const listed = ask("remote");
|
|
426
|
+
if (!listed.ok) return { state: "could-not-look", why: listed.why };
|
|
427
|
+
const remotes = listed.out.split("\n").filter(Boolean);
|
|
428
|
+
// `-q` makes a detached HEAD a quiet exit 1, which is an answer (no branch, so nothing tracks). Any
|
|
429
|
+
// other failure is git refusing to say, and is reported as that.
|
|
430
|
+
const head = ask("symbolic-ref", "--short", "-q", "HEAD");
|
|
431
|
+
if (!head.ok) return head.status === 1 ? { state: "stays-here", root, branch: null, remotes } : { state: "could-not-look", why: head.why };
|
|
432
|
+
const branch = head.out;
|
|
433
|
+
// `config --get` exits 1 for a key that is not set, which is again an answer; anything else is not.
|
|
434
|
+
const setting = (key) => {
|
|
435
|
+
const r = ask("config", "--get", key);
|
|
436
|
+
if (r.ok) return { value: r.out };
|
|
437
|
+
return r.status === 1 ? { value: null } : { why: r.why };
|
|
438
|
+
};
|
|
439
|
+
const remote = setting(`branch.${branch}.remote`);
|
|
440
|
+
if (remote.why) return { state: "could-not-look", why: remote.why };
|
|
441
|
+
// A branch tracking another LOCAL branch names the remote `.`: a pull merges within this repository,
|
|
442
|
+
// and unless a push remote is named below, a push goes nowhere else.
|
|
443
|
+
if (!remote.value || remote.value === ".") {
|
|
444
|
+
// ── NO TRACKING IS NOT THE SAME AS NOTHING LEAVES ────────────────────────────────────────────────
|
|
445
|
+
//
|
|
446
|
+
// A bare `git push` on a branch with no tracking still publishes it in three configurations. Measured
|
|
447
|
+
// with git 2.43 against real remotes, 2026-09-10, after a review drove the first one:
|
|
448
|
+
// `push.autoSetupRemote=true` (the push sets up tracking as it goes), `push.default=current`, and
|
|
449
|
+
// `push.default=matching` once the remote has a branch of the same name. `upstream` and `nothing` refuse
|
|
450
|
+
// with no upstream. Git's default, `simple`, refuses too, UNLESS the push is triangular: a push remote
|
|
451
|
+
// (the branch's `pushRemote`, or `remote.pushDefault`) that is not the remote the branch fetches from.
|
|
452
|
+
// Then `simple` pushes as `current` does. A review drove that case with git 2.43 after this comment had
|
|
453
|
+
// said `simple` refuses whenever only those keys are set; that is true only while they name the fetch
|
|
454
|
+
// remote. Where a push goes is git's order: the branch's push remote, then `remote.pushDefault`, then
|
|
455
|
+
// `origin`, then the only remote there is. The remote a branch with no upstream fetches from is found
|
|
456
|
+
// the same way without the first two. The test beside this pushes for real in each configuration and
|
|
457
|
+
// holds this function to what git did.
|
|
458
|
+
const pushRemote = setting(`branch.${branch}.pushRemote`), pushDefault = setting("remote.pushDefault"), mode = setting("push.default");
|
|
459
|
+
for (const x of [pushRemote, pushDefault, mode]) if (x.why) return { state: "could-not-look", why: x.why };
|
|
460
|
+
const auto = ask("config", "--type=bool", "--get", "push.autoSetupRemote");
|
|
461
|
+
if (!auto.ok && auto.status !== 1) return { state: "could-not-look", why: auto.why };
|
|
462
|
+
// ── WHICH GIT IS ANSWERING ─────────────────────────────────────────────────────────────────────────
|
|
463
|
+
//
|
|
464
|
+
// Two of these rules arrived in git 2.37.0: `push.autoSetupRemote`, and the fallback that makes the only
|
|
465
|
+
// remote the default when it is not named `origin`. Before 2.37 the default remote is `origin` whether
|
|
466
|
+
// or not one exists, so a lone remote with another name is never the default. A push remote naming it
|
|
467
|
+
// is then triangular against a missing `origin`, and `simple` pushes. A review found that for the git on
|
|
468
|
+
// Ubuntu 22.04 (2.34), where the 2.37 reading here said the store stayed: a missed publish, the one
|
|
469
|
+
// direction this must not err in. A version git will not state is read both ways, below.
|
|
470
|
+
const ver = /(\d+)\.(\d+)/.exec(String(gitVersion ?? ask("version").out ?? ""));
|
|
471
|
+
const pushing = mode.value || "simple";
|
|
472
|
+
const reading = (modern) => {
|
|
473
|
+
const fallback = remotes.includes("origin") ? "origin" : modern && remotes.length === 1 ? remotes[0] : null;
|
|
474
|
+
const fetchesFrom = remote.value || (modern ? fallback : "origin");
|
|
475
|
+
const dest = pushRemote.value || pushDefault.value || (remote.value === "." ? null : fallback);
|
|
476
|
+
const triangular = dest !== fetchesFrom;
|
|
477
|
+
const autoOn = modern && auto.ok && auto.out === "true";
|
|
478
|
+
const via = !dest || !remotes.includes(dest) ? null
|
|
479
|
+
: pushing === "current" ? "push.default is current"
|
|
480
|
+
// `upstream` refuses a push to a remote the branch does not fetch from, tracking set up or not:
|
|
481
|
+
// "You are pushing to remote 'x', which is not the upstream of your current branch".
|
|
482
|
+
: autoOn && (pushing === "simple" || (pushing === "upstream" && !triangular)) ? "push.autoSetupRemote is on"
|
|
483
|
+
: pushing === "simple" && triangular ? `push.default is simple, and it pushes to ${dest}, not the remote this branch fetches from`
|
|
484
|
+
: pushing === "matching" && ask("rev-parse", "--verify", "-q", `refs/remotes/${dest}/${branch}`).ok
|
|
485
|
+
? "push.default is matching, and the remote has this branch"
|
|
486
|
+
: null;
|
|
487
|
+
return { via, dest };
|
|
488
|
+
};
|
|
489
|
+
// A GIT THAT WILL NOT STATE ITS VERSION IS READ BOTH WAYS, and publishing wins. Reading it as current
|
|
490
|
+
// was the exception to "never err toward staying": in the lone-remote case the older reading is the one
|
|
491
|
+
// that publishes. A review of that exception named it; every real git states its version, so this
|
|
492
|
+
// costs nothing today and removes the one case where the promise above did not hold.
|
|
493
|
+
const modernNow = ver && (Number(ver[1]) > 2 || (Number(ver[1]) === 2 && Number(ver[2]) >= 37));
|
|
494
|
+
const now = ver ? reading(modernNow) : reading(true);
|
|
495
|
+
const older = ver ? null : reading(false);
|
|
496
|
+
const r = now.via ? now
|
|
497
|
+
: older?.via ? { ...older, via: `${older.via}, as a git older than 2.37 would push it; this git did not state its version` }
|
|
498
|
+
: now;
|
|
499
|
+
const { via, dest } = r;
|
|
500
|
+
if (!via) return { state: "stays-here", root, branch, remotes };
|
|
501
|
+
const known = ask("rev-list", "--count", `refs/remotes/${dest}/${branch}..HEAD`);
|
|
502
|
+
return { state: "publishes", root, branch, upstream: `${dest}/${branch}`, via,
|
|
503
|
+
ahead: known.ok && /^\d+$/.test(known.out) ? Number(known.out) : null };
|
|
504
|
+
}
|
|
505
|
+
const merge = setting(`branch.${branch}.merge`);
|
|
506
|
+
if (merge.why) return { state: "could-not-look", why: merge.why };
|
|
507
|
+
const upstream = `${remote.value}/${String(merge.value ?? branch).replace(/^refs\/heads\//, "")}`;
|
|
508
|
+
const count = ask("rev-list", "--count", "@{u}..HEAD");
|
|
509
|
+
const ahead = count.ok && /^\d+$/.test(count.out) ? Number(count.out) : null;
|
|
510
|
+
return { state: "publishes", root, branch, upstream, ahead, via: "tracking" };
|
|
511
|
+
}
|
|
@@ -48,17 +48,17 @@
|
|
|
48
48
|
// present, and cannot see whether it ought to be. So it states the mode and what that means for the
|
|
49
49
|
// three readers, and leaves the judgement to a reader who knows which tree they are looking at.
|
|
50
50
|
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
51
|
+
// WHAT HOLDS THE ANNOUNCEMENT: `driver/test/the-public-residue-is-a-floor.test.mjs`, in a throwaway tree
|
|
52
|
+
// in both modes. Each mode says so, the two say different things, the present one counts its entries, the
|
|
53
|
+
// line is said once and returned on every call, and the stricter mode does not call itself an alarm. Those
|
|
54
|
+
// arms hold the property rather than the sentence, so the wording can be corrected without re-pointing them.
|
|
55
55
|
|
|
56
56
|
let record = null;
|
|
57
57
|
try {
|
|
58
58
|
record = await import("./withheld-paths.mjs");
|
|
59
59
|
} catch {
|
|
60
|
-
// Absent is the
|
|
61
|
-
//
|
|
60
|
+
// Absent is the state of every tree today, as the header says. Whether that is right is the caller's
|
|
61
|
+
// to judge from `CUT_RECORD_PRESENT`; this module only refuses to guess.
|
|
62
62
|
record = null;
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
3
|
+
//
|
|
4
|
+
// A YES/NO QUESTION ECHOES ONLY A YES/NO ANSWER.
|
|
5
|
+
//
|
|
6
|
+
// Setup asks "Enter a Perplexity API key now? [Y/n]", and a person who already holds the key pastes it
|
|
7
|
+
// there. The wizard answered "Please answer y or n" and asked again, which is right, but the key had
|
|
8
|
+
// already been echoed to the terminal, into its scrollback and into whatever gets pasted into a bug
|
|
9
|
+
// report. Reported from a real install, 2026-09-10.
|
|
10
|
+
//
|
|
11
|
+
// THE ECHO IS DECIDED CHARACTER BY CHARACTER, because that is how it happens. In terminal mode readline
|
|
12
|
+
// writes each keypress as it arrives, a paste included, so by the time the line is submitted every
|
|
13
|
+
// character has already been shown. Waiting for the answer and then deciding is too late. So the echo is
|
|
14
|
+
// allowed only while the line typed so far can still become `y`, `yes`, `n` or `no`, and a key never is.
|
|
15
|
+
|
|
16
|
+
/** Whether the line typed so far can still become a yes/no answer. The empty line can: it takes the default. */
|
|
17
|
+
export const couldBeYesNo = (line) => /^(y|ye|yes|n|no)?$/i.test(String(line ?? ""));
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether an answer is plainly a credential rather than a yes/no: sixteen or more characters with no space
|
|
21
|
+
* in them. No yes/no answer is that long, and every key this product asks for is longer.
|
|
22
|
+
*/
|
|
23
|
+
export const looksLikeAKey = (answer) => /^\S{16,}$/.test(String(answer ?? "").trim());
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What a yes/no question lets readline write, given the chunk it is about to write, the line typed so far
|
|
27
|
+
* and the question's prompt. Cursor movement and the newline always pass, so the terminal stays in step.
|
|
28
|
+
* Readline redraws the prompt and the whole line as one write after an edit, so a redraw keeps the prompt
|
|
29
|
+
* and drops the line.
|
|
30
|
+
*/
|
|
31
|
+
export function yesNoEchoOf(chunk, { line = "", prompt = "" } = {}) {
|
|
32
|
+
const s = String(chunk ?? "");
|
|
33
|
+
if (!s || s.charCodeAt(0) === 0x1b || /^[\r\n]+$/.test(s) || couldBeYesNo(line)) return s;
|
|
34
|
+
return prompt && s.startsWith(prompt) ? prompt : "";
|
|
35
|
+
}
|