@jam-mcp/server 1.4.6 → 1.6.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/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.6 doctor`, or just `jam
77
+ Written out, that is `npx --yes @jam-mcp/launcher@1.6.0 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.6 init` instead.
80
+ `npx --yes @jam-mcp/bootstrap@1.6.0 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
@@ -54,6 +54,7 @@ export declare const defaultHostRunner: HostRunner;
54
54
  export declare const persistentHostRunner: HostRunner;
55
55
  export declare function hostRegistration(id: HostId, options?: {
56
56
  bare?: boolean;
57
+ version?: string;
57
58
  }): HostCommand | undefined;
58
59
  /** The removal that has to precede re-registering an entry this host already has. */
59
60
  export declare function hostUnregistration(id: HostId): HostCommand | undefined;
@@ -1,5 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { JAM_MCP_ENTRY } from "./mcp-config-merger.js";
2
+ import { LAUNCHER_PACKAGE } from "@jam-mcp/launcher";
3
+ import { JAM_MCP_ENTRY, LAUNCHER_PACKAGE_SPEC } from "./mcp-config-merger.js";
3
4
  import { shellInvocation, stripPackageRunnerPath } from "./shell-command.js";
4
5
  /**
5
6
  * These boot a whole Node CLI, and Claude Code health-checks every configured
@@ -75,8 +76,18 @@ export function hostRegistration(id, options = {}) {
75
76
  const adapter = ADAPTERS.find((a) => a.id === id);
76
77
  if (!adapter)
77
78
  return undefined;
78
- if (!options.bare)
79
- return adapter.register;
79
+ if (!options.bare) {
80
+ // `jam update` registers a version this build is not - that is the whole
81
+ // point of an update. Only the pin moves; the rest of the argv is the same
82
+ // line setup writes, so the two cannot drift into different registrations.
83
+ if (!options.version)
84
+ return adapter.register;
85
+ const pinned = `${LAUNCHER_PACKAGE}@${options.version}`;
86
+ return {
87
+ command: adapter.register.command,
88
+ args: adapter.register.args.map((arg) => (arg === LAUNCHER_PACKAGE_SPEC ? pinned : arg)),
89
+ };
90
+ }
80
91
  const at = adapter.register.args.indexOf("--");
81
92
  return { command: adapter.register.command, args: [...adapter.register.args.slice(0, at), ...LAUNCH_BARE] };
82
93
  }
@@ -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.6", "serve"];
16
+ readonly args: readonly ["--yes", "@jam-mcp/launcher@1.6.0", "serve"];
17
17
  };
18
18
  /**
19
19
  * Recognise wiring from before the launcher existed: a hard-coded `node` path
@@ -0,0 +1,66 @@
1
+ import { type HostId, type HostRunner, type HostState } from "../bootstrap/host-mcp.js";
2
+ export type LifecycleOptions = {
3
+ json?: boolean;
4
+ /** Injected by tests. Nothing here may reach a real host CLI or npm unasked. */
5
+ run?: HostRunner;
6
+ hosts?: () => HostState[];
7
+ };
8
+ export type RefreshHostPlan = {
9
+ id: HostId;
10
+ from?: string;
11
+ bare: boolean;
12
+ action: "repin" | "none";
13
+ };
14
+ export type RefreshPlan = {
15
+ /** The version this build is. `refresh` never moves off it - that is `update`. */
16
+ version: string;
17
+ hosts: RefreshHostPlan[];
18
+ steps: readonly string[];
19
+ };
20
+ /**
21
+ * What `refresh` would converge. Pure: it runs nothing and writes nothing.
22
+ *
23
+ * The scope is deliberately small. A registration that points at a different
24
+ * launcher than this build is what goes stale here. The project binding, the
25
+ * credentials, `~/.jam/config.yaml` and every byte of Jira data are outside
26
+ * it - re-deciding those is `setup`, and calling it "refresh" is how a person
27
+ * loses a binding they never asked to change.
28
+ */
29
+ export declare function planRefresh(hosts: readonly HostState[], version?: string): RefreshPlan;
30
+ export declare function refreshLine(plan: RefreshPlan): string;
31
+ /**
32
+ * `jam refresh` - keep the version, make the registration match it again.
33
+ *
34
+ * Nothing is removed before the replacement is known to work, which is the
35
+ * same order `update` uses and for the same reason: a failed refresh leaves
36
+ * the machine running what it was running.
37
+ */
38
+ export declare function jamRefreshCommand(command: string | undefined, options?: LifecycleOptions): Promise<number>;
39
+ export type UninstallPlan = {
40
+ /** Registrations that would be removed. */
41
+ hosts: HostId[];
42
+ /** Whether a global launcher install would be removed with it. */
43
+ runtime: string | null;
44
+ preserve: string[];
45
+ };
46
+ /**
47
+ * `jam uninstall` - remove what JAM installed, keep what is the person's.
48
+ *
49
+ * Removed: the MCP registrations JAM wrote, and the global launcher when one
50
+ * is installed. Kept: `~/.jam` in full - the project bindings, the runtime
51
+ * choice and the credentials in the OS secret store. There is no purge here
52
+ * on purpose: nothing yet needs a command that destroys those, and an
53
+ * irreversible one that nobody asked for is worse than a missing one.
54
+ */
55
+ export declare function planUninstall(hosts: readonly HostState[], installed: string | null): UninstallPlan;
56
+ export declare function jamUninstallCommand(command: string | undefined, options?: LifecycleOptions): Promise<number>;
57
+ /**
58
+ * `jam status` - the first place a person asks what is going on.
59
+ *
60
+ * This is `doctor`'s user-facing role under the name both products use. The
61
+ * judgement is not reimplemented: the same health gate and the same per-axis
62
+ * verdicts answer here, so the two commands can never disagree.
63
+ */
64
+ export declare function jamStatusCommand(options?: {
65
+ json?: boolean;
66
+ }): Promise<number>;
@@ -0,0 +1,233 @@
1
+ import { LAUNCHER_PACKAGE, SERVER_VERSION } from "@jam-mcp/launcher";
2
+ import { spawnSync } from "node:child_process";
3
+ import { doctorJsonCommand } from "./agent-api.js";
4
+ import { doctor } from "./doctor.js";
5
+ import { detectHosts, hostRegistration, hostUnregistration, } from "../bootstrap/host-mcp.js";
6
+ /**
7
+ * The lifecycle words JAM shares with ASC: `refresh` and `uninstall`.
8
+ *
9
+ * The two products answer to the same vocabulary because a person should not
10
+ * have to remember which one uses which verb:
11
+ *
12
+ * setup make it usable for the first time
13
+ * status what is configured, what works, what is blocked
14
+ * update move to a newer published release
15
+ * refresh keep the version, re-converge what this build registered
16
+ * uninstall remove the product; the person's state stays
17
+ * runtime which build this machine actually runs
18
+ *
19
+ * What is different is ownership, and that stays different. JAM is the Jira
20
+ * access layer: it has no execution mode, no approval path and no session
21
+ * model. A Jira write still travels ASC's decision path and lands through
22
+ * JAM's own write plan/apply - this file does not change that.
23
+ */
24
+ /** A runner with room for npm. The host runner's 20s is not enough for an install. */
25
+ const defaultRunner = ({ command, args }) => {
26
+ const result = spawnSync(command, args, {
27
+ encoding: "utf8",
28
+ timeout: 180_000,
29
+ shell: process.platform === "win32",
30
+ });
31
+ if (result.error)
32
+ return { status: null, failed: true, stdout: "" };
33
+ return { status: result.status, failed: false, stdout: result.stdout ?? "" };
34
+ };
35
+ /**
36
+ * What `refresh` would converge. Pure: it runs nothing and writes nothing.
37
+ *
38
+ * The scope is deliberately small. A registration that points at a different
39
+ * launcher than this build is what goes stale here. The project binding, the
40
+ * credentials, `~/.jam/config.yaml` and every byte of Jira data are outside
41
+ * it - re-deciding those is `setup`, and calling it "refresh" is how a person
42
+ * loses a binding they never asked to change.
43
+ */
44
+ export function planRefresh(hosts, version = SERVER_VERSION) {
45
+ const registered = hosts.filter((host) => host.cliAvailable && host.hasJamEntry);
46
+ const plans = registered.map((host) => ({
47
+ id: host.id,
48
+ ...(host.entryVersion ? { from: host.entryVersion } : {}),
49
+ bare: host.entryBare === true,
50
+ // A bare entry runs the global executable, so its line is already whatever
51
+ // that executable is. Only a pinned line can point somewhere else.
52
+ action: host.entryBare === true || host.entryVersion === version ? "none" : "repin",
53
+ }));
54
+ const moving = plans.filter((host) => host.action === "repin");
55
+ return {
56
+ version,
57
+ hosts: plans,
58
+ steps: moving.length === 0 ? [] : ["switch-registration", "verify"],
59
+ };
60
+ }
61
+ export function refreshLine(plan) {
62
+ const moving = plan.hosts.filter((host) => host.action === "repin");
63
+ if (plan.hosts.length === 0)
64
+ return "No host has a JAM registration - `jam setup` is what adds one.";
65
+ return moving.length === 0
66
+ ? `Registration is current - JAM ${plan.version}. Version unchanged.`
67
+ : `Would re-register: ${moving.map((host) => `${host.id} runs ${host.from ?? "?"}`).join(", ")}`;
68
+ }
69
+ /**
70
+ * `jam refresh` - keep the version, make the registration match it again.
71
+ *
72
+ * Nothing is removed before the replacement is known to work, which is the
73
+ * same order `update` uses and for the same reason: a failed refresh leaves
74
+ * the machine running what it was running.
75
+ */
76
+ export async function jamRefreshCommand(command, options = {}) {
77
+ if (command !== undefined && command !== "check" && command !== "plan") {
78
+ process.stderr.write(`Unknown refresh command: ${command}\nUsage: jam refresh [check|plan] [--json]\n`);
79
+ return 1;
80
+ }
81
+ const run = options.run ?? defaultRunner;
82
+ const hosts = (options.hosts ?? (() => detectHosts(run)))();
83
+ const plan = planRefresh(hosts);
84
+ if (command === "check" || command === "plan") {
85
+ if (options.json)
86
+ process.stdout.write(`${JSON.stringify({ package: LAUNCHER_PACKAGE, ...plan }, null, 2)}\n`);
87
+ else
88
+ process.stdout.write(`${refreshLine(plan)}\n`);
89
+ return 0;
90
+ }
91
+ if (plan.steps.length === 0) {
92
+ if (options.json)
93
+ process.stdout.write(`${JSON.stringify({ package: LAUNCHER_PACKAGE, ...plan, changed: [] }, null, 2)}\n`);
94
+ else
95
+ process.stdout.write(`${refreshLine(plan)}\n`);
96
+ return 0;
97
+ }
98
+ const changed = [];
99
+ for (const host of plan.hosts.filter((h) => h.action === "repin")) {
100
+ // `mcp add` over an existing entry changes nothing on Claude Code - it
101
+ // answers "already exists". The removal is what makes the re-pin land.
102
+ const remove = hostUnregistration(host.id);
103
+ if (remove)
104
+ run(remove);
105
+ const register = hostRegistration(host.id, { version: plan.version });
106
+ if (!register)
107
+ continue;
108
+ const result = run(register);
109
+ if (result.failed || result.status !== 0) {
110
+ process.stderr.write(`refresh failed on ${host.id} - re-register with \`jam setup --agent\`.\n`);
111
+ return 1;
112
+ }
113
+ changed.push(host.id);
114
+ }
115
+ // Read it back. A registration JAM could not verify is never reported as done.
116
+ const after = (options.hosts ?? (() => detectHosts(run)))();
117
+ const stale = after.filter((host) => changed.includes(host.id) && host.entryBare !== true && host.entryVersion !== plan.version);
118
+ if (stale.length > 0) {
119
+ process.stderr.write(`health: ${stale.map((host) => `${host.id} still runs ${host.entryVersion ?? "?"}`).join(", ")}\n`);
120
+ return 1;
121
+ }
122
+ if (options.json) {
123
+ process.stdout.write(`${JSON.stringify({ package: LAUNCHER_PACKAGE, ...plan, changed }, null, 2)}\n`);
124
+ }
125
+ else {
126
+ for (const id of changed)
127
+ process.stdout.write(`registered: ${id} -> ${plan.version}\n`);
128
+ process.stdout.write(`JAM ${plan.version} is registered. Version unchanged.\n`);
129
+ }
130
+ return 0;
131
+ }
132
+ /**
133
+ * `jam uninstall` - remove what JAM installed, keep what is the person's.
134
+ *
135
+ * Removed: the MCP registrations JAM wrote, and the global launcher when one
136
+ * is installed. Kept: `~/.jam` in full - the project bindings, the runtime
137
+ * choice and the credentials in the OS secret store. There is no purge here
138
+ * on purpose: nothing yet needs a command that destroys those, and an
139
+ * irreversible one that nobody asked for is worse than a missing one.
140
+ */
141
+ export function planUninstall(hosts, installed) {
142
+ return {
143
+ hosts: hosts.filter((host) => host.cliAvailable && host.hasJamEntry).map((host) => host.id),
144
+ runtime: installed,
145
+ preserve: [
146
+ "~/.jam/projects.yaml - which project each checkout is bound to",
147
+ "~/.jam/config.yaml - the runtime this machine chose",
148
+ "Jira credentials in the OS secret store",
149
+ "every .jira-agent/project.yaml a repository carries",
150
+ ],
151
+ };
152
+ }
153
+ export async function jamUninstallCommand(command, options = {}) {
154
+ if (command !== undefined && command !== "plan") {
155
+ process.stderr.write(`Unknown uninstall command: ${command}\nUsage: jam uninstall [plan] [--json]\n`);
156
+ return 1;
157
+ }
158
+ const run = options.run ?? defaultRunner;
159
+ const hosts = (options.hosts ?? (() => detectHosts(run)))();
160
+ const installed = globalLauncher(run);
161
+ const plan = planUninstall(hosts, installed);
162
+ if (command === "plan") {
163
+ if (options.json)
164
+ process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
165
+ else {
166
+ process.stdout.write(plan.hosts.length === 0
167
+ ? "No host registration to remove.\n"
168
+ : `Would remove the JAM registration from: ${plan.hosts.join(", ")}\n`);
169
+ if (plan.runtime)
170
+ process.stdout.write(`Would remove ${LAUNCHER_PACKAGE}@${plan.runtime}\n`);
171
+ for (const kept of plan.preserve)
172
+ process.stdout.write(`Would keep: ${kept}\n`);
173
+ }
174
+ return 0;
175
+ }
176
+ let worst = 0;
177
+ for (const id of plan.hosts) {
178
+ const remove = hostUnregistration(id);
179
+ if (!remove)
180
+ continue;
181
+ const result = run(remove);
182
+ if (result.failed || result.status !== 0) {
183
+ process.stderr.write(`could not remove the registration from ${id}\n`);
184
+ worst = 1;
185
+ continue;
186
+ }
187
+ process.stdout.write(`removed: ${id} registration\n`);
188
+ }
189
+ if (plan.runtime) {
190
+ const removal = run({ command: "npm", args: ["uninstall", "-g", LAUNCHER_PACKAGE] });
191
+ if (removal.failed || removal.status !== 0) {
192
+ process.stderr.write(`could not remove ${LAUNCHER_PACKAGE} - run: npm uninstall -g ${LAUNCHER_PACKAGE}\n`);
193
+ worst = 1;
194
+ }
195
+ else {
196
+ process.stdout.write(`removed: ${LAUNCHER_PACKAGE}@${plan.runtime}\n`);
197
+ }
198
+ }
199
+ process.stdout.write("\nYour state stays:\n");
200
+ for (const kept of plan.preserve)
201
+ process.stdout.write(` ${kept}\n`);
202
+ return worst;
203
+ }
204
+ /** The globally installed launcher version, or null when there is none. */
205
+ function globalLauncher(run) {
206
+ const result = run({ command: "npm", args: ["ls", "-g", "--depth=0", "--json", LAUNCHER_PACKAGE] });
207
+ if (result.failed)
208
+ return null;
209
+ try {
210
+ const parsed = JSON.parse(result.stdout);
211
+ return parsed.dependencies?.[LAUNCHER_PACKAGE]?.version ?? null;
212
+ }
213
+ catch {
214
+ return null;
215
+ }
216
+ }
217
+ /**
218
+ * `jam status` - the first place a person asks what is going on.
219
+ *
220
+ * This is `doctor`'s user-facing role under the name both products use. The
221
+ * judgement is not reimplemented: the same health gate and the same per-axis
222
+ * verdicts answer here, so the two commands can never disagree.
223
+ */
224
+ export async function jamStatusCommand(options = {}) {
225
+ if (options.json)
226
+ return doctorJsonCommand();
227
+ process.stdout.write(`jam ${SERVER_VERSION}\n`);
228
+ const code = await doctor();
229
+ process.stdout.write(code === 0
230
+ ? "\nNext: nothing - reading Jira is ready. `jam update` when a newer release is out.\n"
231
+ : "\nNext: `jam setup` binds this project and stores what is missing. `jam refresh` only re-registers.\n");
232
+ return code;
233
+ }
@@ -0,0 +1,74 @@
1
+ import { type HostId, type HostRunner } from "../bootstrap/host-mcp.js";
2
+ /**
3
+ * `jam update` - move this machine to the published release without redoing setup.
4
+ *
5
+ * What actually goes stale here is not a directory. JAM is launched by the
6
+ * host from a registration line, and that line carries an exact pin:
7
+ *
8
+ * jam: npx --yes @jam-mcp/launcher@1.4.5 serve
9
+ *
10
+ * A newer JAM on the registry changes nothing until that line moves. Right
11
+ * after 1.4.6 was published, this machine measured
12
+ * `registration: HOST_REGISTRATION_STALE (registered 1.4.5)` - the agent was
13
+ * still talking to the previous server. The command that fixed it was
14
+ * `jam setup --agent`, which also binds the project, re-detects credentials
15
+ * and re-plans everything else. Re-running setup is not what "update" means,
16
+ * so this path never calls it.
17
+ *
18
+ * The order is the same one ASC uses, and for the same reason: the new build
19
+ * is proven to answer *before* the registration is pointed at it. Nothing is
20
+ * removed first - a failed update leaves the machine running what it was
21
+ * running.
22
+ */
23
+ export type UpdateStep = "install" | "verify-install" | "switch-registration" | "verify-health";
24
+ export declare const UPDATE_ORDER: readonly UpdateStep[];
25
+ export type UpdateState =
26
+ /** Every registration this machine has runs the published release. */
27
+ "CURRENT" | "UPDATE_AVAILABLE"
28
+ /** A registration exists whose version cannot be read - it may run anything. */
29
+ | "BROKEN"
30
+ /** The registry could not be asked. Nothing is claimed about being up to date. */
31
+ | "UNKNOWN";
32
+ export type HostPlan = {
33
+ id: HostId;
34
+ from?: string;
35
+ /** A bare `jam serve` entry: the version lives in the global install, not the line. */
36
+ bare: boolean;
37
+ action: "repin" | "none" | "unreadable";
38
+ };
39
+ export type JamUpdatePlan = {
40
+ state: UpdateState;
41
+ latest?: string;
42
+ running: string;
43
+ hosts: HostPlan[];
44
+ steps: readonly UpdateStep[];
45
+ detail?: string;
46
+ };
47
+ export type HostFacts = {
48
+ id: HostId;
49
+ cliAvailable: boolean;
50
+ hasJamEntry: boolean;
51
+ entryVersion?: string;
52
+ entryBare?: boolean;
53
+ };
54
+ /**
55
+ * Decide, from what was measured. Pure - it runs nothing and writes nothing.
56
+ *
57
+ * A host with no `jam` entry is left alone. Registering JAM somewhere it was
58
+ * never registered is adoption, not an update, and `jam setup` is where the
59
+ * person says they want that.
60
+ */
61
+ export declare function planJamUpdate(input: {
62
+ latest?: string;
63
+ hosts: HostFacts[];
64
+ }): JamUpdatePlan;
65
+ export declare function updateLine(plan: JamUpdatePlan): string;
66
+ export type UpdateOptions = {
67
+ json?: boolean;
68
+ /** Injected by tests. Nothing here may reach a real host CLI or npm unasked. */
69
+ run?: HostRunner;
70
+ /** Injected by tests so a plan never depends on the registry. */
71
+ latest?: () => string | undefined;
72
+ hosts?: () => HostFacts[];
73
+ };
74
+ export declare function jamUpdateCommand(command: string | undefined, options?: UpdateOptions): Promise<number>;
@@ -0,0 +1,233 @@
1
+ import { LAUNCHER_PACKAGE, SERVER_VERSION } from "@jam-mcp/launcher";
2
+ import { spawnSync } from "node:child_process";
3
+ import { detectHosts, hostRegistration, hostUnregistration, } from "../bootstrap/host-mcp.js";
4
+ export const UPDATE_ORDER = [
5
+ "install",
6
+ "verify-install",
7
+ "switch-registration",
8
+ "verify-health",
9
+ ];
10
+ /**
11
+ * Decide, from what was measured. Pure - it runs nothing and writes nothing.
12
+ *
13
+ * A host with no `jam` entry is left alone. Registering JAM somewhere it was
14
+ * never registered is adoption, not an update, and `jam setup` is where the
15
+ * person says they want that.
16
+ */
17
+ export function planJamUpdate(input) {
18
+ const running = SERVER_VERSION;
19
+ const registered = input.hosts.filter((host) => host.cliAvailable && host.hasJamEntry);
20
+ if (!input.latest) {
21
+ return {
22
+ state: "UNKNOWN",
23
+ running,
24
+ hosts: [],
25
+ steps: [],
26
+ detail: "the registry could not be asked - nothing is claimed about being up to date",
27
+ };
28
+ }
29
+ const hosts = registered.map((host) => ({
30
+ id: host.id,
31
+ ...(host.entryVersion ? { from: host.entryVersion } : {}),
32
+ bare: host.entryBare === true,
33
+ action: host.entryVersion === undefined
34
+ ? "unreadable"
35
+ : host.entryVersion === input.latest
36
+ ? "none"
37
+ : "repin",
38
+ }));
39
+ // An entry whose version cannot be read is not "current" - it is a line
40
+ // running something nobody measured. Say that instead of moving it silently.
41
+ if (hosts.some((host) => host.action === "unreadable")) {
42
+ return {
43
+ state: "BROKEN",
44
+ latest: input.latest,
45
+ running,
46
+ hosts,
47
+ steps: [...UPDATE_ORDER],
48
+ detail: "a registration exists whose version could not be read",
49
+ };
50
+ }
51
+ if (hosts.every((host) => host.action === "none")) {
52
+ return { state: "CURRENT", latest: input.latest, running, hosts, steps: [] };
53
+ }
54
+ return { state: "UPDATE_AVAILABLE", latest: input.latest, running, hosts, steps: [...UPDATE_ORDER] };
55
+ }
56
+ export function updateLine(plan) {
57
+ switch (plan.state) {
58
+ case "CURRENT":
59
+ return `Up to date - JAM ${plan.latest} is registered.`;
60
+ case "UPDATE_AVAILABLE": {
61
+ const moving = plan.hosts.filter((host) => host.action === "repin");
62
+ return `Update available - ${plan.latest}: ${moving
63
+ .map((host) => `${host.id} runs ${host.from}`)
64
+ .join(", ")}`;
65
+ }
66
+ case "BROKEN":
67
+ case "UNKNOWN":
68
+ return `${plan.state}: ${plan.detail ?? "(no detail)"}`;
69
+ }
70
+ }
71
+ /** A process runner with room for an install. The host runner's 20s is not enough for npm. */
72
+ const defaultRunner = ({ command, args }) => {
73
+ const result = spawnSync(command, args, {
74
+ encoding: "utf8",
75
+ timeout: 180_000,
76
+ shell: process.platform === "win32",
77
+ });
78
+ if (result.error)
79
+ return { status: null, failed: true, stdout: "" };
80
+ return { status: result.status, failed: false, stdout: result.stdout ?? "" };
81
+ };
82
+ export async function jamUpdateCommand(command, options = {}) {
83
+ if (command !== undefined && command !== "check" && command !== "plan") {
84
+ process.stderr.write(`Unknown update command: ${command}\nUsage: jam update [check|plan] [--json]\n`);
85
+ return 1;
86
+ }
87
+ const run = options.run ?? defaultRunner;
88
+ const latest = (options.latest ?? (() => registryLatest(run)))();
89
+ const facts = (options.hosts ?? (() => detectHosts(run)))();
90
+ const plan = planJamUpdate({ ...(latest ? { latest } : {}), hosts: facts });
91
+ if (command === "check" || command === "plan") {
92
+ emit(plan, options);
93
+ return 0;
94
+ }
95
+ if (plan.steps.length === 0) {
96
+ emit(plan, options);
97
+ return plan.state === "CURRENT" ? 0 : 1;
98
+ }
99
+ return apply(plan, run, options);
100
+ }
101
+ function emit(plan, options) {
102
+ if (options.json)
103
+ process.stdout.write(`${JSON.stringify({ package: LAUNCHER_PACKAGE, ...plan }, null, 2)}\n`);
104
+ else
105
+ process.stdout.write(`${updateLine(plan)}\n`);
106
+ }
107
+ /** What the registry has. undefined when it could not be asked - never a guess. */
108
+ function registryLatest(run) {
109
+ const result = run({ command: "npm", args: ["view", LAUNCHER_PACKAGE, "version"] });
110
+ if (result.failed || result.status !== 0)
111
+ return undefined;
112
+ const version = result.stdout.trim();
113
+ return /^\d+\.\d+\.\d+/.test(version) ? version : undefined;
114
+ }
115
+ /**
116
+ * Does that version actually answer?
117
+ *
118
+ * `runtime status --json` is the cheapest honest question: it resolves the
119
+ * runtime the registered entry would resolve, and needs no bound project. A
120
+ * pin that cannot answer here is one the host would fail to start - which is
121
+ * exactly what must not be registered.
122
+ */
123
+ function launcherAnswers(run, version, bare) {
124
+ const result = bare
125
+ ? run({ command: "jam", args: ["runtime", "status", "--json"] })
126
+ : run({ command: "npx", args: ["--yes", `${LAUNCHER_PACKAGE}@${version}`, "runtime", "status", "--json"] });
127
+ if (result.failed || result.status !== 0)
128
+ return undefined;
129
+ try {
130
+ const parsed = JSON.parse(result.stdout);
131
+ return typeof parsed.version === "string" ? parsed.version : undefined;
132
+ }
133
+ catch {
134
+ return undefined;
135
+ }
136
+ }
137
+ async function apply(plan, run, options) {
138
+ const target = plan.latest;
139
+ process.stdout.write(`${updateLine(plan)}\n`);
140
+ const moving = plan.hosts.filter((host) => host.action !== "none");
141
+ // A bare entry runs the global executable, so that is what has to move.
142
+ // An npx pin is fetched at launch; verifying it is what "install" means there.
143
+ if (moving.some((host) => host.bare)) {
144
+ const installed = run({ command: "npm", args: ["install", "-g", `${LAUNCHER_PACKAGE}@${target}`] });
145
+ if (installed.failed || installed.status !== 0) {
146
+ process.stderr.write(`install failed: npm install -g ${LAUNCHER_PACKAGE}@${target}\n`);
147
+ // Nothing was re-registered. The machine still runs what it ran.
148
+ return 1;
149
+ }
150
+ }
151
+ for (const host of moving) {
152
+ const answered = launcherAnswers(run, target, host.bare);
153
+ if (answered !== target) {
154
+ process.stderr.write(`verify failed: ${target} did not answer as ${target}${answered ? ` (got ${answered})` : ""} - registration left as it is\n`);
155
+ return 1;
156
+ }
157
+ }
158
+ process.stdout.write(`verified: ${LAUNCHER_PACKAGE}@${target} answers\n`);
159
+ for (const host of moving) {
160
+ // `mcp add` over an existing entry changes nothing on Claude Code - it
161
+ // answers "already exists". The removal is what makes the re-pin land.
162
+ if (!host.bare) {
163
+ const remove = hostUnregistration(host.id);
164
+ if (remove)
165
+ run(remove);
166
+ }
167
+ const register = hostRegistration(host.id, { ...(host.bare ? { bare: true } : { version: target }) });
168
+ if (!register)
169
+ continue;
170
+ const result = run(register);
171
+ if (result.failed || result.status !== 0) {
172
+ process.stderr.write(`switch failed on ${host.id}\n`);
173
+ return rollback(plan, host, run);
174
+ }
175
+ process.stdout.write(`registered: ${host.id} -> ${target}\n`);
176
+ }
177
+ // Read it back. A registration JAM could not verify is never reported as done.
178
+ const after = (options.hosts ?? (() => detectHosts(run)))();
179
+ const stale = after.filter((host) => moving.some((m) => m.id === host.id) && host.entryVersion !== target);
180
+ if (stale.length > 0) {
181
+ process.stderr.write(`health: ${stale.map((host) => `${host.id} still runs ${host.entryVersion ?? "?"}`).join(", ")}\n`);
182
+ return 1;
183
+ }
184
+ // A registration that points at a build which cannot read Jira is not a
185
+ // finished update. `doctor` is the existing health axis - config, credentials
186
+ // and one live read - so it is asked here rather than reimplemented.
187
+ const health = doctorVerdict(run, target, moving.some((host) => host.bare));
188
+ if (health !== "ready") {
189
+ process.stderr.write(`doctor: ${health} - rolling back\n`);
190
+ const first = moving[0];
191
+ return first ? rollback(plan, first, run) : 1;
192
+ }
193
+ process.stdout.write(`JAM ${target} is registered, doctor ready.\n`);
194
+ return 0;
195
+ }
196
+ /**
197
+ * What `jam doctor --json` says about the build now registered.
198
+ *
199
+ * Run through the same entry the host would run, so this measures the thing
200
+ * that was just registered rather than the process doing the registering.
201
+ */
202
+ function doctorVerdict(run, version, bare) {
203
+ const result = bare
204
+ ? run({ command: "jam", args: ["doctor", "--json"] })
205
+ : run({ command: "npx", args: ["--yes", `${LAUNCHER_PACKAGE}@${version}`, "doctor", "--json"] });
206
+ if (result.failed)
207
+ return "could not be run";
208
+ try {
209
+ const parsed = JSON.parse(result.stdout);
210
+ return typeof parsed.status === "string" ? parsed.status : "unreadable";
211
+ }
212
+ catch {
213
+ return result.status === 0 ? "ready" : "unreadable";
214
+ }
215
+ }
216
+ /** Put back the pin that was there. Only possible when it was readable. */
217
+ function rollback(plan, host, run) {
218
+ if (!host.from || host.bare) {
219
+ process.stderr.write(`Nothing to roll back to on ${host.id} - re-register with \`jam setup --agent\`.\n`);
220
+ return 1;
221
+ }
222
+ const remove = hostUnregistration(host.id);
223
+ if (remove)
224
+ run(remove);
225
+ const back = hostRegistration(host.id, { version: host.from });
226
+ const result = back ? run(back) : undefined;
227
+ if (result && !result.failed && result.status === 0) {
228
+ process.stderr.write(`rolled back - ${host.id} runs ${host.from} again.\n`);
229
+ return 1;
230
+ }
231
+ process.stderr.write(`rollback failed on ${host.id} - re-register with \`jam setup --agent\`.\n`);
232
+ return 1;
233
+ }
@@ -3,5 +3,5 @@
3
3
  * points (notably @jam-mcp/bootstrap) can forward to exactly these commands
