clearotron 0.3.0-beta.7 → 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/README.md +28 -21
- package/bin/example.mjs +14 -2
- package/bin/key.mjs +23 -5
- package/bin/onboard.mjs +52 -16
- package/bin/start.mjs +95 -35
- package/bin/status.mjs +23 -3
- package/bin/stop.mjs +23 -8
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +20 -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 +12 -5
- package/driver/suite-census.json +41 -5
- package/driver/systemd/render-units.mjs +7 -13
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-DBUVdQT-.js → index-qes7gLpM.js} +2 -1
- package/portal-ui/dist/index.html +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/shared/access-audience.mjs +7 -6
- package/shared/client-door.mjs +39 -0
- package/shared/env-file-merge.mjs +24 -0
- package/shared/invocation.mjs +49 -2
- package/shared/permanent-install.mjs +34 -0
- package/shared/running-start.mjs +73 -0
|
@@ -201,3 +201,37 @@ export function demoProgramPlan({ base, installDir = INSTALL_DIR, platform = pro
|
|
|
201
201
|
npmArgs: ["install", "--global", "--prefix", prefix, "--prefer-offline", "--no-fund", "--no-audit", `clearotron@${version}`],
|
|
202
202
|
};
|
|
203
203
|
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Lays the demo's copy down when it is missing or another version, and returns its root; `null` when there
|
|
207
|
+
* is no copy to use (not run from npx, or a copy that could not be made). It never stops the demo: if npm
|
|
208
|
+
* fails, the demo runs from npx's cache as before and says what that costs. `demoProgramPlan` decides;
|
|
209
|
+
* this is the one place that acts on it, for `demo` and for a `start --demo` run from npx.
|
|
210
|
+
*/
|
|
211
|
+
export function ensureDemoProgram({ base, say, env = process.env, run = spawnSync, exists = existsSync, plan = demoProgramPlan({ base }) }) {
|
|
212
|
+
if (plan?.current) return plan.root;
|
|
213
|
+
if (!plan || plan.skip) return null;
|
|
214
|
+
say(` program copying clearotron ${plan.version} into ${plan.prefix}, so the commands below and your assistant's connection survive npm cleaning its cache`);
|
|
215
|
+
// The npm that launched this, when npm says which: no second npm is guessed at.
|
|
216
|
+
const npmCli = env.npm_execpath;
|
|
217
|
+
const opts = { stdio: ["ignore", "ignore", "pipe"], encoding: "utf8", timeout: 180_000 };
|
|
218
|
+
const r = npmCli && exists(npmCli) ? run(process.execPath, [npmCli, ...plan.npmArgs], opts) : run("npm", plan.npmArgs, opts);
|
|
219
|
+
if (r.status === 0 && exists(join(plan.root, "mcp-server", "server.mjs"))) return plan.root;
|
|
220
|
+
const why = r.error ? r.error.message : String(r.stderr ?? "").trim().split("\n").pop() || `npm exited ${r.status}`;
|
|
221
|
+
say(` program could not be copied (${why}). The commands and the connect line below run from npm's temporary`);
|
|
222
|
+
say(" cache, so they stop working when npm cleans it; start the demo again to retry.");
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The environment a demo's services get when they run from the demo's copy. What makes their printed
|
|
228
|
+
* commands name the copy is running from it: `invocationPrefix` answers from the running install. npm's
|
|
229
|
+
* marks of an npx arrival (`npm_command=exec` among them) and the npx path the dispatcher hands down
|
|
230
|
+
* describe a process npm started, which the copy is not, so they are taken off, as `install` takes them
|
|
231
|
+
* off when it moves out of the cache.
|
|
232
|
+
*/
|
|
233
|
+
export function demoProgramEnv(env = process.env) {
|
|
234
|
+
const out = { ...env };
|
|
235
|
+
for (const k of ["npm_command", "npm_lifecycle_event", "npm_execpath", "CLEAROTRON_INVOKED_AS"]) delete out[k];
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
@@ -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
|
+
}
|