@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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -0
  3. package/dist/adapters/cache/noop-cache.d.ts +7 -0
  4. package/dist/adapters/cache/noop-cache.js +12 -0
  5. package/dist/adapters/credentials/composite.d.ts +30 -0
  6. package/dist/adapters/credentials/composite.js +79 -0
  7. package/dist/adapters/credentials/process-env.d.ts +13 -0
  8. package/dist/adapters/credentials/process-env.js +17 -0
  9. package/dist/adapters/credentials/secret-store.d.ts +69 -0
  10. package/dist/adapters/credentials/secret-store.js +259 -0
  11. package/dist/adapters/credentials/windows-user-env.d.ts +20 -0
  12. package/dist/adapters/credentials/windows-user-env.js +51 -0
  13. package/dist/adapters/jira-cloud/adf-to-text.d.ts +12 -0
  14. package/dist/adapters/jira-cloud/adf-to-text.js +151 -0
  15. package/dist/adapters/jira-cloud/jira-client.d.ts +32 -0
  16. package/dist/adapters/jira-cloud/jira-client.js +137 -0
  17. package/dist/adapters/jira-cloud/jira-read.adapter.d.ts +13 -0
  18. package/dist/adapters/jira-cloud/jira-read.adapter.js +97 -0
  19. package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +12 -0
  20. package/dist/adapters/jira-cloud/jira-write.adapter.js +17 -0
  21. package/dist/adapters/jira-cloud/mapper.d.ts +34 -0
  22. package/dist/adapters/jira-cloud/mapper.js +143 -0
  23. package/dist/adapters/telemetry/console-telemetry.d.ts +13 -0
  24. package/dist/adapters/telemetry/console-telemetry.js +31 -0
  25. package/dist/application/get-full-issue-context.d.ts +18 -0
  26. package/dist/application/get-full-issue-context.js +120 -0
  27. package/dist/application/get-issue-context.d.ts +19 -0
  28. package/dist/application/get-issue-context.js +79 -0
  29. package/dist/application/search-issues.d.ts +22 -0
  30. package/dist/application/search-issues.js +93 -0
  31. package/dist/bootstrap/boot-health-gate.d.ts +25 -0
  32. package/dist/bootstrap/boot-health-gate.js +137 -0
  33. package/dist/bootstrap/bootstrap-orchestrator.d.ts +15 -0
  34. package/dist/bootstrap/bootstrap-orchestrator.js +14 -0
  35. package/dist/bootstrap/host-mcp.d.ts +54 -0
  36. package/dist/bootstrap/host-mcp.js +98 -0
  37. package/dist/bootstrap/jira-projects.d.ts +16 -0
  38. package/dist/bootstrap/jira-projects.js +24 -0
  39. package/dist/bootstrap/mcp-config-merger.d.ts +58 -0
  40. package/dist/bootstrap/mcp-config-merger.js +119 -0
  41. package/dist/bootstrap/migration-target.d.ts +60 -0
  42. package/dist/bootstrap/migration-target.js +80 -0
  43. package/dist/bootstrap/project-bindings.d.ts +48 -0
  44. package/dist/bootstrap/project-bindings.js +112 -0
  45. package/dist/bootstrap/project-config-bootstrapper.d.ts +37 -0
  46. package/dist/bootstrap/project-config-bootstrapper.js +69 -0
  47. package/dist/bootstrap/project-config-resolver.d.ts +53 -0
  48. package/dist/bootstrap/project-config-resolver.js +59 -0
  49. package/dist/bootstrap/project-root-resolver.d.ts +22 -0
  50. package/dist/bootstrap/project-root-resolver.js +43 -0
  51. package/dist/bootstrap/setup-apply.d.ts +31 -0
  52. package/dist/bootstrap/setup-apply.js +61 -0
  53. package/dist/bootstrap/setup-plan.d.ts +102 -0
  54. package/dist/bootstrap/setup-plan.js +224 -0
  55. package/dist/bootstrap/setup-state.d.ts +73 -0
  56. package/dist/bootstrap/setup-state.js +89 -0
  57. package/dist/bootstrap/workspace-identity.d.ts +52 -0
  58. package/dist/bootstrap/workspace-identity.js +114 -0
  59. package/dist/cli/agent-api.d.ts +63 -0
  60. package/dist/cli/agent-api.js +149 -0
  61. package/dist/cli/auth.d.ts +39 -0
  62. package/dist/cli/auth.js +201 -0
  63. package/dist/cli/doctor.d.ts +7 -0
  64. package/dist/cli/doctor.js +31 -0
  65. package/dist/cli/runtime.d.ts +16 -0
  66. package/dist/cli/runtime.js +97 -0
  67. package/dist/cli/serve.d.ts +7 -0
  68. package/dist/cli/serve.js +23 -0
  69. package/dist/cli/setup-wizard.d.ts +37 -0
  70. package/dist/cli/setup-wizard.js +338 -0
  71. package/dist/cli/setup.d.ts +26 -0
  72. package/dist/cli/setup.js +214 -0
  73. package/dist/cli/ui.d.ts +118 -0
  74. package/dist/cli/ui.js +338 -0
  75. package/dist/cli-entry.d.ts +7 -0
  76. package/dist/cli-entry.js +124 -0
  77. package/dist/config/load-config.d.ts +18 -0
  78. package/dist/config/load-config.js +48 -0
  79. package/dist/config/schema.d.ts +36 -0
  80. package/dist/config/schema.js +74 -0
  81. package/dist/deps.d.ts +50 -0
  82. package/dist/deps.js +39 -0
  83. package/dist/domain/completeness.d.ts +63 -0
  84. package/dist/domain/completeness.js +20 -0
  85. package/dist/domain/context.d.ts +38 -0
  86. package/dist/domain/context.js +1 -0
  87. package/dist/domain/errors.d.ts +27 -0
  88. package/dist/domain/errors.js +49 -0
  89. package/dist/domain/issue.d.ts +35 -0
  90. package/dist/domain/issue.js +1 -0
  91. package/dist/index.d.ts +2 -0
  92. package/dist/index.js +17 -0
  93. package/dist/mcp/create-server.d.ts +9 -0
  94. package/dist/mcp/create-server.js +32 -0
  95. package/dist/mcp/tool-result.d.ts +10 -0
  96. package/dist/mcp/tool-result.js +33 -0
  97. package/dist/mcp/tools/jira-context.tool.d.ts +3 -0
  98. package/dist/mcp/tools/jira-context.tool.js +25 -0
  99. package/dist/mcp/tools/jira-full.tool.d.ts +3 -0
  100. package/dist/mcp/tools/jira-full.tool.js +27 -0
  101. package/dist/mcp/tools/jira-search.tool.d.ts +3 -0
  102. package/dist/mcp/tools/jira-search.tool.js +31 -0
  103. package/dist/policy/completeness-policy.d.ts +19 -0
  104. package/dist/policy/completeness-policy.js +34 -0
  105. package/dist/policy/consistency-policy.d.ts +13 -0
  106. package/dist/policy/consistency-policy.js +6 -0
  107. package/dist/policy/field-policy.d.ts +11 -0
  108. package/dist/policy/field-policy.js +26 -0
  109. package/dist/policy/output-budget-policy.d.ts +23 -0
  110. package/dist/policy/output-budget-policy.js +96 -0
  111. package/dist/policy/pagination-policy.d.ts +15 -0
  112. package/dist/policy/pagination-policy.js +13 -0
  113. package/dist/ports/cache.port.d.ts +5 -0
  114. package/dist/ports/cache.port.js +1 -0
  115. package/dist/ports/credentials.port.d.ts +27 -0
  116. package/dist/ports/credentials.port.js +1 -0
  117. package/dist/ports/jira-read.port.d.ts +73 -0
  118. package/dist/ports/jira-read.port.js +1 -0
  119. package/dist/ports/jira-write.port.d.ts +12 -0
  120. package/dist/ports/jira-write.port.js +1 -0
  121. package/dist/ports/telemetry.port.d.ts +13 -0
  122. package/dist/ports/telemetry.port.js +1 -0
  123. 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;