clearotron 0.2.0 → 0.3.0-beta.0
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 +52 -0
- package/INSTALL.md +9 -7
- package/README.md +2 -1
- package/bin/example.mjs +97 -32
- package/bin/onboard.mjs +58 -20
- package/bin/start.mjs +7 -0
- package/bin/stop.mjs +65 -3
- package/build-info.json +2 -2
- package/docs/RELEASES.md +6 -4
- package/docs/architecture/04-configuration-reference.md +1 -1
- package/driver/CHANGELOG.md +51 -0
- package/driver/ask-ledger.mjs +69 -1
- package/driver/declination-call.mjs +32 -0
- package/driver/driver.config.mjs +20 -0
- package/driver/engine/mcp/recording-server.mjs +4 -0
- package/driver/gateway.mjs +8 -3
- package/driver/knockout-assess-record.mjs +5 -1
- package/driver/package.json +1 -1
- package/driver/pipeline.mjs +123 -3
- package/driver/predelivery-lint.mjs +23 -5
- package/driver/publish/knockout.mjs +12 -6
- package/driver/publish/render-knockout.mjs +133 -23
- package/driver/publish/report-data.mjs +13 -3
- package/driver/publish/seed-pool.mjs +24 -9
- package/driver/record-carry.mjs +139 -0
- package/driver/reference-score.mjs +53 -3
- package/driver/reference-strip-signatures.mjs +68 -0
- package/driver/register-digest-record.mjs +31 -1
- package/driver/repairs.mjs +1 -1
- package/driver/result-noun-fields.mjs +7 -0
- package/driver/skills/knockout-assess/SKILL.md +10 -4
- package/driver/stages-knockout.mjs +1 -1
- package/driver/stages.mjs +1 -1
- package/driver/suite-census.json +108 -30
- package/driver/unit-inventory.mjs +47 -0
- package/driver/unit-state-verdict.mjs +8 -8
- package/driver/verify-knockout.mjs +9 -1
- package/driver/verify.mjs +2 -2
- package/driver/whatif-memo-run.mjs +45 -4
- package/mcp-server/CHANGELOG.md +10 -0
- package/mcp-server/lib/brief.mjs +15 -0
- package/mcp-server/lib/driver.mjs +6 -0
- package/mcp-server/lib/knockout.mjs +435 -0
- package/mcp-server/lib/scrub.mjs +1 -1
- package/mcp-server/lib/whatif.mjs +10 -1
- package/mcp-server/package.json +1 -1
- package/mcp-server/server.mjs +69 -4
- package/package.json +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +10 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/ai-page-render-check.mjs +2 -1
- package/scripts/clearances-render-check.mjs +2 -1
- package/scripts/drain-preflight.mjs +2 -2
- package/scripts/env-audit.mjs +20 -0
- package/scripts/freeze-example-run.mjs +3 -3
- package/scripts/headless-page.mjs +274 -0
- package/scripts/home-render-check.mjs +2 -1
- package/scripts/live-surface-check.mjs +86 -17
- package/scripts/mint-reference-strip-backlog.mjs +41 -0
- package/scripts/release-await-cut.mjs +95 -7
- package/scripts/release-version-pr-checks.mjs +25 -1
- package/scripts/render-check.mjs +61 -2
- package/scripts/report-frame-check.mjs +12 -0
- package/scripts/report-screenshot.mjs +62 -2
- package/scripts/revisit-render-check.mjs +3 -2
- package/scripts/score.mjs +14 -0
- package/shared/access-audience.mjs +215 -0
- package/shared/tracked-files.mjs +31 -0
- package/scripts/deploy-test.sh +0 -309
package/.env.example
CHANGED
|
@@ -403,3 +403,55 @@ CLEAROTRON_DEMO=
|
|
|
403
403
|
# Read by driver/whatif-worker.mjs. Refs tracker issue 1953.
|
|
404
404
|
# effect: tuning
|
|
405
405
|
CLEAROTRON_WHATIF_MAX_CONCURRENT=1
|
|
406
|
+
|
|
407
|
+
# The checkout every shipped systemd unit interpolates — and the one name in this file you are most
|
|
408
|
+
# likely to meet before you meet this file. Ten units name it in their `ExecStart`
|
|
409
|
+
# (clearotron-portal, -worker, -deploy, -client-mcp, -mcp-face, prelim-driver, prelim-outbox, the two
|
|
410
|
+
# mcp-server/remote units and the courtlistener bridge), and production's units all depend on it.
|
|
411
|
+
#
|
|
412
|
+
# YOU ALMOST CERTAINLY DO NOT SET THIS. `clearotron connect` writes it into the install's env file, set
|
|
413
|
+
# to the checkout it just installed (bin/connect.mjs, shared/checkout-move.mjs), and it rewrites it when
|
|
414
|
+
# the checkout moves. So this is not a decision waiting for you at install time, and the row does not
|
|
415
|
+
# exist because a deployment cannot be configured without it.
|
|
416
|
+
#
|
|
417
|
+
# IT EXISTS BECAUSE OF WHO READS IT AT 2AM. Somebody whose unit will not start reads
|
|
418
|
+
# `${CLEAROTRON_CHECKOUT_DIR}` in an ExecStart, comes to the file INSTALL.md §8 calls the catalogue, and
|
|
419
|
+
# finds nothing. `shared/names-in-force.mjs` already knew the name; neither governance document did.
|
|
420
|
+
# A row that says "the installer writes this" is the answer to their question — the absence was not.
|
|
421
|
+
#
|
|
422
|
+
# Surfaced by the cut-over overlay control once the withheld `ops/` bucket was laid back over the public
|
|
423
|
+
# tree: the ratchets that would have caught it had had no subject since the cut, so this is a gap of
|
|
424
|
+
# long standing rather than a regression. Refs tracker issue 213.
|
|
425
|
+
#
|
|
426
|
+
# Left EMPTY here on purpose. A path in this file would read as a default; there is none, and the
|
|
427
|
+
# installer supplies the real value.
|
|
428
|
+
# effect: deployment
|
|
429
|
+
CLEAROTRON_CHECKOUT_DIR=
|
|
430
|
+
|
|
431
|
+
# ── The release pipeline's own two names ─────────────────────────────────────────────────────────
|
|
432
|
+
# Both are set by `.github/workflows/release.yml` and read by the release scripts. NEITHER is an
|
|
433
|
+
# operator's decision, and on an install both are unset and the defaults are the right answer. They are
|
|
434
|
+
# catalogued for the same reason CLEAROTRON_CHECKOUT_DIR above is: they are OUR names, read by code that
|
|
435
|
+
# ships, and a reader who meets one in a workflow log needs somewhere to look it up.
|
|
436
|
+
#
|
|
437
|
+
# They are NOT in the system-owned list beside GITHUB_OUTPUT, and the line between them is worth stating:
|
|
438
|
+
# GitHub sets GITHUB_*, so a row would document somebody else's decision. We set these, so a row
|
|
439
|
+
# documents ours.
|
|
440
|
+
#
|
|
441
|
+
# Found by the audit while closing tracker issue 213, not by that issue's diagnosis — they arrived with
|
|
442
|
+
# tonight's release-pipeline work and the catalogue ratchet caught them on the next run, which is the
|
|
443
|
+
# ratchet doing its job. Raised with the release lane rather than silently absorbed.
|
|
444
|
+
|
|
445
|
+
# The commit the cut decision is ABOUT. The workflow sets it to `github.sha` — the push — because the
|
|
446
|
+
# checkout may have moved on since, and a decision made about "whatever HEAD is now" tried to republish
|
|
447
|
+
# an already-released version. Unset falls back to HEAD, which is correct for the cron and for a hand
|
|
448
|
+
# run, where nothing has moved. Read by scripts/release-cut-decision.mjs.
|
|
449
|
+
# effect: deployment
|
|
450
|
+
CLEAROTRON_CUT_REF=
|
|
451
|
+
|
|
452
|
+
# How long publishing waits for a parked version run, in whole milliseconds. Unset uses the built-in
|
|
453
|
+
# budget; the workflow sets it to 0 on a `workflow_dispatch` rehearsal so a dry run does not sit out the
|
|
454
|
+
# full wait. A malformed value REFUSES rather than defaulting — taken as 0 it would skip the wait
|
|
455
|
+
# entirely, which is that script not running at all. Read by scripts/release-await-cut.mjs.
|
|
456
|
+
# effect: tuning
|
|
457
|
+
CLEAROTRON_RELEASE_WAIT_MS=
|
package/INSTALL.md
CHANGED
|
@@ -41,10 +41,11 @@ run is [mcp-server/CONNECT.md](mcp-server/CONNECT.md), and why something is the
|
|
|
41
41
|
- **Node.js >= 22**, and npm. A hard floor: `package.json` declares it, `.nvmrc` pins it, and the free
|
|
42
42
|
US register runs on `node:sqlite`, which ships with FTS5 from Node 22. Node 20 installs and then fails
|
|
43
43
|
at the first US search. `nvm use` picks the pin up.
|
|
44
|
-
- **macOS, Linux, or Windows
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
- **macOS, Linux, or native Windows for the demo; WSL2 with Node 22 for a clearance.** `npx clearotron
|
|
45
|
+
demo` runs anywhere Node does, native Windows included. A real clearance does not: the engine resolves
|
|
46
|
+
the reasoning CLI the POSIX way, so a native-Windows clearance refuses at preflight even with the CLI
|
|
47
|
+
on `PATH`. On Windows, `wsl --install`, then install Node 22 **inside** the Linux distribution and work
|
|
48
|
+
through this page from there. A *hosted*
|
|
48
49
|
deployment needs Linux for one further thing, the systemd outbox trigger —
|
|
49
50
|
[driver/systemd/README.md](driver/systemd/README.md).
|
|
50
51
|
- **A reasoning CLI on your `PATH`, signed in.** This is the prerequisite people miss. Every stage runs
|
|
@@ -115,12 +116,13 @@ Nothing here requires an agent platform or a database — reports are written to
|
|
|
115
116
|
|
|
116
117
|
```
|
|
117
118
|
npm install -g clearotron # stable — the tested one
|
|
118
|
-
npm install -g clearotron@beta # newest —
|
|
119
|
+
npm install -g clearotron@beta # newest — cut when there is something worth testing
|
|
119
120
|
```
|
|
120
121
|
|
|
121
122
|
A **stable** has run a real clearance end to end and had a from-scratch install driven by somebody who has
|
|
122
|
-
never seen the product, before it was published. A **beta** is published
|
|
123
|
-
|
|
123
|
+
never seen the product, before it was published. A **beta** is published when a change lands that is worth
|
|
124
|
+
testing, or while a stable is being prepared — days apart, not on every merge; it built and the suite
|
|
125
|
+
passed, and nothing has driven a live register through it.
|
|
124
126
|
|
|
125
127
|
**Upgrade production to stables only.** What each channel promises and how often one is cut:
|
|
126
128
|
**[docs/RELEASES.md](docs/RELEASES.md)**.
|
package/README.md
CHANGED
|
@@ -39,7 +39,8 @@ Node 22 or newer, on macOS, Linux, or Windows via WSL2. That puts `clearotron` o
|
|
|
39
39
|
command below works in that short form.
|
|
40
40
|
|
|
41
41
|
That command gives you the **stable** release — the one that has run a real clearance end to end before it
|
|
42
|
-
was published. If you want the newest code instead,
|
|
42
|
+
was published. If you want the newest code instead, a beta is published whenever there is something worth
|
|
43
|
+
testing — deliberately, days apart, not on every merge:
|
|
43
44
|
|
|
44
45
|
```bash
|
|
45
46
|
npm install -g clearotron@beta
|
package/bin/example.mjs
CHANGED
|
@@ -40,8 +40,8 @@ import "../shared/env-local.mjs"; // step 4 / — FIRST: this program read a
|
|
|
40
40
|
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
41
41
|
import { homedir, tmpdir } from "node:os";
|
|
42
42
|
import { invoke } from "../shared/invocation.mjs"; // — the printed command is resolved once, for the reader who is actually standing there
|
|
43
|
-
import { join, resolve,
|
|
44
|
-
import { fileURLToPath } from "node:url";
|
|
43
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
44
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
45
45
|
import { spawn } from "node:child_process";
|
|
46
46
|
import { BRAND } from "../shared/brand.mjs"; // — the installer's own name, from the tenant seam
|
|
47
47
|
import { envFrom } from "../shared/env-aliases.mjs"; // — resolves EITHER spelling; names the retired one because that is the live-writable half
|
|
@@ -68,7 +68,11 @@ if (has("--help") || has("-h")) {
|
|
|
68
68
|
const shipped = demoChildren(join(REPO, "demo"));
|
|
69
69
|
if (shipped.length) {
|
|
70
70
|
console.log(`\n --product takes one of:`);
|
|
71
|
-
|
|
71
|
+
// NO "DEFAULT" MARKER (tracker issue 277). This printed "(the default, when --product is not given)"
|
|
72
|
+
// beside the first id, and kept printing it after the default became ALL of them — so `--help` taught
|
|
73
|
+
// a reader the exact belief the change removed, in the command it was reported against.
|
|
74
|
+
for (const id of shipped) console.log(` ${id}`);
|
|
75
|
+
console.log(`\n With no --product, every one of them is published.`);
|
|
72
76
|
console.log("");
|
|
73
77
|
} else {
|
|
74
78
|
// An absence is a finding: a tree with no demo says so rather than printing an empty list.
|
|
@@ -96,15 +100,30 @@ const DEMO_ROOT = join(REPO, "demo");
|
|
|
96
100
|
// disagreed. That file records what a knockout demo carries instead of a report.md, and why this line
|
|
97
101
|
// once let `demo/knockout-search` ship and stay unopenable..
|
|
98
102
|
|
|
99
|
-
// --run-dir takes a directory outright. --product names a child. Neither given:
|
|
100
|
-
//
|
|
103
|
+
// --run-dir takes a directory outright. --product names a child. Neither given: EVERY child, and the
|
|
104
|
+
// names are PRINTED below rather than assumed, because "the demo" means several.
|
|
101
105
|
const wanted = flag("--product");
|
|
102
106
|
const children = demoChildren(DEMO_ROOT);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
|
|
108
|
+
// ── ALL OF THEM, UNLESS THE CALLER NARROWED IT (tracker issue 277) ──────────────────────────────────
|
|
109
|
+
//
|
|
110
|
+
// This used to replay `children[0]` and stop. The package ships one finished report per product, and a
|
|
111
|
+
// first-time reader met one of them with nothing on screen saying the other three existed — so three
|
|
112
|
+
// quarters of what was shipped was reachable only by someone who already knew to ask for it. The owner's
|
|
113
|
+
// ruling is that they auto-load: "it should auto-load since it is NOT obvious these runs are there unless
|
|
114
|
+
// you know about it."
|
|
115
|
+
//
|
|
116
|
+
// `--product` and `--run-dir` still narrow to one, because "show me just this one" is a real thing to
|
|
117
|
+
// want. Neither given now means every product this tree ships, in the container's own order.
|
|
118
|
+
const sampleDirs = flag("--run-dir")
|
|
119
|
+
? [resolve(flag("--run-dir"))]
|
|
120
|
+
: wanted
|
|
121
|
+
? [resolve(join(DEMO_ROOT, wanted))]
|
|
122
|
+
: children.map((c) => resolve(join(DEMO_ROOT, c)));
|
|
123
|
+
// The refusal below is about ONE directory, and with nothing shipped there is no directory to name — so
|
|
124
|
+
// the container itself is what it looks at, which is what it always did when `demo/` was empty.
|
|
125
|
+
const sampleDir = sampleDirs[0] ?? resolve(DEMO_ROOT);
|
|
126
|
+
if (!sampleDirs.length || !isFrozen(sampleDir)) {
|
|
108
127
|
// AN ABSENCE IS A FINDING, AND IT NAMES WHAT IT LOOKED AT. This exits 1 and always has; tracker issue
|
|
109
128
|
// 2193 reported it exiting 0, which did not reproduce at v0.1.0 or at main's tip. An arm pins it.
|
|
110
129
|
die(
|
|
@@ -128,10 +147,17 @@ if (!isFrozen(sampleDir)) {
|
|
|
128
147
|
// PUBLISHING WRITES A RECEIPT INTO THE RUN DIRECTORY, and `demo/` is tracked — so a reader who only READ
|
|
129
148
|
// the demo came back to a dirty checkout. `publishSource` is the one definition of that rule, shared with
|
|
130
149
|
// the launcher, which seeds the pool from the same container on every `--demo` start.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
150
|
+
// EVERY ONE THAT WAS ASKED FOR IS READ BEFORE ANY IS PUBLISHED, so a manifest missing a runId is a
|
|
151
|
+
// refusal about that demo by name rather than a partial pool nobody can account for.
|
|
152
|
+
const samples = sampleDirs.map((dir) => {
|
|
153
|
+
const manifest = join(dir, "meta.json");
|
|
154
|
+
if (!isFrozen(dir)) die(`demo: ${dir} is not a frozen demo — it holds no meta.json and lane entry file.`);
|
|
155
|
+
const m = JSON.parse(readFileSync(manifest, "utf8"));
|
|
156
|
+
if (!m?.runId) die(`example: ${manifest} names no runId — it is not a frozen example manifest.`);
|
|
157
|
+
return { dir, meta: m, publishFrom: publishSource(dir, { repoRoot: REPO }), name: basename(dir) };
|
|
158
|
+
});
|
|
159
|
+
const publishFrom = samples[0].publishFrom;
|
|
160
|
+
const meta = samples[0].meta;
|
|
135
161
|
|
|
136
162
|
// ── 2. the pool guard ────────────────────────────────────────────────────────────────────────────────
|
|
137
163
|
// Resolve through symlinks. A $HOME that resolves inside the archive is exactly the shape a `===` test
|
|
@@ -192,19 +218,38 @@ if (existsSync(poolRoot) && !statSync(poolRoot).isDirectory()) die(`demo: ${pool
|
|
|
192
218
|
|
|
193
219
|
// ── 3. replay ────────────────────────────────────────────────────────────────────────────────────────
|
|
194
220
|
console.log(`\n ${BRAND.name} ${BRAND.product.toLowerCase()} — demo\n`);
|
|
195
|
-
console.log(
|
|
221
|
+
console.log(samples.length === 1
|
|
222
|
+
? ` sample: ${samples[0].dir}`
|
|
223
|
+
: ` samples: ${samples.length} — ${samples.map((x) => x.name).join(", ")}`);
|
|
196
224
|
console.log(` pool: ${poolRoot}\n`);
|
|
197
225
|
|
|
198
226
|
mkdirSync(poolRoot, { recursive: true });
|
|
199
|
-
const { republishRun } = await import(join(REPO, "driver", "publish", "report-registry.mjs"));
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
227
|
+
const { republishRun } = await import(pathToFileURL(join(REPO, "driver", "publish", "report-registry.mjs")).href);
|
|
228
|
+
|
|
229
|
+
// ── ONE FAILURE MUST NOT COST THE OTHERS, AND MUST NOT BE A QUIET COUNT (tracker issue 277) ─────────
|
|
230
|
+
//
|
|
231
|
+
// Publishing stopped at the first error, which was right when there was one demo and is wrong now: a
|
|
232
|
+
// reader whose knockout capture is unreadable should still get the other three, and should be TOLD which
|
|
233
|
+
// one is missing. "3 published" with no other line is the shape this repository calls a silent count —
|
|
234
|
+
// the number is true and the reader cannot tell it is short.
|
|
235
|
+
//
|
|
236
|
+
// The failures are collected and reported together at the end, and the process exits non-zero, because a
|
|
237
|
+
// demo that came up missing a quarter of itself is not a success however good the three look.
|
|
238
|
+
const results = [];
|
|
239
|
+
const failures = [];
|
|
240
|
+
for (const s0 of samples) {
|
|
241
|
+
try {
|
|
242
|
+
// poolUrl "" on purpose: the report's own link block is for a deployment that serves the pool at a
|
|
243
|
+
// public URL. This one is served from this process, at a port picked below.
|
|
244
|
+
results.push({ ...s0, published: await republishRun({ runId: s0.meta.runId, meta: s0.meta, pool: poolRoot, poolUrl: "", runDir: join(s0.publishFrom, "run") }) });
|
|
245
|
+
} catch (e) {
|
|
246
|
+
failures.push({ name: s0.name, why: String(e?.message ?? e) });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (!results.length) {
|
|
250
|
+
die(`demo: no demo could be replayed.`, "", ...failures.map((f) => ` ${f.name}: ${f.why}`));
|
|
207
251
|
}
|
|
252
|
+
const published = results[0].published;
|
|
208
253
|
|
|
209
254
|
// THE LABEL. The reader is about to look at a document that reads like advice about a real mark. It is
|
|
210
255
|
// not, and the demo says so before the browser opens rather than in a footnote nobody reaches.
|
|
@@ -227,18 +272,34 @@ console.log(" re-rendered from its artifacts. It is an example, not advice.\n")
|
|
|
227
272
|
//
|
|
228
273
|
// The third branch is the point: a lane whose publisher reports no count says so. This line printed a
|
|
229
274
|
// bare "?" to the knockout — a could-not-look wearing the costume of a number.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
275
|
+
// PER LANE, FOR EVERY DEMO — not for the first one with the rest reduced to a product name. The three
|
|
276
|
+
// branches below are the whole point of this sentence: two lanes count different populations and a third
|
|
277
|
+
// counts none, and printing one lane's number beside four reports would state the wrong population three
|
|
278
|
+
// times.
|
|
279
|
+
const spineOf = (pub) =>
|
|
280
|
+
Number.isFinite(pub.counts?.findings)
|
|
281
|
+
? `${pub.counts.findings} finding(s) recorded in the run's audit spine; the report shows
|
|
233
282
|
the ones it retains`
|
|
234
|
-
: Number.isFinite(
|
|
235
|
-
? `${
|
|
236
|
-
evidence, on ${
|
|
283
|
+
: Number.isFinite(pub.receipts?.findings)
|
|
284
|
+
? `${pub.receipts.findings} finding(s) with citations traced to this run's own held
|
|
285
|
+
evidence, on ${pub.receipts.citing}/${pub.receipts.marks} mark(s)`
|
|
237
286
|
: `this lane's publisher reported no finding count — the report itself is the record`;
|
|
238
|
-
console.log(` published: ${published.runId}
|
|
287
|
+
for (const r of results) console.log(` published: ${r.published.runId}\n ${r.name} — ${spineOf(r.published)}`);
|
|
288
|
+
if (results.length > 1) console.log(`\n ${results.length} demo reports are published and listed — one per product.`);
|
|
289
|
+
// LOUD, AND ON STDERR, AND NON-ZERO. Said after the successes so a reader sees what they DID get first,
|
|
290
|
+
// and cannot mistake the run for a clean one.
|
|
291
|
+
if (failures.length) {
|
|
292
|
+
console.error(`\n ${failures.length} of ${samples.length} demo(s) could NOT be replayed:`);
|
|
293
|
+
for (const f of failures) console.error(` ${f.name}: ${f.why}`);
|
|
294
|
+
console.error(` The portal below lists the ${results.length} that published. This exits non-zero.`);
|
|
295
|
+
process.exitCode = 1;
|
|
296
|
+
}
|
|
239
297
|
|
|
240
298
|
if (has("--once")) {
|
|
241
|
-
|
|
299
|
+
// EVERY REPORT THAT WAS PUBLISHED, not the first one. Naming one of four here is the same defect as
|
|
300
|
+
// publishing one of four: the reader is handed a path and has no way to learn the others exist.
|
|
301
|
+
console.log("");
|
|
302
|
+
for (const r of results) console.log(` report: ${join(poolRoot, r.published.runId, "report.html")}`);
|
|
242
303
|
//, criterion 5 — SAY WHAT WAS CREATED, ON EVERY PATH THAT CREATES SOMETHING.
|
|
243
304
|
//
|
|
244
305
|
// The portal path below prints this and `--once` did not, so the one invocation a reader is most
|
|
@@ -251,7 +312,11 @@ if (has("--once")) {
|
|
|
251
312
|
// does — worse than silence, because it reads as an answer.
|
|
252
313
|
const created = flag("--pool") ? poolRoot : demoBase;
|
|
253
314
|
console.log(` Removing it later is one directory: rm -rf ${created}\n`);
|
|
254
|
-
|
|
315
|
+
// NOT A BARE ZERO. A demo that failed to replay one of its four sets `exitCode` above, and exiting 0
|
|
316
|
+
// here would discard it — printing the failure and then reporting success, which is the shape this
|
|
317
|
+
// change exists to remove. `--once` is also the invocation a script is most likely to use, so it is
|
|
318
|
+
// the one where a swallowed code does the most damage.
|
|
319
|
+
process.exit(process.exitCode ?? 0);
|
|
255
320
|
}
|
|
256
321
|
|
|
257
322
|
/**
|
package/bin/onboard.mjs
CHANGED
|
@@ -693,8 +693,8 @@ export async function preflightCandidate(candidate) {
|
|
|
693
693
|
/** Ask EUIPO for a token. This is the check that makes "refuses to persist a bad secret" true. */
|
|
694
694
|
export async function validateEuipo({ clientId, clientSecret, environment }) {
|
|
695
695
|
try {
|
|
696
|
-
const { resolveConfig } = await import(join(REPO, "providers", "euipo", "src", "core.js"));
|
|
697
|
-
const { getAccessToken } = await import(join(REPO, "providers", "euipo", "src", "euipo-client.js"));
|
|
696
|
+
const { resolveConfig } = await import(pathToFileURL(join(REPO, "providers", "euipo", "src", "core.js")).href);
|
|
697
|
+
const { getAccessToken } = await import(pathToFileURL(join(REPO, "providers", "euipo", "src", "euipo-client.js")).href);
|
|
698
698
|
await getAccessToken(resolveConfig({ clientId, clientSecret, environment }), { force: true });
|
|
699
699
|
return { ok: true };
|
|
700
700
|
} catch (e) {
|
|
@@ -705,7 +705,7 @@ export async function validateEuipo({ clientId, clientSecret, environment }) {
|
|
|
705
705
|
/** One minimal Perplexity call. Costs a request, so it is never made without being asked for. */
|
|
706
706
|
export async function validatePerplexity(apiKey) {
|
|
707
707
|
try {
|
|
708
|
-
const { buildRequestBody, callAgentAPI } = await import(join(REPO, "providers", "perplexity", "src", "core.js"));
|
|
708
|
+
const { buildRequestBody, callAgentAPI } = await import(pathToFileURL(join(REPO, "providers", "perplexity", "src", "core.js")).href);
|
|
709
709
|
await callAgentAPI(apiKey, buildRequestBody({ task: "Reply with the single word: ok.", preset: "fast-search" }), { retries: 0 });
|
|
710
710
|
return { ok: true };
|
|
711
711
|
} catch (e) {
|
|
@@ -1053,7 +1053,7 @@ export async function runCheck() {
|
|
|
1053
1053
|
// deliberate — a second reader would drift from this one exactly as the composer and the checker did
|
|
1054
1054
|
// in F41, and the drift is invisible because both sides keep passing their own arms.
|
|
1055
1055
|
const unitDir = join(homedir(), ".config", "systemd", "user");
|
|
1056
|
-
const { BACKGROUND_UNITS } = await import(join(REPO, "bin", "start.mjs"));
|
|
1056
|
+
const { BACKGROUND_UNITS } = await import(pathToFileURL(join(REPO, "bin", "start.mjs")).href);
|
|
1057
1057
|
const hosted = BACKGROUND_UNITS.some((u) => existsSync(join(unitDir, u)));
|
|
1058
1058
|
const unitEnv = hosted
|
|
1059
1059
|
? unitEnvironment({
|
|
@@ -1908,8 +1908,8 @@ export async function runCheck() {
|
|
|
1908
1908
|
say("\n Register lane — proven, not inferred");
|
|
1909
1909
|
try {
|
|
1910
1910
|
const [{ activeProvider }, { makeLaneProbe, probeSpend, DEFAULT_CONTROLS, loadProviderCapabilities }] = await Promise.all([
|
|
1911
|
-
import(join(REPO, "driver", "driver.config.mjs")),
|
|
1912
|
-
import(join(REPO, "providers", "_shared", "lane-probe.mjs")),
|
|
1911
|
+
import(pathToFileURL(join(REPO, "driver", "driver.config.mjs")).href),
|
|
1912
|
+
import(pathToFileURL(join(REPO, "providers", "_shared", "lane-probe.mjs")).href),
|
|
1913
1913
|
]);
|
|
1914
1914
|
const adapter = activeProvider();
|
|
1915
1915
|
// NOT `adapter.capabilities` — that is null on every adapter, and reading it announced a LOCAL
|
|
@@ -1995,7 +1995,7 @@ export async function runCheck() {
|
|
|
1995
1995
|
// green over that state would bless the owner's fresh-install 500.
|
|
1996
1996
|
say("\n Profile store");
|
|
1997
1997
|
try {
|
|
1998
|
-
const { loadProfiles } = await import(join(REPO, "driver", "profiles.mjs"));
|
|
1998
|
+
const { loadProfiles } = await import(pathToFileURL(join(REPO, "driver", "profiles.mjs")).href);
|
|
1999
1999
|
const resolved = loadProfiles({ force: true });
|
|
2000
2000
|
const named = [...resolved.keys()];
|
|
2001
2001
|
ok(`resolves: ${named.length} profile(s) (${named.slice(0, 6).join(", ")}${named.length > 6 ? ", …" : ""}) — the universal fallback is present`);
|
|
@@ -2024,7 +2024,7 @@ export async function runCheck() {
|
|
|
2024
2024
|
// an absence of evidence is not evidence of absence, and this whole check exists because something
|
|
2025
2025
|
// invisible was being read as fine.
|
|
2026
2026
|
{
|
|
2027
|
-
const { retiredSpellingsIn, retiredSpellingLine } = await import(join(REPO, "shared", "env-aliases.mjs"));
|
|
2027
|
+
const { retiredSpellingsIn, retiredSpellingLine } = await import(pathToFileURL(join(REPO, "shared", "env-aliases.mjs")).href);
|
|
2028
2028
|
const sources = [
|
|
2029
2029
|
{ label: "your environment file", env: fileEnv, known: true },
|
|
2030
2030
|
{ label: "this shell's environment", env: process.env, known: true },
|
|
@@ -2077,7 +2077,7 @@ export async function runCheck() {
|
|
|
2077
2077
|
|
|
2078
2078
|
say("\n Submit lane");
|
|
2079
2079
|
try {
|
|
2080
|
-
const { triggerLaneVerdict, HOSTED, SUPERVISED } = await import(join(REPO, "shared", "trigger-lane.mjs"));
|
|
2080
|
+
const { triggerLaneVerdict, HOSTED, SUPERVISED } = await import(pathToFileURL(join(REPO, "shared", "trigger-lane.mjs")).href);
|
|
2081
2081
|
// POSTURE FROM THE BOX, NOT FROM A FLAG. Units installed means the units serve, and the units read
|
|
2082
2082
|
// %h/.env — so an absent value there is the incident. No units means `start` supervises and derives
|
|
2083
2083
|
// the value at runtime, where reading this process's environment says nothing either way.
|
|
@@ -2117,7 +2117,7 @@ export async function runCheck() {
|
|
|
2117
2117
|
// Stop control's availability on — one reader, two surfaces.
|
|
2118
2118
|
let verbs = null;
|
|
2119
2119
|
try {
|
|
2120
|
-
const { opsTokenPosture } = await import(join(REPO, "driver", "portal-service.mjs"));
|
|
2120
|
+
const { opsTokenPosture } = await import(pathToFileURL(join(REPO, "driver", "portal-service.mjs")).href);
|
|
2121
2121
|
verbs = opsTokenPosture(opsToken).verbs;
|
|
2122
2122
|
} catch { /* unreadable posture leaves verbs null, which reads as full ops — see the verdict */ }
|
|
2123
2123
|
let probe = null;
|
|
@@ -2158,7 +2158,7 @@ export async function runCheck() {
|
|
|
2158
2158
|
// measured on a default install, a revoked key completed a full handshake with nothing logged. Nothing
|
|
2159
2159
|
// surfaced that state anywhere, which is why it survived to be found by hand.
|
|
2160
2160
|
{
|
|
2161
|
-
const { defaultDenylistPath } = await import(join(REPO, "shared", "client-door.mjs"));
|
|
2161
|
+
const { defaultDenylistPath } = await import(pathToFileURL(join(REPO, "shared", "client-door.mjs")).href);
|
|
2162
2162
|
const dl = effective("TRADEMARK_MCP_TOKEN_DENYLIST")?.v || defaultDenylistPath(homedir());
|
|
2163
2163
|
try {
|
|
2164
2164
|
const n = readFileSync(dl, "utf8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")).length;
|
|
@@ -2172,8 +2172,8 @@ export async function runCheck() {
|
|
|
2172
2172
|
}
|
|
2173
2173
|
}
|
|
2174
2174
|
try {
|
|
2175
|
-
const { clientDoorState: doorState, describeDoorState, connectKeyReport, CLIENT_DOOR_UNIT: doorUnit } = await import(join(REPO, "shared", "client-door.mjs"));
|
|
2176
|
-
const { loadGrants: readGrantsFile, isRevoked: revokedCheck } = await import(join(REPO, "shared", "scope.mjs"));
|
|
2175
|
+
const { clientDoorState: doorState, describeDoorState, connectKeyReport, CLIENT_DOOR_UNIT: doorUnit } = await import(pathToFileURL(join(REPO, "shared", "client-door.mjs")).href);
|
|
2176
|
+
const { loadGrants: readGrantsFile, isRevoked: revokedCheck } = await import(pathToFileURL(join(REPO, "shared", "scope.mjs")).href);
|
|
2177
2177
|
// ── CONFIGURED IS NOT RUNNING, AND THIS IS THE SURFACE IT WAS MEASURED ON ──
|
|
2178
2178
|
//
|
|
2179
2179
|
// `describeDoorState` was written for exactly this block and then never called from it: the split
|
|
@@ -2249,7 +2249,7 @@ export async function runCheck() {
|
|
|
2249
2249
|
// names the old one. A door that is up, listening, and refuses everything is the worst of the three
|
|
2250
2250
|
// states to debug, and nothing surfaced the mismatch.
|
|
2251
2251
|
{
|
|
2252
|
-
const { clientDoorPort: portOf, allowedHosts: hostsFor } = await import(join(REPO, "shared", "client-door.mjs"));
|
|
2252
|
+
const { clientDoorPort: portOf, allowedHosts: hostsFor } = await import(pathToFileURL(join(REPO, "shared", "client-door.mjs")).href);
|
|
2253
2253
|
const declared = String(effective("CLIENT_MCP_ALLOWED_HOSTS")?.v ?? "").trim();
|
|
2254
2254
|
if (declared) {
|
|
2255
2255
|
const port = portOf(doorEnv);
|
|
@@ -2272,7 +2272,7 @@ export async function runCheck() {
|
|
|
2272
2272
|
// null — a probe that could not be made is not a door that is absent.
|
|
2273
2273
|
let doorListening = null;
|
|
2274
2274
|
try {
|
|
2275
|
-
const { clientDoorPort: portOf } = await import(join(REPO, "shared", "client-door.mjs"));
|
|
2275
|
+
const { clientDoorPort: portOf } = await import(pathToFileURL(join(REPO, "shared", "client-door.mjs")).href);
|
|
2276
2276
|
const { createConnection } = await import("node:net");
|
|
2277
2277
|
doorListening = await new Promise((resolve) => {
|
|
2278
2278
|
const sock = createConnection({ host: "127.0.0.1", port: portOf(doorEnv), timeout: 700 });
|
|
@@ -2310,8 +2310,13 @@ export async function runCheck() {
|
|
|
2310
2310
|
// THE PUBLISHED ADDRESS, AND WHETHER IT ANSWERS (, acceptance 2). Reported here
|
|
2311
2311
|
// rather than beside the unit, because the unit running and the address being reachable are
|
|
2312
2312
|
// different facts and the second is the one a client depends on.
|
|
2313
|
-
const { clientDoorReachability } = await import(join(REPO, "shared", "client-door.mjs"));
|
|
2314
|
-
|
|
2313
|
+
const { clientDoorReachability } = await import(pathToFileURL(join(REPO, "shared", "client-door.mjs")).href);
|
|
2314
|
+
// FROM THE FILE THE UNITS LOAD, NOT THE SHELL THIS COMMAND WAS TYPED IN (the tracker issue 226
|
|
2315
|
+
// family, met again here). On a hosted box the published address lives in the units' environment,
|
|
2316
|
+
// and reading `process.env` reported "no client connector address is published — that is correct
|
|
2317
|
+
// for a local install" about a deployment that publishes one. Every verdict below rests on this
|
|
2318
|
+
// value, including the audience comparison, so a wrong reading here is not one wrong line.
|
|
2319
|
+
const published = effectiveForService("CLEAROTRON_CLIENT_MCP_URL")?.v ?? null;
|
|
2315
2320
|
// A SHORT, CHEAP ASK, and a failure to reach IS the finding rather than a failure to look — the
|
|
2316
2321
|
// property under test is reachability itself. What would be dishonest is calling an address green
|
|
2317
2322
|
// because nothing asked, which is why `probe` stays null when there is nothing to ask about.
|
|
@@ -2327,8 +2332,13 @@ export async function runCheck() {
|
|
|
2327
2332
|
// returns null when the header is absent — a looked-and-none answer, not a did-not-look — and
|
|
2328
2333
|
// the readers separate those, so a probe that omits the field reads as never-looked rather
|
|
2329
2334
|
// than silently as "no challenge".
|
|
2335
|
+
// THE REDIRECT TARGET TRAVELS WITH IT (tracker issue 241). This request is already made with
|
|
2336
|
+
// `redirect: "manual"`, so the Location an unauthenticated caller is handed is right here —
|
|
2337
|
+
// and it is where Cloudflare Access puts the audience. Reading it from THIS response rather
|
|
2338
|
+
// than asking a second time keeps `doctor` to one request per address, and keeps both answers
|
|
2339
|
+
// about the same moment.
|
|
2330
2340
|
probe = { ok: res.status < 500, status: res.status, error: null,
|
|
2331
|
-
challenge: res.headers.get("www-authenticate") };
|
|
2341
|
+
challenge: res.headers.get("www-authenticate"), location: res.headers.get("location") };
|
|
2332
2342
|
} catch (e) { probe = { ok: false, status: null, error: String(e?.cause?.code ?? e?.name ?? e?.message ?? e) }; }
|
|
2333
2343
|
}
|
|
2334
2344
|
const reach = clientDoorReachability({ url: published, probe });
|
|
@@ -2336,6 +2346,34 @@ export async function runCheck() {
|
|
|
2336
2346
|
else if (reach.state === "fail") problem(reach.message);
|
|
2337
2347
|
else info(reach.message);
|
|
2338
2348
|
|
|
2349
|
+
// ── AND IS IT THE AUDIENCE THIS INSTALL CHECKS AGAINST? (tracker issue 241) ───────────────────
|
|
2350
|
+
//
|
|
2351
|
+
// The other half of the recreation trap, and the half with no symptom of its own. Deleting and
|
|
2352
|
+
// recreating an Access application changes the audience; the existing warning fires on the
|
|
2353
|
+
// CHALLENGE being wrong, so on a box where somebody recreated the application and then fixed the
|
|
2354
|
+
// sign-in, everything above reads healthy and the stale audience stays invisible until a real
|
|
2355
|
+
// request is rejected.
|
|
2356
|
+
//
|
|
2357
|
+
// No new credential and no provider API: the audience is in the redirect handed to a caller with
|
|
2358
|
+
// no session at all, which is the request just made.
|
|
2359
|
+
{
|
|
2360
|
+
const { readAudience, audienceVerdict } = await import(pathToFileURL(join(REPO, "shared", "access-audience.mjs")).href);
|
|
2361
|
+
const configuredAud = effectiveForService("CLEAROTRON_OIDC_AUDIENCE")?.v ?? "";
|
|
2362
|
+
const read = readAudience(probe ?? { error: "the published address was never asked" });
|
|
2363
|
+
// NOTHING IS CLAIMED ABOUT A BOX THAT USES NO EDGE. With no audience configured AND no Access
|
|
2364
|
+
// challenge in front of the address, there is no question here — a local install is not a
|
|
2365
|
+
// misconfigured hosted one, and saying so every time is how a reader learns to skim this.
|
|
2366
|
+
if (configuredAud || read.kind !== "not-fronted") {
|
|
2367
|
+
const v = audienceVerdict({ configured: configuredAud, read });
|
|
2368
|
+
if (v.ok) ok(v.message);
|
|
2369
|
+
// A COULD-NOT-LOOK IS SAID, NOT RAISED. The line above has already reported the address as
|
|
2370
|
+
// unreachable; repeating it as a second failure teaches the reader that this section
|
|
2371
|
+
// double-counts. It still states what was NOT established, which is the whole job.
|
|
2372
|
+
else if (v.kind === "could-not-look") info(v.message);
|
|
2373
|
+
else problem(v.message);
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2339
2377
|
// ── CAN AN ASSISTANT ACTUALLY SIGN IN? (tracker issue 149, opt-in) ────────────────────────────
|
|
2340
2378
|
//
|
|
2341
2379
|
// The check above reads the CHALLENGE FORM, which is the first of two provider settings that decide
|
|
@@ -2345,7 +2383,7 @@ export async function runCheck() {
|
|
|
2345
2383
|
// announces the side effect first, and its control runs before any vendor.
|
|
2346
2384
|
if (PROBE_CONNECTOR && String(published ?? "").trim()) {
|
|
2347
2385
|
const { registrationEndpointFrom, probeRegistration, describeRegistration } =
|
|
2348
|
-
await import(join(REPO, "shared", "connector-signin-probe.mjs"));
|
|
2386
|
+
await import(pathToFileURL(join(REPO, "shared", "connector-signin-probe.mjs")).href);
|
|
2349
2387
|
say("");
|
|
2350
2388
|
say(" --probe-connector: asking this door's sign-in whether each assistant vendor could register.");
|
|
2351
2389
|
say(" Each attempt that SUCCEEDS creates a throwaway OAuth client on your account, which you may");
|
|
@@ -3096,7 +3134,7 @@ try {
|
|
|
3096
3134
|
prose("No default — it is your hostname. Skip it and this stays a local install: everything works",
|
|
3097
3135
|
"here, and the Use-your-AI page says so rather than handing out an address that fails.");
|
|
3098
3136
|
prose("The usual shape, and why: mcp-server/CONNECT.md.");
|
|
3099
|
-
const { clientDoorReachability } = await import(join(REPO, "shared", "client-door.mjs"));
|
|
3137
|
+
const { clientDoorReachability } = await import(pathToFileURL(join(REPO, "shared", "client-door.mjs")).href);
|
|
3100
3138
|
for (;;) {
|
|
3101
3139
|
const clientUrl = await askValue("Public connector address:", {
|
|
3102
3140
|
skippable: true,
|
package/bin/start.mjs
CHANGED
|
@@ -1057,6 +1057,12 @@ if (isMain) {
|
|
|
1057
1057
|
// — `clearotron demo` hands over to this — so fixing the player alone left the defect where it was.
|
|
1058
1058
|
const { publishSource } = await import("../driver/demo-container.mjs");
|
|
1059
1059
|
const seed = await seedPool({ pool: paths.pool, examplesDir: publishSource(join(REPO, "demo"), { repoRoot: REPO }), republish: republishRun });
|
|
1060
|
+
// WHAT WAS ALREADY THERE IS SAID TOO (tracker issue 277). This branch used to run only when the pool
|
|
1061
|
+
// was empty; it now tops a stale pool up to the package's set, so "seeded 1" on an upgrade is a fact
|
|
1062
|
+
// about what was MISSING and says nothing on its own about how many are now listed.
|
|
1063
|
+
if (seed.already?.length) {
|
|
1064
|
+
say(` archive ${seed.already.length} example report(s) already published here`);
|
|
1065
|
+
}
|
|
1060
1066
|
if (seed.seeded.length) {
|
|
1061
1067
|
say(` seeded ${seed.seeded.length} example report(s) into ${paths.pool}`);
|
|
1062
1068
|
// THE LABEL. is delivered: the report now carries the owner's own sample sentence on its
|
|
@@ -1070,6 +1076,7 @@ if (isMain) {
|
|
|
1070
1076
|
}
|
|
1071
1077
|
// Never a silent nothing. "The archive is empty" and "the archive is empty and nobody noticed why"
|
|
1072
1078
|
// look identical in the browser, so both other outcomes are said out loud.
|
|
1079
|
+
if (seed.skipped) say(` archive ${seed.skipped}`);
|
|
1073
1080
|
for (const p of seed.problems) err(` WARNING: sample seeding — ${p}`);
|
|
1074
1081
|
} catch (e) {
|
|
1075
1082
|
err(` WARNING: the example report could not be seeded (${String(e?.message ?? e)}) — the archive will come up empty. Everything else works; \`npm run example\` shows a sample without touching this install.`);
|
package/bin/stop.mjs
CHANGED
|
@@ -19,12 +19,16 @@ import { existsSync, rmSync } from "node:fs";
|
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { homedir } from "node:os";
|
|
21
21
|
import { execFileSync } from "node:child_process";
|
|
22
|
+
import { systemdSaid, looksLikeBusFailure, busRemedy, CAPTURE_STDERR } from "../shared/systemd-failure.mjs"; // tracker issue 270 — a stop that could not look must not report that it stopped
|
|
22
23
|
import { BACKGROUND_UNITS } from "./start.mjs";
|
|
23
24
|
import { CLIENT_DOOR_UNIT } from "../shared/client-door.mjs";
|
|
24
25
|
import { invoke } from "../shared/invocation.mjs";
|
|
25
26
|
|
|
26
27
|
const UNIT_DIR = join(homedir(), ".config", "systemd", "user");
|
|
27
28
|
const say = (s = "") => console.log(s);
|
|
29
|
+
// ON STDERR, because a refusal that scrolls past in the same stream as the success lines is a
|
|
30
|
+
// refusal a script cannot act on and a reader skims (tracker issue 270).
|
|
31
|
+
const err = (s = "") => console.error(s);
|
|
28
32
|
|
|
29
33
|
const argv = process.argv.slice(2);
|
|
30
34
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
@@ -39,20 +43,76 @@ if (argv.includes("--help") || argv.includes("-h")) {
|
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
let found = 0;
|
|
46
|
+
const failures = [];
|
|
42
47
|
for (const u of BACKGROUND_UNITS) {
|
|
43
48
|
if (u === CLIENT_DOOR_UNIT) continue; // structurally unreachable (the pin's census forbids it); belt anyway
|
|
44
49
|
const file = join(UNIT_DIR, u);
|
|
45
50
|
if (!existsSync(file)) continue;
|
|
46
51
|
found++;
|
|
47
|
-
|
|
52
|
+
// ── THE STOP IS VERIFIED, AND THE FILE GOES ONLY IF IT WORKED (tracker issue 270) ────────────────
|
|
53
|
+
//
|
|
54
|
+
// This was `catch { /* already down */ }` — a COMMENT standing in for a check. The comment guessed why
|
|
55
|
+
// the call failed, the next line deleted the unit file regardless, and the line after that announced
|
|
56
|
+
// "stopped and removed". Measured on a test box: `disable --now` failed for want of a session bus, all
|
|
57
|
+
// four services stayed up on unchanged pids holding all three ports, three unit files were deleted, and
|
|
58
|
+
// the command exited 0 saying the box ran nothing. The services were then unmanageable — running, with
|
|
59
|
+
// no unit file to stop them by — which is strictly worse than leaving both alone.
|
|
60
|
+
//
|
|
61
|
+
// `is-active` is what decides, not the exit code of the disable, because the exit code is what lied.
|
|
62
|
+
let stopped = false;
|
|
63
|
+
let why = null;
|
|
64
|
+
try {
|
|
65
|
+
execFileSync("systemctl", ["--user", "disable", "--now", u], CAPTURE_STDERR);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
why = systemdSaid(e);
|
|
68
|
+
}
|
|
69
|
+
// ASKED AFTER, WHATEVER THE DISABLE SAID. A disable that reported failure may still have stopped it,
|
|
70
|
+
// and one that reported success may not have — only the state answers.
|
|
71
|
+
try {
|
|
72
|
+
const state = execFileSync("systemctl", ["--user", "is-active", u], { ...CAPTURE_STDERR, stdio: ["ignore", "pipe", "pipe"] });
|
|
73
|
+
stopped = String(state).trim() !== "active";
|
|
74
|
+
} catch (e) {
|
|
75
|
+
// `is-active` exits non-zero for an inactive unit, which is the answer we want — but it exits
|
|
76
|
+
// non-zero for "cannot reach systemd" too, and those must not read the same. The stdout is the
|
|
77
|
+
// discriminator: an unreachable systemd prints nothing there.
|
|
78
|
+
const said = String(e?.stdout ?? "").trim();
|
|
79
|
+
if (said && said !== "active") stopped = true;
|
|
80
|
+
else why = why ?? systemdSaid(e);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!stopped) {
|
|
84
|
+
// NOT DELETED. A running service with no unit file cannot be stopped by any ordinary means.
|
|
85
|
+
failures.push({ unit: u, why: why ?? "systemd still reports it active" });
|
|
86
|
+
say(` COULD NOT STOP ${u} — its unit file is left in place, so it can still be stopped`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
48
89
|
try { rmSync(file, { force: true }); } catch { /* already gone */ }
|
|
49
90
|
say(` stopped and removed ${u}`);
|
|
50
91
|
}
|
|
51
|
-
|
|
92
|
+
// THE COMMENT HERE ALREADY NAMED THE CAUSE AND SHRUGGED AT IT. If there is no user bus, the disables
|
|
93
|
+
// above did not happen either — which is the whole of tracker issue 270 — so this is where that is said.
|
|
94
|
+
try {
|
|
95
|
+
execFileSync("systemctl", ["--user", "daemon-reload"], CAPTURE_STDERR);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
const said = systemdSaid(e);
|
|
98
|
+
err(` could not ask systemd to reload its units — ${said}`);
|
|
99
|
+
if (looksLikeBusFailure(said)) err(`\n${busRemedy()}\n`);
|
|
100
|
+
}
|
|
52
101
|
|
|
53
102
|
if (!found) {
|
|
54
103
|
say(" Nothing was running in the background — no pinned unit is installed on this box.");
|
|
55
104
|
say(" Nothing to do, and nothing was changed.");
|
|
105
|
+
} else if (failures.length) {
|
|
106
|
+
// THE SENTENCE THAT WAS WRONG. "The background product is stopped and the box runs nothing again" was
|
|
107
|
+
// printed unconditionally — including on the run where four services stayed up. A reader who is told
|
|
108
|
+
// that has no reason to look, which is what made the state unmanageable rather than merely wrong.
|
|
109
|
+
err("");
|
|
110
|
+
err(` ${failures.length} of ${found} service(s) could NOT be stopped, and their unit files are left in place:`);
|
|
111
|
+
for (const f of failures) err(` ${f.unit}: ${f.why}`);
|
|
112
|
+
if (failures.some((f) => looksLikeBusFailure(f.why))) err(`\n${busRemedy()}\n`);
|
|
113
|
+
err(" Nothing was removed for these, so they can still be stopped once systemd can be reached.");
|
|
114
|
+
err(" The box is NOT idle. This exits non-zero.");
|
|
115
|
+
process.exitCode = 1;
|
|
56
116
|
} else {
|
|
57
117
|
say("");
|
|
58
118
|
say(" The background product is stopped and the box runs nothing again — plain `clearotron start`");
|
|
@@ -60,4 +120,6 @@ if (!found) {
|
|
|
60
120
|
const door = existsSync(join(UNIT_DIR, CLIENT_DOOR_UNIT));
|
|
61
121
|
if (door) say(` Your assistant connection is untouched and still up; \`${invoke("disconnect")}\` is what closes it and revokes its key.`);
|
|
62
122
|
}
|
|
63
|
-
|
|
123
|
+
// NOT A BARE ZERO. A stop that could not stop something sets `exitCode` above, and exiting 0 here would
|
|
124
|
+
// discard it — printing the refusal and then reporting success, which is the shape this change removes.
|
|
125
|
+
process.exit(process.exitCode ?? 0);
|
package/build-info.json
CHANGED