clearotron 0.3.0-beta.4 → 0.3.0-beta.6

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.
Files changed (39) hide show
  1. package/.env.example +9 -0
  2. package/INSTALL.md +13 -0
  3. package/bin/connect.mjs +3 -1
  4. package/bin/onboard.mjs +80 -28
  5. package/bin/start.mjs +82 -18
  6. package/bin/update.mjs +38 -6
  7. package/build-info.json +2 -2
  8. package/docs/architecture/04-configuration-reference.md +1 -0
  9. package/driver/CHANGELOG.md +36 -10
  10. package/driver/contract-e3-backlog.mjs +1 -1
  11. package/driver/demo-container.mjs +30 -2
  12. package/driver/engine/openai-agent.mjs +60 -2
  13. package/driver/enqueue-schema.mjs +3 -1
  14. package/driver/gateway.mjs +12 -0
  15. package/driver/package.json +1 -1
  16. package/driver/portal-local-auth.mjs +35 -3
  17. package/driver/portal-service.mjs +80 -18
  18. package/driver/portal-upstream.mjs +20 -1
  19. package/driver/search-policy.mjs +7 -2
  20. package/driver/suite-census.json +71 -23
  21. package/mcp-server/CHANGELOG.md +12 -2
  22. package/mcp-server/CONNECT.md +3 -2
  23. package/mcp-server/lib/options.mjs +13 -5
  24. package/mcp-server/package.json +1 -1
  25. package/package.json +1 -1
  26. package/portal-ui/dist/assets/{index-DWYCsOCJ.js → index-DBUVdQT-.js} +6 -0
  27. package/portal-ui/dist/index.html +1 -1
  28. package/portal-ui/package.json +1 -1
  29. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  30. package/providers/oauth-mcp-bridge/package.json +1 -1
  31. package/scripts/e2e.mjs +84 -12
  32. package/scripts/engine-probe.mjs +2 -2
  33. package/scripts/env-classify.mjs +3 -0
  34. package/shared/connect-clients.mjs +8 -0
  35. package/shared/names-in-force.mjs +1 -0
  36. package/shared/permanent-install.mjs +167 -0
  37. package/shared/stdio-connect.mjs +32 -14
  38. package/shared/verb-shim.mjs +10 -1
  39. package/shared/wsl.mjs +23 -0
