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
|
@@ -2049,9 +2049,14 @@ input.filter {
|
|
|
2049
2049
|
cursor: not-allowed;
|
|
2050
2050
|
}
|
|
2051
2051
|
|
|
2052
|
-
/*
|
|
2053
|
-
.home2-
|
|
2052
|
+
/* The sentence that replaces the Stop button while a stop is in flight.
|
|
2053
|
+
ITS OWN ROW, BELOW THE FOOT, not a flex item inside it. `home2-card-foot` is a flex row of short
|
|
2054
|
+
things — the elapsed line and one button — and this is a paragraph. As a member of that row it
|
|
2055
|
+
squeezed "1 min so far" into a two-character column and the card read as garbled on a real run. */
|
|
2056
|
+
.home2-stop-note {
|
|
2057
|
+
margin-top: 10px;
|
|
2054
2058
|
font-size: 12px;
|
|
2059
|
+
line-height: 1.5;
|
|
2055
2060
|
color: var(--text-muted);
|
|
2056
2061
|
font-style: italic;
|
|
2057
2062
|
}
|
|
@@ -49,8 +49,8 @@
|
|
|
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-
|
|
53
|
-
<link rel="stylesheet" crossorigin href="/portal/assets/index-
|
|
52
|
+
<script type="module" crossorigin src="/portal/assets/index-CcFjgM78.js"></script>
|
|
53
|
+
<link rel="stylesheet" crossorigin href="/portal/assets/index-CsCuPshD.css">
|
|
54
54
|
</head>
|
|
55
55
|
<body>
|
|
56
56
|
<div id="root"></div>
|
package/portal-ui/package.json
CHANGED
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
"name": "portal-ui",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.3.0-beta.
|
|
5
|
+
"version": "0.3.0-beta.2",
|
|
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": {
|
|
9
|
-
"node": ">=22"
|
|
9
|
+
"node": ">=22.13.0"
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"build": "vite build",
|
|
13
13
|
"dev": "vite",
|
|
14
14
|
"typecheck": "tsc --noEmit",
|
|
15
|
-
"test": "npm run typecheck && node ../scripts/test-run.mjs node --test test/*.test.ts",
|
|
15
|
+
"test": "npm run typecheck && node ../scripts/test-run.mjs node --experimental-strip-types --test test/*.test.ts",
|
|
16
16
|
"test:full": "npm run test"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-oauth-mcp-bridge",
|
|
3
|
-
"version": "0.3.0-beta.
|
|
3
|
+
"version": "0.3.0-beta.2",
|
|
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).",
|
|
@@ -9,5 +9,5 @@
|
|
|
9
9
|
"dependencies": {
|
|
10
10
|
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
11
11
|
},
|
|
12
|
-
"engines": { "node": ">=22" }
|
|
12
|
+
"engines": { "node": ">=22.13.0" }
|
|
13
13
|
}
|
|
@@ -17,48 +17,25 @@
|
|
|
17
17
|
// to who wrote the line.
|
|
18
18
|
import { readFileSync } from "node:fs";
|
|
19
19
|
import { isEntrypoint } from "../shared/is-entrypoint.mjs";
|
|
20
|
+
import { lineFindings, sourceDirectories, userDocs } from "./plain-language-rules.mjs";
|
|
20
21
|
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
export const BANNED_WORDS = [
|
|
25
|
-
"refactor", "refactors", "refactored", "refactoring",
|
|
26
|
-
"implement", "implements", "implemented", "implementing", "implementation", "implementations",
|
|
27
|
-
"leverage", "leverages", "leveraged", "leveraging",
|
|
28
|
-
"optimise", "optimises", "optimised", "optimising", "optimisation",
|
|
29
|
-
"optimize", "optimizes", "optimized", "optimizing", "optimization",
|
|
30
|
-
"utilise", "utilises", "utilised", "utilising", "utilisation",
|
|
31
|
-
"utilize", "utilizes", "utilized", "utilizing", "utilization",
|
|
32
|
-
];
|
|
22
|
+
// THE RULES live in plain-language-rules.mjs, shared with the note lint, so a note CI passes cannot turn
|
|
23
|
+
// this gate red after the merge. BANNED_WORDS is re-exported for the callers that read it from here.
|
|
24
|
+
export { BANNED_WORDS } from "./plain-language-rules.mjs";
|
|
33
25
|
|
|
34
|
-
// A FILE NAME is the thing a reader cannot act on: they do not have the tree open. Matched by
|
|
35
|
-
// extension, because a bare word with a dot in it is how every file name in this repository reads.
|
|
36
|
-
const FILE_NAME_RE = /\b[\w.-]+\.(mjs|js|cjs|ts|tsx|jsx|json|yml|yaml|md|sh|txt)\b/g;
|
|
37
|
-
// A PATH, with or without an extension: two segments joined by a slash.
|
|
38
|
-
const PATH_RE = /\b[\w.-]+\/[\w./-]+/g;
|
|
39
|
-
// A FUNCTION NAME, in the two shapes this tree writes them: a call, and a bare camelCase identifier.
|
|
40
|
-
const FUNCTION_RE = /\b[a-z][A-Za-z0-9_]*\(\)|\b[a-z]+[A-Z][A-Za-z0-9]*\b/g;
|
|
41
|
-
|
|
42
|
-
const WORD_RE = new RegExp(`\\b(${BANNED_WORDS.join("|")})\\b`, "gi");
|
|
43
26
|
|
|
44
27
|
/**
|
|
45
28
|
* Every reason this text may not be shown to a reader. Empty means it may.
|
|
46
29
|
*
|
|
47
|
-
*
|
|
48
|
-
* type, which is the opposite of jargon. Everything outside the backticks is still checked.
|
|
30
|
+
* The line rules are plain-language-rules.mjs's, the same ones the note lint applies on the pull request.
|
|
49
31
|
*/
|
|
50
|
-
export function findings(text) {
|
|
32
|
+
export function findings(text, { sourceDirs = sourceDirectories(), docs = userDocs() } = {}) {
|
|
51
33
|
const out = [];
|
|
52
34
|
const lines = text.split("\n");
|
|
53
35
|
for (const [i, raw] of lines.entries()) {
|
|
54
36
|
// A heading is changesets' own furniture ("## 0.2.0", "### Patch Changes"), not authored prose.
|
|
55
37
|
if (/^\s*#{1,6}\s/.test(raw)) continue;
|
|
56
|
-
const
|
|
57
|
-
const at = (what, m) => out.push({ line: i + 1, kind: what, match: m, text: raw.trim() });
|
|
58
|
-
for (const m of line.matchAll(WORD_RE)) at("jargon word", m[0]);
|
|
59
|
-
for (const m of line.matchAll(FILE_NAME_RE)) at("file name", m[0]);
|
|
60
|
-
for (const m of line.matchAll(PATH_RE)) at("path", m[0]);
|
|
61
|
-
for (const m of line.matchAll(FUNCTION_RE)) at("function name", m[0]);
|
|
38
|
+
for (const f of lineFindings(raw, { sourceDirs, docs })) out.push({ line: i + 1, kind: f.kind, match: f.match, text: raw.trim() });
|
|
62
39
|
}
|
|
63
40
|
return out;
|
|
64
41
|
}
|
package/scripts/e2e.mjs
CHANGED
|
@@ -2393,6 +2393,8 @@ export function doorRefusal(receipt, ref, expect = null) {
|
|
|
2393
2393
|
const secs = (n) => (n == null ? "?" : n >= 3600 ? `${Math.floor(n / 3600)}h${String(Math.floor((n % 3600) / 60)).padStart(2, "0")}m`
|
|
2394
2394
|
: n >= 60 ? `${Math.floor(n / 60)}m${String(Math.round(n % 60)).padStart(2, "0")}s` : `${Math.round(n)}s`);
|
|
2395
2395
|
|
|
2396
|
+
const DEPTH_UNREAD = "narrative-write-ups:could-not-read";
|
|
2397
|
+
|
|
2396
2398
|
function runLedger(runDir) {
|
|
2397
2399
|
const st = readJson(join(runDir, "status.json")) ?? {};
|
|
2398
2400
|
const dd = driverDir(runDir);
|
|
@@ -2437,7 +2439,15 @@ function runLedger(runDir) {
|
|
|
2437
2439
|
if (row?.degraded) degraded.push(`unit ${key}: ${row.degradedCause ?? "cause not recorded"} (attempt ${row.attempts ?? "not recorded"})`);
|
|
2438
2440
|
}
|
|
2439
2441
|
|
|
2440
|
-
|
|
2442
|
+
// A GRADED RUN WHOSE NARRATIVE THE DEPTH CHECK COULD NOT READ delivered with the depth rules applied to
|
|
2443
|
+
// nothing. The receipt says so in a check of its own, and nothing a test lane reads carried it: the
|
|
2444
|
+
// client's mail holds no machine-check lines, and the workbook's Machine Checks sheet is for the reviewing
|
|
2445
|
+
// lawyer. Read from either shape a receipt carries, the check object or the stored list of failing ids.
|
|
2446
|
+
const lint = readJson(join(dd, "predelivery-lint.json"));
|
|
2447
|
+
const depthCheck = (Array.isArray(lint?.checks) ? lint.checks : []).find((c) => c?.id === DEPTH_UNREAD) ?? null;
|
|
2448
|
+
const depthUnread = depthCheck ? depthCheck.pass === false : (Array.isArray(lint?.failures) && lint.failures.includes(DEPTH_UNREAD));
|
|
2449
|
+
|
|
2450
|
+
return { st, attempts, wall, degraded, depthUnread, stageLogs: files.length };
|
|
2441
2451
|
}
|
|
2442
2452
|
|
|
2443
2453
|
// Everything worth a human's attention, in one list. Each entry is a thing to INVESTIGATE — never a
|
|
@@ -2453,7 +2463,7 @@ function brief(s, max = 150) {
|
|
|
2453
2463
|
return `${t.slice(0, head)} … ${t.slice(-(max - head - 3))}`;
|
|
2454
2464
|
}
|
|
2455
2465
|
|
|
2456
|
-
function investigate({ st, attempts, degraded }) {
|
|
2466
|
+
function investigate({ st, attempts, degraded, depthUnread = false }) {
|
|
2457
2467
|
const out = [];
|
|
2458
2468
|
if (!attempts.length) out.push(`no model-attempt records found — the ledger cannot describe this run; do not read that as clean`);
|
|
2459
2469
|
|
|
@@ -2489,6 +2499,7 @@ function investigate({ st, attempts, degraded }) {
|
|
|
2489
2499
|
// the row names its own kind ("lane zh: …" / "unit serp-grid:zh: …") — units degrade for different
|
|
2490
2500
|
// reasons than lanes do, and a hardcoded "lane" here would mislabel every one of them
|
|
2491
2501
|
for (const d of degraded) out.push(`degraded — ${d}`);
|
|
2502
|
+
if (depthUnread) out.push(`depth rules applied to nothing — ${DEPTH_UNREAD}: the narrative carries no write-up block this check can read, so neither the band-rank cut nor the word cap was verified on this run`);
|
|
2492
2503
|
if (st.state && st.state !== "delivered") out.push(`terminal state is "${st.state}"${st.failedStage ? ` at ${st.failedStage}` : ""}${st.reason ? ` — ${brief(st.reason, 120)}` : ""}`);
|
|
2493
2504
|
return out;
|
|
2494
2505
|
}
|
package/scripts/env-audit.mjs
CHANGED
|
@@ -79,7 +79,13 @@ const asList = process.argv.includes("--list");
|
|
|
79
79
|
// the catalogue ratchet then demands an operator row for a value the driver overwrites — a row
|
|
80
80
|
// teaching the operator to configure something they must not touch. `==`/`===` are excluded from
|
|
81
81
|
// the exclusion, because a comparison is a read.
|
|
82
|
-
|
|
82
|
+
//
|
|
83
|
+
// AND `?.` IS THE SAME READ. `env?.X`, `process.env?.X` and `env?.["X"]` read exactly what `env.X`
|
|
84
|
+
// reads, and the pattern used to stop at the `?`: a name read only that way needed no governance row,
|
|
85
|
+
// no catalogue row and no effect declaration, and every catalogue check stayed green about it. Measured
|
|
86
|
+
// 2026-09-10, three product names were read only through `?.`. An optional chain cannot be assigned
|
|
87
|
+
// through, so the assignment guard has nothing new to exclude.
|
|
88
|
+
const READ_RE = /(?<![.\w$])(?:process\??\.)?env\??\.([A-Z][A-Z0-9_]*)(?![A-Z0-9_])(?!\s*=[^=])|(?<![.\w$])(?:process\??\.)?env(?:\?\.)?\[\s*["']([A-Z][A-Z0-9_]*)["']\s*\](?!\s*=[^=])/g;
|
|
83
89
|
const CODE_RE = /\.(mjs|js|cjs|ts)$/;
|
|
84
90
|
|
|
85
91
|
// ── AN ACCESSOR CALL IS A READ, AND STEP 4 MADE IT THE COMMON ONE ────────────────────────────────
|
|
@@ -193,8 +199,9 @@ export function mergeEnvNameBindings(perFile) {
|
|
|
193
199
|
}
|
|
194
200
|
|
|
195
201
|
// The computed read whose subscript is a BARE IDENTIFIER. `env["X"]` is READ_RE's; this is the one that
|
|
196
|
-
// needs the map. Same assignment guard as its siblings — `env[X] = v` is a write.
|
|
197
|
-
|
|
202
|
+
// needs the map. Same assignment guard as its siblings — `env[X] = v` is a write. `env?.[X]` is the same
|
|
203
|
+
// read as `env[X]`, for the reason READ_RE gives.
|
|
204
|
+
const CONST_READ_RE = /(?<![.\w$])(?:process\??\.)?env(?:\?\.)?\[\s*([A-Z][A-Z0-9_]*)\s*\](?!\s*=[^=])/g;
|
|
198
205
|
|
|
199
206
|
export function namesRead(text, bindings = null) {
|
|
200
207
|
const found = new Set();
|
package/scripts/env-classify.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// node scripts/env-classify.mjs print the classification
|
|
11
11
|
// node scripts/env-classify.mjs --apply write docs/architecture/env-classification.json
|
|
12
12
|
// node scripts/env-classify.mjs --check rebuild and diff against the committed artifact
|
|
13
|
+
// node scripts/env-classify.mjs --check --artifact <path> …against another copy, for a driven check
|
|
13
14
|
// node scripts/env-classify.mjs --gather-prod --unit-dir <d> --env-file <f> the production name list
|
|
14
15
|
//
|
|
15
16
|
// ── WHAT IS READ, AND WHAT IS NEVER READ ─────────────────────────────────────────────────────────
|
|
@@ -47,6 +48,7 @@ import { execFileSync } from "node:child_process";
|
|
|
47
48
|
import { join, dirname } from "node:path";
|
|
48
49
|
import { fileURLToPath } from "node:url";
|
|
49
50
|
import { isEntrypoint } from "../shared/is-entrypoint.mjs";
|
|
51
|
+
import { catalogueRows } from "./env-audit.mjs";
|
|
50
52
|
|
|
51
53
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
52
54
|
const ART = join(ROOT, "docs/architecture/env-classification.json");
|
|
@@ -54,6 +56,33 @@ const PROD = join(ROOT, "docs/architecture/env-set-in-production.txt");
|
|
|
54
56
|
const read = (p) => { try { return readFileSync(p, "utf8"); } catch { return ""; } };
|
|
55
57
|
const git = (...a) => { try { return execFileSync("git", ["-C", ROOT, ...a], { encoding: "utf8", maxBuffer: 1e8 }); } catch { return ""; } };
|
|
56
58
|
|
|
59
|
+
// THE ENV EXAMPLE FILES, NAMED ONCE AND READ ONE WAY. This module had two lists of them, and the one that
|
|
60
|
+
// decides `documented` left out `.env.deployment.example`, the largest, while the other read all four.
|
|
61
|
+
// Every name documented only there was recorded as undocumented: 159 in the committed classification
|
|
62
|
+
// when that was measured, 2026-09-10. A row is read by the catalogue's own `catalogueRows`, so "has a
|
|
63
|
+
// row" means here what it means in the audit.
|
|
64
|
+
export const ENV_EXAMPLE_FILES = [".env.example", ".env.deployment.example", ".env.dev.example", ".env.prod.example"];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Every name the example files carry a row for, commented or not.
|
|
68
|
+
*
|
|
69
|
+
* AN ABSENT FILE ADDS NOTHING. Three of the four are withheld from the public tree, and a public checkout
|
|
70
|
+
* must still gather; the artifact is minted only where they are laid, because it refuses without the
|
|
71
|
+
* production list that is withheld with them. A file that EXISTS and cannot be read refuses instead, for
|
|
72
|
+
* the reason `mustRead` gives: read as empty, every name it documents would record as undocumented.
|
|
73
|
+
*/
|
|
74
|
+
export function exampleNames(root = ROOT) {
|
|
75
|
+
return ENV_EXAMPLE_FILES.flatMap((f) => {
|
|
76
|
+
const p = join(root, f);
|
|
77
|
+
let text = "";
|
|
78
|
+
try { text = readFileSync(p, "utf8"); } catch (e) {
|
|
79
|
+
if (e.code !== "ENOENT")
|
|
80
|
+
throw new Error(`env-classify: ${p} exists and could not be read (${e.code}). Read as empty, every name it documents would record as undocumented.`);
|
|
81
|
+
}
|
|
82
|
+
return catalogueRows(text).map((r) => r.name);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
57
86
|
/** Assignment NAMES from a shell-shaped env file. Values are matched and discarded, never returned. */
|
|
58
87
|
export function namesInEnvFile(text) {
|
|
59
88
|
return [...text.matchAll(/^[ \t]*(?:export[ \t]+)?([A-Z][A-Z0-9_]*)=/gm)].map((m) => m[1]);
|
|
@@ -169,8 +198,7 @@ export function gather({ root = ROOT, prodList = null } = {}) {
|
|
|
169
198
|
e2e: set(["scripts/e2e.mjs", "scripts/test-run.mjs"]
|
|
170
199
|
.flatMap((f) => namesMentioned(mustRead(root, f, "It is the end-to-end surface, and the names it sets are set nowhere else.")))),
|
|
171
200
|
// A DESCRIPTION, never a setting. Kept apart so `everSet` cannot be satisfied by documentation.
|
|
172
|
-
docs: set(
|
|
173
|
-
.flatMap((f) => [...read(join(root, f)).matchAll(/^#?\s*([A-Z][A-Z0-9_]*)=/gm)].map((m) => m[1]))),
|
|
201
|
+
docs: set(exampleNames(root)),
|
|
174
202
|
_tracked: tracked,
|
|
175
203
|
};
|
|
176
204
|
}
|
|
@@ -194,11 +222,101 @@ export function setupNames(root = ROOT) {
|
|
|
194
222
|
// the wizard writes it through a COMPUTED key, `candidate[eng.headless.tokenEnv]`, so the literal
|
|
195
223
|
// candidate-write matchers above cannot see it; the table field is the one derivable spelling).
|
|
196
224
|
for (const m of cfg.matchAll(/(?:env|authEnv|apiKeyEnv|credEnv|tokenEnv):\s*"([A-Z][A-Z0-9_]*)"/g)) seed.add(m[1]);
|
|
225
|
+
// THE NAMES A HELPER RETURNS AND THE WIZARD MERGES IN. The sign-in step hands back the `.env` keys it
|
|
226
|
+
// wants written, and the wizard writes them with `Object.assign(candidate, await askSignIn(…))`. None of
|
|
227
|
+
// the matchers above sees a key inside a returned object, so both names that step writes — the address
|
|
228
|
+
// that signs in and the organisation's name — fell through every shape, the organisation's name to
|
|
229
|
+
// `tuning`: the residual bucket, and the one a cleanup deletes from. Read from the MERGE, not from the
|
|
230
|
+
// object's shape: a rule taking every object literal with upper-case keys would take in every constant
|
|
231
|
+
// table in the wizard, and grow this population by names nothing writes.
|
|
232
|
+
for (const n of namesMergedIntoCandidate(onboard)) seed.add(n);
|
|
197
233
|
const out = new Set();
|
|
198
234
|
for (const n of seed) for (const sp of [n]) out.add(sp);
|
|
199
235
|
return out;
|
|
200
236
|
}
|
|
201
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Every name the wizard writes through an object a helper RETURNS: each `Object.assign(candidate, NAME(…))`
|
|
240
|
+
* names a helper, and the top-level keys of the object literals it returns are the names it writes.
|
|
241
|
+
*
|
|
242
|
+
* A helper named at a merge and not defined in the wizard REFUSES, and so does one whose returns name no
|
|
243
|
+
* key. Either way its names are written at install and cannot be read here, and reading them as none puts
|
|
244
|
+
* them in `tuning` with nothing saying the look failed — the silence `mustRead` exists for.
|
|
245
|
+
*/
|
|
246
|
+
function namesMergedIntoCandidate(src) {
|
|
247
|
+
const names = new Set();
|
|
248
|
+
const helpers = new Set([...src.matchAll(/Object\.assign\(\s*candidate\s*,\s*(?:await\s+)?([A-Za-z_$][\w$]*)\s*\(/g)]
|
|
249
|
+
.map((m) => m[1]));
|
|
250
|
+
for (const fn of helpers) {
|
|
251
|
+
const at = src.search(new RegExp(`\\bfunction\\s+${fn.replace(/\$/g, "\\$")}\\s*\\(`));
|
|
252
|
+
const refuse = (why) => new Error(`env-classify: bin/onboard.mjs writes what ${fn}() returns, and ${why}, so `
|
|
253
|
+
+ "the names it writes cannot be read. Read as none, they would land in `tuning` and on the deletion "
|
|
254
|
+
+ "population; that is this script failing to look, not a finding about the wizard.");
|
|
255
|
+
if (at < 0) throw refuse(`no function ${fn} is defined there`);
|
|
256
|
+
// From the declaration on, with comments and the inside of every string blanked, so a brace, a comma or
|
|
257
|
+
// a `return` in either is not read as code. A parameter default can hold braces, so the body is the
|
|
258
|
+
// first `{` after the parameter list, not the first `{`.
|
|
259
|
+
const text = src.slice(at), code = blankNonCode(text);
|
|
260
|
+
const open = code.indexOf("{", closingOf(code, code.indexOf("(")));
|
|
261
|
+
const close = closingOf(code, open);
|
|
262
|
+
let found = 0;
|
|
263
|
+
for (const r of code.slice(open, close).matchAll(/\breturn\s*\{/g)) {
|
|
264
|
+
for (const key of topLevelKeys(text, code, open + r.index + r[0].length - 1)) { names.add(key); found++; }
|
|
265
|
+
}
|
|
266
|
+
if (!found) throw refuse("none of its returns is an object literal naming a key");
|
|
267
|
+
}
|
|
268
|
+
return names;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** `text` with every comment and the inside of every string turned to spaces. Same length, same lines. */
|
|
272
|
+
function blankNonCode(text) {
|
|
273
|
+
let out = "", i = 0;
|
|
274
|
+
const blank = (to) => { out += text.slice(i, to).replace(/[^\n]/g, " "); i = to; };
|
|
275
|
+
while (i < text.length) {
|
|
276
|
+
const c = text[i], d = text[i + 1];
|
|
277
|
+
if (c === "/" && (d === "/" || d === "*")) {
|
|
278
|
+
const end = d === "/" ? text.indexOf("\n", i) : text.indexOf("*/", i + 2) + 2;
|
|
279
|
+
blank(end < i + 2 ? text.length : end);
|
|
280
|
+
} else if (c === '"' || c === "'" || c === "`") {
|
|
281
|
+
let j = i + 1;
|
|
282
|
+
while (j < text.length && text[j] !== c && (c === "`" || text[j] !== "\n")) j += text[j] === "\\" ? 2 : 1;
|
|
283
|
+
out += c; i += 1; blank(Math.min(j, text.length));
|
|
284
|
+
if (i < text.length) out += text[i++];
|
|
285
|
+
} else {
|
|
286
|
+
out += c; i += 1;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The index of the bracket closing the one at `at`, counting every kind; `code` is already blanked. */
|
|
293
|
+
function closingOf(code, at) {
|
|
294
|
+
let depth = 0;
|
|
295
|
+
for (let i = at; i < code.length; i++) {
|
|
296
|
+
if ("([{".includes(code[i])) depth++;
|
|
297
|
+
else if (")]}".includes(code[i]) && --depth === 0) return i;
|
|
298
|
+
}
|
|
299
|
+
return code.length;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The top-level keys of the object literal opening at `open`: a nested object's keys are its own. */
|
|
303
|
+
function topLevelKeys(text, code, open) {
|
|
304
|
+
const keys = [];
|
|
305
|
+
const close = closingOf(code, open);
|
|
306
|
+
let depth = 0, from = open + 1;
|
|
307
|
+
for (let i = open + 1; i <= close; i++) {
|
|
308
|
+
const c = code[i];
|
|
309
|
+
if (i < close && "([{".includes(c)) depth++;
|
|
310
|
+
else if (i < close && ")]}".includes(c)) depth--;
|
|
311
|
+
else if (i === close || (c === "," && depth === 0)) {
|
|
312
|
+
const m = /^\s*(["']?)([A-Z][A-Z0-9_]*)\1\s*:/.exec(text.slice(from, i));
|
|
313
|
+
if (m) keys.push(m[2]);
|
|
314
|
+
from = i + 1;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return keys;
|
|
318
|
+
}
|
|
319
|
+
|
|
202
320
|
// THE AUDIENCE DECISION FOR NAMES A PREFIX USED TO CARRY — listed, because a rename must not move it.
|
|
203
321
|
//
|
|
204
322
|
// Every name here was classified `deployment` by one of DEPLOY_RE's PREFIX arms and by nothing else.
|
|
@@ -210,7 +328,9 @@ export function setupNames(root = ROOT) {
|
|
|
210
328
|
//
|
|
211
329
|
// Nothing would have said so. `--check` is the only thing that compares the artifact to the tree, and
|
|
212
330
|
// it ran in no workflow, no hook and no test; the reproducibility arm deliberately does not re-run the
|
|
213
|
-
// classifier.
|
|
331
|
+
// classifier. It cannot run in this repository's CI at all: the artifact and the production name list it
|
|
332
|
+
// needs are withheld from the public tree, so here it stops at "could not look". It runs where they are
|
|
333
|
+
// laid, in the private control, and a stale artifact there names the rows that moved.
|
|
214
334
|
//
|
|
215
335
|
// The comment beside AUDIENCE/ISSUER below already recorded this class, one name at a time:
|
|
216
336
|
// "a rename is exactly when a shape-based classifier goes wrong, and this one is a rename programme."
|
|
@@ -226,6 +346,14 @@ export const DEPLOYMENT_NAMES = new Set([
|
|
|
226
346
|
// by nothing else, so moving it to the house prefix dropped it through to `tuning` — the bucket step 3
|
|
227
347
|
// deletes from. The audience did not change; only the spelling did, and this list is what says so.
|
|
228
348
|
"CLEAROTRON_DEMO",
|
|
349
|
+
// Three names read only through optional chaining, so no catalogue check saw them until the scanner
|
|
350
|
+
// learned `?.`. Each row declares `deployment`, and no shape matches them (`_PORT$` does not take
|
|
351
|
+
// `_PORTS`), so unlisted each fell through to `tuning`, the bucket step 3 deletes from. The first is set
|
|
352
|
+
// by the dispatcher for the verb it runs and never by an operator; the other two are a server install's
|
|
353
|
+
// port and deploy-health settings.
|
|
354
|
+
"CLEAROTRON_INVOKED_AS",
|
|
355
|
+
"CLEAROTRON_REQUIRE_EXPLICIT_PORTS",
|
|
356
|
+
"CLEAROTRON_UPDATER_STAMP",
|
|
229
357
|
"CLIENT_MCP_ACCOUNT_ACCESS",
|
|
230
358
|
"CLIENT_MCP_ALLOWED_HOSTS",
|
|
231
359
|
"CLIENT_MCP_AUTH_DISABLED",
|
|
@@ -270,6 +398,9 @@ export const DEPLOYMENT_NAMES = new Set([
|
|
|
270
398
|
"TRADEMARK_MCP_AUTH_MODE",
|
|
271
399
|
"TRADEMARK_MCP_DEV",
|
|
272
400
|
"TRADEMARK_MCP_EMAIL_CLAIM",
|
|
401
|
+
// The key door's socket path. Its row declares `deployment`, and until it was listed the
|
|
402
|
+
// `TRADEMARK_MCP_` prefix arm was its only route there, which a house-prefix sweep would take away.
|
|
403
|
+
"TRADEMARK_MCP_KEY_SOCKET",
|
|
273
404
|
"TRADEMARK_MCP_MAX_BYTES",
|
|
274
405
|
"TRADEMARK_MCP_SESSION_MAX",
|
|
275
406
|
"TRADEMARK_MCP_SESSION_TTL_MS",
|
|
@@ -373,6 +504,9 @@ export function classify({ catalogue, sources, setup = setupNames(), readSites =
|
|
|
373
504
|
// `declared` is the catalogue's own `# effect:` for this name, carried on the row so the artifact
|
|
374
505
|
// says what the document claims beside what this script derived. The two are different questions and
|
|
375
506
|
// the row is where a reader compares them.
|
|
507
|
+
//
|
|
508
|
+
// `documented` is a row in any example file. The minted catalogue is itself read from two of those
|
|
509
|
+
// files, so every row there reads true; a false would mean a name reached it from somewhere else.
|
|
376
510
|
return { name, class: cls(name), everSet: setIn, declared: declared.get(name) ?? null,
|
|
377
511
|
documented: Boolean(sources.docs?.has(name)) };
|
|
378
512
|
});
|
|
@@ -465,6 +599,60 @@ export function classify({ catalogue, sources, setup = setupNames(), readSites =
|
|
|
465
599
|
} };
|
|
466
600
|
}
|
|
467
601
|
|
|
602
|
+
/**
|
|
603
|
+
* PURE. Which rows of the classification moved between the committed artifact and the tree.
|
|
604
|
+
*
|
|
605
|
+
* @param {object|null} prev the committed artifact, parsed; null when it could not be read
|
|
606
|
+
* @param {object} next the classification this tree produces
|
|
607
|
+
* @returns {{ added: string[], removed: string[], changed: Array<{ name: string, fields: string[] }>, header: string[] }}
|
|
608
|
+
* `changed` lists every field that differs, compared as JSON, so a field one side lacks counts as changed.
|
|
609
|
+
* `header` names the top-level keys other than `rows` that differ, such as the counts.
|
|
610
|
+
*/
|
|
611
|
+
export function classificationDrift(prev, next) {
|
|
612
|
+
// A NAME CAN SIT ON MORE THAN ONE ROW. The catalogue can list a name more than once, and the
|
|
613
|
+
// classification then carries it once per listing, the rows identical in every field. So rows are
|
|
614
|
+
// grouped by name and compared as lists. Keyed by name alone, the last row of each name hid the others,
|
|
615
|
+
// and a change to the first of two rows read as "only the formatting differs".
|
|
616
|
+
const rowsOf = (a) => {
|
|
617
|
+
const m = new Map();
|
|
618
|
+
for (const r of Array.isArray(a?.rows) ? a.rows : []) m.set(r?.name, [...(m.get(r?.name) ?? []), r]);
|
|
619
|
+
return m;
|
|
620
|
+
};
|
|
621
|
+
const was = rowsOf(prev), now = rowsOf(next);
|
|
622
|
+
const added = [...now.keys()].filter((n) => !was.has(n)).sort();
|
|
623
|
+
const removed = [...was.keys()].filter((n) => !now.has(n)).sort();
|
|
624
|
+
const changed = [];
|
|
625
|
+
for (const [name, rows] of now) {
|
|
626
|
+
const before = was.get(name);
|
|
627
|
+
if (!before) continue;
|
|
628
|
+
if (before.length !== rows.length) { changed.push({ name, fields: [`${before.length} row${before.length === 1 ? "" : "s"}, now ${rows.length}`] }); continue; }
|
|
629
|
+
const fields = new Set();
|
|
630
|
+
rows.forEach((row, i) => {
|
|
631
|
+
for (const k of new Set([...Object.keys(before[i]), ...Object.keys(row)]))
|
|
632
|
+
if (JSON.stringify(before[i][k]) !== JSON.stringify(row[k])) fields.add(k);
|
|
633
|
+
});
|
|
634
|
+
if (fields.size) changed.push({ name, fields: [...fields].sort() });
|
|
635
|
+
}
|
|
636
|
+
changed.sort((a, b) => a.name.localeCompare(b.name));
|
|
637
|
+
const header = [...new Set([...Object.keys(prev ?? {}), ...Object.keys(next ?? {})])]
|
|
638
|
+
.filter((k) => k !== "rows" && JSON.stringify(prev?.[k]) !== JSON.stringify(next?.[k])).sort();
|
|
639
|
+
return { added, removed, changed, header };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** PURE. The drift as lines a reader can act on, each list capped so a large drift stays readable. */
|
|
643
|
+
export function describeDrift(d, { cap = 12, unreadable = false, missing = false } = {}) {
|
|
644
|
+
const list = (names) => names.length > cap ? `${names.slice(0, cap).join(", ")} and ${names.length - cap} more` : names.join(", ");
|
|
645
|
+
const out = [];
|
|
646
|
+
if (missing) out.push(" the committed artifact is absent, so every row below reads as added");
|
|
647
|
+
if (unreadable) out.push(" the committed artifact is not valid JSON, so every row below reads as added");
|
|
648
|
+
if (d.added.length) out.push(` ${d.added.length} row(s) added: ${list(d.added)}`);
|
|
649
|
+
if (d.removed.length) out.push(` ${d.removed.length} row(s) removed: ${list(d.removed)}`);
|
|
650
|
+
if (d.changed.length) out.push(` ${d.changed.length} row(s) changed: ${list(d.changed.map((c) => `${c.name} (${c.fields.join(", ")})`))}`);
|
|
651
|
+
if (d.header.length) out.push(` outside the rows: ${d.header.join(", ")}`);
|
|
652
|
+
if (!out.length) out.push(" no row differs, and neither does anything outside the rows: the difference is in the file's formatting");
|
|
653
|
+
return out;
|
|
654
|
+
}
|
|
655
|
+
|
|
468
656
|
function build() {
|
|
469
657
|
const audit = JSON.parse(execFileSync(process.execPath, [join(ROOT, "scripts/env-audit.mjs"), "--json"],
|
|
470
658
|
{ encoding: "utf8", cwd: ROOT, maxBuffer: 1e8 }));
|
|
@@ -534,13 +722,8 @@ function main() {
|
|
|
534
722
|
// AND IT REPORTS WHAT IT DROPPED. A filter that drops silently replaces one invisible loss with
|
|
535
723
|
// another; the shape goes to stderr so the artifact stays exactly the names, and it is grouped by
|
|
536
724
|
// first token and COUNTED rather than listed, because a dropped name can identify someone.
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
try {
|
|
540
|
-
const txt = readFileSync(new URL(`../${f}`, import.meta.url), "utf8");
|
|
541
|
-
for (const m of txt.matchAll(/^#?\s*([A-Z][A-Z0-9_]*)\s*=/gm)) catalogueNames.push(m[1]);
|
|
542
|
-
} catch { /* an absent example file narrows the allowlist; the drop report shows the effect */ }
|
|
543
|
-
}
|
|
725
|
+
// An absent example file narrows the allowlist; the drop report shows the effect.
|
|
726
|
+
const catalogueNames = exampleNames();
|
|
544
727
|
const isOwned = makeIsProductOwned({ credentialNames: AMBIENT_KEYS, catalogueNames });
|
|
545
728
|
const { kept, dropped } = partitionByOwnership([...names], isOwned);
|
|
546
729
|
console.error(`# ${FILTER_DECLARATION}`);
|
|
@@ -564,10 +747,20 @@ function main() {
|
|
|
564
747
|
}
|
|
565
748
|
if (arg === "--apply") { writeFileSync(ART, JSON.stringify(next, null, 2) + "\n"); console.log(`wrote ${ART}`); return; }
|
|
566
749
|
if (arg === "--check") {
|
|
567
|
-
|
|
750
|
+
// `--artifact <path>` compares against another copy instead of the committed one, so a driven check
|
|
751
|
+
// can hand it an artifact with a row removed without touching the file a reviewer reads.
|
|
752
|
+
const against = flag("artifact") || ART;
|
|
753
|
+
const prev = existsSync(against) ? readFileSync(against, "utf8") : "";
|
|
568
754
|
const same = prev === JSON.stringify(next, null, 2) + "\n";
|
|
569
755
|
console.log(same ? "CHECK — the committed classification matches this tree." : "CHECK FAILED — the classification is stale. Re-stamp: node scripts/env-classify.mjs --apply");
|
|
570
|
-
if (!same)
|
|
756
|
+
if (!same) {
|
|
757
|
+
// SAY WHICH ROWS. "Stale" alone sends the reader to diff two large JSON files by eye; the rows that
|
|
758
|
+
// moved are what they have to judge, and they are the part a regeneration would silently absorb.
|
|
759
|
+
let before = null;
|
|
760
|
+
try { before = JSON.parse(prev); } catch { /* unreadable: said below */ }
|
|
761
|
+
for (const line of describeDrift(classificationDrift(before, next), { unreadable: prev !== "" && before === null, missing: prev === "" })) console.log(line);
|
|
762
|
+
process.exitCode = 1;
|
|
763
|
+
}
|
|
571
764
|
return;
|
|
572
765
|
}
|
|
573
766
|
console.log(JSON.stringify(next._counts));
|
|
@@ -88,7 +88,7 @@ import { rosterVerdict } from "../driver/roster-verdict.mjs";
|
|
|
88
88
|
import { triggerCapGap, triggerCapWarning } from "../driver/trigger-cap.mjs"; // the portal's own sentence about a key's gap
|
|
89
89
|
import { bundledDemoKeys } from "../driver/bundled-demos.mjs";
|
|
90
90
|
import { unitsActiveVerdict } from "../driver/unit-state-verdict.mjs";
|
|
91
|
-
import { deploymentBox } from "../shared/deployment-box.mjs"; // — extracted; one allowlist,
|
|
91
|
+
import { deploymentBox, DEPLOYMENT_BOXES } from "../shared/deployment-box.mjs"; // — extracted; one allowlist, every reader
|
|
92
92
|
import { unitFileDriftVerdict } from "../driver/unit-file-drift.mjs"; //
|
|
93
93
|
import { placeholdersIn, resolveValues, renderUnit } from "../driver/systemd/render-units.mjs"; //
|
|
94
94
|
import { CHECKED_UNITS, CHECKED_TIMERS, timerVerdict, unitInventoryVerdict, serviceCommitVerdict, unitWorkingDirectory, unitClone } from "../driver/unit-inventory.mjs"; // · -bundle ·
|
|
@@ -100,6 +100,7 @@ import { config } from "../driver/driver.config.mjs"; /
|
|
|
100
100
|
import { probeQueueWatch, probeWorker, probeTimer } from "../driver/queue-watch-probe.mjs"; // · and, tracker issue 206, the units that say HOW this box drains
|
|
101
101
|
import { doorPostureVerdict } from "../mcp-server/door-posture.mjs"; // — a door whose mode came from another door's variables
|
|
102
102
|
import { readDrainerStamp, drainerVerdict, defaultPpidOf } from "../driver/drainer-identity.mjs"; // — the process that EXECUTES runs
|
|
103
|
+
import { readUpdaterStamp, updaterVerdict, resolveUpdaterStampPath, updaterAbsentHere, UPDATER_STAMP_BASENAME } from "../driver/updater-identity.mjs"; // — the mechanism that PLACES commits
|
|
103
104
|
import { claimerIsAlive } from "../driver/claim-liveness.mjs"; // the shared liveness test, same polarity as the queue's
|
|
104
105
|
import { processTable } from "../shared/process-table.mjs"; // — /proc is not the only box
|
|
105
106
|
import { envFrom } from "../shared/env-aliases.mjs"; // — the name a reader is told to set is the one in force
|
|
@@ -801,6 +802,77 @@ else {
|
|
|
801
802
|
}
|
|
802
803
|
}
|
|
803
804
|
|
|
805
|
+
// ── 8c. — THE MECHANISM THAT PLACES THE COMMITS, WHICH ARM 8 CANNOT ATTRIBUTE ────────────
|
|
806
|
+
//
|
|
807
|
+
// Arm 8 attributes a unit to a commit two ways: the tree under its WorkingDirectory, and the tree the
|
|
808
|
+
// running process was launched from. Both fail for exactly one unit, and it is the updater — a oneshot
|
|
809
|
+
// running from a home directory with no checkout under it, whose command line is the update script
|
|
810
|
+
// rather than the product entry point. So the unit that places every commit on this box was the one
|
|
811
|
+
// unit reported as "could NOT be read and was NOT compared". An updater running from a stale copy
|
|
812
|
+
// would place a stale tree and this check would pass, which is the straddle pointed at the mechanism
|
|
813
|
+
// that does the deploying.
|
|
814
|
+
//
|
|
815
|
+
// IT IS JUDGED ON A STAMP IT WRITES ABOUT ITSELF, the way the drainer above is, and arm 8 now names it
|
|
816
|
+
// as stamped rather than dropping it into the unreadable pile. The two arms cannot disagree because
|
|
817
|
+
// neither judges the other's population: this one owns the updater, that one owns everything with a
|
|
818
|
+
// tree under it, and the inventory decides which is which.
|
|
819
|
+
//
|
|
820
|
+
// IT FAILS ON COULD-NOT-LOOK, for the reason arm 8b does and one more of its own: the stamp writer
|
|
821
|
+
// shipped IN the updater, so a copy old enough to predate it writes no stamp — and that is not a gap in
|
|
822
|
+
// the reporting, it is the stale updater this arm exists to catch, arriving as an absence.
|
|
823
|
+
{
|
|
824
|
+
// WHERE THE STAMP IS, WITHOUT SPELLING A LOGIN INTO A PUBLIC FILE. The environment wins, because a
|
|
825
|
+
// box that redirects its updater must not be told it has no updater; otherwise the directory is the
|
|
826
|
+
// one the unit itself reports, which is where the updater runs and therefore where it stamps. A
|
|
827
|
+
// hardcoded home would publish an account name AND red every box that moved it.
|
|
828
|
+
//
|
|
829
|
+
// THE ENVIRONMENT IS READ INSIDE THE MODULE, under the name the WRITER already honours, so the two
|
|
830
|
+
// halves cannot look in different places. A reader-only variable would let a redirected updater stamp
|
|
831
|
+
// one path while this looked at another and reported "no stamp" — the loud branch — about a
|
|
832
|
+
// deployment that is working. What is derived here is only the fallback.
|
|
833
|
+
//
|
|
834
|
+
// A BOX WITH NO UPDATER IS ASKED FIRST (updaterAbsentHere): skipped with its reason, never failed.
|
|
835
|
+
const absent = updaterAbsentHere(clones.find((c) => c.unit === "clearotron-deploy"));
|
|
836
|
+
if (absent) skip("the updater that deploys this box is the current one", absent);
|
|
837
|
+
else {
|
|
838
|
+
let deployDir = null, resolveWhy = null;
|
|
839
|
+
{
|
|
840
|
+
const row = clones.find((c) => c.unit === "clearotron-deploy");
|
|
841
|
+
if (!row) resolveWhy = "no updater unit was probed on this box";
|
|
842
|
+
else {
|
|
843
|
+
// The unit is a oneshot: between ticks it is inactive and reports its WorkingDirectory anyway,
|
|
844
|
+
// which is the whole reason this attribution works where the process-based one cannot.
|
|
845
|
+
let wd = null;
|
|
846
|
+
try {
|
|
847
|
+
const shown = execFileSync("systemctl", ["--user", "show", "clearotron-deploy", "-p", "WorkingDirectory"],
|
|
848
|
+
{ encoding: "utf8", env: userBusEnv(), stdio: ["ignore", "pipe", "pipe"] });
|
|
849
|
+
wd = /^WorkingDirectory=(.*)$/m.exec(shown)?.[1]?.trim() || null;
|
|
850
|
+
} catch (e) { resolveWhy = `systemctl could not be asked where the updater runs: ${String(e?.message ?? e).slice(0, 100)}`; }
|
|
851
|
+
if (wd !== null) {
|
|
852
|
+
const parsed = unitWorkingDirectory(wd);
|
|
853
|
+
if (!parsed.path) resolveWhy = parsed.why;
|
|
854
|
+
else deployDir = join(parsed.path, "deploy");
|
|
855
|
+
} else if (!resolveWhy) resolveWhy = "the updater unit reported no WorkingDirectory, so where it stamps could not be derived";
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (!resolveUpdaterStampPath(deployDir)) {
|
|
860
|
+
fail("the updater that deploys this box is the current one",
|
|
861
|
+
`where the updater stamps itself could not be resolved (${resolveWhy ?? "no value"}), so `
|
|
862
|
+
+ `${UPDATER_STAMP_BASENAME} was not looked for. This is a failure to look, never a pass.`);
|
|
863
|
+
} else {
|
|
864
|
+
const v = updaterVerdict({
|
|
865
|
+
stamp: readUpdaterStamp(deployDir),
|
|
866
|
+
now: Math.floor(Date.now() / 1000),
|
|
867
|
+
// The same clone arm 8 scoped itself to, so the two arms cannot hold different opinions about
|
|
868
|
+
// which tree this deploy is. Reading it twice is how they would come to.
|
|
869
|
+
deployClone,
|
|
870
|
+
});
|
|
871
|
+
record("the updater that deploys this box is the current one", v.state, v.message);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
804
876
|
//: "0 active" is not a pass. It is either "I could not look" or "there is nothing running here".
|
|
805
877
|
//: and "not active" is not "broken". Both state comparisons this arm used to make lived here as
|
|
806
878
|
// inline string equality over a two-word vocabulary, which is why a `Type=oneshot` doing its job read as
|
|
@@ -940,7 +1012,7 @@ else {
|
|
|
940
1012
|
// unit missing. Deployments set it in the service environment file beside the other box-scoped vars.
|
|
941
1013
|
const box = deploymentBox(); // — the shared rule, so /portal/health cannot disagree with this
|
|
942
1014
|
const v = unitInventoryVerdict({ live: liveUnits, files: walk.files, collisions: walk.collisions,
|
|
943
|
-
filesError: walk.error, box, probe });
|
|
1015
|
+
filesError: walk.error, box, probe, boxNames: DEPLOYMENT_BOXES });
|
|
944
1016
|
record("every live unit is declared", v.state, v.message);
|
|
945
1017
|
|
|
946
1018
|
// — AND WHETHER ANYTHING STILL STARTS THE TIMER-DRIVEN ONES. Reported separately from the line above
|