clearotron 0.3.0-beta.8 → 0.3.0-beta.9
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/bin/key.mjs +23 -5
- package/bin/onboard.mjs +3 -0
- package/bin/start.mjs +89 -13
- package/bin/status.mjs +23 -3
- package/bin/stop.mjs +23 -8
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +10 -0
- package/driver/compose-read.mjs +21 -2
- package/driver/engine/jx-turn.mjs +4 -1
- package/driver/package.json +1 -1
- package/driver/portal-service.mjs +6 -2
- package/driver/suite-census.json +31 -1
- package/driver/systemd/render-units.mjs +7 -13
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/shared/client-door.mjs +39 -0
- package/shared/env-file-merge.mjs +24 -0
- package/shared/invocation.mjs +18 -0
- package/shared/running-start.mjs +73 -0
package/bin/key.mjs
CHANGED
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
import "../shared/env-local.mjs"; // side effect: apply the install's .env — FIRST, before anything reads process.env
|
|
30
30
|
import { invocationPrefix } from "../shared/invocation.mjs";
|
|
31
31
|
import { existsSync, readFileSync } from "node:fs";
|
|
32
|
-
import { defaultGrantsPath } from "./start.mjs";
|
|
32
|
+
import { defaultGrantsPath, installPaths } from "./start.mjs";
|
|
33
|
+
import { demoTokenSecretPath } from "../shared/client-door.mjs";
|
|
34
|
+
import { resolvePerson } from "../shared/scope.mjs"; // the door's own reading of the guest list, so one answer serves both
|
|
33
35
|
import { mintFromOptions } from "../mcp-server/mint-token.mjs";
|
|
34
36
|
|
|
35
37
|
const argv = process.argv.slice(2);
|
|
@@ -44,6 +46,7 @@ const USAGE = `usage: ${p}clearotron key issue <email> [options]
|
|
|
44
46
|
|
|
45
47
|
--accounts a,b cap the key to these account keys as well (omit = whatever their grant allows)
|
|
46
48
|
--ttl-days <n> how long it is valid (default 90)
|
|
49
|
+
--base <dir> the install or demo to issue for, when it is not the one set up in ~/trademark
|
|
47
50
|
|
|
48
51
|
The token is printed ONCE on stdout and stored nowhere — possession is the credential. Everything
|
|
49
52
|
else goes to stderr, including the jti that revokes it.`;
|
|
@@ -65,6 +68,17 @@ const email = positional[0];
|
|
|
65
68
|
if (!email) die(`who is the key for? Give the person's email.\n\n${USAGE}`);
|
|
66
69
|
if (!email.includes("@")) die(`"${email}" is not an email address — the subject of an account key is the identity their assistant presents, and the grants file is keyed on it.`);
|
|
67
70
|
|
|
71
|
+
// `--base` NAMES THE INSTALL, as it does for `start` and `passphrase`: its guest list, and — for a demo,
|
|
72
|
+
// which keeps its signing secret in its own base rather than in any settings file — that secret. Without
|
|
73
|
+
// it the verb read the install's settings and refused to issue a key for a running demo.
|
|
74
|
+
const baseAt = rest.indexOf("--base");
|
|
75
|
+
const base = baseAt >= 0 ? rest[baseAt + 1] : null;
|
|
76
|
+
if (baseAt >= 0 && (!base || base.startsWith("--"))) die(`--base needs a directory.\n\n${USAGE}`);
|
|
77
|
+
if (base) {
|
|
78
|
+
const secretFile = demoTokenSecretPath(base);
|
|
79
|
+
if (existsSync(secretFile)) process.env.TRADEMARK_MCP_TOKEN_SECRET = readFileSync(secretFile, "utf8").trim();
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
const ttlDays = Number(flag("--ttl-days") ?? 90);
|
|
69
83
|
const accounts = flag("--accounts") ? flag("--accounts").split(",").map((s) => s.trim()).filter(Boolean) : null;
|
|
70
84
|
|
|
@@ -93,15 +107,19 @@ for (const line of minted.notes) console.error(line);
|
|
|
93
107
|
// inert. STDERR, so the token stays alone on stdout and `key issue ... > token.txt` keeps working —
|
|
94
108
|
// that split is deliberate and this must not undo it.
|
|
95
109
|
try {
|
|
96
|
-
const rosterPath = defaultGrantsPath();
|
|
110
|
+
const rosterPath = base ? installPaths(base).grants : defaultGrantsPath();
|
|
97
111
|
if (!existsSync(rosterPath)) {
|
|
98
112
|
console.error(`\nNOTE: no guest list at ${rosterPath} yet, so nothing grants ${email} anything and this key will be refused at the door. \`clearotron start\` writes the list; then: clearotron grant add ${email} --tenant <name> --accounts <brand-owner-key>`);
|
|
99
113
|
} else {
|
|
114
|
+
// THE DOOR'S OWN RESOLVER DECIDES, not a second reading of the file. This walked `tenants[].users`
|
|
115
|
+
// alone, and the demo's guest list grants its account through the top-level `people` map — so the
|
|
116
|
+
// demo's own key came with a note saying the door would refuse it, over a key the door accepts
|
|
117
|
+
// (driven 2026-09-11). One reader for one question.
|
|
100
118
|
const roster = JSON.parse(readFileSync(rosterPath, "utf8"));
|
|
101
|
-
const
|
|
102
|
-
const listed =
|
|
119
|
+
const person = resolvePerson(email, roster);
|
|
120
|
+
const listed = Boolean(person && (person.everything || person.accounts?.length || person.organisations?.length));
|
|
103
121
|
if (!listed)
|
|
104
|
-
console.error(`\nNOTE: ${email} is
|
|
122
|
+
console.error(`\nNOTE: ${email} is granted nothing in ${rosterPath}, so this key resolves to no accounts and the door will refuse it. Grant them access with: clearotron grant add ${email} --tenant <name> --accounts <brand-owner-key>`);
|
|
105
123
|
}
|
|
106
124
|
} catch (e) {
|
|
107
125
|
// A ROSTER THIS COMMAND CANNOT READ IS NOT A ROSTER SAYING THE SUBJECT IS ABSENT. Said as what it is,
|
package/bin/onboard.mjs
CHANGED
|
@@ -1503,6 +1503,9 @@ export async function runCheck() {
|
|
|
1503
1503
|
} else if (form.form === "shim-path") {
|
|
1504
1504
|
warn(`${form.dir} is not on this shell's PATH, so the bare \`clearotron\` will not resolve here`);
|
|
1505
1505
|
info(`add it with: export PATH="${form.dir}:$PATH" — or open a new login shell`);
|
|
1506
|
+
} else if (form.form === "npx-pinned") {
|
|
1507
|
+
info(`this is running from npm's npx cache, so the commands below name this version through npx (\`${form.prefix.trim()}\`), `
|
|
1508
|
+
+ `which works from any directory and after npm cleans its cache; \`${invoke("install")}\` puts \`clearotron\` on your PATH`);
|
|
1506
1509
|
} else if (form.shimKind === "ours-other-install") {
|
|
1507
1510
|
warn(`${form.shim} is a shim for a DIFFERENT install (${form.otherInstall})`);
|
|
1508
1511
|
info(`re-run \`${invoke("install")}\` to point the bare name at this one`);
|
package/bin/start.mjs
CHANGED
|
@@ -118,15 +118,16 @@ import { SERVER_INSTALL_SET, unitsToRestartOnRefresh, unitHealthVerdict } from "
|
|
|
118
118
|
// — the door --background now INSTALLS, and the one authority for the settings
|
|
119
119
|
// it refuses to start without. (Until 2026-09-03 this import read "the one unit --background may
|
|
120
120
|
// tolerate and never manage"; settled point 2 superseded that.)
|
|
121
|
-
import { defaultDenylistPath, denylistPathFor, denylistFor, ensureDenylistFile, CLIENT_DOOR_UNIT, enablePlan, clientDoorPort } from "../shared/client-door.mjs"; // — one owner for the revocation list's path
|
|
121
|
+
import { defaultDenylistPath, denylistPathFor, denylistFor, ensureDenylistFile, CLIENT_DOOR_UNIT, enablePlan, clientDoorPort, demoTokenSecret, demoTokenSecretPath, keyIssueCommand } from "../shared/client-door.mjs"; // — one owner for the revocation list's path
|
|
122
122
|
import { createServer } from "node:net";
|
|
123
123
|
import { listenErrorMessage, nextFreePort } from "../shared/listen.mjs";
|
|
124
124
|
import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
125
125
|
import { randomBytes } from "node:crypto";
|
|
126
126
|
import { invocationPrefix, invoke, reachableCommand } from "../shared/invocation.mjs"; // — the banner names the verb
|
|
127
|
-
import { unitEnvPath } from "../shared/env-local.mjs"; // — the file the units read, named once
|
|
127
|
+
import { unitEnvPath, activeEnvPath } from "../shared/env-local.mjs"; // — the file the units read, named once
|
|
128
|
+
import { parseEnvFile } from "../shared/env-file-merge.mjs"; // ONE KEY=value reader, in a leaf: render-units is a COMMAND, and importing it from here closed a cycle
|
|
128
129
|
import { homedir, userInfo } from "node:os";
|
|
129
|
-
import { dirname, join } from "node:path";
|
|
130
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
130
131
|
import { fileURLToPath } from "node:url";
|
|
131
132
|
import { usageBlock } from "../shared/usage-block.mjs";
|
|
132
133
|
import { isEntrypoint } from "../shared/is-entrypoint.mjs"; // — one entry-point test, all spellings
|
|
@@ -142,6 +143,7 @@ import { addressRefusal } from "../shared/staff-domain.mjs";
|
|
|
142
143
|
import { withPerson, withOrganisation, withCompany } from "../shared/grants-edit.mjs";
|
|
143
144
|
import { assertGrantsShape, resolvePerson } from "../shared/scope.mjs";
|
|
144
145
|
import { backgroundManager } from "../shared/os-advice.mjs";
|
|
146
|
+
import { recordRunning } from "../shared/running-start.mjs";
|
|
145
147
|
import { frontingVariablesSet } from "../shared/install-auth.mjs"; // — one owner for what counts as a proxy in front of a door
|
|
146
148
|
|
|
147
149
|
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -909,6 +911,11 @@ if (isMain) {
|
|
|
909
911
|
// Decided before any path is, because in a demo every path below is the demo's own. The posture
|
|
910
912
|
// itself is described at the DEMO block further down.
|
|
911
913
|
const DEMO = argv.includes("--demo");
|
|
914
|
+
// A DEMO HAS NO BACKGROUND FORM, and asking for one is refused before anything is written. The
|
|
915
|
+
// background path installs units that run the reader's own install, so `--demo --background` would set
|
|
916
|
+
// up an empty install in their home and call it the demo.
|
|
917
|
+
if (DEMO && wantBackground)
|
|
918
|
+
fatal("the demo has no background form — it runs as long as its terminal does. `--background` starts your own install as services, not the demo.");
|
|
912
919
|
// The same base `npm run setup` writes under, so whichever of the two a reader ran first, the other
|
|
913
920
|
// finds the same install rather than a second one beside it.
|
|
914
921
|
// Whatever the environment already says wins over the base-derived default, for every path — a reader
|
|
@@ -916,6 +923,35 @@ if (isMain) {
|
|
|
916
923
|
// which `startPaths` says why. One author, because `doctor` asks the same function what the services
|
|
917
924
|
// were handed.
|
|
918
925
|
const paths = startPaths({ env: process.env, base: flag("--base", join(homedir(), DEMO ? "trademark-demo" : "trademark")), demo: DEMO });
|
|
926
|
+
// ── NOTHING OF THE DEMO LANDS IN AN INSTALL, AND THAT IS CHECKED BEFORE ANYTHING IS WRITTEN ───────
|
|
927
|
+
//
|
|
928
|
+
// The demo keeps its signing secret in its base and `key issue --base` reads it from there, so a demo
|
|
929
|
+
// pointed at an install's directory would leave a secret where that install's own key command looks:
|
|
930
|
+
// every key issued afterwards would be signed with the demo's while the install's door verified with
|
|
931
|
+
// its own — a key that looks issued and is refused, with nothing said either way. The settings are
|
|
932
|
+
// READ here and never applied; a demo still takes nothing from them.
|
|
933
|
+
if (DEMO) {
|
|
934
|
+
const base = resolve(paths.base);
|
|
935
|
+
const inside = (p) => { const v = String(p ?? "").trim(); if (!v) return false; const r = resolve(v); return r === base || r.startsWith(base + sep); };
|
|
936
|
+
let settings = {};
|
|
937
|
+
try { settings = parseEnvFile(readFileSync(activeEnvPath(), "utf8")); } catch { /* no settings in force: nothing of an install to collide with */ }
|
|
938
|
+
const found = [];
|
|
939
|
+
if (inside(settings.CLEAROTRON_REPORTS_DIR)) found.push(`an install keeps its reports in ${settings.CLEAROTRON_REPORTS_DIR}`);
|
|
940
|
+
if (inside(settings.RECIPE_REPO_ROOT)) found.push(`an install keeps its saved searches in ${settings.RECIPE_REPO_ROOT}`);
|
|
941
|
+
if (inside(settings.CLEAROTRON_WORK_DIR)) found.push(`an install works in ${settings.CLEAROTRON_WORK_DIR}`);
|
|
942
|
+
if (existsSync(join(paths.base, ".env"))) found.push(`it holds a settings file, ${join(paths.base, ".env")}`);
|
|
943
|
+
if (!found.length && base === resolve(join(homedir(), "trademark"))) found.push("it is the directory an install is set up in by default");
|
|
944
|
+
// AN INSTALL NOBODY CONFIGURED still answers. `start --base <dir>` writes a guest list on every start
|
|
945
|
+
// and no settings at all, so an install somewhere of its own passes every check above. A guest list
|
|
946
|
+
// with no demo secret beside it is somebody's install; a demo's own base has both.
|
|
947
|
+
if (!found.length && existsSync(paths.grants) && !existsSync(demoTokenSecretPath(paths.base)))
|
|
948
|
+
found.push(`it holds a guest list, ${paths.grants}, and no demo of its own`);
|
|
949
|
+
if (found.length)
|
|
950
|
+
fatal(`--demo cannot run in ${paths.base}: ${found.join("; ")}.\n`
|
|
951
|
+
+ " The demo keeps its own data, and its own signing secret, in its base. Leaving those in an\n"
|
|
952
|
+
+ " install's directory would make keys issued for that install refuse at its door.\n"
|
|
953
|
+
+ " Run the demo without --base, or give it a directory of its own.");
|
|
954
|
+
}
|
|
919
955
|
// ── THIS INSTALL'S FIRST START, read before this start writes either file that answers it ────────────
|
|
920
956
|
//
|
|
921
957
|
// The grants file and the config store's repository are both written further down, on every start
|
|
@@ -1171,7 +1207,15 @@ if (isMain) {
|
|
|
1171
1207
|
return v;
|
|
1172
1208
|
};
|
|
1173
1209
|
const portalSecret = secretFor("PORTAL_SECRET");
|
|
1174
|
-
|
|
1210
|
+
// A DEMO'S SIGNING SECRET IS KEPT IN ITS OWN BASE, so the key command it prints can sign for it; see
|
|
1211
|
+
// `demoTokenSecret`. Every other secret a demo uses stays in memory.
|
|
1212
|
+
const tokenSecret = DEMO
|
|
1213
|
+
? demoTokenSecret(paths.base, {
|
|
1214
|
+
read: (f) => readFileSync(f, "utf8"),
|
|
1215
|
+
write: (f, text) => { mkdirSync(dirname(f), { recursive: true }); writeSecretFile(f, text); },
|
|
1216
|
+
mint: () => randomBytes(32).toString("base64url"),
|
|
1217
|
+
})
|
|
1218
|
+
: secretFor("TRADEMARK_MCP_TOKEN_SECRET");
|
|
1175
1219
|
if (!process.env.PORTAL_LOCAL_USER) generated.PORTAL_LOCAL_USER = user;
|
|
1176
1220
|
const stores = DEMO ? {} : storesForOtherReaders(paths);
|
|
1177
1221
|
|
|
@@ -2176,12 +2220,26 @@ if (isMain) {
|
|
|
2176
2220
|
if (adoptedClientDoor)
|
|
2177
2221
|
say(` Already running as ${CLIENT_DOOR_UNIT}; this start kept it, so existing keys still work.`);
|
|
2178
2222
|
else
|
|
2179
|
-
say(` It refuses every caller until a key is issued: ${invocationPrefix()
|
|
2223
|
+
say(` It refuses every caller until a key is issued: ${keyIssueCommand({ prefix: invocationPrefix(), demo: DEMO, user, base: paths.base, defaultBase: join(homedir(), "trademark") })}`);
|
|
2180
2224
|
} else {
|
|
2181
2225
|
say(` Client door NOT RUNNING on ${HOST}:${ports.client} — its output above says why. The portal and`);
|
|
2182
2226
|
say(" the engine door are unaffected; a client assistant cannot connect until it is up.");
|
|
2183
2227
|
}
|
|
2184
2228
|
say("");
|
|
2229
|
+
// ── WHAT `status` AND `stop` READ ABOUT THIS START ───────────────────────────────────────────────
|
|
2230
|
+
//
|
|
2231
|
+
// Both verbs knew only about background units, so with this start serving, `status` described units
|
|
2232
|
+
// nobody installed and `stop` said nothing was running (measured on a published beta, 2026-09-11). The
|
|
2233
|
+
// record is the address this banner just printed. It goes on every exit — Ctrl-C, a fatal refusal, a
|
|
2234
|
+
// crash — and a start killed outright leaves a record whose process is gone, which the readers ignore.
|
|
2235
|
+
try {
|
|
2236
|
+
const forget = recordRunning({ pid: process.pid, demo: DEMO, base: paths.base, url: envs.url, host: HOST,
|
|
2237
|
+
ports: { portal: ports.portal, mcp: ports.mcp, client: doorRunning ? ports.client : null },
|
|
2238
|
+
startedAt: new Date().toISOString() });
|
|
2239
|
+
process.on("exit", forget);
|
|
2240
|
+
} catch (e) {
|
|
2241
|
+
say(` (\`status\` will not see this start: its record could not be written — ${String(e?.message ?? e)})`);
|
|
2242
|
+
}
|
|
2185
2243
|
// — THE BANNER TOLD THE SAME STORY ON EVERY START, and it was only true of the first.
|
|
2186
2244
|
//
|
|
2187
2245
|
// "printed once, above" describes what a FIRST start does. On every start after it the passphrase was
|
|
@@ -2273,17 +2331,35 @@ if (isMain) {
|
|
|
2273
2331
|
// cannot succeed and given a service manager that is not on the machine and cannot be put there.
|
|
2274
2332
|
// Reported from a real run. Same rule as the engine refusal above: do not name a route this platform
|
|
2275
2333
|
// does not have.
|
|
2276
|
-
const manager
|
|
2277
|
-
if (manager) {
|
|
2278
|
-
say(` To get your prompt back instead, stop this and run ${invoke("start")} --background`);
|
|
2279
|
-
say(` — same product, managed by ${manager}, and it survives logout.`);
|
|
2280
|
-
} else {
|
|
2281
|
-
say(" There is no background form on this platform: the product runs as long as this window does.");
|
|
2282
|
-
say(" Leave it open and use a second terminal for the commands above.");
|
|
2283
|
-
}
|
|
2334
|
+
for (const line of backgroundOfferLines({ demo: DEMO, manager: backgroundManager(), start: invoke("start") })) say(line);
|
|
2284
2335
|
say("");
|
|
2285
2336
|
}
|
|
2286
2337
|
|
|
2338
|
+
/**
|
|
2339
|
+
* The closing offer of a foreground start: how to get the prompt back, where there is a way.
|
|
2340
|
+
*
|
|
2341
|
+
* NOT IN A DEMO. The offer ran `start --background`, which set up a new, empty install in the reader's
|
|
2342
|
+
* home — not the demo, its samples or its sign-in — and then failed where the user's systemd was not
|
|
2343
|
+
* reachable (measured on a published beta, 2026-09-11). A demo has no background form, so it is not
|
|
2344
|
+
* offered one. Where the offer stands, it names what it needs BEFORE the reader stops what is running.
|
|
2345
|
+
* PURE.
|
|
2346
|
+
*/
|
|
2347
|
+
export function backgroundOfferLines({ demo = false, manager = null, start = "clearotron start" } = {}) {
|
|
2348
|
+
if (demo) return [
|
|
2349
|
+
" The demo has no background form: it runs as long as this window does. Leave it open and use a",
|
|
2350
|
+
" second terminal for the commands above.",
|
|
2351
|
+
];
|
|
2352
|
+
if (!manager) return [
|
|
2353
|
+
" There is no background form on this platform: the product runs as long as this window does.",
|
|
2354
|
+
" Leave it open and use a second terminal for the commands above.",
|
|
2355
|
+
];
|
|
2356
|
+
return [
|
|
2357
|
+
` To get your prompt back instead, stop this and run ${start} --background`,
|
|
2358
|
+
` — same product, managed by ${manager}, and it survives logout. It needs ${manager}'s user manager`,
|
|
2359
|
+
" reachable from this session; where it is not, that command says so and changes nothing you use.",
|
|
2360
|
+
];
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2287
2363
|
/**
|
|
2288
2364
|
* What a reader is told when a child this install cannot run without exits.
|
|
2289
2365
|
*
|
package/bin/status.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { BACKGROUND_UNITS, resolvePorts } from "./start.mjs";
|
|
|
18
18
|
import { parseEnvFile } from "../driver/systemd/render-units.mjs"; // ONE KEY=value reader — what systemd actually reads
|
|
19
19
|
import { CLIENT_DOOR_UNIT, clientDoorPort } from "../shared/client-door.mjs";
|
|
20
20
|
import { invoke } from "../shared/invocation.mjs";
|
|
21
|
+
import { readRunning, probe } from "../shared/running-start.mjs";
|
|
21
22
|
import { configStaleness, stalenessWarning, parseSystemdTimestamp } from "../driver/config-staleness.mjs"; // — F48
|
|
22
23
|
|
|
23
24
|
const UNIT_DIR = join(homedir(), ".config", "systemd", "user");
|
|
@@ -57,15 +58,34 @@ const startedEpochMs = (u) => {
|
|
|
57
58
|
return parseSystemdTimestamp(line?.slice("ActiveEnterTimestamp=".length));
|
|
58
59
|
} catch { return null; }
|
|
59
60
|
};say("");
|
|
61
|
+
// ── A FOREGROUND START ANSWERS HERE TOO ─────────────────────────────────────────────────────────────
|
|
62
|
+
//
|
|
63
|
+
// The README starts the product in a terminal, and this verb answered only for background units: with
|
|
64
|
+
// everything up it printed a sentence about units the reader never installed (measured on a published
|
|
65
|
+
// beta, 2026-09-11). The record gives the address `start` printed; the portal itself says whether it is up.
|
|
66
|
+
const running = readRunning();
|
|
67
|
+
for (const r of running) {
|
|
68
|
+
const what = r.demo ? "The demo" : "The product";
|
|
69
|
+
// The portal's liveness route, which answers without a session: the address itself asks for a sign-in
|
|
70
|
+
// and read as "not answering" over a portal that was up (driven 2026-09-11).
|
|
71
|
+
if (await probe(new URL("/portal/health", r.url).href)) say(` ${what} is up, in the foreground — a terminal holds \`start\` (pid ${r.pid}); Ctrl-C there stops it.`);
|
|
72
|
+
else say(` ${what} was started in the foreground (pid ${r.pid}), but its portal at ${r.url} is not answering.`);
|
|
73
|
+
say(` Open ${r.url}`);
|
|
74
|
+
say(` Engine door http://${r.host}:${r.ports.mcp}/mcp`);
|
|
75
|
+
if (r.ports.client) say(` Client door http://${r.host}:${r.ports.client}/mcp`);
|
|
76
|
+
say("");
|
|
77
|
+
}
|
|
60
78
|
let installed = 0;
|
|
61
79
|
for (const u of BACKGROUND_UNITS) {
|
|
62
80
|
if (!existsSync(join(UNIT_DIR, u))) continue;
|
|
63
81
|
installed++;
|
|
64
82
|
say(` ${u.padEnd(34)} ${state(u)}`);
|
|
65
83
|
}
|
|
66
|
-
if (!installed) {
|
|
67
|
-
say(" No background units are installed
|
|
68
|
-
|
|
84
|
+
if (!installed && running.length) {
|
|
85
|
+
say(" No background units are installed.");
|
|
86
|
+
} else if (!installed) {
|
|
87
|
+
say(" The product is not running: no terminal holds `start`, and no background units are installed.");
|
|
88
|
+
say(` \`${invoke("start")}\` runs it in a terminal; \`${invoke("start")} --background\` runs it as services that survive logout.`);
|
|
69
89
|
} else {
|
|
70
90
|
// The ports come from the same resolver start uses, RESOLVED OVER THE UNITS' OWN FILE: the running
|
|
71
91
|
// services read %h/.env via systemd, and this CLI's shell env may know nothing of it — the first
|
package/bin/stop.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { systemdSaid, looksLikeBusFailure, busRemedy, CAPTURE_STDERR } from "../
|
|
|
23
23
|
import { BACKGROUND_UNITS } from "./start.mjs";
|
|
24
24
|
import { CLIENT_DOOR_UNIT } from "../shared/client-door.mjs";
|
|
25
25
|
import { invoke } from "../shared/invocation.mjs";
|
|
26
|
+
import { readRunning } from "../shared/running-start.mjs";
|
|
26
27
|
|
|
27
28
|
const UNIT_DIR = join(homedir(), ".config", "systemd", "user");
|
|
28
29
|
const say = (s = "") => console.log(s);
|
|
@@ -91,17 +92,31 @@ for (const u of BACKGROUND_UNITS) {
|
|
|
91
92
|
}
|
|
92
93
|
// THE COMMENT HERE ALREADY NAMED THE CAUSE AND SHRUGGED AT IT. If there is no user bus, the disables
|
|
93
94
|
// above did not happen either — so this is where that is said.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
// ONLY WHEN THERE WAS SOMETHING TO RELOAD. On a box with no unit installed the reload has nothing to do,
|
|
96
|
+
// and asking anyway printed a bus error and `su` advice to a reader whose product was running in a
|
|
97
|
+
// terminal — a WSL distribution without systemd meets exactly that (measured 2026-09-11).
|
|
98
|
+
if (found) {
|
|
99
|
+
try {
|
|
100
|
+
execFileSync("systemctl", ["--user", "daemon-reload"], CAPTURE_STDERR);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
const said = systemdSaid(e);
|
|
103
|
+
err(` could not ask systemd to reload its units — ${said}`);
|
|
104
|
+
if (looksLikeBusFailure(said)) err(`\n${busRemedy()}\n`);
|
|
105
|
+
}
|
|
100
106
|
}
|
|
101
107
|
|
|
102
108
|
if (!found) {
|
|
103
|
-
|
|
104
|
-
|
|
109
|
+
// A FOREGROUND START IS NOT "NOTHING RUNNING". It is not this verb's to stop — its terminal holds it —
|
|
110
|
+
// but saying nothing was running while it served is the answer that sent a reader looking elsewhere.
|
|
111
|
+
const foreground = readRunning();
|
|
112
|
+
if (foreground.length) {
|
|
113
|
+
say(" No background product is installed, but the product is running in the foreground:");
|
|
114
|
+
for (const r of foreground) say(` ${r.demo ? "the demo" : "the product"} at ${r.url} (pid ${r.pid})`);
|
|
115
|
+
say(" Ctrl-C in the terminal that holds it stops it. This verb stops only what `start --background` installed.");
|
|
116
|
+
} else {
|
|
117
|
+
say(" Nothing was running in the background — no pinned unit is installed on this box.");
|
|
118
|
+
say(" Nothing to do, and nothing was changed.");
|
|
119
|
+
}
|
|
105
120
|
} else if (failures.length) {
|
|
106
121
|
// THE SENTENCE THAT WAS WRONG. "The background product is stopped and the box runs nothing again" was
|
|
107
122
|
// printed unconditionally — including on the run where four services stayed up. A reader who is told
|
package/build-info.json
CHANGED
package/driver/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# clearotron-driver
|
|
2
2
|
|
|
3
|
+
## 0.3.0-beta.9
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 86061f4: Fixed: Commands printed while running from npx name the published version, so they still work after npm cleans its cache.
|
|
8
|
+
- ab9dc2d: Fixed: When the engine cannot answer "Describe it", the page names the check that finds out why. It says "just now" only for a usage limit.
|
|
9
|
+
- ab9dc2d: Fixed: `clearotron status` says whether a product started in a terminal is up, and on which addresses. `clearotron stop` says how to stop it.
|
|
10
|
+
- ab9dc2d: Fixed: The demo no longer offers `start --background`, which set up an empty install in your home instead of running the demo.
|
|
11
|
+
- ab9dc2d: Fixed: The key command the demo prints issues a key the demo's client door accepts, run exactly as printed.
|
|
12
|
+
|
|
3
13
|
## 0.3.0-beta.8
|
|
4
14
|
|
|
5
15
|
### Patch Changes
|
package/driver/compose-read.mjs
CHANGED
|
@@ -325,7 +325,26 @@ export function jsonFromTurnText(text) {
|
|
|
325
325
|
return null;
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
-
|
|
328
|
+
/**
|
|
329
|
+
* What the page says when the engine did not answer a read.
|
|
330
|
+
*
|
|
331
|
+
* "JUST NOW" ONLY WHERE IT IS TRUE. Every failed turn said the reader "could not reach the engine just
|
|
332
|
+
* now", and on a fresh install whose engine was not signed in that sent a stranger to press the button
|
|
333
|
+
* again, forever: the failure was the sign-in, which no retry changes (measured on a published beta,
|
|
334
|
+
* 2026-09-11). A rate limit is the one failure known to pass, so it alone says so, with its reset time
|
|
335
|
+
* when the engine gave one. Everything else names the check that finds the cause — `checkCommand`, in the
|
|
336
|
+
* reader's own invocation and with no machine path, because the page is not a place to print one.
|
|
337
|
+
*/
|
|
338
|
+
export function engineFailureMessage(t, { checkCommand = "clearotron doctor --probe-engine" } = {}) {
|
|
339
|
+
if (t?.rateLimited) {
|
|
340
|
+
const at = t.resetsAt ? new Date(t.resetsAt) : null;
|
|
341
|
+
const when = at && !Number.isNaN(at.getTime()) ? ` It resets at ${at.toISOString().slice(11, 16)} UTC.` : "";
|
|
342
|
+
return `The engine is at its usage limit just now.${when} Try again then, or set the search up below.`;
|
|
343
|
+
}
|
|
344
|
+
return `The request could not be read: the engine did not answer it. \`${checkCommand}\` finds out why — set the search up below meanwhile.`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function makeComposeReader({ turn, now = () => new Date(), checkCommand } = {}) {
|
|
329
348
|
if (typeof turn !== "function") return null;
|
|
330
349
|
return async function read(brief) {
|
|
331
350
|
const text = String(brief ?? "").trim();
|
|
@@ -361,7 +380,7 @@ export function makeComposeReader({ turn, now = () => new Date() } = {}) {
|
|
|
361
380
|
+ `${JSON.stringify(READ_SCHEMA)}\n\nThe brief:\n${text}`,
|
|
362
381
|
});
|
|
363
382
|
if (!t?.ok) {
|
|
364
|
-
return { ok: false, error: "engine", message:
|
|
383
|
+
return { ok: false, error: "engine", message: engineFailureMessage(t, { ...(checkCommand ? { checkCommand } : {}) }),
|
|
365
384
|
cause: t?.cause ?? "the engine turn did not complete", vendor: t?.vendor ?? null, authMode: t?.authMode ?? null,
|
|
366
385
|
engine: t?.engine ?? null, model: t?.model ?? null };
|
|
367
386
|
}
|
|
@@ -94,7 +94,10 @@ export function readJxTuple(tuple, { vendor, authMode, engine }) {
|
|
|
94
94
|
if (tuple?.killed || tuple?.signals?.stalled)
|
|
95
95
|
return { ok: false, cause: "the engine turn was killed before it answered (stall or wall)", truncationObservable: observable, ...base };
|
|
96
96
|
if (tuple?.signals?.rateLimited)
|
|
97
|
-
|
|
97
|
+
// FLAGGED, not only described: a rate limit is the one failure a caller may call passing, and it
|
|
98
|
+
// needs to know that without parsing this sentence. `resetsAt` is the adapter's, when it read one.
|
|
99
|
+
return { ok: false, cause: "the engine turn was rate-limited", rateLimited: true, resetsAt: tuple?.signals?.resetsAt ?? null,
|
|
100
|
+
truncationObservable: observable, ...base };
|
|
98
101
|
if (tuple?.code !== 0 || tuple?.json?.status !== "ok")
|
|
99
102
|
return { ok: false, cause: `the engine turn did not complete cleanly (code ${tuple?.code ?? "?"}, status ${tuple?.json?.status ?? "none"})`,
|
|
100
103
|
truncationObservable: observable, ...base };
|
package/driver/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "clearotron-driver",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.3.0-beta.
|
|
5
|
+
"version": "0.3.0-beta.9",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
7
7
|
"description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
|
|
8
8
|
"engines": {
|
|
@@ -1590,7 +1590,10 @@ export function makePortalService({
|
|
|
1590
1590
|
// A provider outage is not a portal fault and must not read as one. The composer shows the
|
|
1591
1591
|
// sentence and keeps every field the user already typed — the brief is still in the box.
|
|
1592
1592
|
audit({ event: "compose-read", by: principal.email, ok: false, error: String(e?.message ?? e) });
|
|
1593
|
-
|
|
1593
|
+
// NOT "just now": a throw says nothing about whether it will pass, and that wording sent a reader
|
|
1594
|
+
// back to the button for a failure no retry fixes. It names the check instead.
|
|
1595
|
+
return { status: 502, json: { code: "read_failed",
|
|
1596
|
+
error: `The request could not be read: the reader failed. \`${browserCommand("doctor --probe-engine")}\` finds out why — set the search up below meanwhile.` } };
|
|
1594
1597
|
}
|
|
1595
1598
|
// Length, never content. A brief is client material — whose mark, for whose product, before
|
|
1596
1599
|
// whose deadline — and an audit log is a different disclosure surface from a run store.
|
|
@@ -4711,7 +4714,8 @@ const PORT = PORT_CHOICE.port;
|
|
|
4711
4714
|
return null;
|
|
4712
4715
|
}
|
|
4713
4716
|
log(`brief reading ON — engine=${runner.engine} billing=${runner.authMode} model=${model}`);
|
|
4714
|
-
|
|
4717
|
+
// The check a failed read points at, as a reader types it, with no path of this machine in it.
|
|
4718
|
+
return makeComposeReader({ turn: runner.turn, checkCommand: browserCommand("doctor --probe-engine") });
|
|
4715
4719
|
} catch (e) {
|
|
4716
4720
|
// A missing dependency or a bad key shape must not stop the portal booting: everything else on
|
|
4717
4721
|
// this service is the load-bearing product, and the composer degrades to exactly what it did
|
package/driver/suite-census.json
CHANGED
|
@@ -141,6 +141,12 @@
|
|
|
141
141
|
"skips": 0,
|
|
142
142
|
"todos": 0
|
|
143
143
|
},
|
|
144
|
+
"a-command-printed-from-npx-outlives-the-cache.test.mjs": {
|
|
145
|
+
"tests": 3,
|
|
146
|
+
"asserts": 14,
|
|
147
|
+
"skips": 0,
|
|
148
|
+
"todos": 0
|
|
149
|
+
},
|
|
144
150
|
"a-company-can-be-given-a-framework-after-it-exists.test.mjs": {
|
|
145
151
|
"tests": 8,
|
|
146
152
|
"asserts": 19,
|
|
@@ -375,6 +381,12 @@
|
|
|
375
381
|
"skips": 0,
|
|
376
382
|
"todos": 0
|
|
377
383
|
},
|
|
384
|
+
"a-key-the-demo-prints-signs-for-that-demo.test.mjs": {
|
|
385
|
+
"tests": 5,
|
|
386
|
+
"asserts": 30,
|
|
387
|
+
"skips": 0,
|
|
388
|
+
"todos": 0
|
|
389
|
+
},
|
|
378
390
|
"a-killed-browser-takes-its-children.test.mjs": {
|
|
379
391
|
"tests": 2,
|
|
380
392
|
"asserts": 4,
|
|
@@ -543,6 +555,12 @@
|
|
|
543
555
|
"skips": 0,
|
|
544
556
|
"todos": 0
|
|
545
557
|
},
|
|
558
|
+
"a-read-the-engine-could-not-answer-names-the-check.test.mjs": {
|
|
559
|
+
"tests": 4,
|
|
560
|
+
"asserts": 14,
|
|
561
|
+
"skips": 0,
|
|
562
|
+
"todos": 0
|
|
563
|
+
},
|
|
546
564
|
"a-real-dispatch-records-the-tool-that-served-it.test.mjs": {
|
|
547
565
|
"tests": 1,
|
|
548
566
|
"asserts": 5,
|
|
@@ -3095,7 +3113,7 @@
|
|
|
3095
3113
|
},
|
|
3096
3114
|
"portal-service.test.mjs": {
|
|
3097
3115
|
"tests": 124,
|
|
3098
|
-
"asserts":
|
|
3116
|
+
"asserts": 673,
|
|
3099
3117
|
"skips": 1,
|
|
3100
3118
|
"todos": 0
|
|
3101
3119
|
},
|
|
@@ -4221,6 +4239,12 @@
|
|
|
4221
4239
|
"skips": 0,
|
|
4222
4240
|
"todos": 0
|
|
4223
4241
|
},
|
|
4242
|
+
"status-and-stop-see-a-foreground-start.test.mjs": {
|
|
4243
|
+
"tests": 6,
|
|
4244
|
+
"asserts": 25,
|
|
4245
|
+
"skips": 0,
|
|
4246
|
+
"todos": 0
|
|
4247
|
+
},
|
|
4224
4248
|
"status-says-when-a-service-predates-its-config.test.mjs": {
|
|
4225
4249
|
"tests": 7,
|
|
4226
4250
|
"asserts": 24,
|
|
@@ -4515,6 +4539,12 @@
|
|
|
4515
4539
|
"skips": 0,
|
|
4516
4540
|
"todos": 0
|
|
4517
4541
|
},
|
|
4542
|
+
"the-demo-offers-no-background-form.test.mjs": {
|
|
4543
|
+
"tests": 4,
|
|
4544
|
+
"asserts": 14,
|
|
4545
|
+
"skips": 0,
|
|
4546
|
+
"todos": 0
|
|
4547
|
+
},
|
|
4518
4548
|
"the-door-answers-the-address-it-advertises.test.mjs": {
|
|
4519
4549
|
"tests": 9,
|
|
4520
4550
|
"asserts": 37,
|
|
@@ -112,19 +112,13 @@ export function unitsNeedingRender(root = ROOT) {
|
|
|
112
112
|
// through `envFrom`, so a name that later gains an old spelling in the alias table keeps resolving —
|
|
113
113
|
// a literal `process.env.X` here would be a spelling, and a spelling goes stale in silence.
|
|
114
114
|
//
|
|
115
|
-
// A `KEY=value` parser and nothing more
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
let v = m[2].trim();
|
|
123
|
-
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
|
124
|
-
out[m[1]] = v;
|
|
125
|
-
}
|
|
126
|
-
return out;
|
|
127
|
-
}
|
|
115
|
+
// A `KEY=value` parser and nothing more, and it lives in `shared/env-file-merge.mjs` — a file that
|
|
116
|
+
// imports nothing. THIS module is a command too: its `--apply` path awaits at the top level and imports
|
|
117
|
+
// `bin/start.mjs` from inside that await, so anything that took the parser from here put a CLI in its
|
|
118
|
+
// import path, and `start.mjs` doing so closed a cycle that stopped 21 install arms at once. Re-exported
|
|
119
|
+
// so every reader here keeps one reader and one spelling.
|
|
120
|
+
export { parseEnvFile } from "../../shared/env-file-merge.mjs";
|
|
121
|
+
import { parseEnvFile } from "../../shared/env-file-merge.mjs";
|
|
128
122
|
|
|
129
123
|
export function resolveValues(names, { env = process.env, envFile = null } = {}) {
|
|
130
124
|
const fileVals = envFile && existsSync(envFile) ? parseEnvFile(readFileSync(envFile, "utf8")) : {};
|
package/mcp-server/CHANGELOG.md
CHANGED
package/mcp-server/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-artifacts-mcp",
|
|
3
|
-
"version": "0.3.0-beta.
|
|
3
|
+
"version": "0.3.0-beta.9",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"private": true,
|
|
6
6
|
"description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
|
package/package.json
CHANGED
package/portal-ui/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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.9",
|
|
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": {
|
|
@@ -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.9",
|
|
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).",
|
package/shared/client-door.mjs
CHANGED
|
@@ -102,6 +102,45 @@ export function denylistFor({ paths, demo = false, env = {}, home }) {
|
|
|
102
102
|
return paths.denylist ?? denylistPathFor(env, home);
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* THE DEMO'S SIGNING SECRET, KEPT IN THE DEMO'S OWN BASE.
|
|
107
|
+
*
|
|
108
|
+
* The demo generated its signing secret per run and held it in memory only, so the key command its
|
|
109
|
+
* terminal printed could never sign a key its door would accept: run from a second terminal, as printed,
|
|
110
|
+
* it read the install's settings and refused (measured on a published beta, 2026-09-11). The secret now
|
|
111
|
+
* lives beside the demo's revocation list, mode 600, so `key issue --base <demo base>` reads the one the
|
|
112
|
+
* door was given, and removing the demo is still removing one directory.
|
|
113
|
+
*/
|
|
114
|
+
export const demoTokenSecretPath = (base) => join(base, "token-secret");
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The secret a demo's door signs with: the one its base already holds, or a new one written there.
|
|
118
|
+
* `io` is `{ read, write, mint }`; a read that finds nothing is the first start.
|
|
119
|
+
*/
|
|
120
|
+
export function demoTokenSecret(base, io) {
|
|
121
|
+
const path = demoTokenSecretPath(base);
|
|
122
|
+
let held = "";
|
|
123
|
+
try { held = String(io.read(path) ?? "").trim(); } catch { /* not yet written */ }
|
|
124
|
+
if (held) return held;
|
|
125
|
+
const fresh = io.mint();
|
|
126
|
+
io.write(path, `${fresh}\n`);
|
|
127
|
+
return fresh;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The key command a start prints beside its client door — runnable exactly as printed.
|
|
132
|
+
*
|
|
133
|
+
* A demo's door admits the demo's own account, so the command names it rather than a placeholder a reader
|
|
134
|
+
* cannot type, and it names the demo's base so the verb reads that demo's secret and guest list. An
|
|
135
|
+
* install moved with `--base` is named too, for its guest list; the default install needs neither.
|
|
136
|
+
*/
|
|
137
|
+
export function keyIssueCommand({ prefix = "", demo = false, user = null, base = null, defaultBase = null } = {}) {
|
|
138
|
+
const q = (d) => (/\s/.test(d) ? `"${d}"` : d);
|
|
139
|
+
const who = demo && user ? user : "<email>";
|
|
140
|
+
const where = base && (demo || base !== defaultBase) ? ` --base ${q(base)}` : "";
|
|
141
|
+
return `${prefix}clearotron key issue ${who}${where}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
105
144
|
/**
|
|
106
145
|
* Create the denylist if it is absent, so the door is born consulting a file that exists.
|
|
107
146
|
*
|
|
@@ -34,6 +34,30 @@
|
|
|
34
34
|
* like an engine fault. Names here must be values this code MINTS; putting
|
|
35
35
|
* a credential a reader supplied in this list would delete their work.
|
|
36
36
|
*/
|
|
37
|
+
// ── THE ONE `KEY=value` READER, AND IT LIVES IN A LEAF ────────────────────────────────────────────
|
|
38
|
+
//
|
|
39
|
+
// This reads the same file systemd's EnvironmentFile= reads, and systemd does no shell expansion there
|
|
40
|
+
// either. Quotes are stripped because operators write them.
|
|
41
|
+
//
|
|
42
|
+
// IT USED TO LIVE IN `driver/systemd/render-units.mjs`, which is a COMMAND as well as a module, and its
|
|
43
|
+
// `--apply` path awaits at the top level and imports `bin/start.mjs` from inside that await. So a reader
|
|
44
|
+
// that took the parser from there put the command's own module in its import path: `start.mjs` importing
|
|
45
|
+
// it closed a cycle, the CLI's top-level await never settled, and 21 install arms died at once with
|
|
46
|
+
// "Detected unsettled top-level await" and nothing naming the cause (measured 2026-09-12). The parser has
|
|
47
|
+
// no business depending on any of that — it is eleven lines of string handling — so it sits here, in a
|
|
48
|
+
// file that imports nothing, and `render-units.mjs` re-exports it so its readers keep one reader.
|
|
49
|
+
export function parseEnvFile(text) {
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
52
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
53
|
+
if (!m) continue;
|
|
54
|
+
let v = m[2].trim();
|
|
55
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
|
56
|
+
out[m[1]] = v;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
37
61
|
export function mergeEnvFile(text, additions, { by = "`npm start` (bin/start.mjs)", notes = {}, refresh = [] } = {}) {
|
|
38
62
|
const body = typeof text === "string" ? text : "";
|
|
39
63
|
const present = new Set();
|
package/shared/invocation.mjs
CHANGED
|
@@ -205,6 +205,24 @@ export function invocationForm(env = process.env, io = FS, installDir = INSTALL_
|
|
|
205
205
|
// right there and naming it in full is true from any directory.
|
|
206
206
|
return { form: "shim-path", prefix: `${globalDir}${sep}`, shim: globalExe, dir: globalDir, onPath, shadowedBy, staleInterpreter: null, via: "global" };
|
|
207
207
|
}
|
|
208
|
+
// ── FROM NPX'S CACHE, THE PUBLISHED VERSION, NOT THE CACHE ──────────────────────────────────────────
|
|
209
|
+
//
|
|
210
|
+
// `in-place` below names the directory this install runs from, and under npx that is npm's cache:
|
|
211
|
+
// `cd <npm's cache> && npx clearotron …`. npm deletes that directory when it cleans its cache, so every
|
|
212
|
+
// command a verb printed there stopped working before its reader typed it (a packaged-install drive of
|
|
213
|
+
// `doctor`, 2026-09-11, found thirteen). `npx -p clearotron@<version>` fetches this same version from any
|
|
214
|
+
// directory. It is the decision `browserCommand` makes for a page, `npx clearotron@<version> <verb>`,
|
|
215
|
+
// spelled for a PREFIX, which the word `clearotron` follows. Asked after a shim of ours and a global
|
|
216
|
+
// install, which are stronger evidence than the cache path; a version that cannot be read falls through
|
|
217
|
+
// to `in-place` rather than naming a guess.
|
|
218
|
+
const npxVersion = npxVersionOf(installDir, io.read ?? readFileSync);
|
|
219
|
+
if (npxVersion) {
|
|
220
|
+
return {
|
|
221
|
+
form: "npx-pinned", prefix: `npx -p clearotron@${npxVersion} `, shim: path, dir,
|
|
222
|
+
onPath: false, shadowedBy: null, shimKind: shim.kind, otherInstall: shim.installDir,
|
|
223
|
+
staleInterpreter: shim.interpreterMissing === true ? shim.interpreter : null,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
208
226
|
// ✕ A BROKEN SHIM MUST NOT BE THE FORM WE HAND BACK, however well it identifies itself. Driven and
|
|
209
227
|
// caught: doctor reported the stale interpreter and then told the reader to run `clearotron install`
|
|
210
228
|
// to fix it — through the very shim it had just called broken. The advice for repairing a route
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
// running-start.mjs — which foreground `clearotron start` processes are serving on this machine, and where.
|
|
5
|
+
//
|
|
6
|
+
// `status` promises "is the product up, and on which ports", and knew only about background units. With
|
|
7
|
+
// the product running the way the README starts it — `clearotron start` in a terminal — it printed a
|
|
8
|
+
// sentence about units the reader never installed, and `stop` said nothing was running while the portal
|
|
9
|
+
// answered 200 (measured on a published beta, 2026-09-11). A foreground start now leaves one small record
|
|
10
|
+
// here while it serves, and both verbs read it.
|
|
11
|
+
//
|
|
12
|
+
// THE RECORD IS AN ADDRESS, NEVER PROOF OF LIFE. A start killed outright leaves its record behind, so a
|
|
13
|
+
// record whose process is gone is read as absent, and `status` asks the portal itself before saying the
|
|
14
|
+
// product is up. Three answers, all honest: up; started but not answering; not running.
|
|
15
|
+
|
|
16
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where the records live: one file per serving process, beside the settings and the revocation list.
|
|
22
|
+
*
|
|
23
|
+
* `~/.config/clearotron` is this product's per-user directory and the only one it reads. An XDG state
|
|
24
|
+
* directory would be the tidier home for this, and it would be a NEW environment variable read by product
|
|
25
|
+
* code — a thing this repo documents in two contract files and classifies in another repo. Not worth a
|
|
26
|
+
* paired change for a file the reader never opens.
|
|
27
|
+
*/
|
|
28
|
+
export function runningDir({ home = homedir() } = {}) {
|
|
29
|
+
return join(home, ".config", "clearotron", "running");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Is this process alive? EPERM means it exists and belongs to somebody else, which is alive. */
|
|
33
|
+
export function pidAlive(pid) {
|
|
34
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e?.code === "EPERM"; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Record one serving start. Returns the function that removes the record; calling it twice is harmless,
|
|
39
|
+
* so the caller can hang it on both its own shutdown and the process's `exit`.
|
|
40
|
+
*/
|
|
41
|
+
export function recordRunning(rec, { dir = runningDir() } = {}) {
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
const file = join(dir, `${rec.pid}.json`);
|
|
44
|
+
const tmp = `${file}.tmp`;
|
|
45
|
+
writeFileSync(tmp, `${JSON.stringify(rec, null, 2)}\n`, { mode: 0o600 });
|
|
46
|
+
renameSync(tmp, file);
|
|
47
|
+
let gone = false;
|
|
48
|
+
return () => {
|
|
49
|
+
if (gone) return;
|
|
50
|
+
gone = true;
|
|
51
|
+
try { rmSync(file, { force: true }); } catch { /* already gone */ }
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Every record whose process is alive, oldest pid first. Reads; removes nothing. */
|
|
56
|
+
export function readRunning({ dir = runningDir(), alive = pidAlive } = {}) {
|
|
57
|
+
let names;
|
|
58
|
+
try { names = readdirSync(dir); } catch { return []; }
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const name of names.filter((n) => /^\d+\.json$/.test(n))) {
|
|
61
|
+
let rec;
|
|
62
|
+
try { rec = JSON.parse(readFileSync(join(dir, name), "utf8")); } catch { continue; }
|
|
63
|
+
if (!Number.isInteger(rec?.pid) || typeof rec?.url !== "string" || !rec?.ports) continue;
|
|
64
|
+
if (!alive(rec.pid)) continue; // a start that did not exit cleanly: its record says nothing now
|
|
65
|
+
out.push(rec);
|
|
66
|
+
}
|
|
67
|
+
return out.sort((a, b) => a.pid - b.pid);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Does the portal at `url` answer? A timeout or a refusal is "no", never "unknown dressed as yes". */
|
|
71
|
+
export async function probe(url, { timeoutMs = 2000 } = {}) {
|
|
72
|
+
try { return (await fetch(url, { signal: AbortSignal.timeout(timeoutMs) })).ok; } catch { return false; }
|
|
73
|
+
}
|