@@ -0,0 +1,167 @@
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
+ // WHERE THIS PROGRAM LIVES, AND WHY NEVER IN NPX'S CACHE.
5
+ //
6
+ // `npx clearotron install` runs the package from npm's cache, under `_npx/<hash>/`. That directory is
7
+ // npm's, not ours: a later `npx clearotron@<newer>` unpacks somewhere else, and a cache clean deletes it.
8
+ // Everything the install wired to its own directory went with it — the `clearotron` launcher, and the
9
+ // connect line an assistant was registered with — so an assistant kept launching the old server after an
10
+ // update, and lost Clearotron without a word after a clean. Owner ruling, 2026-09-11: an install run from
11
+ // npx puts the program in a permanent place it owns, the one `npm install -g` would use, and points
12
+ // everything there.
13
+ //
14
+ // THE PLACE IS THE PREFIX `~/.local`. npm puts the package at `~/.local/lib/node_modules/clearotron` and
15
+ // its executable in `~/.local/bin`, the directory the launcher already goes in. It needs no root, and it
16
+ // is the layout `invocationForm` already recognises as a global install.
17
+ import { spawnSync } from "node:child_process";
18
+ import { existsSync, readFileSync } from "node:fs";
19
+ import { join, dirname } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { globalBinDirFrom } from "./invocation.mjs";
22
+
23
+ /** The root of THIS install — `<root>/shared/permanent-install.mjs` is this file. */
24
+ export const INSTALL_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
25
+
26
+ /** npx materialises a package under `_npx/` in npm's cache, and nothing else this runs from does. */
27
+ export function inNpxCache(dir = INSTALL_DIR) {
28
+ return /[\\/]_npx[\\/]/.test(String(dir ?? ""));
29
+ }
30
+
31
+ /** The prefix an install run from npx moves to. `null` when the environment names no home. */
32
+ export function permanentPrefix(env = process.env) {
33
+ const home = String(env?.HOME ?? "").trim();
34
+ return home ? join(home, ".local") : null;
35
+ }
36
+
37
+ /** Where npm puts this package under a prefix: the layout `globalBinDirFrom` reads back. */
38
+ export function packageRootUnder(prefix) {
39
+ return join(prefix, "lib", "node_modules", "clearotron");
40
+ }
41
+
42
+ /** The version this install is, from its own manifest. `null` when that cannot be read. */
43
+ export function ownVersion(installDir = INSTALL_DIR, read = readFileSync) {
44
+ try { return JSON.parse(read(join(installDir, "package.json"), "utf8")).version ?? null; } catch { return null; }
45
+ }
46
+
47
+ /**
48
+ * The npm dist-tag a version was published under: `0.3.0-beta.5` came from `beta`, `0.3.0` from `latest`.
49
+ * An update follows the channel the install came from, so a verb that means "newer" never moves a beta
50
+ * install onto the stable line, or the reverse.
51
+ */
52
+ export function channelOf(version) {
53
+ const pre = /^\d+\.\d+\.\d+-([A-Za-z]+)/.exec(String(version ?? ""))?.[1];
54
+ return pre ? pre.toLowerCase() : "latest";
55
+ }
56
+
57
+ /**
58
+ * What `install` does first when it runs from npx. PURE given its inputs.
59
+ *
60
+ * `null` when there is nothing to move, because this install is not in npx's cache. Otherwise a plan, or
61
+ * `{ skip }` naming why the move cannot be made here: Windows, where no launcher is written either; no
62
+ * home; or a version that cannot be read, since installing some other version would be a silent upgrade.
63
+ */
64
+ export function relocationPlan({ installDir = INSTALL_DIR, env = process.env, platform = process.platform, version = ownVersion(installDir) } = {}) {
65
+ if (!inNpxCache(installDir)) return null;
66
+ if (platform === "win32") return { skip: "windows" };
67
+ const prefix = permanentPrefix(env);
68
+ if (!prefix) return { skip: "no-home" };
69
+ if (!version) return { skip: "no-version" };
70
+ const root = packageRootUnder(prefix);
71
+ return {
72
+ prefix, root, version,
73
+ entry: join(root, "bin", "clearotron.mjs"),
74
+ // --prefer-offline: npx has just fetched exactly this version, so npm's cache already holds it.
75
+ npmArgs: ["install", "--global", "--prefix", prefix, "--prefer-offline", "--no-fund", "--no-audit", `clearotron@${version}`],
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Semver order, a prerelease below its release: 0.3.0-beta.5 < 0.3.0-beta.10 < 0.3.0 < 0.3.1. Negative,
81
+ * zero or positive as `a` sorts before, with or after `b`; `null` when either cannot be read as a version.
82
+ */
83
+ export function compareVersions(a, b) {
84
+ const parse = (v) => {
85
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(v ?? "").trim());
86
+ return m ? { core: [+m[1], +m[2], +m[3]], pre: m[4] ? m[4].split(".") : [] } : null;
87
+ };
88
+ const x = parse(a), y = parse(b);
89
+ if (!x || !y) return null;
90
+ for (let i = 0; i < 3; i++) if (x.core[i] !== y.core[i]) return x.core[i] - y.core[i];
91
+ if (!x.pre.length || !y.pre.length) return y.pre.length - x.pre.length;
92
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
93
+ const p = x.pre[i], q = y.pre[i];
94
+ if (p === undefined) return -1;
95
+ if (q === undefined) return 1;
96
+ if (p === q) continue;
97
+ const pn = /^\d+$/.test(p), qn = /^\d+$/.test(q);
98
+ if (pn && qn) return +p - +q;
99
+ if (pn !== qn) return pn ? -1 : 1;
100
+ return p < q ? -1 : 1;
101
+ }
102
+ return 0;
103
+ }
104
+
105
+ /**
106
+ * WHICH PUBLISHED VERSION AN UPDATE INSTALLS. PURE given the dist-tags npm reports.
107
+ *
108
+ * A beta install follows `beta` AND `latest`, and takes whichever is newer. Following `beta` alone left
109
+ * every beta tester on the last beta once the release went to `latest`, because `beta` is not moved on
110
+ * a stable release. A stable install follows `latest` only, so an update never moves it onto a
111
+ * prerelease. The exact version is installed, so npm cannot resolve the tag to something else between
112
+ * this read and the install.
113
+ *
114
+ * `current` when nothing published is newer than the running version. Tags that cannot be read fall back
115
+ * to the channel's own tag, with `unread` set so the caller can say so.
116
+ */
117
+ export function updateTarget({ version, distTags }) {
118
+ const channel = channelOf(version);
119
+ let best = null;
120
+ for (const tag of channel === "latest" ? ["latest"] : [channel, "latest"]) {
121
+ const v = distTags?.[tag];
122
+ if (compareVersions(v, v) !== 0) continue;
123
+ if (!best || compareVersions(v, best.version) > 0) best = { tag, version: v };
124
+ }
125
+ if (!best) return { tag: channel, version: null, spec: channel, current: false, unread: true };
126
+ const newer = compareVersions(best.version, version);
127
+ return { ...best, spec: best.version, current: newer !== null && newer <= 0, unread: false };
128
+ }
129
+
130
+ /** npm's dist-tags for the package, e.g. `{ latest: "0.3.0", beta: "0.3.0-beta.5" }`. `null` when npm cannot answer. */
131
+ export function readDistTags(run = spawnSync) {
132
+ const r = run("npm", ["view", "clearotron", "dist-tags", "--json"], { encoding: "utf8", timeout: 60_000 });
133
+ if (r.error || r.status !== 0) return null;
134
+ try {
135
+ const tags = JSON.parse(r.stdout);
136
+ return tags && typeof tags === "object" && !Array.isArray(tags) ? tags : null;
137
+ } catch { return null; }
138
+ }
139
+
140
+ /**
141
+ * How a packaged install updates itself: the same npm command, at the prefix it lives under, to the
142
+ * version `updateTarget` picks. `null` for a layout this cannot name — a checkout, npx's cache, or a
143
+ * package that is not under `<prefix>/lib/node_modules` — and npm is not asked anything then.
144
+ */
145
+ export function packagedUpdate({ installDir = INSTALL_DIR, version = ownVersion(installDir), distTags } = {}) {
146
+ if (inNpxCache(installDir)) return null;
147
+ const bin = globalBinDirFrom(installDir);
148
+ if (!bin) return null;
149
+ const prefix = dirname(bin);
150
+ const target = updateTarget({ version, distTags: distTags === undefined ? readDistTags() : distTags });
151
+ return {
152
+ prefix, installed: version, ...target,
153
+ npmArgs: ["install", "--global", "--prefix", prefix, "--no-fund", "--no-audit", `clearotron@${target.spec}`],
154
+ };
155
+ }
156
+
157
+ /**
158
+ * The install root a connect line should name. The running root, unless that is npx's cache and the
159
+ * permanent install exists, in which case the permanent one: an assistant registered against the cache
160
+ * loses Clearotron when the cache is cleaned, which is the defect this file exists to end.
161
+ */
162
+ export function stableInstallRoot({ installRoot = INSTALL_DIR, env = process.env, exists = existsSync } = {}) {
163
+ if (!inNpxCache(installRoot)) return installRoot;
164
+ const prefix = permanentPrefix(env);
165
+ const root = prefix ? packageRootUnder(prefix) : null;
166
+ return root && exists(join(root, "mcp-server", "server.mjs")) ? root : installRoot;
167
+ }
@@ -31,6 +31,7 @@
31
31
 