4
4
  * instead of reimplementing them.
5
5
  */
6
- export declare const USAGE = "jam - Jira Agent MCP\n\nUsage:\n jam serve Run the MCP server over stdio (default; this is what Claude Code / Codex launch)\n jam doctor Diagnose config, credentials and Jira connectivity\n jam setup [--project KEY] [--shared] [--migrate] [--non-interactive]\n Wire up this project and run doctor. Binds it to you\n alone, writing nothing to the repository; --shared\n adopts JAM for the team (project.yaml, .mcp.json)\n jam runtime Show which JAM build this machine runs\n jam runtime use package | development <path>\n Change it (writes ~/.jam/config.yaml only, never a project)\n jam auth login Store Jira credentials in this user's OS secret store\n jam auth logout Remove them again\n\nFor coding agents and scripts (stdout is JSON only, never prompts):\n jam setup --agent One shot: detect, plan, apply what is safe, verify\n jam setup plan --json Report what setup would change, changing nothing\n jam setup apply --non-interactive --json\n Execute the plan\n jam doctor --json Health check as structured output\n jam auth status --json Whether Jira credentials are configured (never their value)\n jam jira search <jql> [--scope preview|complete]\n jam jira context <KEY> [KEY...]\n jam jira full <KEY> [KEY...]\n Read Jira from the shell - the same reads the MCP\n tools do, for a session that cannot see them yet\n\nEnvironment:\n JIRA_BASE_URL https://your-site.atlassian.net\n JIRA_EMAIL Atlassian account email\n JIRA_API_TOKEN Atlassian API token\n JAM_PROJECT_KEY Jira project key, used by `jam setup`/`jam serve` when no\n .jira-agent/project.yaml exists yet\n\nCredentials and JAM_PROJECT_KEY are read from the current shell's environment\nfirst, then (on Windows) from the User environment - so a value set with\n`setx` works without opening a new terminal.\n";
6
+ export declare const USAGE = "jam - Jira Agent MCP\n\nLifecycle (the same words ASC uses)\n jam setup [--project KEY] [--shared] [--migrate] [--non-interactive]\n Wire up this project and verify it. Binds it to you\n alone, writing nothing to the repository; --shared\n adopts JAM for the team (project.yaml, .mcp.json)\n jam status What is configured, what works, what is blocked\n jam update Move this machine's registration to the published release\n jam refresh Keep the version; re-register what this build owns\n jam uninstall Remove JAM's registrations; your bindings and credentials stay\n jam runtime Show which JAM build this machine runs\n jam runtime use package | development <path>\n Change it (writes ~/.jam/config.yaml only, never a project)\n\nJira\n jam jira search <jql> [--scope preview|complete]\n jam jira context <KEY> [KEY...]\n jam jira full <KEY> [KEY...]\n Read Jira from the shell - the same reads the MCP\n tools do, for a session that cannot see them yet\n\nAuthentication\n jam auth status [--json] Whether Jira credentials are configured (never their value)\n jam auth login Store them in this user's OS secret store\n jam auth logout Remove them again\n\nFor coding agents and scripts (stdout is JSON only, never prompts):\n jam setup --agent One shot: detect, plan, apply what is safe, verify\n jam setup plan --json Report what setup would change, changing nothing\n jam setup apply --non-interactive --json\n Execute the plan\n jam status --json Health check as structured output\n jam update check|plan [--json]\n jam refresh check|plan [--json]\n jam uninstall plan [--json]\n\nHost runtime\n jam serve Run the MCP server over stdio - this is what Claude\n Code and Codex launch. Not a command a person types.\n\nEnvironment:\n JIRA_BASE_URL https://your-site.atlassian.net\n JIRA_EMAIL Atlassian account email\n JIRA_API_TOKEN Atlassian API token\n JAM_PROJECT_KEY Jira project key, used by `jam setup`/`jam serve` when no\n .jira-agent/project.yaml exists yet\n\nCredentials and JAM_PROJECT_KEY are read from the current shell's environment\nfirst, then (on Windows) from the User environment - so a value set with\n`setx` works without opening a new terminal.\n";
7
7
  export declare function runJamCommand(argv: string[]): Promise<number>;
