@jam-mcp/server 1.0.0
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/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/adapters/cache/noop-cache.d.ts +7 -0
- package/dist/adapters/cache/noop-cache.js +12 -0
- package/dist/adapters/credentials/composite.d.ts +30 -0
- package/dist/adapters/credentials/composite.js +79 -0
- package/dist/adapters/credentials/process-env.d.ts +13 -0
- package/dist/adapters/credentials/process-env.js +17 -0
- package/dist/adapters/credentials/secret-store.d.ts +69 -0
- package/dist/adapters/credentials/secret-store.js +259 -0
- package/dist/adapters/credentials/windows-user-env.d.ts +20 -0
- package/dist/adapters/credentials/windows-user-env.js +51 -0
- package/dist/adapters/jira-cloud/adf-to-text.d.ts +12 -0
- package/dist/adapters/jira-cloud/adf-to-text.js +151 -0
- package/dist/adapters/jira-cloud/jira-client.d.ts +32 -0
- package/dist/adapters/jira-cloud/jira-client.js +137 -0
- package/dist/adapters/jira-cloud/jira-read.adapter.d.ts +13 -0
- package/dist/adapters/jira-cloud/jira-read.adapter.js +97 -0
- package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +12 -0
- package/dist/adapters/jira-cloud/jira-write.adapter.js +17 -0
- package/dist/adapters/jira-cloud/mapper.d.ts +34 -0
- package/dist/adapters/jira-cloud/mapper.js +143 -0
- package/dist/adapters/telemetry/console-telemetry.d.ts +13 -0
- package/dist/adapters/telemetry/console-telemetry.js +31 -0
- package/dist/application/get-full-issue-context.d.ts +18 -0
- package/dist/application/get-full-issue-context.js +120 -0
- package/dist/application/get-issue-context.d.ts +19 -0
- package/dist/application/get-issue-context.js +79 -0
- package/dist/application/search-issues.d.ts +22 -0
- package/dist/application/search-issues.js +93 -0
- package/dist/bootstrap/boot-health-gate.d.ts +25 -0
- package/dist/bootstrap/boot-health-gate.js +137 -0
- package/dist/bootstrap/bootstrap-orchestrator.d.ts +15 -0
- package/dist/bootstrap/bootstrap-orchestrator.js +14 -0
- package/dist/bootstrap/host-mcp.d.ts +54 -0
- package/dist/bootstrap/host-mcp.js +98 -0
- package/dist/bootstrap/jira-projects.d.ts +16 -0
- package/dist/bootstrap/jira-projects.js +24 -0
- package/dist/bootstrap/mcp-config-merger.d.ts +58 -0
- package/dist/bootstrap/mcp-config-merger.js +119 -0
- package/dist/bootstrap/migration-target.d.ts +60 -0
- package/dist/bootstrap/migration-target.js +80 -0
- package/dist/bootstrap/project-bindings.d.ts +48 -0
- package/dist/bootstrap/project-bindings.js +112 -0
- package/dist/bootstrap/project-config-bootstrapper.d.ts +37 -0
- package/dist/bootstrap/project-config-bootstrapper.js +69 -0
- package/dist/bootstrap/project-config-resolver.d.ts +53 -0
- package/dist/bootstrap/project-config-resolver.js +59 -0
- package/dist/bootstrap/project-root-resolver.d.ts +22 -0
- package/dist/bootstrap/project-root-resolver.js +43 -0
- package/dist/bootstrap/setup-apply.d.ts +31 -0
- package/dist/bootstrap/setup-apply.js +61 -0
- package/dist/bootstrap/setup-plan.d.ts +102 -0
- package/dist/bootstrap/setup-plan.js +224 -0
- package/dist/bootstrap/setup-state.d.ts +73 -0
- package/dist/bootstrap/setup-state.js +89 -0
- package/dist/bootstrap/workspace-identity.d.ts +52 -0
- package/dist/bootstrap/workspace-identity.js +114 -0
- package/dist/cli/agent-api.d.ts +63 -0
- package/dist/cli/agent-api.js +149 -0
- package/dist/cli/auth.d.ts +39 -0
- package/dist/cli/auth.js +201 -0
- package/dist/cli/doctor.d.ts +7 -0
- package/dist/cli/doctor.js +31 -0
- package/dist/cli/runtime.d.ts +16 -0
- package/dist/cli/runtime.js +97 -0
- package/dist/cli/serve.d.ts +7 -0
- package/dist/cli/serve.js +23 -0
- package/dist/cli/setup-wizard.d.ts +37 -0
- package/dist/cli/setup-wizard.js +338 -0
- package/dist/cli/setup.d.ts +26 -0
- package/dist/cli/setup.js +214 -0
- package/dist/cli/ui.d.ts +118 -0
- package/dist/cli/ui.js +338 -0
- package/dist/cli-entry.d.ts +7 -0
- package/dist/cli-entry.js +124 -0
- package/dist/config/load-config.d.ts +18 -0
- package/dist/config/load-config.js +48 -0
- package/dist/config/schema.d.ts +36 -0
- package/dist/config/schema.js +74 -0
- package/dist/deps.d.ts +50 -0
- package/dist/deps.js +39 -0
- package/dist/domain/completeness.d.ts +63 -0
- package/dist/domain/completeness.js +20 -0
- package/dist/domain/context.d.ts +38 -0
- package/dist/domain/context.js +1 -0
- package/dist/domain/errors.d.ts +27 -0
- package/dist/domain/errors.js +49 -0
- package/dist/domain/issue.d.ts +35 -0
- package/dist/domain/issue.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +17 -0
- package/dist/mcp/create-server.d.ts +9 -0
- package/dist/mcp/create-server.js +32 -0
- package/dist/mcp/tool-result.d.ts +10 -0
- package/dist/mcp/tool-result.js +33 -0
- package/dist/mcp/tools/jira-context.tool.d.ts +3 -0
- package/dist/mcp/tools/jira-context.tool.js +25 -0
- package/dist/mcp/tools/jira-full.tool.d.ts +3 -0
- package/dist/mcp/tools/jira-full.tool.js +27 -0
- package/dist/mcp/tools/jira-search.tool.d.ts +3 -0
- package/dist/mcp/tools/jira-search.tool.js +31 -0
- package/dist/policy/completeness-policy.d.ts +19 -0
- package/dist/policy/completeness-policy.js +34 -0
- package/dist/policy/consistency-policy.d.ts +13 -0
- package/dist/policy/consistency-policy.js +6 -0
- package/dist/policy/field-policy.d.ts +11 -0
- package/dist/policy/field-policy.js +26 -0
- package/dist/policy/output-budget-policy.d.ts +23 -0
- package/dist/policy/output-budget-policy.js +96 -0
- package/dist/policy/pagination-policy.d.ts +15 -0
- package/dist/policy/pagination-policy.js +13 -0
- package/dist/ports/cache.port.d.ts +5 -0
- package/dist/ports/cache.port.js +1 -0
- package/dist/ports/credentials.port.d.ts +27 -0
- package/dist/ports/credentials.port.js +1 -0
- package/dist/ports/jira-read.port.d.ts +73 -0
- package/dist/ports/jira-read.port.js +1 -0
- package/dist/ports/jira-write.port.d.ts +12 -0
- package/dist/ports/jira-write.port.js +1 -0
- package/dist/ports/telemetry.port.d.ts +13 -0
- package/dist/ports/telemetry.port.js +1 -0
- package/package.json +69 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { JAM_MCP_ENTRY } from "./mcp-config-merger.js";
|
|
3
|
+
/**
|
|
4
|
+
* These boot a whole Node CLI, and Claude Code health-checks every configured
|
|
5
|
+
* server while listing, which is seconds rather than milliseconds.
|
|
6
|
+
*/
|
|
7
|
+
const HOST_TIMEOUT_MS = 20_000;
|
|
8
|
+
export const defaultHostRunner = ({ command, args }) => {
|
|
9
|
+
const result = spawnSync(command, args, {
|
|
10
|
+
encoding: "utf8",
|
|
11
|
+
timeout: HOST_TIMEOUT_MS,
|
|
12
|
+
// Both CLIs are npm shims on Windows, and Node refuses to spawn a .cmd
|
|
13
|
+
// without a shell. Every argument JAM passes is a bare token - no JSON, no
|
|
14
|
+
// spaces - precisely so this cannot become a quoting hazard.
|
|
15
|
+
shell: process.platform === "win32",
|
|
16
|
+
});
|
|
17
|
+
if (result.error)
|
|
18
|
+
return { status: null, failed: true, stdout: "" };
|
|
19
|
+
return { status: result.status, failed: false, stdout: result.stdout ?? "" };
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* `add`, not `add-json`: the JSON form would put a quoted blob through a
|
|
23
|
+
* Windows shell, and every argument here is a bare token instead.
|
|
24
|
+
*/
|
|
25
|
+
const LAUNCH = ["--", JAM_MCP_ENTRY.command, ...JAM_MCP_ENTRY.args];
|
|
26
|
+
const ADAPTERS = [
|
|
27
|
+
{
|
|
28
|
+
id: "claude-code",
|
|
29
|
+
probe: { command: "claude", args: ["mcp", "list"] },
|
|
30
|
+
// `-s user` is the whole point: registered for this user on this machine,
|
|
31
|
+
// not for whichever project happens to be open.
|
|
32
|
+
register: { command: "claude", args: ["mcp", "add", "jam", "-s", "user", ...LAUNCH] },
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "codex",
|
|
36
|
+
probe: { command: "codex", args: ["mcp", "list"] },
|
|
37
|
+
register: { command: "codex", args: ["mcp", "add", "jam", ...LAUNCH] },
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
export function hostRegistration(id) {
|
|
41
|
+
return ADAPTERS.find((a) => a.id === id)?.register;
|
|
42
|
+
}
|
|
43
|
+
const ANSI = /\[[0-9;?]*[A-Za-z]/g;
|
|
44
|
+
/**
|
|
45
|
+
* Is `jam` in this listing?
|
|
46
|
+
*
|
|
47
|
+
* Both CLIs exit 0 whether or not a server exists, so the exit code says
|
|
48
|
+
* nothing and the name column is the only signal available. Matched as the
|
|
49
|
+
* first token of a line so a server called `jam-something`, or the word
|
|
50
|
+
* appearing in a URL, cannot be mistaken for it.
|
|
51
|
+
*
|
|
52
|
+
* ponytail: this reads another program's table. If either changes its listing
|
|
53
|
+
* format the effect is a redundant registration attempt, not a wrong one -
|
|
54
|
+
* `mcp add` on an existing entry writes the same launcher line back.
|
|
55
|
+
*/
|
|
56
|
+
export function listsJamEntry(stdout) {
|
|
57
|
+
return stdout
|
|
58
|
+
.replace(ANSI, "")
|
|
59
|
+
.split(/\r?\n/)
|
|
60
|
+
.some((line) => /^\s*jam(?=[\s:])/.test(line));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Ask each host what it has, and whether it is there at all.
|
|
64
|
+
*
|
|
65
|
+
* Read-only by construction: `mcp list` reports. A host that cannot be reached
|
|
66
|
+
* is recorded as unavailable rather than assumed empty - so planning has
|
|
67
|
+
* something to refuse rather than something to guess.
|
|
68
|
+
*/
|
|
69
|
+
export function detectHosts(run = defaultHostRunner) {
|
|
70
|
+
// The wizard re-detects after each step, and Claude Code health-checks every
|
|
71
|
+
// configured server while listing - seconds each time. The answer cannot
|
|
72
|
+
// change inside one command, so it is asked once. Only the real runner is
|
|
73
|
+
// cached; an injected one is a test, and must always be called.
|
|
74
|
+
if (run === defaultHostRunner && cachedHosts)
|
|
75
|
+
return cachedHosts;
|
|
76
|
+
const probed = probeHosts(run);
|
|
77
|
+
if (run === defaultHostRunner)
|
|
78
|
+
cachedHosts = probed;
|
|
79
|
+
return probed;
|
|
80
|
+
}
|
|
81
|
+
let cachedHosts;
|
|
82
|
+
function probeHosts(run) {
|
|
83
|
+
return ADAPTERS.map((adapter) => {
|
|
84
|
+
const result = run(adapter.probe);
|
|
85
|
+
if (result.failed || result.status !== 0) {
|
|
86
|
+
return { id: adapter.id, cliAvailable: false, hasJamEntry: false };
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
id: adapter.id,
|
|
90
|
+
cliAvailable: true,
|
|
91
|
+
hasJamEntry: listsJamEntry(result.stdout),
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/** How a person would do it by hand, for the hosts JAM could not reach. */
|
|
96
|
+
export function describeHostCommand({ command, args }) {
|
|
97
|
+
return [command, ...args].join(" ");
|
|
98
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CredentialPort } from "../ports/credentials.port.js";
|
|
2
|
+
import type { ProjectRef } from "../ports/jira-read.port.js";
|
|
3
|
+
export type VisibleProjects = {
|
|
4
|
+
projects: ProjectRef[];
|
|
5
|
+
truncated: boolean;
|
|
6
|
+
/** Set when the list could not be fetched; the caller still has to ask the user. */
|
|
7
|
+
error?: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Advisory list of Jira projects this account can see.
|
|
11
|
+
*
|
|
12
|
+
* Used only to help a person (or an agent's user) pick a key when none could
|
|
13
|
+
* be decided safely. It never selects one - Safe Bootstrap holds regardless of
|
|
14
|
+
* how many projects come back.
|
|
15
|
+
*/
|
|
16
|
+
export declare function listVisibleProjects(credentials?: CredentialPort): Promise<VisibleProjects>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { CompositeCredentialProvider } from "../adapters/credentials/composite.js";
|
|
2
|
+
import { ProjectConfigSchema } from "../config/schema.js";
|
|
3
|
+
/**
|
|
4
|
+
* Advisory list of Jira projects this account can see.
|
|
5
|
+
*
|
|
6
|
+
* Used only to help a person (or an agent's user) pick a key when none could
|
|
7
|
+
* be decided safely. It never selects one - Safe Bootstrap holds regardless of
|
|
8
|
+
* how many projects come back.
|
|
9
|
+
*/
|
|
10
|
+
export async function listVisibleProjects(credentials = new CompositeCredentialProvider()) {
|
|
11
|
+
const described = credentials.describe();
|
|
12
|
+
if (!described.baseUrl || !described.email || !described.hasToken) {
|
|
13
|
+
return { projects: [], truncated: false, error: "Jira credentials are not configured." };
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const { JiraCloudReadAdapter } = await import("../adapters/jira-cloud/jira-read.adapter.js");
|
|
17
|
+
const jira = new JiraCloudReadAdapter(credentials, ProjectConfigSchema.parse({}));
|
|
18
|
+
return await jira.listProjects();
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
const { toJamError } = await import("../domain/errors.js");
|
|
22
|
+
return { projects: [], truncated: false, error: toJamError(err).message };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical jam entry for a project's `.mcp.json`.
|
|
3
|
+
*
|
|
4
|
+
* Goes through the launcher rather than naming a JAM install directly, so the
|
|
5
|
+
* file says only "this project uses JAM" - which build actually runs is the
|
|
6
|
+
* reader's own choice, in their own ~/.jam/config.yaml. That is what makes
|
|
7
|
+
* this line safe to commit and share across machines.
|
|
8
|
+
*
|
|
9
|
+
* Pinned to an exact version. A floating tag would silently change what a
|
|
10
|
+
* teammate's editor launches.
|
|
11
|
+
*/
|
|
12
|
+
export declare const LAUNCHER_PACKAGE_SPEC = "@jam-mcp/launcher@1.0.0";
|
|
13
|
+
export declare const JAM_MCP_ENTRY: {
|
|
14
|
+
readonly command: "npx";
|
|
15
|
+
readonly args: readonly ["--yes", "@jam-mcp/launcher@1.0.0", "serve"];
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Recognise wiring from before the launcher existed: a hard-coded path to one
|
|
19
|
+
* machine's checkout, or a bare `jam` that depends on a global PATH install.
|
|
20
|
+
* Both work only where they were written, which is why `--migrate` exists.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isLegacyJamEntry(entry: unknown): boolean;
|
|
23
|
+
export type McpMergeResult = {
|
|
24
|
+
path: string;
|
|
25
|
+
/** "created" | "added" | "unchanged" - what actually happened to the file. */
|
|
26
|
+
action: "created" | "added" | "unchanged";
|
|
27
|
+
};
|
|
28
|
+
export type McpInspection = {
|
|
29
|
+
path: string;
|
|
30
|
+
exists: boolean;
|
|
31
|
+
/** True when the file exists but is not valid JSON - callers must not overwrite it blindly. */
|
|
32
|
+
unreadable: boolean;
|
|
33
|
+
hasJamEntry: boolean;
|
|
34
|
+
/** Entry names other than "jam", which must survive any mutation. */
|
|
35
|
+
otherServers: string[];
|
|
36
|
+
jamEntry?: unknown;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Read `.mcp.json` without touching it. This is what lets `setup plan` report
|
|
40
|
+
* what it *would* do: the decision is made here once, and apply only writes.
|
|
41
|
+
*/
|
|
42
|
+
export declare function inspectMcpConfig(root: string): McpInspection;
|
|
43
|
+
/**
|
|
44
|
+
* Write a jam entry into `.mcp.json`, preserving every other server verbatim.
|
|
45
|
+
*
|
|
46
|
+
* Unconditional by design - the caller decides *whether* to write; this only
|
|
47
|
+
* decides *how*, so that "should I touch this file" lives in exactly one place.
|
|
48
|
+
*/
|
|
49
|
+
export declare function writeJamMcpEntry(root: string, entry?: unknown): string;
|
|
50
|
+
/**
|
|
51
|
+
* Merge a PATH-based JAM entry into `.mcp.json`, preserving everything else.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately does NOT record an absolute path to this JAM checkout - that
|
|
54
|
+
* would break the moment a teammate clones to a different location. `command:
|
|
55
|
+
* "jam"` relies on `jam` being on PATH (see `jam setup`'s PATH check), which is
|
|
56
|
+
* what keeps this file safe to commit and share.
|
|
57
|
+
*/
|
|
58
|
+
export declare function mergeMcpConfig(root: string): McpMergeResult;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { SERVER_VERSION } from "@jam-mcp/launcher";
|
|
4
|
+
/**
|
|
5
|
+
* The canonical jam entry for a project's `.mcp.json`.
|
|
6
|
+
*
|
|
7
|
+
* Goes through the launcher rather than naming a JAM install directly, so the
|
|
8
|
+
* file says only "this project uses JAM" - which build actually runs is the
|
|
9
|
+
* reader's own choice, in their own ~/.jam/config.yaml. That is what makes
|
|
10
|
+
* this line safe to commit and share across machines.
|
|
11
|
+
*
|
|
12
|
+
* Pinned to an exact version. A floating tag would silently change what a
|
|
13
|
+
* teammate's editor launches.
|
|
14
|
+
*/
|
|
15
|
+
export const LAUNCHER_PACKAGE_SPEC = `@jam-mcp/launcher@${SERVER_VERSION}`;
|
|
16
|
+
export const JAM_MCP_ENTRY = {
|
|
17
|
+
command: "npx",
|
|
18
|
+
args: ["--yes", LAUNCHER_PACKAGE_SPEC, "serve"],
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Recognise wiring from before the launcher existed: a hard-coded path to one
|
|
22
|
+
* machine's checkout, or a bare `jam` that depends on a global PATH install.
|
|
23
|
+
* Both work only where they were written, which is why `--migrate` exists.
|
|
24
|
+
*/
|
|
25
|
+
export function isLegacyJamEntry(entry) {
|
|
26
|
+
if (!entry || typeof entry !== "object")
|
|
27
|
+
return false;
|
|
28
|
+
const { command, args } = entry;
|
|
29
|
+
if (command === "jam")
|
|
30
|
+
return true;
|
|
31
|
+
if (command === "node")
|
|
32
|
+
return true;
|
|
33
|
+
if (command === "npx" && Array.isArray(args)) {
|
|
34
|
+
return !args.some((arg) => typeof arg === "string" && arg.startsWith("@jam-mcp/launcher@"));
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Read `.mcp.json` without touching it. This is what lets `setup plan` report
|
|
40
|
+
* what it *would* do: the decision is made here once, and apply only writes.
|
|
41
|
+
*/
|
|
42
|
+
export function inspectMcpConfig(root) {
|
|
43
|
+
const path = join(root, ".mcp.json");
|
|
44
|
+
if (!existsSync(path)) {
|
|
45
|
+
return { path, exists: false, unreadable: false, hasJamEntry: false, otherServers: [] };
|
|
46
|
+
}
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return { path, exists: true, unreadable: true, hasJamEntry: false, otherServers: [] };
|
|
53
|
+
}
|
|
54
|
+
const servers = parsed.mcpServers ?? {};
|
|
55
|
+
const inspection = {
|
|
56
|
+
path,
|
|
57
|
+
exists: true,
|
|
58
|
+
unreadable: false,
|
|
59
|
+
hasJamEntry: Boolean(servers["jam"]),
|
|
60
|
+
otherServers: Object.keys(servers).filter((name) => name !== "jam"),
|
|
61
|
+
};
|
|
62
|
+
if (servers["jam"])
|
|
63
|
+
inspection.jamEntry = servers["jam"];
|
|
64
|
+
return inspection;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Write a jam entry into `.mcp.json`, preserving every other server verbatim.
|
|
68
|
+
*
|
|
69
|
+
* Unconditional by design - the caller decides *whether* to write; this only
|
|
70
|
+
* decides *how*, so that "should I touch this file" lives in exactly one place.
|
|
71
|
+
*/
|
|
72
|
+
export function writeJamMcpEntry(root, entry = JAM_MCP_ENTRY) {
|
|
73
|
+
const path = join(root, ".mcp.json");
|
|
74
|
+
let parsed = {};
|
|
75
|
+
if (existsSync(path)) {
|
|
76
|
+
try {
|
|
77
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
throw new Error(`Could not parse existing ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const servers = parsed["mcpServers"] ?? {};
|
|
84
|
+
const merged = { ...parsed, mcpServers: { ...servers, jam: entry } };
|
|
85
|
+
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
|
86
|
+
return path;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Merge a PATH-based JAM entry into `.mcp.json`, preserving everything else.
|
|
90
|
+
*
|
|
91
|
+
* Deliberately does NOT record an absolute path to this JAM checkout - that
|
|
92
|
+
* would break the moment a teammate clones to a different location. `command:
|
|
93
|
+
* "jam"` relies on `jam` being on PATH (see `jam setup`'s PATH check), which is
|
|
94
|
+
* what keeps this file safe to commit and share.
|
|
95
|
+
*/
|
|
96
|
+
export function mergeMcpConfig(root) {
|
|
97
|
+
const path = join(root, ".mcp.json");
|
|
98
|
+
if (!existsSync(path)) {
|
|
99
|
+
writeFileSync(path, `${JSON.stringify({ mcpServers: { jam: JAM_MCP_ENTRY } }, null, 2)}\n`, "utf8");
|
|
100
|
+
return { path, action: "created" };
|
|
101
|
+
}
|
|
102
|
+
const raw = readFileSync(path, "utf8");
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(raw);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
throw new Error(`Could not parse existing ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
109
|
+
}
|
|
110
|
+
if (parsed.mcpServers?.["jam"]) {
|
|
111
|
+
return { path, action: "unchanged" };
|
|
112
|
+
}
|
|
113
|
+
const merged = {
|
|
114
|
+
...parsed,
|
|
115
|
+
mcpServers: { ...(parsed.mcpServers ?? {}), jam: JAM_MCP_ENTRY },
|
|
116
|
+
};
|
|
117
|
+
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
|
118
|
+
return { path, action: "added" };
|
|
119
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type PlanOptions, type SetupPlan } from "./setup-plan.js";
|
|
2
|
+
import type { SetupState } from "./setup-state.js";
|
|
3
|
+
/**
|
|
4
|
+
* Whether the package a migration would point `.mcp.json` at can actually be
|
|
5
|
+
* resolved right now.
|
|
6
|
+
*
|
|
7
|
+
* `--migrate` is the one path that rewrites wiring a teammate already has
|
|
8
|
+
* working. Rewriting it toward something that cannot be fetched turns a working
|
|
9
|
+
* configuration into a broken one, and the failure surfaces much later - as a
|
|
10
|
+
* module-not-found from inside the editor's MCP child process.
|
|
11
|
+
*/
|
|
12
|
+
export type MigrationTarget = {
|
|
13
|
+
spec: string;
|
|
14
|
+
available: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* `not-found` - the registry answered, and this spec is not there. That may
|
|
17
|
+
* mean unpublished, or simply absent from the registry this user is
|
|
18
|
+
* configured against; the check cannot tell those apart and does not claim to.
|
|
19
|
+
* `unverifiable` - no usable answer at all (offline, proxy, timeout, no npm).
|
|
20
|
+
*/
|
|
21
|
+
reason?: "not-found" | "unverifiable";
|
|
22
|
+
detail?: string;
|
|
23
|
+
};
|
|
24
|
+
/** Result shape of a `spawnSync`-style call, narrowed to what the check reads. */
|
|
25
|
+
export type RunResult = {
|
|
26
|
+
status: number | null;
|
|
27
|
+
stderr: string;
|
|
28
|
+
error?: NodeJS.ErrnoException;
|
|
29
|
+
};
|
|
30
|
+
/** Injected by tests so the suite never shells out or reaches a registry. */
|
|
31
|
+
export type RunFn = (command: string, args: string[]) => RunResult;
|
|
32
|
+
/**
|
|
33
|
+
* Ask npm whether `spec` resolves, using the user's own npm configuration.
|
|
34
|
+
*
|
|
35
|
+
* `npm view` rather than a direct registry request on purpose: it honours the
|
|
36
|
+
* user's `.npmrc`, scoped auth, proxy and custom registry, so a privately
|
|
37
|
+
* published launcher answers correctly instead of looking missing.
|
|
38
|
+
*
|
|
39
|
+
* Fails closed. Everything that cannot be verified - offline, blocked proxy,
|
|
40
|
+
* npm not on PATH, timeout - would also break `npx --yes <spec> serve` at launch
|
|
41
|
+
* time, so "cannot verify" and "would not work" are the same population.
|
|
42
|
+
*
|
|
43
|
+
* The spec is derived from a release constant, never from user input.
|
|
44
|
+
*/
|
|
45
|
+
export declare function checkMigrationTarget(spec?: string, run?: RunFn): MigrationTarget;
|
|
46
|
+
/**
|
|
47
|
+
* Plan, probing the migration target only when a destructive replacement is
|
|
48
|
+
* actually pending.
|
|
49
|
+
*
|
|
50
|
+
* The probe is gated on the *plan*, not on the `--migrate` flag. A flag alone
|
|
51
|
+
* would make setup reach the network in cases that never rewrite anything -
|
|
52
|
+
* no `.mcp.json`, no jam entry, already canonical wiring, or a stop earlier in
|
|
53
|
+
* the plan - which would add a network dependency and an offline timeout for
|
|
54
|
+
* nothing.
|
|
55
|
+
*
|
|
56
|
+
* `computeSetupPlan` is pure, so planning twice costs nothing and keeps the
|
|
57
|
+
* probe out of the planner: the first pass answers "is an unverified
|
|
58
|
+
* replacement pending", the second decides with the fact in hand.
|
|
59
|
+
*/
|
|
60
|
+
export declare function computeSetupPlanWithPreflight(state: SetupState, options?: PlanOptions, check?: () => MigrationTarget): SetupPlan;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { LAUNCHER_PACKAGE_SPEC } from "./mcp-config-merger.js";
|
|
3
|
+
import { computeSetupPlan } from "./setup-plan.js";
|
|
4
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
5
|
+
function runNpm(command, args) {
|
|
6
|
+
const result = spawnSync(command, args, {
|
|
7
|
+
encoding: "utf8",
|
|
8
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
9
|
+
// npm on Windows is a shell script, not an executable.
|
|
10
|
+
shell: process.platform === "win32",
|
|
11
|
+
});
|
|
12
|
+
return {
|
|
13
|
+
status: result.status,
|
|
14
|
+
stderr: result.stderr ?? "",
|
|
15
|
+
...(result.error ? { error: result.error } : {}),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Ask npm whether `spec` resolves, using the user's own npm configuration.
|
|
20
|
+
*
|
|
21
|
+
* `npm view` rather than a direct registry request on purpose: it honours the
|
|
22
|
+
* user's `.npmrc`, scoped auth, proxy and custom registry, so a privately
|
|
23
|
+
* published launcher answers correctly instead of looking missing.
|
|
24
|
+
*
|
|
25
|
+
* Fails closed. Everything that cannot be verified - offline, blocked proxy,
|
|
26
|
+
* npm not on PATH, timeout - would also break `npx --yes <spec> serve` at launch
|
|
27
|
+
* time, so "cannot verify" and "would not work" are the same population.
|
|
28
|
+
*
|
|
29
|
+
* The spec is derived from a release constant, never from user input.
|
|
30
|
+
*/
|
|
31
|
+
export function checkMigrationTarget(spec = LAUNCHER_PACKAGE_SPEC, run = runNpm) {
|
|
32
|
+
const result = run("npm", ["view", spec, "version"]);
|
|
33
|
+
// Checked first: both a failed spawn and a timeout leave `status` null.
|
|
34
|
+
if (result.error) {
|
|
35
|
+
return unverifiable(spec, result.error.code ?? result.error.message);
|
|
36
|
+
}
|
|
37
|
+
if (result.status === 0) {
|
|
38
|
+
return { spec, available: true };
|
|
39
|
+
}
|
|
40
|
+
if (/\bE404\b/.test(result.stderr)) {
|
|
41
|
+
return {
|
|
42
|
+
spec,
|
|
43
|
+
available: false,
|
|
44
|
+
reason: "not-found",
|
|
45
|
+
detail: `npm could not find ${spec} in the configured registry.`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return unverifiable(spec, `npm view exited ${result.status ?? "without a status"}`);
|
|
49
|
+
}
|
|
50
|
+
function unverifiable(spec, detail) {
|
|
51
|
+
return {
|
|
52
|
+
spec,
|
|
53
|
+
available: false,
|
|
54
|
+
reason: "unverifiable",
|
|
55
|
+
detail: `Could not verify ${spec}: ${detail}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Plan, probing the migration target only when a destructive replacement is
|
|
60
|
+
* actually pending.
|
|
61
|
+
*
|
|
62
|
+
* The probe is gated on the *plan*, not on the `--migrate` flag. A flag alone
|
|
63
|
+
* would make setup reach the network in cases that never rewrite anything -
|
|
64
|
+
* no `.mcp.json`, no jam entry, already canonical wiring, or a stop earlier in
|
|
65
|
+
* the plan - which would add a network dependency and an offline timeout for
|
|
66
|
+
* nothing.
|
|
67
|
+
*
|
|
68
|
+
* `computeSetupPlan` is pure, so planning twice costs nothing and keeps the
|
|
69
|
+
* probe out of the planner: the first pass answers "is an unverified
|
|
70
|
+
* replacement pending", the second decides with the fact in hand.
|
|
71
|
+
*/
|
|
72
|
+
export function computeSetupPlanWithPreflight(state, options = {}, check = () => checkMigrationTarget()) {
|
|
73
|
+
// An injected target is authoritative - never probe over the caller's answer.
|
|
74
|
+
if (options.migrationTarget)
|
|
75
|
+
return computeSetupPlan(state, options);
|
|
76
|
+
const planned = computeSetupPlan(state, options);
|
|
77
|
+
if (planned.code !== "JAM_MIGRATION_TARGET_UNAVAILABLE")
|
|
78
|
+
return planned;
|
|
79
|
+
return computeSetupPlan(state, { ...options, migrationTarget: check() });
|
|
80
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One workspace bound to one Jira project, for this user only.
|
|
3
|
+
*
|
|
4
|
+
* `path` is provenance for the human reading the file - JAM resolves by
|
|
5
|
+
* `workspace` and never by path, so editing it repoints nothing.
|
|
6
|
+
*/
|
|
7
|
+
export type ProjectBinding = {
|
|
8
|
+
workspace: string;
|
|
9
|
+
key: string;
|
|
10
|
+
path?: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Why reading distinguishes three states rather than returning a list:
|
|
14
|
+
* discovery can shrug at a broken file, but a write must not. Rewriting the
|
|
15
|
+
* whole file from an empty list that actually meant "could not parse" destroys
|
|
16
|
+
* bindings that are still in there.
|
|
17
|
+
*/
|
|
18
|
+
export type BindingsInspection = {
|
|
19
|
+
path: string;
|
|
20
|
+
status: "absent" | "parsed" | "unreadable";
|
|
21
|
+
bindings: ProjectBinding[];
|
|
22
|
+
/** Set when status is "unreadable". */
|
|
23
|
+
reason?: string;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* User-local, and deliberately separate from `~/.jam/config.yaml`: that file
|
|
27
|
+
* answers which JAM build runs, this one answers which Jira project a
|
|
28
|
+
* workspace belongs to, and neither should move when the other changes.
|
|
29
|
+
*/
|
|
30
|
+
export declare function projectBindingsPath(home?: string): string;
|
|
31
|
+
/** Read-only. Never creates the directory, so `detect` stays free of writes. */
|
|
32
|
+
export declare function inspectProjectBindings(home?: string): BindingsInspection;
|
|
33
|
+
/**
|
|
34
|
+
* Discovery. A damaged file yields no bindings rather than turning every
|
|
35
|
+
* command into an error - the same stance the preset file and the runtime
|
|
36
|
+
* config already take.
|
|
37
|
+
*/
|
|
38
|
+
export declare function readProjectBindings(home?: string): ProjectBinding[];
|
|
39
|
+
export declare function findProjectBinding(workspaceId: string, home?: string): ProjectBinding | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Record one binding, preserving every other entry.
|
|
42
|
+
*
|
|
43
|
+
* Fail-closed on a file that exists but will not parse: refusing costs the
|
|
44
|
+
* user one edit, while rewriting would silently drop bindings this build
|
|
45
|
+
* could not read. Same reasoning as `.mcp.json`, which is never overwritten
|
|
46
|
+
* when it does not parse.
|
|
47
|
+
*/
|
|
48
|
+
export declare function writeProjectBinding(binding: ProjectBinding, home?: string): string;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
import { JamError } from "../domain/errors.js";
|
|
6
|
+
const FILE_VERSION = 1;
|
|
7
|
+
const HEADER = "# JAM personal project bindings for this user. Safe to edit by hand.\n" +
|
|
8
|
+
"# Never put Jira credentials here.\n" +
|
|
9
|
+
"# `path` is provenance only - JAM matches on `workspace`.\n";
|
|
10
|
+
/**
|
|
11
|
+
* User-local, and deliberately separate from `~/.jam/config.yaml`: that file
|
|
12
|
+
* answers which JAM build runs, this one answers which Jira project a
|
|
13
|
+
* workspace belongs to, and neither should move when the other changes.
|
|
14
|
+
*/
|
|
15
|
+
export function projectBindingsPath(home = homedir()) {
|
|
16
|
+
return join(home, ".jam", "projects.yaml");
|
|
17
|
+
}
|
|
18
|
+
/** Read-only. Never creates the directory, so `detect` stays free of writes. */
|
|
19
|
+
export function inspectProjectBindings(home) {
|
|
20
|
+
const path = projectBindingsPath(home);
|
|
21
|
+
if (!existsSync(path))
|
|
22
|
+
return { path, status: "absent", bindings: [] };
|
|
23
|
+
let raw;
|
|
24
|
+
try {
|
|
25
|
+
raw = parseYaml(readFileSync(path, "utf8"));
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
return {
|
|
29
|
+
path,
|
|
30
|
+
status: "unreadable",
|
|
31
|
+
bindings: [],
|
|
32
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
if (!raw || typeof raw !== "object") {
|
|
36
|
+
return { path, status: "unreadable", bindings: [], reason: "not a mapping" };
|
|
37
|
+
}
|
|
38
|
+
const version = raw.version;
|
|
39
|
+
if (version !== undefined && version !== FILE_VERSION) {
|
|
40
|
+
// A newer file is not a broken one, but this build cannot promise to
|
|
41
|
+
// rewrite it without losing whatever it does not understand.
|
|
42
|
+
return {
|
|
43
|
+
path,
|
|
44
|
+
status: "unreadable",
|
|
45
|
+
bindings: [],
|
|
46
|
+
reason: `version ${String(version)} is not supported by this build`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// `bindings:` with nothing after it parses as null, which is what a person
|
|
50
|
+
// is left with after deleting the last entry by hand. That is an empty list,
|
|
51
|
+
// not a damaged file - refusing to write there would be a dead end.
|
|
52
|
+
const list = raw.bindings ?? [];
|
|
53
|
+
if (!Array.isArray(list)) {
|
|
54
|
+
return { path, status: "unreadable", bindings: [], reason: "`bindings` is not a list" };
|
|
55
|
+
}
|
|
56
|
+
const bindings = [];
|
|
57
|
+
for (const entry of (list ?? [])) {
|
|
58
|
+
if (!entry || typeof entry !== "object")
|
|
59
|
+
continue;
|
|
60
|
+
const workspace = entry.workspace;
|
|
61
|
+
const key = entry.key;
|
|
62
|
+
if (typeof workspace !== "string" || !workspace.trim())
|
|
63
|
+
continue;
|
|
64
|
+
if (typeof key !== "string" || !key.trim())
|
|
65
|
+
continue;
|
|
66
|
+
const path_ = entry.path;
|
|
67
|
+
bindings.push({
|
|
68
|
+
workspace: workspace.trim(),
|
|
69
|
+
key: key.trim(),
|
|
70
|
+
...(typeof path_ === "string" && path_.trim() ? { path: path_.trim() } : {}),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return { path, status: "parsed", bindings };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Discovery. A damaged file yields no bindings rather than turning every
|
|
77
|
+
* command into an error - the same stance the preset file and the runtime
|
|
78
|
+
* config already take.
|
|
79
|
+
*/
|
|
80
|
+
export function readProjectBindings(home) {
|
|
81
|
+
return inspectProjectBindings(home).bindings;
|
|
82
|
+
}
|
|
83
|
+
export function findProjectBinding(workspaceId, home) {
|
|
84
|
+
return readProjectBindings(home).find((b) => b.workspace === workspaceId);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Record one binding, preserving every other entry.
|
|
88
|
+
*
|
|
89
|
+
* Fail-closed on a file that exists but will not parse: refusing costs the
|
|
90
|
+
* user one edit, while rewriting would silently drop bindings this build
|
|
91
|
+
* could not read. Same reasoning as `.mcp.json`, which is never overwritten
|
|
92
|
+
* when it does not parse.
|
|
93
|
+
*/
|
|
94
|
+
export function writeProjectBinding(binding, home) {
|
|
95
|
+
const inspection = inspectProjectBindings(home);
|
|
96
|
+
if (inspection.status === "unreadable") {
|
|
97
|
+
throw new JamError("JAM_BINDINGS_UNREADABLE", `Refusing to rewrite ${inspection.path}: ${inspection.reason ?? "it could not be read"}. Fix or remove that file, then try again.`, { path: inspection.path });
|
|
98
|
+
}
|
|
99
|
+
const kept = inspection.bindings.filter((b) => b.workspace !== binding.workspace);
|
|
100
|
+
const next = [...kept, binding].sort((a, b) => a.workspace.localeCompare(b.workspace));
|
|
101
|
+
const body = next
|
|
102
|
+
.map((b) => {
|
|
103
|
+
const lines = [` - workspace: ${JSON.stringify(b.workspace)}`, ` key: ${b.key}`];
|
|
104
|
+
if (b.path)
|
|
105
|
+
lines.push(` path: ${JSON.stringify(b.path)}`);
|
|
106
|
+
return lines.join("\n");
|
|
107
|
+
})
|
|
108
|
+
.join("\n");
|
|
109
|
+
mkdirSync(dirname(inspection.path), { recursive: true });
|
|
110
|
+
writeFileSync(inspection.path, `${HEADER}version: ${FILE_VERSION}\n\nbindings:\n${body}\n`, "utf8");
|
|
111
|
+
return inspection.path;
|
|
112
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type BootstrapSource = "explicit" | "env" | "binding" | "preset";
|
|
2
|
+
export type KeyDecision = {
|
|
3
|
+
key: string;
|
|
4
|
+
source: BootstrapSource;
|
|
5
|
+
};
|
|
6
|
+
export declare const DEFAULT_PRESETS_PATH: string;
|
|
7
|
+
export type DecideProjectKeyOptions = {
|
|
8
|
+
explicitKey?: string;
|
|
9
|
+
env?: NodeJS.ProcessEnv;
|
|
10
|
+
/**
|
|
11
|
+
* The key this user has bound to this workspace, already looked up. Passed
|
|
12
|
+
* in rather than read here so this function stays a pure ordering of
|
|
13
|
+
* explicit sources.
|
|
14
|
+
*/
|
|
15
|
+
bindingKey?: string;
|
|
16
|
+
presetsPath?: string;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Decide a project key from explicit sources only - JAM never guesses a Jira
|
|
20
|
+
* project from a folder or repo name. Order: `--project` flag, then
|
|
21
|
+
* `JAM_PROJECT_KEY`, then this user's binding for the workspace, then a
|
|
22
|
+
* matching entry in the legacy preset file. Returns undefined when none
|
|
23
|
+
* apply, which the caller must treat as "cannot decide safely".
|
|
24
|
+
*
|
|
25
|
+
* Flag over environment over persisted, because what someone typed for this
|
|
26
|
+
* run should beat what a shell exported, which should beat what was recorded
|
|
27
|
+
* some time ago.
|
|
28
|
+
*/
|
|
29
|
+
export declare function decideProjectKey(root: string, options?: DecideProjectKeyOptions): KeyDecision | undefined;
|
|
30
|
+
/** Windows paths are case-insensitive; every other platform is compared as-is. */
|
|
31
|
+
export declare function normalizePath(p: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Writes the minimal `.jira-agent/project.yaml`. Only the key is set - every
|
|
34
|
+
* other field is left to the schema's built-in defaults so this file stays
|
|
35
|
+
* legible and merge-friendly.
|
|
36
|
+
*/
|
|
37
|
+
export declare function writeBootstrapConfig(root: string, key: string): string;
|