32
32
  import { join } from "node:path";
33
33
  import { fileURLToPath } from "node:url";
34
+ import { stableInstallRoot } from "./permanent-install.mjs";
34
35
 
35
36
  /** The install root — the directory holding `mcp-server/`, resolved from this module rather than cwd. */
36
37
  export const INSTALL_ROOT = join(fileURLToPath(new URL(".", import.meta.url)), "..");
@@ -49,10 +50,8 @@ export const STDIO_SERVER_NAME = "trademark-artifacts";
49
50
  * @param {{ installRoot?: string, workDir?: string|null }} [opts]
50
51
  * @returns {string} the exact command to run
51
52
  */
52
- export function stdioConnectCommand({ installRoot = INSTALL_ROOT, workDir = null } = {}) {
53
- const server = join(installRoot, "mcp-server", "server.mjs");
54
- const env = workDir ? ` -e CLEAROTRON_WORK_DIR=${workDir}` : "";
55
- return `claude mcp add ${STDIO_SERVER_NAME}${env} -- node ${server}`;
53
+ export function stdioConnectCommand({ installRoot = stableInstallRoot({ installRoot: INSTALL_ROOT }), workDir = null, reportsDir = null, platform = process.platform } = {}) {
54
+ return STDIO_SHAPES["claude-cli"].render({ server: join(installRoot, "mcp-server", "server.mjs"), workDir, reportsDir, platform });
56
55
  }
57
56
 
58
57
  /**
@@ -89,24 +88,42 @@ export function stdioConnectOffer(opts = {}) {
89
88
  // So the three facts are resolved ONCE and rendered three ways. A row names its shape; nothing switches
90
89
  // on a client's name. Adding a host is a renderer plus a row, and it cannot invent a different route.
91
90
 
91
+ /**
92
+ * The settings the server is started with: the workspace it reads runs from, and the folder its reports are
93
+ * published to. Handed over rather than left to the server's own defaults, so it reads what the services
94
+ * read: a demo's own base, not the real install's workspace. Only what is set is written.
95
+ */
96
+ function envOf({ workDir = null, reportsDir = null } = {}) {
97
+ return { ...(workDir ? { CLEAROTRON_WORK_DIR: workDir } : {}), ...(reportsDir ? { CLEAROTRON_REPORTS_DIR: reportsDir } : {}) };
98
+ }
99
+ const envFlags = (o) => Object.entries(envOf(o)).map(([k, v]) => ` -e ${k}=${v}`).join("");
100
+ const envBlock = (o) => (Object.keys(envOf(o)).length ? { env: envOf(o) } : {});
101
+
102
+ /**
103
+ * The argument that ends Claude Code's own options. Windows PowerShell 5.1 drops a bare `--` before it
104
+ * reaches a native program, so the line failed with "missing required argument"; quoted, every shell passes
105
+ * it through (bash, zsh, PowerShell and cmd alike), so a Windows install is handed the quoted form.
106
+ */
107
+ const separator = (platform = process.platform) => (platform === "win32" ? '"--"' : "--");
108
+
92
109
  /** How each host takes the same three facts. A row of CONNECT_CLIENTS names one of these by key. */
93
110
  export const STDIO_SHAPES = Object.freeze({
94
111
  "claude-cli": {
95
112
  kind: "command",
96
113
  where: null,
97
- render: ({ server, workDir }) =>
98
- `claude mcp add ${STDIO_SERVER_NAME}${workDir ? ` -e CLEAROTRON_WORK_DIR=${workDir}` : ""} -- node ${server}`,
114
+ render: ({ server, workDir, reportsDir, platform }) =>
115
+ `claude mcp add ${STDIO_SERVER_NAME} --scope user${envFlags({ workDir, reportsDir })} ${separator(platform)} node ${server}`,
99
116
  after: null,
100
117
  },
101
118
  "desktop-json": {
102
119
  kind: "config",
103
120
  where: "Settings → Developer → Edit Config",
104
- render: ({ server, workDir }) => JSON.stringify({
121
+ render: ({ server, workDir, reportsDir }) => JSON.stringify({
105
122
  mcpServers: {
106
123
  [STDIO_SERVER_NAME]: {
107
124
  command: "node",
108
125
  args: [server],
109
- ...(workDir ? { env: { CLEAROTRON_WORK_DIR: workDir } } : {}),
126
+ ...envBlock({ workDir, reportsDir }),
110
127
  },
111
128
  },
112
129
  }, null, 2),
@@ -117,10 +134,10 @@ export const STDIO_SHAPES = Object.freeze({
117
134
  "generic-json": {
118
135
  kind: "config",
119
136
  where: "your agent's MCP server configuration",
120
- render: ({ server, workDir }) => JSON.stringify({
137
+ render: ({ server, workDir, reportsDir }) => JSON.stringify({
121
138
  command: "node",
122
139
  args: [server],
123
- ...(workDir ? { env: { CLEAROTRON_WORK_DIR: workDir } } : {}),
140
+ ...envBlock({ workDir, reportsDir }),
124
141
  }, null, 2),
125
142
  after: null,
126
143
  },
@@ -134,11 +151,12 @@ export const STDIO_SHAPES = Object.freeze({
134
151
  // Codex does not forward the shell environment, and a credential would have to be forwarded BY NAME
135
152
  // rather than written into a file. This server takes no credential — the work directory is a path,
136
153
  // not a secret — so `env` is correct here and would not be for a server that wanted a key.
137
- render: ({ server, workDir }) => [
154
+ render: ({ server, workDir, reportsDir }) => [
138
155
  `[mcp_servers.${STDIO_SERVER_NAME}]`,
139
156
  `command = "node"`,
140
157
  `args = ["${server}"]`,
141
- ...(workDir ? [`env = { CLEAROTRON_WORK_DIR = "${workDir}" }`] : []),
158
+ ...(Object.keys(envOf({ workDir, reportsDir })).length
159
+ ? [`env = { ${Object.entries(envOf({ workDir, reportsDir })).map(([k, v]) => `${k} = "${v}"`).join(", ")} }`] : []),
142
160
  ].join("\n"),
143
161
  after: null,
144
162
  },
@@ -215,13 +233,13 @@ export function remoteConnectFor(shape, { address = null } = {}) {
215
233
  * absence is a finding: a row naming a shape nobody implemented should surface as missing, and the
216
234
  * table's own arm refuses such a row outright.
217
235
  */
218
- export function stdioConnectFor(shape, { installRoot = INSTALL_ROOT, workDir = null } = {}) {
236
+ export function stdioConnectFor(shape, { installRoot = stableInstallRoot({ installRoot: INSTALL_ROOT }), workDir = null, reportsDir = null, platform = process.platform } = {}) {
219
237
  const spec = Object.hasOwn(STDIO_SHAPES, String(shape ?? "")) ? STDIO_SHAPES[shape] : null;
220
238
  if (!spec) return null;
221
239
  const server = join(installRoot, "mcp-server", "server.mjs");
222
240
  return {
223
241
  shape, kind: spec.kind, where: spec.where, after: spec.after,
224
- text: spec.render({ server, workDir }),
242
+ text: spec.render({ server, workDir, reportsDir, platform }),
225
243
  name: STDIO_SERVER_NAME,
226
244
  };
227
245
  }
@@ -18,7 +18,7 @@
18
18
  // install "I wrote that shim, and it points at ME". If the sentence that writes it and the sentence
19
19
  // that matches it sat in different files, a change to either would leave the other quietly answering
20
20
  // the old question — the shape recorded as "a mechanical rename must move both ends".
21
- import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
21
+ import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
22
22
  import { delimiter, dirname, join, resolve } from "node:path";
23
23
  import { fileURLToPath } from "node:url";
24
24
 
@@ -84,6 +84,15 @@ function readHead(path, cap = 4096) {
84
84
  * exactly the `which` mistake shared/invocation.mjs refuses to make, wearing different clothes.
85
85
  */
86
86
  export function inspectShim(path, { installDir = INSTALL_DIR, read = readHead } = {}) {
87
+ // NPM'S OWN LINK INTO THIS INSTALL IS OURS TO REPLACE. An install moved out of npx's cache is put in
88
+ // place by `npm install --global --prefix ~/.local`, and npm links `~/.local/bin/clearotron` to the
89
+ // package's executable: the very path the shim goes in. Read as a file it carries no marker, so it was
90
+ // reported as somebody else's and left alone, and it reaches `node` through `env`, which a service's
91
+ // PATH may not carry. A link that resolves to THIS install's own executable is replaced with the shim.
92
+ try {
93
+ if (lstatSync(path).isSymbolicLink() && realpathSync(path) === realpathSync(join(installDir, "bin", "clearotron.mjs")))
94
+ return { kind: "npm-link", installDir, interpreter: null, interpreterMissing: null };
95
+ } catch { /* not a link, or one that leads nowhere: read below as a file */ }
87
96
  let head;
88
97
  try {
89
98
  head = read(path);
package/shared/wsl.mjs ADDED
@@ -0,0 +1,23 @@
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
+ // Whether this is a Linux running under Windows. Shared because two readers need the one answer: the install
5
+ // (which binaries on a Windows drive to pass over) and the connect lines (which must say to run them inside
6
+ // WSL, where their paths exist).
7
+ import { readFileSync } from "node:fs";
8
+
9
+ /**
10
+ * BOTH SIGNALS INJECTABLE, for the reason `platformEngineRefusal` gives: the readers this protects are the
11
+ * ones who cannot run this suite to find out, so a Linux runner has to be able to drive both answers
12
+ * rather than read the source and agree with it.
13
+ *
14
+ * A READ THAT FAILS ANSWERS "NOT WSL", and that is the direction that changes nothing: it leaves the
15
+ * resolution exactly as it was before this existed. Claiming WSL on a could-not-read would start refusing
16
+ * candidates under /mnt on an ordinary Linux box with an ordinary mount.
17
+ */
18
+ export function isWsl({ env = process.env, procVersion = null } = {}) {
19
+ if (String(env.WSL_DISTRO_NAME ?? "").trim()) return true;
20
+ if (String(env.WSL_INTEROP ?? "").trim()) return true;
21
+ const v = procVersion ?? (() => { try { return readFileSync("/proc/version", "utf8"); } catch { return ""; } })();
22
+ return /microsoft|wsl/i.test(v);
23
+ }