package/dist/cli-entry.js CHANGED
@@ -3,6 +3,8 @@ import { doctor } from "./cli/doctor.js";
3
3
  import { showRuntime, useRuntime } from "./cli/runtime.js";
4
4
  import { serve } from "./cli/serve.js";
5
5
  import { setup } from "./cli/setup.js";
6
+ import { jamUpdateCommand } from "./cli/update.js";
7
+ import { jamRefreshCommand, jamStatusCommand, jamUninstallCommand } from "./cli/lifecycle.js";
6
8
  import { runSetupWizard } from "./cli/setup-wizard.js";
7
9
  import { reportPromptError, Ui } from "./cli/ui.js";
8
10
  import { authStatusCommand, doctorJsonCommand, setupAgentCommand, setupApplyCommand, setupPlanCommand, } from "./cli/agent-api.js";
@@ -14,32 +16,45 @@ import { runJiraRead } from "./cli/jira-read.js";
14
16
  */
15
17
  export const USAGE = `jam - Jira Agent MCP
16
18
 
17
- Usage:
18
- jam serve Run the MCP server over stdio (default; this is what Claude Code / Codex launch)
19
- jam doctor Diagnose config, credentials and Jira connectivity
19
+ Lifecycle (the same words ASC uses)
20
20
  jam setup [--project KEY] [--shared] [--migrate] [--non-interactive]
21
- Wire up this project and run doctor. Binds it to you
21
+ Wire up this project and verify it. Binds it to you
22
22
  alone, writing nothing to the repository; --shared
23
23
  adopts JAM for the team (project.yaml, .mcp.json)
24
+ jam status What is configured, what works, what is blocked
25
+ jam update Move this machine's registration to the published release
26
+ jam refresh Keep the version; re-register what this build owns
27
+ jam uninstall Remove JAM's registrations; your bindings and credentials stay
24
28
  jam runtime Show which JAM build this machine runs
25
29
  jam runtime use package | development <path>
26
30
  Change it (writes ~/.jam/config.yaml only, never a project)
27
- jam auth login Store Jira credentials in this user's OS secret store
28
- jam auth logout Remove them again
29
31
 
30
- For coding agents and scripts (stdout is JSON only, never prompts):
31
- jam setup --agent One shot: detect, plan, apply what is safe, verify
32
- jam setup plan --json Report what setup would change, changing nothing
33
- jam setup apply --non-interactive --json
34
- Execute the plan
35
- jam doctor --json Health check as structured output
36
- jam auth status --json Whether Jira credentials are configured (never their value)
32
+ Jira
37
33
  jam jira search <jql> [--scope preview|complete]
38
34
  jam jira context <KEY> [KEY...]
39
35
  jam jira full <KEY> [KEY...]
40
36
  Read Jira from the shell - the same reads the MCP
41
37
  tools do, for a session that cannot see them yet
42
38
 
39
+ Authentication
40
+ jam auth status [--json] Whether Jira credentials are configured (never their value)
41
+ jam auth login Store them in this user's OS secret store
42
+ jam auth logout Remove them again
43
+
44
+ For coding agents and scripts (stdout is JSON only, never prompts):
45
+ jam setup --agent One shot: detect, plan, apply what is safe, verify
46
+ jam setup plan --json Report what setup would change, changing nothing
47
+ jam setup apply --non-interactive --json
48
+ Execute the plan
49
+ jam status --json Health check as structured output
50
+ jam update check|plan [--json]
51
+ jam refresh check|plan [--json]
52
+ jam uninstall plan [--json]
53
+
54
+ Host runtime
55
+ jam serve Run the MCP server over stdio - this is what Claude
56
+ Code and Codex launch. Not a command a person types.
57
+
43
58
  Environment:
44
59
  JIRA_BASE_URL https://your-site.atlassian.net
45
60
  JIRA_EMAIL Atlassian account email
@@ -77,7 +92,12 @@ export async function runJamCommand(argv) {
77
92
  switch (command ?? "serve") {
78
93
  case "serve":
79
94
  return serve();
95
+ case "status":
96
+ return jamStatusCommand({ json: rest.includes("--json") });
97
+ // The old name for the same question. It keeps working for two minor
98
+ // releases; `status` is the word both products answer to.
80
99
  case "doctor":
100
+ process.stderr.write("Deprecated. Use `jam status`.\n");
81
101
  return rest.includes("--json") ? doctorJsonCommand() : doctor();
82
102
  case "setup": {
83
103
  const explicitKey = findFlagValue(rest, "--project");
@@ -99,6 +119,22 @@ export async function runJamCommand(argv) {
99
119
  // The wizard can ask; the plain path never does.
100
120
  return rest.includes("--non-interactive") ? setup(common) : runSetupWizard(common);
101
121
  }
122
+ case "update":
123
+ // Not `setup` again: setup re-plans the project binding, credentials and
124
+ // everything else. An update moves the registration pin and nothing more.
125
+ return jamUpdateCommand(rest[0] === "--json" ? undefined : rest[0], {
126
+ json: rest.includes("--json"),
127
+ });
128
+ case "refresh":
129
+ // Not `update`: the version does not move here. Only the registration
130
+ // this build owns is brought back to it.
131
+ return jamRefreshCommand(rest[0]?.startsWith("--") ? undefined : rest[0], {
132
+ json: rest.includes("--json"),
133
+ });
134
+ case "uninstall":
135
+ return jamUninstallCommand(rest[0]?.startsWith("--") ? undefined : rest[0], {
136
+ json: rest.includes("--json"),
137
+ });
102
138
  case "runtime": {
103
139
  const json = rest.includes("--json");
104
140
  if (rest[0] === "use")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jam-mcp/server",
3
- "version": "1.4.6",
3
+ "version": "1.6.0",
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.6",
44
+ "@jam-mcp/launcher": "1.6.0",
45
45
  "@modelcontextprotocol/sdk": "^1.30.0",
46
46
  "yaml": "^2.9.0",
47
47
  "zod": "^4.4.3"