@jam-mcp/server 1.4.1 → 1.4.3
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 +2 -2
- package/dist/bootstrap/host-mcp.d.ts +47 -6
- package/dist/bootstrap/host-mcp.js +113 -15
- package/dist/bootstrap/live-toolset.js +6 -2
- package/dist/bootstrap/mcp-config-merger.d.ts +1 -1
- package/dist/bootstrap/migration-target.js +6 -3
- package/dist/bootstrap/setup-plan.js +6 -2
- package/dist/bootstrap/setup-state.d.ts +6 -0
- package/dist/bootstrap/setup-state.js +7 -1
- package/dist/bootstrap/shell-command.d.ts +36 -0
- package/dist/bootstrap/shell-command.js +31 -0
- package/dist/cli/agent-api.js +19 -4
- package/dist/cli/setup.js +6 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -74,10 +74,10 @@ auth login Store Jira credentials in this user's OS secret store
|
|
|
74
74
|
runtime Show or change which JAM build this machine runs
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
-
Written out, that is `npx --yes @jam-mcp/launcher@1.4.
|
|
77
|
+
Written out, that is `npx --yes @jam-mcp/launcher@1.4.3 doctor`, or just `jam
|
|
78
78
|
doctor` if you took the launcher's optional global install. Starting from
|
|
79
79
|
nothing — no install, no runtime chosen yet — use
|
|
80
|
-
`npx --yes @jam-mcp/bootstrap@1.4.
|
|
80
|
+
`npx --yes @jam-mcp/bootstrap@1.4.3 init` instead.
|
|
81
81
|
|
|
82
82
|
Credentials come from the process environment or this user's OS secret store —
|
|
83
83
|
never from a repository file — and never appear in logs, telemetry, or tool
|
|
@@ -30,6 +30,8 @@ export type HostState = {
|
|
|
30
30
|
entryVersion?: string;
|
|
31
31
|
/** The entry is present but does not run the launcher this release registers. */
|
|
32
32
|
entryStale?: boolean;
|
|
33
|
+
/** The entry runs the global `jam` executable rather than an npx pin. */
|
|
34
|
+
entryBare?: boolean;
|
|
33
35
|
};
|
|
34
36
|
export type HostRunResult = {
|
|
35
37
|
status: number | null;
|
|
@@ -40,7 +42,19 @@ export type HostRunResult = {
|
|
|
40
42
|
/** Injected by tests. Nothing in this module may reach a real CLI unasked. */
|
|
41
43
|
export type HostRunner = (command: HostCommand) => HostRunResult;
|
|
42
44
|
export declare const defaultHostRunner: HostRunner;
|
|
43
|
-
|
|
45
|
+
/**
|
|
46
|
+
* A runner that only sees what is persistently installed on this machine.
|
|
47
|
+
*
|
|
48
|
+
* Under `npx --yes @jam-mcp/bootstrap@X`, PATH carries npx's cache
|
|
49
|
+
* `node_modules/.bin` - which contains a `jam` shim that vanishes when npx
|
|
50
|
+
* exits. Any measurement of "does a global jam exist" through the normal PATH
|
|
51
|
+
* therefore lies during bootstrap, which is exactly when the answer matters
|
|
52
|
+
* most. This runner strips those entries first.
|
|
53
|
+
*/
|
|
54
|
+
export declare const persistentHostRunner: HostRunner;
|
|
55
|
+
export declare function hostRegistration(id: HostId, options?: {
|
|
56
|
+
bare?: boolean;
|
|
57
|
+
}): HostCommand | undefined;
|
|
44
58
|
/** The removal that has to precede re-registering an entry this host already has. */
|
|
45
59
|
export declare function hostUnregistration(id: HostId): HostCommand | undefined;
|
|
46
60
|
/**
|
|
@@ -60,15 +74,42 @@ export declare function listsJamEntry(stdout: string): boolean;
|
|
|
60
74
|
export declare function jamEntryLine(stdout: string): string | null;
|
|
61
75
|
/** The launcher version a listing line runs, when the line names one. */
|
|
62
76
|
export declare function entryLauncherVersion(line: string): string | undefined;
|
|
77
|
+
/**
|
|
78
|
+
* Does this entry run the persistent `jam` executable rather than an npx pin?
|
|
79
|
+
*
|
|
80
|
+
* `jam: jam serve`, `jam: /usr/local/bin/jam serve`, `jam: C:\...\jam.cmd serve`
|
|
81
|
+
* all count; an npx line never does - its command token is `npx`. A bare entry
|
|
82
|
+
* carries no version in the listing, so its staleness has to be measured from
|
|
83
|
+
* the executable it would actually run (bareJamVersion), not from the line.
|
|
84
|
+
*/
|
|
85
|
+
export declare function isBareJamEntry(line: string): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* The version a bare `jam` registration actually runs, measured by asking the
|
|
88
|
+
* executable itself. `runtime status --json` answers from ~/.jam/config.yaml
|
|
89
|
+
* and the resolved build - the same resolution the registered entry performs.
|
|
90
|
+
*
|
|
91
|
+
* undefined when `jam` is not on PATH or does not answer: a registration that
|
|
92
|
+
* cannot be measured counts as stale, same as an unreadable pin.
|
|
93
|
+
*
|
|
94
|
+
* Measured through persistentHostRunner by default: under an npx bootstrap
|
|
95
|
+
* the ordinary PATH resolves `jam` to npx's own ephemeral cache shim, and a
|
|
96
|
+
* bare registration decided on that evidence dies as soon as npx exits.
|
|
97
|
+
*/
|
|
98
|
+
export declare function bareJamVersion(run?: HostRunner): string | undefined;
|
|
99
|
+
/** Is this measured launcher version the one this release registers? */
|
|
100
|
+
export declare function preferBareRegistration(version: string | undefined): boolean;
|
|
63
101
|
/**
|
|
64
102
|
* Does this entry run the launcher this release registers?
|
|
65
103
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
104
|
+
* An npx pin answers from the line itself. A bare `jam` line names no version,
|
|
105
|
+
* so the caller passes what the executable measured (`bareVersion`) - without
|
|
106
|
+
* it, bare stays stale. Anything else - an older pin, an unpinned spec, a line
|
|
107
|
+
* whose command JAM cannot read - counts as stale. That direction is
|
|
108
|
+
* deliberate: `mcp add` rewrites the same entry, so a needless repair costs
|
|
109
|
+
* one command, while a missed one leaves the agent talking to a server nobody
|
|
110
|
+
* tested it against.
|
|
70
111
|
*/
|
|
71
|
-
export declare function isEntryStale(line: string): boolean;
|
|
112
|
+
export declare function isEntryStale(line: string, bareVersion?: string): boolean;
|
|
72
113
|
/**
|
|
73
114
|
* Ask each host what it has, and whether it is there at all.
|
|
74
115
|
*
|
|
@@ -1,18 +1,43 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { JAM_MCP_ENTRY } from "./mcp-config-merger.js";
|
|
3
|
+
import { shellInvocation, stripPackageRunnerPath } from "./shell-command.js";
|
|
3
4
|
/**
|
|
4
5
|
* These boot a whole Node CLI, and Claude Code health-checks every configured
|
|
5
6
|
* server while listing, which is seconds rather than milliseconds.
|
|
6
7
|
*/
|
|
7
8
|
const HOST_TIMEOUT_MS = 20_000;
|
|
8
9
|
export const defaultHostRunner = ({ command, args }) => {
|
|
9
|
-
|
|
10
|
+
// Both CLIs are npm shims on Windows, and Node refuses to spawn a .cmd
|
|
11
|
+
// without a shell. Every argument JAM passes is a bare token - no JSON, no
|
|
12
|
+
// spaces - and shellInvocation validates exactly that before joining the
|
|
13
|
+
// argv into one line (an args array plus shell:true is DEP0190).
|
|
14
|
+
const invocation = shellInvocation(command, args);
|
|
15
|
+
const result = spawnSync(invocation.command, invocation.args, {
|
|
10
16
|
encoding: "utf8",
|
|
11
17
|
timeout: HOST_TIMEOUT_MS,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
shell: invocation.shell,
|
|
19
|
+
});
|
|
20
|
+
if (result.error)
|
|
21
|
+
return { status: null, failed: true, stdout: "" };
|
|
22
|
+
return { status: result.status, failed: false, stdout: result.stdout ?? "" };
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* A runner that only sees what is persistently installed on this machine.
|
|
26
|
+
*
|
|
27
|
+
* Under `npx --yes @jam-mcp/bootstrap@X`, PATH carries npx's cache
|
|
28
|
+
* `node_modules/.bin` - which contains a `jam` shim that vanishes when npx
|
|
29
|
+
* exits. Any measurement of "does a global jam exist" through the normal PATH
|
|
30
|
+
* therefore lies during bootstrap, which is exactly when the answer matters
|
|
31
|
+
* most. This runner strips those entries first.
|
|
32
|
+
*/
|
|
33
|
+
export const persistentHostRunner = ({ command, args }) => {
|
|
34
|
+
const invocation = shellInvocation(command, args);
|
|
35
|
+
const pathValue = process.env.PATH ?? process.env.Path ?? "";
|
|
36
|
+
const result = spawnSync(invocation.command, invocation.args, {
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
timeout: HOST_TIMEOUT_MS,
|
|
39
|
+
shell: invocation.shell,
|
|
40
|
+
env: { ...process.env, PATH: stripPackageRunnerPath(pathValue) },
|
|
16
41
|
});
|
|
17
42
|
if (result.error)
|
|
18
43
|
return { status: null, failed: true, stdout: "" };
|
|
@@ -23,6 +48,13 @@ export const defaultHostRunner = ({ command, args }) => {
|
|
|
23
48
|
* Windows shell, and every argument here is a bare token instead.
|
|
24
49
|
*/
|
|
25
50
|
const LAUNCH = ["--", JAM_MCP_ENTRY.command, ...JAM_MCP_ENTRY.args];
|
|
51
|
+
/**
|
|
52
|
+
* What a persistent install registers: the global `jam` executable, no
|
|
53
|
+
* package runner, no cache. Only offered when the measured global launcher
|
|
54
|
+
* is exactly this release (preferBareRegistration) - registering bare over
|
|
55
|
+
* an older global would silently downgrade the served toolset.
|
|
56
|
+
*/
|
|
57
|
+
const LAUNCH_BARE = ["--", "jam", "serve"];
|
|
26
58
|
const ADAPTERS = [
|
|
27
59
|
{
|
|
28
60
|
id: "claude-code",
|
|
@@ -39,8 +71,14 @@ const ADAPTERS = [
|
|
|
39
71
|
unregister: { command: "codex", args: ["mcp", "remove", "jam"] },
|
|
40
72
|
},
|
|
41
73
|
];
|
|
42
|
-
export function hostRegistration(id) {
|
|
43
|
-
|
|
74
|
+
export function hostRegistration(id, options = {}) {
|
|
75
|
+
const adapter = ADAPTERS.find((a) => a.id === id);
|
|
76
|
+
if (!adapter)
|
|
77
|
+
return undefined;
|
|
78
|
+
if (!options.bare)
|
|
79
|
+
return adapter.register;
|
|
80
|
+
const at = adapter.register.args.indexOf("--");
|
|
81
|
+
return { command: adapter.register.command, args: [...adapter.register.args.slice(0, at), ...LAUNCH_BARE] };
|
|
44
82
|
}
|
|
45
83
|
/** The removal that has to precede re-registering an entry this host already has. */
|
|
46
84
|
export function hostUnregistration(id) {
|
|
@@ -74,16 +112,64 @@ export function jamEntryLine(stdout) {
|
|
|
74
112
|
export function entryLauncherVersion(line) {
|
|
75
113
|
return /@jam-mcp\/launcher@([^\s"']+)/.exec(line)?.[1];
|
|
76
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Does this entry run the persistent `jam` executable rather than an npx pin?
|
|
117
|
+
*
|
|
118
|
+
* `jam: jam serve`, `jam: /usr/local/bin/jam serve`, `jam: C:\...\jam.cmd serve`
|
|
119
|
+
* all count; an npx line never does - its command token is `npx`. A bare entry
|
|
120
|
+
* carries no version in the listing, so its staleness has to be measured from
|
|
121
|
+
* the executable it would actually run (bareJamVersion), not from the line.
|
|
122
|
+
*/
|
|
123
|
+
export function isBareJamEntry(line) {
|
|
124
|
+
const command = line.replace(/^\s*jam\s*:?\s*/, "");
|
|
125
|
+
return /^(?:\S*[\\/])?jam(?:\.cmd|\.exe)?["']?\s+serve\b/i.test(command);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The version a bare `jam` registration actually runs, measured by asking the
|
|
129
|
+
* executable itself. `runtime status --json` answers from ~/.jam/config.yaml
|
|
130
|
+
* and the resolved build - the same resolution the registered entry performs.
|
|
131
|
+
*
|
|
132
|
+
* undefined when `jam` is not on PATH or does not answer: a registration that
|
|
133
|
+
* cannot be measured counts as stale, same as an unreadable pin.
|
|
134
|
+
*
|
|
135
|
+
* Measured through persistentHostRunner by default: under an npx bootstrap
|
|
136
|
+
* the ordinary PATH resolves `jam` to npx's own ephemeral cache shim, and a
|
|
137
|
+
* bare registration decided on that evidence dies as soon as npx exits.
|
|
138
|
+
*/
|
|
139
|
+
export function bareJamVersion(run = persistentHostRunner) {
|
|
140
|
+
const result = run({ command: "jam", args: ["runtime", "status", "--json"] });
|
|
141
|
+
if (result.failed || result.status !== 0)
|
|
142
|
+
return undefined;
|
|
143
|
+
try {
|
|
144
|
+
const version = JSON.parse(result.stdout).version;
|
|
145
|
+
return typeof version === "string" ? version : undefined;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Is this measured launcher version the one this release registers? */
|
|
152
|
+
export function preferBareRegistration(version) {
|
|
153
|
+
return version !== undefined && version === EXPECTED_LAUNCHER_VERSION;
|
|
154
|
+
}
|
|
77
155
|
/**
|
|
78
156
|
* Does this entry run the launcher this release registers?
|
|
79
157
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
158
|
+
* An npx pin answers from the line itself. A bare `jam` line names no version,
|
|
159
|
+
* so the caller passes what the executable measured (`bareVersion`) - without
|
|
160
|
+
* it, bare stays stale. Anything else - an older pin, an unpinned spec, a line
|
|
161
|
+
* whose command JAM cannot read - counts as stale. That direction is
|
|
162
|
+
* deliberate: `mcp add` rewrites the same entry, so a needless repair costs
|
|
163
|
+
* one command, while a missed one leaves the agent talking to a server nobody
|
|
164
|
+
* tested it against.
|
|
84
165
|
*/
|
|
85
|
-
export function isEntryStale(line) {
|
|
86
|
-
|
|
166
|
+
export function isEntryStale(line, bareVersion) {
|
|
167
|
+
const pinned = entryLauncherVersion(line);
|
|
168
|
+
if (pinned !== undefined)
|
|
169
|
+
return pinned !== EXPECTED_LAUNCHER_VERSION;
|
|
170
|
+
if (isBareJamEntry(line))
|
|
171
|
+
return bareVersion !== EXPECTED_LAUNCHER_VERSION;
|
|
172
|
+
return true;
|
|
87
173
|
}
|
|
88
174
|
const EXPECTED_LAUNCHER_VERSION = entryLauncherVersion(JAM_MCP_ENTRY.args.join(" "));
|
|
89
175
|
/**
|
|
@@ -107,6 +193,7 @@ export function detectHosts(run = defaultHostRunner) {
|
|
|
107
193
|
}
|
|
108
194
|
let cachedHosts;
|
|
109
195
|
function probeHosts(run) {
|
|
196
|
+
let bareCache;
|
|
110
197
|
return ADAPTERS.map((adapter) => {
|
|
111
198
|
const result = run(adapter.probe);
|
|
112
199
|
if (result.failed || result.status !== 0) {
|
|
@@ -115,15 +202,26 @@ function probeHosts(run) {
|
|
|
115
202
|
const line = jamEntryLine(result.stdout);
|
|
116
203
|
if (!line)
|
|
117
204
|
return { id: adapter.id, cliAvailable: true, hasJamEntry: false };
|
|
118
|
-
|
|
205
|
+
// A bare entry's version lives in the executable, not the line - measure
|
|
206
|
+
// it once, only when a bare entry actually shows up.
|
|
207
|
+
const bare = isBareJamEntry(line);
|
|
208
|
+
const version = entryLauncherVersion(line) ?? (bare ? measuredBare() : undefined);
|
|
119
209
|
return {
|
|
120
210
|
id: adapter.id,
|
|
121
211
|
cliAvailable: true,
|
|
122
212
|
hasJamEntry: true,
|
|
123
213
|
...(version ? { entryVersion: version } : {}),
|
|
124
|
-
|
|
214
|
+
...(bare ? { entryBare: true } : {}),
|
|
215
|
+
entryStale: isEntryStale(line, version),
|
|
125
216
|
};
|
|
126
217
|
});
|
|
218
|
+
function measuredBare() {
|
|
219
|
+
// The real probe runner sees npx's contaminated PATH; the persistent
|
|
220
|
+
// runner is the honest one for this question. An injected runner is a
|
|
221
|
+
// test, and stays in charge of its own answers.
|
|
222
|
+
bareCache ??= { version: bareJamVersion(run === defaultHostRunner ? persistentHostRunner : run) };
|
|
223
|
+
return bareCache.version;
|
|
224
|
+
}
|
|
127
225
|
}
|
|
128
226
|
/** How a person would do it by hand, for the hosts JAM could not reach. */
|
|
129
227
|
export function describeHostCommand({ command, args }) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { shellInvocation } from "./shell-command.js";
|
|
2
3
|
import { SERVER_VERSION } from "@jam-mcp/launcher";
|
|
3
4
|
import { TOOL_NAMES } from "../mcp/create-server.js";
|
|
4
5
|
const HANDSHAKE_TIMEOUT_MS = 30_000;
|
|
@@ -9,9 +10,12 @@ export const expectedTools = () => [...TOOL_NAMES].sort();
|
|
|
9
10
|
* one question that is three lines of JSON.
|
|
10
11
|
*/
|
|
11
12
|
export const defaultToolsetProbe = ({ command, args }) => new Promise((resolve) => {
|
|
12
|
-
|
|
13
|
+
// npm shims need a shell on Windows; the argv is joined by shellInvocation
|
|
14
|
+
// (validated bare tokens) because an args array plus shell:true is DEP0190.
|
|
15
|
+
const invocation = shellInvocation(command, args);
|
|
16
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
13
17
|
stdio: ["pipe", "pipe", "ignore"],
|
|
14
|
-
shell:
|
|
18
|
+
shell: invocation.shell,
|
|
15
19
|
});
|
|
16
20
|
let buffer = "";
|
|
17
21
|
let settled = false;
|
|
@@ -13,7 +13,7 @@ import { LAUNCHER_PACKAGE_SPEC } from "@jam-mcp/launcher";
|
|
|
13
13
|
export { LAUNCHER_PACKAGE_SPEC };
|
|
14
14
|
export declare const JAM_MCP_ENTRY: {
|
|
15
15
|
readonly command: "npx";
|
|
16
|
-
readonly args: readonly ["--yes", "@jam-mcp/launcher@1.4.
|
|
16
|
+
readonly args: readonly ["--yes", "@jam-mcp/launcher@1.4.3", "serve"];
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
19
|
* Recognise wiring from before the launcher existed: a hard-coded `node` path
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { shellInvocation } from "./shell-command.js";
|
|
2
3
|
import { LAUNCHER_PACKAGE_SPEC } from "./mcp-config-merger.js";
|
|
3
4
|
import { computeSetupPlan } from "./setup-plan.js";
|
|
4
5
|
const PROBE_TIMEOUT_MS = 10_000;
|
|
5
6
|
function runNpm(command, args) {
|
|
6
|
-
|
|
7
|
+
// npm on Windows is a shell script, not an executable; shellInvocation
|
|
8
|
+
// joins the validated argv because an args array plus shell:true is DEP0190.
|
|
9
|
+
const invocation = shellInvocation(command, args);
|
|
10
|
+
const result = spawnSync(invocation.command, invocation.args, {
|
|
7
11
|
encoding: "utf8",
|
|
8
12
|
timeout: PROBE_TIMEOUT_MS,
|
|
9
|
-
|
|
10
|
-
shell: process.platform === "win32",
|
|
13
|
+
shell: invocation.shell,
|
|
11
14
|
});
|
|
12
15
|
return {
|
|
13
16
|
status: result.status,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { CONFIG_RELATIVE_PATH } from "../config/load-config.js";
|
|
3
|
-
import { hostRegistration, hostUnregistration } from "./host-mcp.js";
|
|
3
|
+
import { hostRegistration, preferBareRegistration, hostUnregistration } from "./host-mcp.js";
|
|
4
4
|
import { projectBindingsPath } from "./project-bindings.js";
|
|
5
5
|
import { decideProjectKey } from "./project-config-bootstrapper.js";
|
|
6
6
|
import { portableBootstrapCommand } from "@jam-mcp/launcher";
|
|
@@ -161,7 +161,11 @@ function planHostChanges(state) {
|
|
|
161
161
|
// that pin decides which server, and so which tools, the agent actually gets.
|
|
162
162
|
if (host.hasJamEntry && host.entryStale !== true)
|
|
163
163
|
continue;
|
|
164
|
-
|
|
164
|
+
// A machine whose global `jam` already runs this release gets the
|
|
165
|
+
// persistent registration; anything else keeps the npx pin fallback.
|
|
166
|
+
const registration = hostRegistration(host.id, {
|
|
167
|
+
bare: preferBareRegistration(state.bareLauncher),
|
|
168
|
+
});
|
|
165
169
|
if (!registration)
|
|
166
170
|
continue;
|
|
167
171
|
const removal = hostUnregistration(host.id);
|
|
@@ -50,6 +50,12 @@ export type SetupState = {
|
|
|
50
50
|
* host, which `jam doctor` has no reason to pay.
|
|
51
51
|
*/
|
|
52
52
|
hosts: HostState[];
|
|
53
|
+
/**
|
|
54
|
+
* The version the global `jam` executable actually runs, when one answers.
|
|
55
|
+
* Measured with the hosts (same probe budget); undefined otherwise. This is
|
|
56
|
+
* what decides whether a repair registers bare `jam` or an npx pin.
|
|
57
|
+
*/
|
|
58
|
+
bareLauncher?: string;
|
|
53
59
|
};
|
|
54
60
|
export type DetectOptions = {
|
|
55
61
|
cwd?: string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readRuntimeConfig, resolveRuntime, } from "@jam-mcp/launcher";
|
|
2
2
|
import { CompositeCredentialProvider } from "../adapters/credentials/composite.js";
|
|
3
3
|
import { loadConfig } from "../config/load-config.js";
|
|
4
|
-
import { detectHosts } from "./host-mcp.js";
|
|
4
|
+
import { bareJamVersion, detectHosts } from "./host-mcp.js";
|
|
5
5
|
import { inspectMcpConfig, isLegacyJamEntry } from "./mcp-config-merger.js";
|
|
6
6
|
import { inspectProjectBindings } from "./project-bindings.js";
|
|
7
7
|
import { resolveProjectRoot } from "./project-root-resolver.js";
|
|
@@ -31,6 +31,12 @@ export function detectSetupState(options = {}) {
|
|
|
31
31
|
project: { ...detectProject(located), ...(binding ? { binding } : {}) },
|
|
32
32
|
mcp: detectMcp(located.root),
|
|
33
33
|
hosts: options.probeHosts ? detectHosts(options.runHost) : [],
|
|
34
|
+
...(options.probeHosts
|
|
35
|
+
? (() => {
|
|
36
|
+
const measured = bareJamVersion(options.runHost);
|
|
37
|
+
return measured !== undefined ? { bareLauncher: measured } : {};
|
|
38
|
+
})()
|
|
39
|
+
: {}),
|
|
34
40
|
};
|
|
35
41
|
}
|
|
36
42
|
function detectRuntime(home) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How to hand an npm-shim CLI to spawnSync without tripping DEP0190.
|
|
3
|
+
*
|
|
4
|
+
* npm-installed CLIs (`claude`, `codex`, `npm`, `npx`, `jam`) are `.cmd`
|
|
5
|
+
* shims on Windows, and Node only runs those through a shell. But passing an
|
|
6
|
+
* args ARRAY together with `shell: true` is deprecated (DEP0190): Node
|
|
7
|
+
* concatenates the arguments without escaping, so the array form is an
|
|
8
|
+
* illusion of safety. On Windows the argv is therefore joined into a single
|
|
9
|
+
* command line HERE, deliberately - and every token is validated bare first,
|
|
10
|
+
* so the join cannot become a quoting hazard. A token that would need cmd.exe
|
|
11
|
+
* quoting is refused outright rather than escaped: nothing JAM runs ever
|
|
12
|
+
* carries one, so meeting one means the input is not ours to guess about.
|
|
13
|
+
*
|
|
14
|
+
* On every other platform the array form without a shell is correct and
|
|
15
|
+
* unchanged.
|
|
16
|
+
*/
|
|
17
|
+
export type ShellInvocation = {
|
|
18
|
+
command: string;
|
|
19
|
+
args: string[];
|
|
20
|
+
shell: boolean;
|
|
21
|
+
};
|
|
22
|
+
export declare function shellInvocation(command: string, args: readonly string[], platform?: NodeJS.Platform): ShellInvocation;
|
|
23
|
+
/**
|
|
24
|
+
* PATH with package-runner injections removed.
|
|
25
|
+
*
|
|
26
|
+
* `npx --yes @jam-mcp/bootstrap@X` prepends its cache's `node_modules/.bin`
|
|
27
|
+
* to PATH, and that directory contains a `jam` shim - so inside a bootstrap
|
|
28
|
+
* run, `jam` resolves even on a machine where nothing is installed. Measuring
|
|
29
|
+
* "does this machine have a persistent jam" through that PATH answered yes on
|
|
30
|
+
* every fresh machine, and setup then registered a bare `jam serve` that died
|
|
31
|
+
* the moment npx's directory evaporated (the v1.4.2 fresh-install field
|
|
32
|
+
* failure). A persistent install lives in npm's global bin, never under a
|
|
33
|
+
* `node_modules` or `_npx` directory, so those entries are dropped before the
|
|
34
|
+
* measurement.
|
|
35
|
+
*/
|
|
36
|
+
export declare function stripPackageRunnerPath(pathValue: string, separator?: string): string;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { delimiter } from "node:path";
|
|
2
|
+
/** Anything cmd.exe would re-interpret, plus whitespace and quotes. */
|
|
3
|
+
const CMD_UNSAFE = /[\s&|<>^"'`;()]/;
|
|
4
|
+
export function shellInvocation(command, args, platform = process.platform) {
|
|
5
|
+
if (platform !== "win32")
|
|
6
|
+
return { command, args: [...args], shell: false };
|
|
7
|
+
const unsafe = [command, ...args].find((token) => token === "" || CMD_UNSAFE.test(token));
|
|
8
|
+
if (unsafe !== undefined) {
|
|
9
|
+
throw new Error(`refusing to pass a token through cmd.exe unquoted: ${JSON.stringify(unsafe)}`);
|
|
10
|
+
}
|
|
11
|
+
return { command: [command, ...args].join(" "), args: [], shell: true };
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* PATH with package-runner injections removed.
|
|
15
|
+
*
|
|
16
|
+
* `npx --yes @jam-mcp/bootstrap@X` prepends its cache's `node_modules/.bin`
|
|
17
|
+
* to PATH, and that directory contains a `jam` shim - so inside a bootstrap
|
|
18
|
+
* run, `jam` resolves even on a machine where nothing is installed. Measuring
|
|
19
|
+
* "does this machine have a persistent jam" through that PATH answered yes on
|
|
20
|
+
* every fresh machine, and setup then registered a bare `jam serve` that died
|
|
21
|
+
* the moment npx's directory evaporated (the v1.4.2 fresh-install field
|
|
22
|
+
* failure). A persistent install lives in npm's global bin, never under a
|
|
23
|
+
* `node_modules` or `_npx` directory, so those entries are dropped before the
|
|
24
|
+
* measurement.
|
|
25
|
+
*/
|
|
26
|
+
export function stripPackageRunnerPath(pathValue, separator = delimiter) {
|
|
27
|
+
return pathValue
|
|
28
|
+
.split(separator)
|
|
29
|
+
.filter((entry) => !/[\\/]node_modules[\\/]|[\\/]_npx[\\/]/.test(`${entry}/`.replace(/[\\/]+$/, "/")))
|
|
30
|
+
.join(separator);
|
|
31
|
+
}
|
package/dist/cli/agent-api.js
CHANGED
|
@@ -69,11 +69,23 @@ export async function setupApplyCommand(options = {}) {
|
|
|
69
69
|
...(options.home ? { home: options.home } : {}),
|
|
70
70
|
...(options.runHost ? { runHost: options.runHost } : {}),
|
|
71
71
|
});
|
|
72
|
-
emitJson({
|
|
72
|
+
emitJson({
|
|
73
|
+
...plan,
|
|
74
|
+
status: applyStatus(plan, result.changesApplied),
|
|
75
|
+
changesApplied: result.changesApplied,
|
|
76
|
+
});
|
|
73
77
|
return plan.requiresUserAction ? 1 : 0;
|
|
74
78
|
}
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
/**
|
|
80
|
+
* The status of an apply, after it ran. "already_configured" means nothing
|
|
81
|
+
* was executed; when changes did run the answer is "applied" - reporting
|
|
82
|
+
* "already_configured" alongside changesApplied:true made the two fields
|
|
83
|
+
* contradict each other, and an agent could believe either one.
|
|
84
|
+
*/
|
|
85
|
+
function applyStatus(plan, changesApplied) {
|
|
86
|
+
if (plan.requiresUserAction)
|
|
87
|
+
return "user_action_required";
|
|
88
|
+
return changesApplied ? "applied" : "already_configured";
|
|
77
89
|
}
|
|
78
90
|
/**
|
|
79
91
|
* `jam setup --agent` - one shot: detect, plan, apply what is safe, verify.
|
|
@@ -161,7 +173,10 @@ async function inspectAxes(state, options) {
|
|
|
161
173
|
// and asking it means launching that older release. Repair first.
|
|
162
174
|
if (registered.entryStale)
|
|
163
175
|
return axes;
|
|
164
|
-
|
|
176
|
+
// Launch what is actually registered: a bare entry runs the global `jam`,
|
|
177
|
+
// an npx pin runs the pinned launcher. Testing the other one would prove
|
|
178
|
+
// nothing about the entry the agent uses.
|
|
179
|
+
const registration = hostRegistration(registered.id, { bare: registered.entryBare === true });
|
|
165
180
|
const launch = registration ? launcherArgv(registration.args) : null;
|
|
166
181
|
if (!launch)
|
|
167
182
|
return { ...axes, live: "UNCHECKED", detail: "could not read the registered command" };
|
package/dist/cli/setup.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { shellInvocation } from "../bootstrap/shell-command.js";
|
|
2
3
|
import { existsSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { runHealthGate } from "../bootstrap/boot-health-gate.js";
|
|
@@ -178,10 +179,13 @@ async function installAndBuild(root) {
|
|
|
178
179
|
{ name: "Build", args: ["run", "build"] },
|
|
179
180
|
]) {
|
|
180
181
|
line(`\n> npm ${step.args.join(" ")}`);
|
|
181
|
-
|
|
182
|
+
// npm is a .cmd shim on Windows; shellInvocation joins the validated argv
|
|
183
|
+
// because an args array plus shell:true is DEP0190.
|
|
184
|
+
const invocation = shellInvocation("npm", step.args);
|
|
185
|
+
const res = spawnSync(invocation.command, invocation.args, {
|
|
182
186
|
cwd: root,
|
|
183
187
|
stdio: "inherit",
|
|
184
|
-
shell:
|
|
188
|
+
shell: invocation.shell,
|
|
185
189
|
});
|
|
186
190
|
if (res.status !== 0) {
|
|
187
191
|
line(`[FAIL] ${step.name}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jam-mcp/server",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.3",
|
|
4
4
|
"description": "JAM (Jira Agent MCP) - agent-facing Jira access layer: MCP server, setup core, and CLI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jira",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"test:watch": "vitest"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@jam-mcp/launcher": "1.4.
|
|
44
|
+
"@jam-mcp/launcher": "1.4.3",
|
|
45
45
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
46
46
|
"yaml": "^2.9.0",
|
|
47
47
|
"zod": "^4.4.3"
|