@jam-mcp/server 1.4.2 → 1.4.4
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/adapters/credentials/composite.js +9 -0
- package/dist/bootstrap/host-mcp.d.ts +14 -0
- package/dist/bootstrap/host-mcp.js +39 -7
- 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.d.ts +17 -3
- package/dist/bootstrap/setup-plan.js +39 -3
- package/dist/bootstrap/setup-state.d.ts +3 -1
- package/dist/bootstrap/setup-state.js +2 -0
- package/dist/bootstrap/shell-command.d.ts +36 -0
- package/dist/bootstrap/shell-command.js +31 -0
- package/dist/cli/agent-api.js +82 -5
- package/dist/cli/auth.js +18 -1
- package/dist/cli/setup.js +18 -3
- package/dist/ports/credentials.port.d.ts +7 -0
- 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.4 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.4 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
|
|
@@ -74,6 +74,15 @@ export class CompositeCredentialProvider {
|
|
|
74
74
|
description.baseUrl = values.JIRA_BASE_URL;
|
|
75
75
|
if (values.JIRA_EMAIL)
|
|
76
76
|
description.email = values.JIRA_EMAIL;
|
|
77
|
+
// Per-field provenance, so "mixed" can be read rather than guessed at.
|
|
78
|
+
const sources = {};
|
|
79
|
+
for (const field of ["JIRA_BASE_URL", "JIRA_EMAIL", "JIRA_API_TOKEN"]) {
|
|
80
|
+
const from = sourceByKey[field];
|
|
81
|
+
if (from)
|
|
82
|
+
sources[field] = from;
|
|
83
|
+
}
|
|
84
|
+
if (Object.keys(sources).length > 0)
|
|
85
|
+
description.sources = sources;
|
|
77
86
|
return description;
|
|
78
87
|
}
|
|
79
88
|
}
|
|
@@ -42,6 +42,16 @@ export type HostRunResult = {
|
|
|
42
42
|
/** Injected by tests. Nothing in this module may reach a real CLI unasked. */
|
|
43
43
|
export type HostRunner = (command: HostCommand) => HostRunResult;
|
|
44
44
|
export declare const defaultHostRunner: HostRunner;
|
|
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;
|
|
45
55
|
export declare function hostRegistration(id: HostId, options?: {
|
|
46
56
|
bare?: boolean;
|
|
47
57
|
}): HostCommand | undefined;
|
|
@@ -80,6 +90,10 @@ export declare function isBareJamEntry(line: string): boolean;
|
|
|
80
90
|
*
|
|
81
91
|
* undefined when `jam` is not on PATH or does not answer: a registration that
|
|
82
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.
|
|
83
97
|
*/
|
|
84
98
|
export declare function bareJamVersion(run?: HostRunner): string | undefined;
|
|
85
99
|
/** Is this measured launcher version the one this release registers? */
|
|
@@ -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: "" };
|
|
@@ -106,8 +131,12 @@ export function isBareJamEntry(line) {
|
|
|
106
131
|
*
|
|
107
132
|
* undefined when `jam` is not on PATH or does not answer: a registration that
|
|
108
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.
|
|
109
138
|
*/
|
|
110
|
-
export function bareJamVersion(run =
|
|
139
|
+
export function bareJamVersion(run = persistentHostRunner) {
|
|
111
140
|
const result = run({ command: "jam", args: ["runtime", "status", "--json"] });
|
|
112
141
|
if (result.failed || result.status !== 0)
|
|
113
142
|
return undefined;
|
|
@@ -187,7 +216,10 @@ function probeHosts(run) {
|
|
|
187
216
|
};
|
|
188
217
|
});
|
|
189
218
|
function measuredBare() {
|
|
190
|
-
|
|
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) };
|
|
191
223
|
return bareCache.version;
|
|
192
224
|
}
|
|
193
225
|
}
|
|
@@ -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.4", "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,
|
|
@@ -3,13 +3,23 @@ import type { MigrationTarget } from "./migration-target.js";
|
|
|
3
3
|
import { type BootstrapSource } from "./project-config-bootstrapper.js";
|
|
4
4
|
import type { SetupState } from "./setup-state.js";
|
|
5
5
|
export type SetupStatus = "already_configured" | "ready_to_apply" | "user_action_required";
|
|
6
|
-
export type SetupCode = "JAM_PROJECT_SELECTION_REQUIRED" | "JAM_BINDINGS_UNREADABLE" | "JAM_AUTH_REQUIRED" | "JAM_RUNTIME_CONFIG_MISSING" | "JAM_PROJECT_CONFIG_INVALID" | "JAM_MCP_CONFIG_UNREADABLE" | "JAM_MIGRATION_TARGET_UNAVAILABLE";
|
|
6
|
+
export type SetupCode = "JAM_PROJECT_SELECTION_REQUIRED" | "JAM_BINDINGS_UNREADABLE" | "JAM_AUTH_REQUIRED" | "JAM_RUNTIME_CONFIG_MISSING" | "JAM_PROJECT_CONFIG_INVALID" | "JAM_MCP_CONFIG_UNREADABLE" | "JAM_MIGRATION_TARGET_UNAVAILABLE" | "JAM_PROJECT_KEY_CONFLICT";
|
|
7
|
+
/**
|
|
8
|
+
* Where a project key came from. `repository` is the team's committed
|
|
9
|
+
* `.jira-agent/project.yaml`; the rest are this user's own settings, in the
|
|
10
|
+
* precedence order `decideProjectKey` applies.
|
|
11
|
+
*/
|
|
12
|
+
export type KeySource = BootstrapSource | "repository";
|
|
13
|
+
export type KeyOrigin = {
|
|
14
|
+
key: string;
|
|
15
|
+
source: KeySource;
|
|
16
|
+
};
|
|
7
17
|
export type SetupChange = {
|
|
8
18
|
type: "create";
|
|
9
19
|
target: "project-config";
|
|
10
20
|
path: string;
|
|
11
21
|
key: string;
|
|
12
|
-
keySource:
|
|
22
|
+
keySource: KeySource;
|
|
13
23
|
} | {
|
|
14
24
|
type: "create";
|
|
15
25
|
target: "mcp-config";
|
|
@@ -30,7 +40,7 @@ export type SetupChange = {
|
|
|
30
40
|
path: string;
|
|
31
41
|
workspaceId: string;
|
|
32
42
|
key: string;
|
|
33
|
-
keySource:
|
|
43
|
+
keySource: KeySource;
|
|
34
44
|
/** Present on a rebind, so the preview shows what is being replaced. */
|
|
35
45
|
previousKey?: string;
|
|
36
46
|
} | {
|
|
@@ -95,7 +105,11 @@ export type SetupPlan = {
|
|
|
95
105
|
project?: {
|
|
96
106
|
root: string;
|
|
97
107
|
key?: string;
|
|
108
|
+
keySource?: KeySource;
|
|
98
109
|
};
|
|
110
|
+
/** On JAM_PROJECT_KEY_CONFLICT: what was asked for, and what already stands. */
|
|
111
|
+
requested?: KeyOrigin;
|
|
112
|
+
existing?: KeyOrigin;
|
|
99
113
|
};
|
|
100
114
|
export type PlanOptions = {
|
|
101
115
|
/**
|
|
@@ -52,7 +52,23 @@ export function computeSetupPlan(state, options = {}) {
|
|
|
52
52
|
project: { root: state.project.root },
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
// The repository's committed key is the team's answer. When a personal
|
|
56
|
+
// `--project` disagrees with it, neither side may win silently: overwriting
|
|
57
|
+
// the repository is not setup's call, and quietly using the repository key
|
|
58
|
+
// makes the flag a lie. Stop, and name both sources.
|
|
59
|
+
const conflict = keyConflict(state, options, key);
|
|
60
|
+
if (conflict) {
|
|
61
|
+
return {
|
|
62
|
+
status: "user_action_required",
|
|
63
|
+
code: "JAM_PROJECT_KEY_CONFLICT",
|
|
64
|
+
changes: [],
|
|
65
|
+
requiresUserAction: true,
|
|
66
|
+
requested: conflict.requested,
|
|
67
|
+
existing: conflict.existing,
|
|
68
|
+
project: { root: state.project.root, key: conflict.existing.key, keySource: conflict.existing.source },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const project = { root: state.project.root, key: key.key, keySource: key.source };
|
|
56
72
|
if (!shared) {
|
|
57
73
|
// Personal scope: the record of "this workspace is that Jira project"
|
|
58
74
|
// lives with the user, and nothing in the repository is touched.
|
|
@@ -207,9 +223,11 @@ function planBindingChange(state, key) {
|
|
|
207
223
|
}
|
|
208
224
|
function resolveKey(state, options) {
|
|
209
225
|
// An existing project.yaml wins: setup must never silently repoint a project,
|
|
210
|
-
// and a personal note must never override what the team committed.
|
|
226
|
+
// and a personal note must never override what the team committed. It is
|
|
227
|
+
// labelled `repository` rather than `explicit` - a reader has to be able to
|
|
228
|
+
// tell the team's committed answer from what someone typed.
|
|
211
229
|
if (state.project.key)
|
|
212
|
-
return { key: state.project.key, source: "
|
|
230
|
+
return { key: state.project.key, source: "repository" };
|
|
213
231
|
const decideOptions = {};
|
|
214
232
|
if (options.explicitKey)
|
|
215
233
|
decideOptions.explicitKey = options.explicitKey;
|
|
@@ -221,6 +239,24 @@ function resolveKey(state, options) {
|
|
|
221
239
|
decideOptions.presetsPath = options.presetsPath;
|
|
222
240
|
return decideProjectKey(state.project.root, decideOptions);
|
|
223
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* A repository key and an explicit `--project` that disagree. Personal
|
|
244
|
+
* sources are not conflicts: explicit already beats them in decideProjectKey,
|
|
245
|
+
* and a stale binding is repaired rather than reported.
|
|
246
|
+
*/
|
|
247
|
+
function keyConflict(state, options, resolved) {
|
|
248
|
+
const explicit = options.explicitKey?.trim();
|
|
249
|
+
if (!explicit)
|
|
250
|
+
return undefined;
|
|
251
|
+
const repository = state.project.key;
|
|
252
|
+
if (!repository || repository === explicit)
|
|
253
|
+
return undefined;
|
|
254
|
+
void resolved;
|
|
255
|
+
return {
|
|
256
|
+
requested: { key: explicit, source: "explicit" },
|
|
257
|
+
existing: { key: repository, source: "repository" },
|
|
258
|
+
};
|
|
259
|
+
}
|
|
224
260
|
function planMcpChange(state, options) {
|
|
225
261
|
if (!state.mcp.exists) {
|
|
226
262
|
return { type: "create", target: "mcp-config", path: state.mcp.path };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type RuntimeMode } from "@jam-mcp/launcher";
|
|
2
|
-
import type { CredentialPort, CredentialSource } from "../ports/credentials.port.js";
|
|
2
|
+
import type { CredentialDescription, CredentialPort, CredentialSource } from "../ports/credentials.port.js";
|
|
3
3
|
import { type HostRunner, type HostState } from "./host-mcp.js";
|
|
4
4
|
import { type McpInspection } from "./mcp-config-merger.js";
|
|
5
5
|
import { type ProjectBinding } from "./project-bindings.js";
|
|
@@ -15,6 +15,8 @@ export type RuntimeState = {
|
|
|
15
15
|
export type CredentialState = {
|
|
16
16
|
present: boolean;
|
|
17
17
|
source: CredentialSource;
|
|
18
|
+
/** Which source supplied each field. Names only - never a value. */
|
|
19
|
+
sources?: CredentialDescription["sources"];
|
|
18
20
|
baseUrl?: string;
|
|
19
21
|
email?: string;
|
|
20
22
|
};
|
|
@@ -61,6 +61,8 @@ function detectCredentials(credentials) {
|
|
|
61
61
|
source: described.source,
|
|
62
62
|
};
|
|
63
63
|
// Presence and origin only - the token value never enters this snapshot.
|
|
64
|
+
if (described.sources)
|
|
65
|
+
state.sources = described.sources;
|
|
64
66
|
if (described.baseUrl)
|
|
65
67
|
state.baseUrl = described.baseUrl;
|
|
66
68
|
if (described.email)
|
|
@@ -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
|
@@ -61,7 +61,8 @@ export async function setupApplyCommand(options = {}) {
|
|
|
61
61
|
}
|
|
62
62
|
if (plan.code === "JAM_PROJECT_CONFIG_INVALID" ||
|
|
63
63
|
plan.code === "JAM_MCP_CONFIG_UNREADABLE" ||
|
|
64
|
-
plan.code === "JAM_BINDINGS_UNREADABLE"
|
|
64
|
+
plan.code === "JAM_BINDINGS_UNREADABLE" ||
|
|
65
|
+
plan.code === "JAM_PROJECT_KEY_CONFLICT") {
|
|
65
66
|
emitJson({ ...plan, changesApplied: false });
|
|
66
67
|
return 1;
|
|
67
68
|
}
|
|
@@ -69,11 +70,23 @@ export async function setupApplyCommand(options = {}) {
|
|
|
69
70
|
...(options.home ? { home: options.home } : {}),
|
|
70
71
|
...(options.runHost ? { runHost: options.runHost } : {}),
|
|
71
72
|
});
|
|
72
|
-
emitJson({
|
|
73
|
+
emitJson({
|
|
74
|
+
...plan,
|
|
75
|
+
status: applyStatus(plan, result.changesApplied),
|
|
76
|
+
changesApplied: result.changesApplied,
|
|
77
|
+
});
|
|
73
78
|
return plan.requiresUserAction ? 1 : 0;
|
|
74
79
|
}
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
/**
|
|
81
|
+
* The status of an apply, after it ran. "already_configured" means nothing
|
|
82
|
+
* was executed; when changes did run the answer is "applied" - reporting
|
|
83
|
+
* "already_configured" alongside changesApplied:true made the two fields
|
|
84
|
+
* contradict each other, and an agent could believe either one.
|
|
85
|
+
*/
|
|
86
|
+
function applyStatus(plan, changesApplied) {
|
|
87
|
+
if (plan.requiresUserAction)
|
|
88
|
+
return "user_action_required";
|
|
89
|
+
return changesApplied ? "applied" : "already_configured";
|
|
77
90
|
}
|
|
78
91
|
/**
|
|
79
92
|
* `jam setup --agent` - one shot: detect, plan, apply what is safe, verify.
|
|
@@ -90,7 +103,8 @@ export async function setupAgentCommand(options = {}) {
|
|
|
90
103
|
}
|
|
91
104
|
if (plan.code === "JAM_PROJECT_CONFIG_INVALID" ||
|
|
92
105
|
plan.code === "JAM_MCP_CONFIG_UNREADABLE" ||
|
|
93
|
-
plan.code === "JAM_BINDINGS_UNREADABLE"
|
|
106
|
+
plan.code === "JAM_BINDINGS_UNREADABLE" ||
|
|
107
|
+
plan.code === "JAM_PROJECT_KEY_CONFLICT") {
|
|
94
108
|
emitJson({ ...plan, changesApplied: false });
|
|
95
109
|
return 1;
|
|
96
110
|
}
|
|
@@ -135,10 +149,71 @@ export async function doctorJsonCommand(options = {}) {
|
|
|
135
149
|
...(health.error ? { error: health.error } : {}),
|
|
136
150
|
project: { root: state.project.root, ...(state.project.key ? { key: state.project.key } : {}) },
|
|
137
151
|
axes,
|
|
152
|
+
diagnosis: diagnose(state, axes, health),
|
|
138
153
|
checks: health.checks,
|
|
139
154
|
});
|
|
140
155
|
return passed ? 0 : 1;
|
|
141
156
|
}
|
|
157
|
+
function diagnose(state, axes, health) {
|
|
158
|
+
const check = (name) => health.checks.find((c) => c.name === name);
|
|
159
|
+
const fromCheck = (name, code) => {
|
|
160
|
+
const found = check(name);
|
|
161
|
+
if (!found)
|
|
162
|
+
return { state: "UNCHECKED" };
|
|
163
|
+
return found.ok
|
|
164
|
+
? { state: "OK", ...(found.detail ? { detail: found.detail } : {}) }
|
|
165
|
+
: { state: "FAILED", code, ...(found.detail ? { detail: found.detail } : {}) };
|
|
166
|
+
};
|
|
167
|
+
// Credentials: this axis answers "are all three fields resolvable, and from
|
|
168
|
+
// where" - the snapshot knows that. Whether Jira accepts them is a different
|
|
169
|
+
// question with its own axis below, and conflating the two is what made a
|
|
170
|
+
// working "mixed" setup read as broken. "mixed" is never a failure here.
|
|
171
|
+
const credentialCheck = check("Credentials present");
|
|
172
|
+
const mixed = state.credentials.source === "mixed";
|
|
173
|
+
const credentials = !state.credentials.present
|
|
174
|
+
? {
|
|
175
|
+
state: "FAILED",
|
|
176
|
+
code: "JAM_AUTH_REQUIRED",
|
|
177
|
+
...(credentialCheck?.detail ? { detail: credentialCheck.detail } : {}),
|
|
178
|
+
}
|
|
179
|
+
: mixed
|
|
180
|
+
? { state: "WARNING", detail: `fields come from more than one source: ${describeSources(state)}` }
|
|
181
|
+
: { state: "OK", detail: `source ${state.credentials.source}` };
|
|
182
|
+
return {
|
|
183
|
+
credentials,
|
|
184
|
+
projectBinding: state.project.key
|
|
185
|
+
? { state: "OK", detail: `key ${state.project.key}` }
|
|
186
|
+
: { state: "FAILED", code: "JAM_PROJECT_SELECTION_REQUIRED", detail: "no project key for this workspace" },
|
|
187
|
+
runtime: axes.package === "PACKAGE_READY"
|
|
188
|
+
? { state: "OK", ...(axes.packageVersion ? { detail: axes.packageVersion } : {}) }
|
|
189
|
+
: { state: "FAILED", code: "JAM_RUNTIME_CONFIG_MISSING", ...(state.runtime.error ? { detail: state.runtime.error } : {}) },
|
|
190
|
+
registration: axes.registration === "OK"
|
|
191
|
+
? { state: "OK", ...(axes.registeredVersion ? { detail: axes.registeredVersion } : {}) }
|
|
192
|
+
: axes.registration === "UNREGISTERED"
|
|
193
|
+
? { state: "UNCHECKED", detail: "no host has a jam entry for this user" }
|
|
194
|
+
: { state: "FAILED", code: axes.registration, ...(axes.detail ? { detail: axes.detail } : {}) },
|
|
195
|
+
liveToolset: axes.live === "OK"
|
|
196
|
+
? { state: "OK" }
|
|
197
|
+
: axes.live === "UNCHECKED"
|
|
198
|
+
? { state: "UNCHECKED", ...(axes.detail ? { detail: axes.detail } : {}) }
|
|
199
|
+
: {
|
|
200
|
+
state: "FAILED",
|
|
201
|
+
code: axes.live,
|
|
202
|
+
...(axes.missingTools ? { detail: `missing: ${axes.missingTools.join(", ")}` } : {}),
|
|
203
|
+
},
|
|
204
|
+
jiraAuthentication: fromCheck("Jira authentication", "JAM_JIRA_AUTHENTICATION_FAILED"),
|
|
205
|
+
jiraProjectAccess: fromCheck(health.checks.find((c) => c.name.startsWith("JQL search"))?.name ?? "JQL search", "JAM_JIRA_PROJECT_ACCESS_FAILED"),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/** Field-to-source names only. A credential value never appears here. */
|
|
209
|
+
function describeSources(state) {
|
|
210
|
+
const sources = state.credentials.sources;
|
|
211
|
+
if (!sources)
|
|
212
|
+
return "unknown";
|
|
213
|
+
return Object.entries(sources)
|
|
214
|
+
.map(([field, from]) => `${field}=${from}`)
|
|
215
|
+
.join(", ");
|
|
216
|
+
}
|
|
142
217
|
async function inspectAxes(state, options) {
|
|
143
218
|
const packageVersion = state.runtime.version;
|
|
144
219
|
const axes = {
|
|
@@ -195,6 +270,8 @@ export function authStatusCommand(options = {}) {
|
|
|
195
270
|
status: credentials.present ? "configured" : "not_configured",
|
|
196
271
|
...(credentials.present ? {} : { code: "JAM_AUTH_REQUIRED" }),
|
|
197
272
|
source: credentials.source,
|
|
273
|
+
// Which field came from where. Names only - a value never appears.
|
|
274
|
+
...(credentials.sources ? { sources: credentials.sources } : {}),
|
|
198
275
|
...(credentials.email ? { email: credentials.email } : {}),
|
|
199
276
|
...(credentials.baseUrl ? { baseUrl: credentials.baseUrl } : {}),
|
|
200
277
|
});
|
package/dist/cli/auth.js
CHANGED
|
@@ -137,7 +137,8 @@ export function authLogoutCommand(options = {}) {
|
|
|
137
137
|
* credential unreachable, and the resulting split shows up as "mixed".
|
|
138
138
|
*/
|
|
139
139
|
function reportOverride(ui, port) {
|
|
140
|
-
const
|
|
140
|
+
const described = port.describe();
|
|
141
|
+
const source = described.source;
|
|
141
142
|
if (source === "secret-store")
|
|
142
143
|
return;
|
|
143
144
|
if (source === "mixed") {
|
|
@@ -147,6 +148,8 @@ function reportOverride(ui, port) {
|
|
|
147
148
|
ui.warn("Current JIRA_* environment variables override the stored credentials");
|
|
148
149
|
}
|
|
149
150
|
ui.line(` Effective source: ${source}`);
|
|
151
|
+
for (const line of fieldSourceLines(described))
|
|
152
|
+
ui.line(line);
|
|
150
153
|
ui.line(" Unset them to use what was just stored.");
|
|
151
154
|
}
|
|
152
155
|
/** After a logout, say plainly whether anything still authenticates JAM. */
|
|
@@ -161,6 +164,8 @@ function reportRemaining(ui, port) {
|
|
|
161
164
|
? "Jira credentials still resolve from outside the secret store"
|
|
162
165
|
: "Part of a Jira credential still resolves from outside the secret store");
|
|
163
166
|
ui.line(` Effective source: ${described.source}`);
|
|
167
|
+
for (const line of fieldSourceLines(described))
|
|
168
|
+
ui.line(line);
|
|
164
169
|
ui.line(" Unset JIRA_BASE_URL, JIRA_EMAIL and JIRA_API_TOKEN to finish logging out.");
|
|
165
170
|
}
|
|
166
171
|
/** undefined when Jira accepted the credentials; otherwise the reason. */
|
|
@@ -200,3 +205,15 @@ export function toJiraOrigin(input) {
|
|
|
200
205
|
}
|
|
201
206
|
return url.protocol === "http:" || url.protocol === "https:" ? url.origin : undefined;
|
|
202
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Which field came from where. "mixed" on its own tells a reader that
|
|
210
|
+
* something is split without telling them what, so they go looking - these
|
|
211
|
+
* lines answer it. Field names and source names only: a credential value is
|
|
212
|
+
* never printed.
|
|
213
|
+
*/
|
|
214
|
+
function fieldSourceLines(described) {
|
|
215
|
+
const sources = described.sources;
|
|
216
|
+
if (!sources)
|
|
217
|
+
return [];
|
|
218
|
+
return Object.entries(sources).map(([field, from]) => ` ${field}: ${from}`);
|
|
219
|
+
}
|
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";
|
|
@@ -43,7 +44,8 @@ export async function setup(options = {}) {
|
|
|
43
44
|
}
|
|
44
45
|
if (plan.code === "JAM_PROJECT_CONFIG_INVALID" ||
|
|
45
46
|
plan.code === "JAM_MCP_CONFIG_UNREADABLE" ||
|
|
46
|
-
plan.code === "JAM_BINDINGS_UNREADABLE"
|
|
47
|
+
plan.code === "JAM_BINDINGS_UNREADABLE" ||
|
|
48
|
+
plan.code === "JAM_PROJECT_KEY_CONFLICT") {
|
|
47
49
|
line(`[FAIL] ${describeBlockingCode(plan)}`);
|
|
48
50
|
return 1;
|
|
49
51
|
}
|
|
@@ -132,6 +134,16 @@ function reportApplied(applied, plan) {
|
|
|
132
134
|
void plan;
|
|
133
135
|
}
|
|
134
136
|
function describeBlockingCode(plan) {
|
|
137
|
+
if (plan.code === "JAM_PROJECT_KEY_CONFLICT") {
|
|
138
|
+
// Both sides named, and no suggestion to delete the repository's file -
|
|
139
|
+
// which project this repository belongs to is the team's decision.
|
|
140
|
+
return [
|
|
141
|
+
`This repository declares ${plan.existing?.key} in .jira-agent/project.yaml,`,
|
|
142
|
+
`but --project asked for ${plan.requested?.key}. JAM will not overwrite the`,
|
|
143
|
+
"committed key. Either drop --project to use the repository's, or change",
|
|
144
|
+
"the repository's project.yaml with the team and re-run.",
|
|
145
|
+
].join(" ");
|
|
146
|
+
}
|
|
135
147
|
if (plan.code === "JAM_PROJECT_CONFIG_INVALID") {
|
|
136
148
|
return "The project's .jira-agent/project.yaml could not be parsed. Fix it and re-run.";
|
|
137
149
|
}
|
|
@@ -178,10 +190,13 @@ async function installAndBuild(root) {
|
|
|
178
190
|
{ name: "Build", args: ["run", "build"] },
|
|
179
191
|
]) {
|
|
180
192
|
line(`\n> npm ${step.args.join(" ")}`);
|
|
181
|
-
|
|
193
|
+
// npm is a .cmd shim on Windows; shellInvocation joins the validated argv
|
|
194
|
+
// because an args array plus shell:true is DEP0190.
|
|
195
|
+
const invocation = shellInvocation("npm", step.args);
|
|
196
|
+
const res = spawnSync(invocation.command, invocation.args, {
|
|
182
197
|
cwd: root,
|
|
183
198
|
stdio: "inherit",
|
|
184
|
-
shell:
|
|
199
|
+
shell: invocation.shell,
|
|
185
200
|
});
|
|
186
201
|
if (res.status !== 0) {
|
|
187
202
|
line(`[FAIL] ${step.name}`);
|
|
@@ -14,6 +14,13 @@ export type CredentialDescription = {
|
|
|
14
14
|
email?: string;
|
|
15
15
|
hasToken: boolean;
|
|
16
16
|
source: CredentialSource;
|
|
17
|
+
/**
|
|
18
|
+
* Which source supplied each field. `source` alone says "mixed" without
|
|
19
|
+
* saying mixed how, which reads as a fault when it is a normal state - a
|
|
20
|
+
* base URL and email in the OS store with the token exported for one shell
|
|
21
|
+
* is a supported setup. Names only: no value ever appears here.
|
|
22
|
+
*/
|
|
23
|
+
sources?: Partial<Record<"JIRA_BASE_URL" | "JIRA_EMAIL" | "JIRA_API_TOKEN", Exclude<CredentialSource, "mixed" | "none">>>;
|
|
17
24
|
};
|
|
18
25
|
export interface CredentialPort {
|
|
19
26
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jam-mcp/server",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.4",
|
|
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.4",
|
|
45
45
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
46
46
|
"yaml": "^2.9.0",
|
|
47
47
|
"zod": "^4.4.3"
|