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.
- package/.env.example +9 -0
- package/INSTALL.md +13 -0
- package/bin/connect.mjs +3 -1
- package/bin/onboard.mjs +80 -28
- package/bin/start.mjs +82 -18
- package/bin/update.mjs +38 -6
- package/build-info.json +2 -2
- package/docs/architecture/04-configuration-reference.md +1 -0
- package/driver/CHANGELOG.md +36 -10
- package/driver/contract-e3-backlog.mjs +1 -1
- package/driver/demo-container.mjs +30 -2
- package/driver/engine/openai-agent.mjs +60 -2
- package/driver/enqueue-schema.mjs +3 -1
- package/driver/gateway.mjs +12 -0
- package/driver/package.json +1 -1
- package/driver/portal-local-auth.mjs +35 -3
- package/driver/portal-service.mjs +80 -18
- package/driver/portal-upstream.mjs +20 -1
- package/driver/search-policy.mjs +7 -2
- package/driver/suite-census.json +71 -23
- package/mcp-server/CHANGELOG.md +12 -2
- package/mcp-server/CONNECT.md +3 -2
- package/mcp-server/lib/options.mjs +13 -5
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-DWYCsOCJ.js → index-DBUVdQT-.js} +6 -0
- 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/scripts/e2e.mjs +84 -12
- package/scripts/engine-probe.mjs +2 -2
- package/scripts/env-classify.mjs +3 -0
- package/shared/connect-clients.mjs +8 -0
- package/shared/names-in-force.mjs +1 -0
- package/shared/permanent-install.mjs +167 -0
- package/shared/stdio-connect.mjs +32 -14
- package/shared/verb-shim.mjs +10 -1
- 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
|
+
}
|
package/shared/stdio-connect.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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
|
}
|
package/shared/verb-shim.mjs
CHANGED
|
@@ -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
|
+
}
|