dsh-plugin-rea 2.0.5 → 2.0.7
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/CHANGELOG.md +85 -0
- package/README.md +37 -0
- package/README.zh-CN.md +35 -1
- package/bin/rea-shim.mjs +491 -23
- package/package.json +5 -3
- package/scripts/host-compat.mjs +224 -0
- package/scripts/rea-bundle-bootstrap.ps1 +5 -0
- package/scripts/setup.mjs +5 -1
- package/scripts/setup.test.mjs +368 -3
- package/scripts/verify-branch.ps1 +9 -2
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* host-compat — does the range this package declares cover the DSH releases it
|
|
4
|
+
* claims to support?
|
|
5
|
+
*
|
|
6
|
+
* `package.json` carries `engines.dsh`, and that single string is what the
|
|
7
|
+
* plugin market reads to say "compatible" instead of "unknown" (the market's
|
|
8
|
+
* `deriveHostCompatibility` treats `engines.dsh` as a host declaration and
|
|
9
|
+
* checks it against the running host version). A declaration is therefore load
|
|
10
|
+
* bearing twice over: it decides whether the market HIDES this plugin from users
|
|
11
|
+
* on a matching host, and it decides whether users on a host we never tested are
|
|
12
|
+
* told it works.
|
|
13
|
+
*
|
|
14
|
+
* The two ways it goes wrong are silent. A typo makes the range unparseable, and
|
|
15
|
+
* the market then reports `unknown` — a plugin that looks undeclared rather than
|
|
16
|
+
* mis-declared. A floor that drifted (say `>=0.2.0`) silently drops hosts the
|
|
17
|
+
* README still promises. Neither shows up as a test failure unless something
|
|
18
|
+
* compares the STRING to the release list, which is what this file does.
|
|
19
|
+
*
|
|
20
|
+
* `HOST_RELEASES` is the dsh release line this package is built against, newest
|
|
21
|
+
* first, as published on github.com/deepseek-ai/deepseek-harness. It is a
|
|
22
|
+
* literal list rather than a GitHub query on purpose: a check that needs the
|
|
23
|
+
* network fails on a plane and passes on a total outage, which is the wrong
|
|
24
|
+
* shape for a gate. When a release is added upstream, add it here too — that
|
|
25
|
+
* edit is the reminder to re-check the row against the new host.
|
|
26
|
+
*
|
|
27
|
+
* SEMANTICS: this file evaluates the declaration the way `includePrerelease:
|
|
28
|
+
* true` does, and that choice is not stylistic. Both consumers of `engines.dsh`
|
|
29
|
+
* in this ecosystem pass that option — dshmarket's discovery
|
|
30
|
+
* (`satisfiesRange(host, declared, { includePrerelease: true })` in
|
|
31
|
+
* `deriveHostCompatibility`) and the host's own peer gate — so a plain semver
|
|
32
|
+
* reading, where `0.2.0-rc.2` does NOT satisfy `>=0.1.7-rc.2 <0.3.0-0` (a
|
|
33
|
+
* prerelease must be named by a comparator on its own tuple), would disagree
|
|
34
|
+
* with the verdict users actually see. The tests pin the option's effect.
|
|
35
|
+
*
|
|
36
|
+
* Usage: `node scripts/host-compat.mjs` (exit 0 = every listed release is
|
|
37
|
+
* covered by the declared range; exit 1 = at least one is not).
|
|
38
|
+
*/
|
|
39
|
+
import { readFileSync } from 'node:fs'
|
|
40
|
+
import path from 'node:path'
|
|
41
|
+
import process from 'node:process'
|
|
42
|
+
import { fileURLToPath } from 'node:url'
|
|
43
|
+
|
|
44
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
45
|
+
const REPO_ROOT = path.join(HERE, '..')
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Every dsh release this bundle is checked against, newest first.
|
|
49
|
+
*
|
|
50
|
+
* Taken from the release list of github.com/deepseek-ai/deepseek-harness. The
|
|
51
|
+
* floor is the oldest release the plugin has been adapted to; everything above
|
|
52
|
+
* it up to the declared ceiling must keep working, so a release that is missing
|
|
53
|
+
* here is a release nobody checked.
|
|
54
|
+
*/
|
|
55
|
+
export const HOST_RELEASES = [
|
|
56
|
+
'0.2.1-rc.2',
|
|
57
|
+
'0.2.1-rc.1',
|
|
58
|
+
'0.2.1-alpha.5',
|
|
59
|
+
'0.2.1-alpha.4',
|
|
60
|
+
'0.2.1-alpha.3',
|
|
61
|
+
'0.2.1-alpha.2',
|
|
62
|
+
'0.2.1-alpha.1',
|
|
63
|
+
'0.2.0-rc.2',
|
|
64
|
+
'0.2.0-rc.1',
|
|
65
|
+
'0.1.7-rc.2',
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
/** Hosts that must fall OUTSIDE the declaration, for the other direction. */
|
|
69
|
+
export const OUTSIDE_HOSTS = [
|
|
70
|
+
'0.3.0',
|
|
71
|
+
// Prereleases of the NEXT line are the case a plain `<0.3.0` ceiling lets
|
|
72
|
+
// through: with `includePrerelease` they sort below `0.3.0` itself. The
|
|
73
|
+
// declared ceiling is `<0.3.0-0`, semver's idiom for "nothing from 0.3.0",
|
|
74
|
+
// and these entries are what proves it does what it says.
|
|
75
|
+
'0.3.0-rc.1',
|
|
76
|
+
'0.3.0-alpha.1',
|
|
77
|
+
'0.1.7-rc.1',
|
|
78
|
+
'0.1.7-alpha.2',
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Split a version into comparable parts.
|
|
85
|
+
*
|
|
86
|
+
* Build metadata (`+sha`) is dropped, as semver says it must be for precedence
|
|
87
|
+
* and as node-semver does for comparison: a host that reports
|
|
88
|
+
* `0.2.0-rc.2+build.7` is running 0.2.0-rc.2. Everything else is deliberately
|
|
89
|
+
* strict — a leading `v` or a zero-padded identifier is rejected rather than
|
|
90
|
+
* guessed at, because this gate exists to catch a declaration nobody checked and
|
|
91
|
+
* a guess would put the guess in charge.
|
|
92
|
+
* @param {string} version - a plain semver string, no range operators.
|
|
93
|
+
* @returns {{ parts: number[], pre: (string|number)[] } | null} null when unparseable.
|
|
94
|
+
*/
|
|
95
|
+
export function parseVersion(version) {
|
|
96
|
+
const cleaned = String(version ?? '').trim().replace(/\+.*$/, '')
|
|
97
|
+
const match = SEMVER.exec(cleaned)
|
|
98
|
+
if (match === null) return null
|
|
99
|
+
const raw = match[4] === undefined ? [] : match[4].split('.')
|
|
100
|
+
// semver forbids a zero-padded numeric prerelease identifier (`0.2.1-01`);
|
|
101
|
+
// accepting one would make this gate read a typo as a version.
|
|
102
|
+
if (raw.some(part => /^\d+$/.test(part) && part.length > 1 && part.startsWith('0'))) return null
|
|
103
|
+
const pre = raw.map(part => (/^\d+$/.test(part) ? Number(part) : part))
|
|
104
|
+
return { parts: [Number(match[1]), Number(match[2]), Number(match[3])], pre }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Compare two versions the way semver does, prerelease included.
|
|
109
|
+
*
|
|
110
|
+
* A prerelease sorts BELOW its release (`0.2.0-rc.2 < 0.2.0`), and among
|
|
111
|
+
* prereleases an identifier that is numeric sorts below one that is not, which
|
|
112
|
+
* is why `0.2.1-alpha.5 < 0.2.1-rc.1` — the whole reason this file cannot lean
|
|
113
|
+
* on a numeric comparison.
|
|
114
|
+
* @returns -1, 0 or 1; NaN-free because unparseable input throws.
|
|
115
|
+
*/
|
|
116
|
+
export function compareVersions(a, b) {
|
|
117
|
+
const left = parseVersion(a)
|
|
118
|
+
const right = parseVersion(b)
|
|
119
|
+
if (left === null) throw new Error(`not a version: ${a}`)
|
|
120
|
+
if (right === null) throw new Error(`not a version: ${b}`)
|
|
121
|
+
for (let index = 0; index < 3; index += 1) {
|
|
122
|
+
if (left.parts[index] !== right.parts[index]) return left.parts[index] < right.parts[index] ? -1 : 1
|
|
123
|
+
}
|
|
124
|
+
if (left.pre.length === 0 && right.pre.length === 0) return 0
|
|
125
|
+
if (left.pre.length === 0) return 1
|
|
126
|
+
if (right.pre.length === 0) return -1
|
|
127
|
+
for (let index = 0; index < Math.max(left.pre.length, right.pre.length); index += 1) {
|
|
128
|
+
const l = left.pre[index]
|
|
129
|
+
const r = right.pre[index]
|
|
130
|
+
if (l === undefined) return -1
|
|
131
|
+
if (r === undefined) return 1
|
|
132
|
+
if (l === r) continue
|
|
133
|
+
const lNum = typeof l === 'number'
|
|
134
|
+
const rNum = typeof r === 'number'
|
|
135
|
+
if (lNum && rNum) return l < r ? -1 : 1
|
|
136
|
+
// Numeric identifiers always have lower precedence than alphanumeric ones.
|
|
137
|
+
if (lNum !== rNum) return lNum ? -1 : 1
|
|
138
|
+
return String(l) < String(r) ? -1 : 1
|
|
139
|
+
}
|
|
140
|
+
return 0
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Evaluate one `>=floor <ceiling` declaration (the only shape this package
|
|
145
|
+
* ships, and the shape the market's `engines.dsh` is compared with).
|
|
146
|
+
*
|
|
147
|
+
* Deliberately narrow: a wider parser here could disagree with the market's
|
|
148
|
+
* semver implementation on a range the manifest would then be trusted for. If a
|
|
149
|
+
* future declaration needs `||` or `^`, this function has to grow with it.
|
|
150
|
+
* @param {string} version - host version to test.
|
|
151
|
+
* @param {string} range - the declared range.
|
|
152
|
+
* @returns {boolean} whether the host is inside the declaration.
|
|
153
|
+
*/
|
|
154
|
+
export function satisfiesDeclaredRange(version, range) {
|
|
155
|
+
const match = /^>=\s*(\S+)\s+<\s*(\S+)$/.exec(range.trim())
|
|
156
|
+
if (match === null) throw new Error(`unsupported host range: ${range}`)
|
|
157
|
+
return compareVersions(version, match[1]) >= 0 && compareVersions(version, match[2]) < 0
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The host range this repository declares, `engines.dsh`.
|
|
162
|
+
* @param {string} [root] - repository root, for tests.
|
|
163
|
+
* @returns {string | null} the declared range, or null when absent.
|
|
164
|
+
*/
|
|
165
|
+
export function declaredHostRange(root = REPO_ROOT) {
|
|
166
|
+
try {
|
|
167
|
+
const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'))
|
|
168
|
+
const value = pkg?.engines?.dsh
|
|
169
|
+
return typeof value === 'string' ? value : null
|
|
170
|
+
} catch {
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Every release covered by the declaration, and every version that must not be.
|
|
177
|
+
* @param {string} range - declared host range.
|
|
178
|
+
* @returns {{ covered: string[], uncovered: string[], leaked: string[] }} findings.
|
|
179
|
+
*/
|
|
180
|
+
export function checkHostCoverage(range) {
|
|
181
|
+
const covered = []
|
|
182
|
+
const uncovered = []
|
|
183
|
+
for (const version of HOST_RELEASES) {
|
|
184
|
+
if (satisfiesDeclaredRange(version, range)) covered.push(version)
|
|
185
|
+
else uncovered.push(version)
|
|
186
|
+
}
|
|
187
|
+
const leaked = OUTSIDE_HOSTS.filter(version => satisfiesDeclaredRange(version, range))
|
|
188
|
+
return { covered, uncovered, leaked }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function main() {
|
|
192
|
+
const range = declaredHostRange()
|
|
193
|
+
if (range === null) {
|
|
194
|
+
console.error('FAIL: package.json declares no engines.dsh — the market would report this plugin as "unknown"')
|
|
195
|
+
process.exit(1)
|
|
196
|
+
}
|
|
197
|
+
let result
|
|
198
|
+
try {
|
|
199
|
+
result = checkHostCoverage(range)
|
|
200
|
+
} catch (error) {
|
|
201
|
+
console.error(`FAIL: ${error.message}`)
|
|
202
|
+
process.exit(1)
|
|
203
|
+
}
|
|
204
|
+
console.log(`engines.dsh = ${range}`)
|
|
205
|
+
for (const version of HOST_RELEASES) {
|
|
206
|
+
const inside = satisfiesDeclaredRange(version, range)
|
|
207
|
+
console.log(` ${inside ? 'ok ' : 'MISS'} ${version}`)
|
|
208
|
+
}
|
|
209
|
+
for (const version of OUTSIDE_HOSTS) {
|
|
210
|
+
const inside = satisfiesDeclaredRange(version, range)
|
|
211
|
+
console.log(` ${inside ? 'LEAK' : 'ok '} ${version} (must stay outside)`)
|
|
212
|
+
}
|
|
213
|
+
if (result.uncovered.length > 0) {
|
|
214
|
+
console.error(`FAIL: the declaration does not cover ${result.uncovered.join(', ')}`)
|
|
215
|
+
process.exit(1)
|
|
216
|
+
}
|
|
217
|
+
if (result.leaked.length > 0) {
|
|
218
|
+
console.error(`FAIL: the declaration also admits ${result.leaked.join(', ')}, which is not a tested host`)
|
|
219
|
+
process.exit(1)
|
|
220
|
+
}
|
|
221
|
+
console.log(`host range ok: ${HOST_RELEASES.length} releases covered, ${OUTSIDE_HOSTS.length} correctly outside`)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main()
|
|
@@ -30,6 +30,11 @@ function Write-Step([string]$Message) { Write-Host "[bootstrap] $Message" }
|
|
|
30
30
|
|
|
31
31
|
# --- where things go -------------------------------------------------------
|
|
32
32
|
|
|
33
|
+
# Same precedence as setup.mjs, the shim and check-updates.ps1: explicit override,
|
|
34
|
+
# then DSH_HOME, then ~/.dsh. An override this script alone ignored would put
|
|
35
|
+
# ~800 MB into <DSH_HOME>/plugins/rea-bundle while every reader looked in
|
|
36
|
+
# REA_BUNDLE_ROOT, saw an empty bundle, and started a second run.
|
|
37
|
+
if (-not $Root -or $Root.Trim() -eq '') { $Root = $env:REA_BUNDLE_ROOT }
|
|
33
38
|
if (-not $Root -or $Root.Trim() -eq '') {
|
|
34
39
|
$home2 = $env:DSH_HOME
|
|
35
40
|
if (-not $home2 -or $home2.Trim() -eq '') { $home2 = Join-Path $HOME '.dsh' }
|
package/scripts/setup.mjs
CHANGED
|
@@ -1553,7 +1553,11 @@ if (!options.dryRun) {
|
|
|
1553
1553
|
const shim = spawnSync(process.execPath, [mirroredShim, "--version"], {
|
|
1554
1554
|
encoding: "utf8",
|
|
1555
1555
|
timeout: 120000,
|
|
1556
|
-
|
|
1556
|
+
// Pin the bundle path: this step exists to confirm the shim relays the REA
|
|
1557
|
+
// THIS run provisioned. Under the default `auto` mode a machine that also
|
|
1558
|
+
// has a newer global REA answers with its own version, and the step would
|
|
1559
|
+
// report success for something this run never verified.
|
|
1560
|
+
env: { ...process.env, REA_BUNDLE_ROOT: options.root, REA_BUNDLE_LAUNCH: "bundle" },
|
|
1557
1561
|
});
|
|
1558
1562
|
const output = `${shim.stdout ?? ""}${shim.stderr ?? ""}`.trim();
|
|
1559
1563
|
const ok = shim.status === 0 && /\d+\.\d+\.\d+/.test(output);
|
package/scripts/setup.test.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { tmpdir } from "node:os";
|
|
|
19
19
|
import path from "node:path";
|
|
20
20
|
import test from "node:test";
|
|
21
21
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
22
|
+
import { HOST_RELEASES, OUTSIDE_HOSTS, checkHostCoverage, declaredHostRange, compareVersions, satisfiesDeclaredRange } from "./host-compat.mjs";
|
|
22
23
|
|
|
23
24
|
/* ------------------------------------------------- probe token substitution */
|
|
24
25
|
|
|
@@ -212,7 +213,10 @@ const SHIM_SOURCE = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."
|
|
|
212
213
|
function shimBundleRoot(shimFile, env) {
|
|
213
214
|
const run = spawnSync(process.execPath, [shimFile, "--shim-status"], {
|
|
214
215
|
encoding: "utf8",
|
|
215
|
-
|
|
216
|
+
// These tests assert bundle-root resolution, not the launch decision. Pinning
|
|
217
|
+
// the mode keeps them independent of whether THIS machine has a REA on PATH
|
|
218
|
+
// (and keeps them from paying for a probe on every spawn).
|
|
219
|
+
env: { ...process.env, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle", ...env },
|
|
216
220
|
});
|
|
217
221
|
// --shim-status prints its report even when no bundle exists yet (exit 78).
|
|
218
222
|
return JSON.parse(run.stdout).bundleRoot;
|
|
@@ -820,6 +824,10 @@ function runShim(bundle, args, extraEnv = {}) {
|
|
|
820
824
|
...process.env,
|
|
821
825
|
DSH_HOME: path.join(path.dirname(bundle), "dsh-home"),
|
|
822
826
|
REA_BUNDLE_ROOT: bundle,
|
|
827
|
+
// The bundle path is what these tests assert. Without this pin the shim
|
|
828
|
+
// would take its `auto` branch and, on a machine that happens to have a REA
|
|
829
|
+
// on PATH, delegate to it instead of exercising the code under test.
|
|
830
|
+
REA_BUNDLE_LAUNCH: "bundle",
|
|
823
831
|
...extraEnv,
|
|
824
832
|
};
|
|
825
833
|
delete env.REA_BUNDLE_AUTO_PROVISION;
|
|
@@ -943,7 +951,7 @@ test("a second REA registration is reported instead of silently duplicated", ()
|
|
|
943
951
|
|
|
944
952
|
const status = spawnSync(process.execPath, [path.join(bundle, "bin", "rea-shim.mjs"), "--shim-status"], {
|
|
945
953
|
encoding: "utf8",
|
|
946
|
-
env: { ...process.env, DSH_HOME: home, REA_BUNDLE_ROOT: bundle },
|
|
954
|
+
env: { ...process.env, DSH_HOME: home, REA_BUNDLE_ROOT: bundle, REA_BUNDLE_LAUNCH: "bundle" },
|
|
947
955
|
});
|
|
948
956
|
assert.equal(JSON.parse(status.stdout).duplicateRegistration.id, "rea-mcp");
|
|
949
957
|
} finally {
|
|
@@ -1122,7 +1130,7 @@ test("the fallback vendored-REA path matches where setup installs it", () => {
|
|
|
1122
1130
|
const run = spawnSync(process.execPath, [path.join(REPO_ROOT, "bin", "rea-shim.mjs"), "--shim-status"], {
|
|
1123
1131
|
encoding: "utf8",
|
|
1124
1132
|
timeout: 60000,
|
|
1125
|
-
env: { ...process.env, REA_BUNDLE_ROOT: bundle, REA_BUNDLE_AUTO_PROVISION: "0" },
|
|
1133
|
+
env: { ...process.env, REA_BUNDLE_ROOT: bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
|
|
1126
1134
|
});
|
|
1127
1135
|
const report = JSON.parse(run.stdout);
|
|
1128
1136
|
assert.equal(report.rea.ok, true, JSON.stringify(report.rea));
|
|
@@ -1199,3 +1207,360 @@ test("the entry loads as a cordis plugin and does nothing", async () => {
|
|
|
1199
1207
|
// which the harness refuses. Returning undefined is the whole contract.
|
|
1200
1208
|
assert.equal(mod.apply(), undefined);
|
|
1201
1209
|
});
|
|
1210
|
+
|
|
1211
|
+
/* --------------------------------------------------------- declared DSH hosts */
|
|
1212
|
+
|
|
1213
|
+
// The market reads `engines.dsh` to answer "is this plugin compatible with the
|
|
1214
|
+
// host I am running" — and hides the plugin from hosts that mismatch. Two silent
|
|
1215
|
+
// failures follow from a declaration nobody checks: a typo leaves the range
|
|
1216
|
+
// unparseable and the market falls back to "undeclared", while a drifted floor
|
|
1217
|
+
// drops hosts the README still promises. Both look exactly like a plugin that
|
|
1218
|
+
// simply has nothing to say, so the string is pinned against the release list.
|
|
1219
|
+
test("the manifest declares a DSH host range the market can read", () => {
|
|
1220
|
+
const range = declaredHostRange(REPO_ROOT);
|
|
1221
|
+
assert.equal(typeof range, "string", "engines.dsh must be declared; without it the market reports the plugin as unknown");
|
|
1222
|
+
// The string is pinned, not just parsed: the ceiling decides which hosts are
|
|
1223
|
+
// admitted, and `<0.3.0` vs `<0.3.0-0` is exactly the difference between
|
|
1224
|
+
// "no 0.3.0" and "0.3.0-alpha.1 is fine" once prereleases are included.
|
|
1225
|
+
assert.equal(range, ">=0.1.7-rc.2 <0.3.0-0");
|
|
1226
|
+
assert.throws(() => satisfiesDeclaredRange("0.2.0-rc.2", "not a range"), "the gate must reject an unparseable range");
|
|
1227
|
+
assert.doesNotThrow(() => satisfiesDeclaredRange("0.2.0-rc.2", range));
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
test("the release list this gate checks is the published one", () => {
|
|
1231
|
+
// Pinned literally: deleting an entry from the list would otherwise shrink the
|
|
1232
|
+
// coverage claim silently, and every test below would still pass.
|
|
1233
|
+
assert.deepEqual(HOST_RELEASES, [
|
|
1234
|
+
"0.2.1-rc.2",
|
|
1235
|
+
"0.2.1-rc.1",
|
|
1236
|
+
"0.2.1-alpha.5",
|
|
1237
|
+
"0.2.1-alpha.4",
|
|
1238
|
+
"0.2.1-alpha.3",
|
|
1239
|
+
"0.2.1-alpha.2",
|
|
1240
|
+
"0.2.1-alpha.1",
|
|
1241
|
+
"0.2.0-rc.2",
|
|
1242
|
+
"0.2.0-rc.1",
|
|
1243
|
+
"0.1.7-rc.2",
|
|
1244
|
+
]);
|
|
1245
|
+
assert.deepEqual(OUTSIDE_HOSTS, ["0.3.0", "0.3.0-rc.1", "0.3.0-alpha.1", "0.1.7-rc.1", "0.1.7-alpha.2"]);
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
test("every released DSH host is inside the declared range, and nothing untested is", () => {
|
|
1249
|
+
const range = declaredHostRange(REPO_ROOT);
|
|
1250
|
+
const { covered, uncovered, leaked } = checkHostCoverage(range);
|
|
1251
|
+
assert.deepEqual(uncovered, [], `these releases fall outside ${range}`);
|
|
1252
|
+
assert.deepEqual(leaked, [], `the declaration also admits ${leaked.join(", ")}, which nobody tested`);
|
|
1253
|
+
assert.deepEqual(covered, HOST_RELEASES, "every release in the list must be covered");
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
test("the host-range gate rejects a plausible but wrong declaration", () => {
|
|
1257
|
+
// Negative control: a gate that always passes proves nothing. `>=0.2.0` is the
|
|
1258
|
+
// shape someone writes after testing only on their own machine — and because a
|
|
1259
|
+
// prerelease sorts BELOW its release, it drops the whole 0.2.0-rc line as well
|
|
1260
|
+
// as 0.1.7. That is the trap this test exists for: the typo looks like it names
|
|
1261
|
+
// the running host.
|
|
1262
|
+
const drifted = checkHostCoverage(">=0.2.0 <0.3.0-0");
|
|
1263
|
+
assert.deepEqual(drifted.uncovered, ["0.2.0-rc.2", "0.2.0-rc.1", "0.1.7-rc.2"], "a 0.2.0 floor must be seen dropping the 0.2.0 prereleases");
|
|
1264
|
+
// The OTHER half of the gate needs its own control: a floor low enough to admit
|
|
1265
|
+
// a host nobody audited must be reported as a leak, not silently accepted.
|
|
1266
|
+
const loose = checkHostCoverage(">=0.1.0 <0.3.0-0");
|
|
1267
|
+
assert.deepEqual(loose.leaked, ["0.1.7-rc.1", "0.1.7-alpha.2"], "a low floor must be seen admitting the earlier prereleases");
|
|
1268
|
+
// And the ceiling is load bearing: the next minor line is a host nobody audited,
|
|
1269
|
+
// including its prereleases, which `<0.3.0` would have let through.
|
|
1270
|
+
assert.equal(satisfiesDeclaredRange("0.3.0", ">=0.1.7-rc.2 <0.3.0-0"), false);
|
|
1271
|
+
assert.equal(satisfiesDeclaredRange("0.3.0-rc.1", ">=0.1.7-rc.2 <0.3.0-0"), false);
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
test("prerelease ordering matches semver, not string order", () => {
|
|
1275
|
+
assert.equal(compareVersions("0.2.1-alpha.5", "0.2.1-rc.1"), -1);
|
|
1276
|
+
assert.equal(compareVersions("0.2.1-rc.2", "0.2.1"), -1);
|
|
1277
|
+
assert.equal(compareVersions("0.2.0-rc.2", "0.2.0-rc.1"), 1);
|
|
1278
|
+
// String comparison would sort "alpha.10" before "alpha.5"; semver does not.
|
|
1279
|
+
assert.equal(compareVersions("0.2.1-alpha.10", "0.2.1-alpha.5"), 1);
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
test("the evaluator reads the two tolerances semver does, and refuses the rest", () => {
|
|
1283
|
+
// Build metadata carries no precedence: a host reporting it is that version.
|
|
1284
|
+
assert.equal(satisfiesDeclaredRange("0.2.0-rc.2+build.7", ">=0.1.7-rc.2 <0.3.0-0"), true);
|
|
1285
|
+
// Everything else is refused rather than guessed at — a gate that accepts `v`
|
|
1286
|
+
// prefixes and zero-padded identifiers would also accept a typo.
|
|
1287
|
+
assert.throws(() => satisfiesDeclaredRange("v0.2.0-rc.2", ">=0.1.7-rc.2 <0.3.0-0"));
|
|
1288
|
+
assert.throws(() => satisfiesDeclaredRange("0.2.1-01", ">=0.1.7-rc.2 <0.3.0-0"));
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
/* ------------------------------------------------------- the launch decision */
|
|
1292
|
+
|
|
1293
|
+
// 2.x supplies a runtime the machine may not have; when it HAS one, the row must
|
|
1294
|
+
// use it — the 1.x contract. These tests own that decision, so every one of them
|
|
1295
|
+
// isolates PATH: without it the suite would silently depend on whatever `rea`
|
|
1296
|
+
// the machine running it happens to have, which is the one thing a test of a
|
|
1297
|
+
// machine-dependent decision must not do.
|
|
1298
|
+
|
|
1299
|
+
function writeFakeRea(dir, { version = "6.9.0", exitForVersion = 0, runExit = 0, record = null } = {}) {
|
|
1300
|
+
const file = path.join(dir, process.platform === "win32" ? "rea.cmd" : "rea");
|
|
1301
|
+
if (process.platform === "win32") {
|
|
1302
|
+
const lines = ["@echo off", 'if "%1"=="--version" (', ` echo ${version}`, ` exit /b ${exitForVersion}`, ")"];
|
|
1303
|
+
if (record !== null) lines.push(`echo %* > "${record}"`);
|
|
1304
|
+
lines.push(`exit /b ${runExit}`);
|
|
1305
|
+
writeFileSync(file, lines.join("\r\n") + "\r\n");
|
|
1306
|
+
} else {
|
|
1307
|
+
const lines = ["#!/bin/sh", `if [ "$1" = "--version" ]; then echo ${version}; exit ${exitForVersion}; fi`];
|
|
1308
|
+
if (record !== null) lines.push(`echo "$*" > "${record}"`);
|
|
1309
|
+
lines.push(`exit ${runExit}`);
|
|
1310
|
+
writeFileSync(file, lines.join("\n") + "\n");
|
|
1311
|
+
chmodSync(file, 0o755);
|
|
1312
|
+
}
|
|
1313
|
+
return file;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function runShimIsolated(env, args) {
|
|
1317
|
+
const merged = { ...process.env, ...env };
|
|
1318
|
+
// Windows keeps PATH and Path as separate variables (both are "in" the
|
|
1319
|
+
// environment), and a child that reads Path while the test set PATH would see
|
|
1320
|
+
// the machine's real search path instead of the fixture's.
|
|
1321
|
+
if (process.platform === "win32" && Object.prototype.hasOwnProperty.call(env, "PATH")) delete merged.Path;
|
|
1322
|
+
return spawnSync(process.execPath, [path.join(REPO_ROOT, "bin", "rea-shim.mjs"), ...args], {
|
|
1323
|
+
encoding: "utf8",
|
|
1324
|
+
timeout: 60000,
|
|
1325
|
+
env: merged,
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function launchFixture() {
|
|
1330
|
+
const root = mkdtempSync(path.join(tmpdir(), "rea-launch-"));
|
|
1331
|
+
const emptyPath = path.join(root, "empty-path");
|
|
1332
|
+
mkdirSync(emptyPath, { recursive: true });
|
|
1333
|
+
return {
|
|
1334
|
+
root,
|
|
1335
|
+
emptyPath,
|
|
1336
|
+
bundleRoot: path.join(root, "bundle"),
|
|
1337
|
+
bin: path.join(root, "bin"),
|
|
1338
|
+
record: path.join(root, "record.txt"),
|
|
1339
|
+
clean() {
|
|
1340
|
+
rmSync(root, { recursive: true, force: true });
|
|
1341
|
+
},
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
test("a machine with its own REA runs the 1.x way, and no bundle is required", () => {
|
|
1346
|
+
const f = launchFixture();
|
|
1347
|
+
mkdirSync(f.bin, { recursive: true });
|
|
1348
|
+
const rea = writeFakeRea(f.bin, { version: "6.9.0", record: f.record });
|
|
1349
|
+
try {
|
|
1350
|
+
const env = {
|
|
1351
|
+
PATH: f.emptyPath,
|
|
1352
|
+
REA_BUNDLE_SYSTEM_REA: rea,
|
|
1353
|
+
REA_BUNDLE_ROOT: f.bundleRoot,
|
|
1354
|
+
REA_BUNDLE_AUTO_PROVISION: "0",
|
|
1355
|
+
REA_BUNDLE_LAUNCH: "",
|
|
1356
|
+
};
|
|
1357
|
+
const status = runShimIsolated(env, ["--shim-status"]);
|
|
1358
|
+
const report = JSON.parse(status.stdout);
|
|
1359
|
+
assert.equal(status.status, 0, status.stderr);
|
|
1360
|
+
assert.equal(report.launch.mode, "system", JSON.stringify(report.launch));
|
|
1361
|
+
assert.equal(report.launch.systemRea.version, "6.9.0");
|
|
1362
|
+
// Unprovisioned and still healthy: the row does not depend on the bundle.
|
|
1363
|
+
assert.equal(report.provisioned, false);
|
|
1364
|
+
assert.equal(existsSync(path.join(f.bundleRoot, "bundle.json")), false, "no provisioning may be started");
|
|
1365
|
+
|
|
1366
|
+
const run = runShimIsolated(env, ["mcp"]);
|
|
1367
|
+
assert.equal(run.status, 0, run.stderr);
|
|
1368
|
+
assert.equal(readFileSync(f.record, "utf8").trim(), "mcp", "the system REA must receive the row's arguments");
|
|
1369
|
+
} finally {
|
|
1370
|
+
f.clean();
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
test("REA_BUNDLE_LAUNCH=bundle keeps the bundle path even with a REA on PATH", () => {
|
|
1375
|
+
const f = launchFixture();
|
|
1376
|
+
mkdirSync(f.bin, { recursive: true });
|
|
1377
|
+
const rea = writeFakeRea(f.bin);
|
|
1378
|
+
try {
|
|
1379
|
+
const run = runShimIsolated(
|
|
1380
|
+
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
|
|
1381
|
+
["--shim-status"],
|
|
1382
|
+
);
|
|
1383
|
+
const report = JSON.parse(run.stdout);
|
|
1384
|
+
assert.equal(report.launch.mode, "bundle");
|
|
1385
|
+
assert.equal(report.launch.reason, "pinned by REA_BUNDLE_LAUNCH=bundle");
|
|
1386
|
+
assert.equal(run.status, 78, "an unpinned bundle that is not provisioned still reports EX_CONFIG");
|
|
1387
|
+
} finally {
|
|
1388
|
+
f.clean();
|
|
1389
|
+
}
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
test("an older system REA does not displace a newer provisioned one unless pinned", () => {
|
|
1393
|
+
const f = launchFixture();
|
|
1394
|
+
mkdirSync(f.bin, { recursive: true });
|
|
1395
|
+
mkdirSync(f.bundleRoot, { recursive: true });
|
|
1396
|
+
const rea = writeFakeRea(f.bin, { version: "6.4.0" });
|
|
1397
|
+
try {
|
|
1398
|
+
writeFileSync(
|
|
1399
|
+
path.join(f.bundleRoot, "bundle.json"),
|
|
1400
|
+
JSON.stringify({ schema: 1, paths: { node: process.execPath }, versions: { reaAgents: "6.5.0" } }),
|
|
1401
|
+
);
|
|
1402
|
+
const base = { PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0" };
|
|
1403
|
+
const auto = JSON.parse(runShimIsolated({ ...base, REA_BUNDLE_LAUNCH: "" }, ["--shim-status"]).stdout);
|
|
1404
|
+
assert.equal(auto.launch.mode, "bundle");
|
|
1405
|
+
assert.match(auto.launch.reason, /older than the provisioned one/);
|
|
1406
|
+
const pinned = JSON.parse(runShimIsolated({ ...base, REA_BUNDLE_LAUNCH: "system" }, ["--shim-status"]).stdout);
|
|
1407
|
+
assert.equal(pinned.launch.mode, "system");
|
|
1408
|
+
assert.match(pinned.launch.reason, /pinned by REA_BUNDLE_LAUNCH=system/);
|
|
1409
|
+
} finally {
|
|
1410
|
+
f.clean();
|
|
1411
|
+
}
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
test("a candidate that cannot answer --version is rejected, not launched", () => {
|
|
1415
|
+
const f = launchFixture();
|
|
1416
|
+
mkdirSync(f.bin, { recursive: true });
|
|
1417
|
+
const rea = writeFakeRea(f.bin, { exitForVersion: 7 });
|
|
1418
|
+
try {
|
|
1419
|
+
const run = runShimIsolated(
|
|
1420
|
+
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" },
|
|
1421
|
+
["--shim-status"],
|
|
1422
|
+
);
|
|
1423
|
+
const report = JSON.parse(run.stdout);
|
|
1424
|
+
assert.equal(report.launch.mode, "bundle");
|
|
1425
|
+
assert.equal(report.launch.systemRea, null);
|
|
1426
|
+
assert.deepEqual(report.launch.rejected.map(r => r.reason), ["exit 7"]);
|
|
1427
|
+
assert.equal(run.status, 78);
|
|
1428
|
+
} finally {
|
|
1429
|
+
f.clean();
|
|
1430
|
+
}
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
test("REA_BUNDLE_LAUNCH=system refuses instead of provisioning", () => {
|
|
1434
|
+
const f = launchFixture();
|
|
1435
|
+
const missing = path.join(f.bin, "no-such-rea");
|
|
1436
|
+
try {
|
|
1437
|
+
const run = runShimIsolated(
|
|
1438
|
+
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: missing, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "1", REA_BUNDLE_LAUNCH: "system" },
|
|
1439
|
+
["mcp"],
|
|
1440
|
+
);
|
|
1441
|
+
assert.equal(run.status, 78, run.stdout);
|
|
1442
|
+
assert.match(run.stderr, /REA_BUNDLE_LAUNCH=system was requested/);
|
|
1443
|
+
assert.equal(existsSync(f.bundleRoot), false, "a pinned system launch must not create a bundle");
|
|
1444
|
+
} finally {
|
|
1445
|
+
f.clean();
|
|
1446
|
+
}
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
test("an unknown REA_BUNDLE_LAUNCH value is reported and treated as auto", () => {
|
|
1450
|
+
const f = launchFixture();
|
|
1451
|
+
mkdirSync(f.bin, { recursive: true });
|
|
1452
|
+
const rea = writeFakeRea(f.bin, { version: "6.9.0" });
|
|
1453
|
+
try {
|
|
1454
|
+
const run = runShimIsolated(
|
|
1455
|
+
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "nonsense" },
|
|
1456
|
+
["--shim-status"],
|
|
1457
|
+
);
|
|
1458
|
+
assert.match(run.stderr, /REA_BUNDLE_LAUNCH="nonsense" is not one of/);
|
|
1459
|
+
assert.equal(JSON.parse(run.stdout).launch.mode, "system");
|
|
1460
|
+
} finally {
|
|
1461
|
+
f.clean();
|
|
1462
|
+
}
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
test("the system REA is found through PATH itself, not only through the override", () => {
|
|
1466
|
+
const f = launchFixture();
|
|
1467
|
+
mkdirSync(f.bin, { recursive: true });
|
|
1468
|
+
writeFakeRea(f.bin, { version: "6.9.0" });
|
|
1469
|
+
try {
|
|
1470
|
+
const run = runShimIsolated(
|
|
1471
|
+
{ PATH: f.bin, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" },
|
|
1472
|
+
["--shim-status"],
|
|
1473
|
+
);
|
|
1474
|
+
const report = JSON.parse(run.stdout);
|
|
1475
|
+
assert.equal(report.launch.mode, "system", JSON.stringify(report.launch));
|
|
1476
|
+
assert.equal(report.launch.systemRea.version, "6.9.0");
|
|
1477
|
+
assert.equal(run.status, 0);
|
|
1478
|
+
} finally {
|
|
1479
|
+
f.clean();
|
|
1480
|
+
}
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
test("a REA installed under a path with a space is probed and launched", () => {
|
|
1484
|
+
const f = launchFixture();
|
|
1485
|
+
const spaced = path.join(f.root, "rea space");
|
|
1486
|
+
mkdirSync(spaced, { recursive: true });
|
|
1487
|
+
const rea = writeFakeRea(spaced, { version: "6.9.0", record: f.record });
|
|
1488
|
+
try {
|
|
1489
|
+
const env = { PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" };
|
|
1490
|
+
const status = JSON.parse(runShimIsolated(env, ["--shim-status"]).stdout);
|
|
1491
|
+
assert.equal(status.launch.mode, "system", JSON.stringify(status.launch));
|
|
1492
|
+
const run = runShimIsolated(env, ["mcp"]);
|
|
1493
|
+
assert.equal(run.status, 0, run.stderr);
|
|
1494
|
+
assert.equal(readFileSync(f.record, "utf8").trim(), "mcp");
|
|
1495
|
+
} finally {
|
|
1496
|
+
f.clean();
|
|
1497
|
+
}
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
test("the delegated process's exit status is the shim's exit status", () => {
|
|
1501
|
+
const f = launchFixture();
|
|
1502
|
+
mkdirSync(f.bin, { recursive: true });
|
|
1503
|
+
const rea = writeFakeRea(f.bin, { version: "6.9.0", runExit: 9 });
|
|
1504
|
+
try {
|
|
1505
|
+
const run = runShimIsolated(
|
|
1506
|
+
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" },
|
|
1507
|
+
["mcp"],
|
|
1508
|
+
);
|
|
1509
|
+
assert.equal(run.status, 9, `the row must see REA's own status, got ${run.status}`);
|
|
1510
|
+
} finally {
|
|
1511
|
+
f.clean();
|
|
1512
|
+
}
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
test("a REA inside the bundle is not treated as a system install", () => {
|
|
1516
|
+
const f = launchFixture();
|
|
1517
|
+
mkdirSync(f.bundleRoot, { recursive: true });
|
|
1518
|
+
const inside = writeFakeRea(f.bundleRoot, { version: "6.9.0" });
|
|
1519
|
+
try {
|
|
1520
|
+
const run = runShimIsolated(
|
|
1521
|
+
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: inside, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" },
|
|
1522
|
+
["--shim-status"],
|
|
1523
|
+
);
|
|
1524
|
+
const report = JSON.parse(run.stdout);
|
|
1525
|
+
assert.equal(report.launch.mode, "bundle");
|
|
1526
|
+
assert.equal(report.launch.systemRea, null);
|
|
1527
|
+
assert.deepEqual(report.launch.rejected.map(r => r.reason), ["inside the bundle"]);
|
|
1528
|
+
} finally {
|
|
1529
|
+
f.clean();
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
test("REA_BUNDLE_LAUNCH=bundle never probes the machine", () => {
|
|
1534
|
+
const f = launchFixture();
|
|
1535
|
+
mkdirSync(f.bin, { recursive: true });
|
|
1536
|
+
// Any invocation at all leaves the record behind — including --version, which
|
|
1537
|
+
// is exactly what `auto` would run.
|
|
1538
|
+
const rea = writeFakeRea(f.bin, { version: "6.9.0", record: f.record });
|
|
1539
|
+
try {
|
|
1540
|
+
runShimIsolated(
|
|
1541
|
+
{ PATH: f.bin, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
|
|
1542
|
+
["--shim-status"],
|
|
1543
|
+
);
|
|
1544
|
+
assert.equal(existsSync(f.record), false, "the pinned bundle path must not pay for a probe");
|
|
1545
|
+
} finally {
|
|
1546
|
+
f.clean();
|
|
1547
|
+
}
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
test("--shim-print-env reports the system launch instead of a child environment", () => {
|
|
1551
|
+
const f = launchFixture();
|
|
1552
|
+
mkdirSync(f.bin, { recursive: true });
|
|
1553
|
+
const rea = writeFakeRea(f.bin, { version: "6.9.0", record: f.record });
|
|
1554
|
+
try {
|
|
1555
|
+
const run = runShimIsolated(
|
|
1556
|
+
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" },
|
|
1557
|
+
["--shim-print-env"],
|
|
1558
|
+
);
|
|
1559
|
+
const printed = JSON.parse(run.stdout);
|
|
1560
|
+
assert.equal(printed.launch, "system");
|
|
1561
|
+
assert.equal(printed.version, "6.9.0");
|
|
1562
|
+
assert.equal(existsSync(f.record), false, "printing the environment must not start REA");
|
|
1563
|
+
} finally {
|
|
1564
|
+
f.clean();
|
|
1565
|
+
}
|
|
1566
|
+
});
|