@jam-mcp/server 1.3.2 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/adapters/credentials/secret-store.d.ts +7 -2
- package/dist/adapters/credentials/secret-store.js +62 -10
- package/dist/bootstrap/host-mcp.d.ts +27 -0
- package/dist/bootstrap/host-mcp.js +36 -3
- package/dist/bootstrap/live-toolset.d.ts +36 -0
- package/dist/bootstrap/live-toolset.js +85 -0
- package/dist/bootstrap/mcp-config-merger.d.ts +11 -4
- package/dist/bootstrap/mcp-config-merger.js +10 -5
- package/dist/bootstrap/setup-apply.js +5 -0
- package/dist/bootstrap/setup-plan.d.ts +13 -1
- package/dist/bootstrap/setup-plan.js +11 -3
- package/dist/cli/agent-api.d.ts +13 -2
- package/dist/cli/agent-api.js +63 -3
- package/dist/cli/setup.js +3 -1
- package/dist/index.js +0 -0
- package/dist/mcp/create-server.d.ts +7 -0
- package/dist/mcp/create-server.js +13 -0
- package/package.json +2 -2
- package/dist/adapters/jira-cloud/jira-edit-metadata.adapter.d.ts +0 -25
- package/dist/adapters/jira-cloud/jira-edit-metadata.adapter.js +0 -84
- package/dist/policy/custom-field-policy.d.ts +0 -93
- package/dist/policy/custom-field-policy.js +0 -230
- package/dist/ports/jira-edit-metadata.port.d.ts +0 -22
- package/dist/ports/jira-edit-metadata.port.js +0 -1
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.
|
|
77
|
+
Written out, that is `npx --yes @jam-mcp/launcher@1.4.1 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.
|
|
80
|
+
`npx --yes @jam-mcp/bootstrap@1.4.1 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
|
|
@@ -22,8 +22,13 @@ export type RunResult = {
|
|
|
22
22
|
stderr: string;
|
|
23
23
|
error?: NodeJS.ErrnoException;
|
|
24
24
|
};
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Injected by tests so the suite never touches a real keychain.
|
|
27
|
+
*
|
|
28
|
+
* `env` is merged over the inherited environment, and carries only values that
|
|
29
|
+
* are not secret - a file path, say. Secrets travel on stdin.
|
|
30
|
+
*/
|
|
31
|
+
export type RunFn = (command: string, args: string[], input?: string, env?: Record<string, string>) => RunResult;
|
|
27
32
|
export interface SecretStore {
|
|
28
33
|
/** Shown by `jam auth login`. Names the mechanism, never a value. */
|
|
29
34
|
readonly label: string;
|
|
@@ -30,10 +30,11 @@ export function secretStoreDisabled(env = process.env) {
|
|
|
30
30
|
function account() {
|
|
31
31
|
return userInfo().username;
|
|
32
32
|
}
|
|
33
|
-
function defaultRun(command, args, input) {
|
|
33
|
+
function defaultRun(command, args, input, env) {
|
|
34
34
|
const result = spawnSync(command, args, {
|
|
35
35
|
encoding: "utf8",
|
|
36
36
|
...(input === undefined ? {} : { input }),
|
|
37
|
+
...(env === undefined ? {} : { env: { ...process.env, ...env } }),
|
|
37
38
|
// No shell: arguments are passed as an array, so nothing is re-parsed.
|
|
38
39
|
windowsHide: true,
|
|
39
40
|
});
|
|
@@ -138,43 +139,94 @@ function linuxStore(run) {
|
|
|
138
139
|
* Kept separate from ~/.jam/config.yaml, which declares itself hand-editable
|
|
139
140
|
* and free of credentials.
|
|
140
141
|
*/
|
|
142
|
+
/**
|
|
143
|
+
* Both scripts need DPAPI, and both must survive a host that rewrote where
|
|
144
|
+
* PowerShell looks for modules.
|
|
145
|
+
*
|
|
146
|
+
* A CI runner does exactly that - it prepends its own paths, including ones
|
|
147
|
+
* belonging to a different PowerShell edition, and then Windows PowerShell 5.1
|
|
148
|
+
* either cannot resolve `ConvertTo-SecureString` at all or trips over type data
|
|
149
|
+
* from a module that was never meant for it. Neither failure says anything
|
|
150
|
+
* about credentials, so both look like a JAM bug to whoever reads them.
|
|
151
|
+
*
|
|
152
|
+
* So the child starts from the machine's own module path and asks for the
|
|
153
|
+
* module by name. This changes nothing outside that one short-lived process.
|
|
154
|
+
*/
|
|
155
|
+
const IMPORT_SECURITY = "$env:PSModulePath=[Environment]::GetEnvironmentVariable('PSModulePath','Machine');" +
|
|
156
|
+
"Import-Module Microsoft.PowerShell.Security -ErrorAction Stop;";
|
|
157
|
+
/**
|
|
158
|
+
* What the child writes, we read as UTF-8 - so say so before it writes anything.
|
|
159
|
+
*
|
|
160
|
+
* `spawnSync` is told `encoding: "utf8"`, but powershell.exe writes through the
|
|
161
|
+
* console code page, which on a Korean install is 949. The bytes and the decoder
|
|
162
|
+
* then disagree and an error message arrives as mojibake: the user is handed a
|
|
163
|
+
* failure they cannot even read. Setting the output encoding inside the child
|
|
164
|
+
* changes nothing outside it.
|
|
165
|
+
*/
|
|
166
|
+
const UTF8_OUTPUT = "[Console]::OutputEncoding=[Text.Encoding]::UTF8;" +
|
|
167
|
+
"$OutputEncoding=[Text.Encoding]::UTF8;";
|
|
168
|
+
/**
|
|
169
|
+
* Read stdin as UTF-8, by saying so on the stream rather than on the console.
|
|
170
|
+
*
|
|
171
|
+
* `[Console]::In` on Windows PowerShell 5.1 is already bound to the console
|
|
172
|
+
* input code page by the time a `-Command` script could change it, so a value
|
|
173
|
+
* with non-ASCII in it - a Jira account under a Korean name, say - arrived
|
|
174
|
+
* mangled and was then encrypted mangled. Opening the standard input stream
|
|
175
|
+
* with an explicit encoding sidesteps that entirely.
|
|
176
|
+
*/
|
|
177
|
+
const READ_STDIN_UTF8 = "$in=(New-Object IO.StreamReader(" +
|
|
178
|
+
"[Console]::OpenStandardInput(),[Text.Encoding]::UTF8)).ReadToEnd();";
|
|
141
179
|
function windowsStore(run) {
|
|
142
180
|
const dir = join(homedir(), ".jam");
|
|
143
181
|
const path = join(dir, "credentials.dpapi");
|
|
144
|
-
|
|
145
|
-
|
|
182
|
+
/**
|
|
183
|
+
* The path reaches PowerShell in an environment variable, never in argv and
|
|
184
|
+
* never interpolated into the script text.
|
|
185
|
+
*
|
|
186
|
+
* It used to ride as a trailing argument with `-args`, which does not work:
|
|
187
|
+
* `powershell.exe -Command` appends what follows to the command text rather
|
|
188
|
+
* than filling `$args` - that is `-File` semantics - so the script read
|
|
189
|
+
* `$args[0]` as `$null` and `Set-Content` refused the null path. Reading was
|
|
190
|
+
* broken the same way and failed quietly, since `Test-Path $null` is false.
|
|
191
|
+
*
|
|
192
|
+
* The variable holds a path, not a secret. The secret still reaches the
|
|
193
|
+
* process on stdin and appears nowhere else.
|
|
194
|
+
*/
|
|
195
|
+
const PATH_VAR = "JAM_SECRET_FILE";
|
|
146
196
|
const decrypt = [
|
|
147
197
|
"-NoProfile",
|
|
148
198
|
"-NonInteractive",
|
|
149
199
|
"-Command",
|
|
150
|
-
|
|
200
|
+
UTF8_OUTPUT +
|
|
201
|
+
IMPORT_SECURITY +
|
|
202
|
+
`$p=$env:${PATH_VAR}; if(!(Test-Path $p)){exit 1};` +
|
|
151
203
|
"$s=Get-Content $p -Raw | ConvertTo-SecureString;" +
|
|
152
204
|
"[Runtime.InteropServices.Marshal]::PtrToStringAuto(" +
|
|
153
205
|
"[Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))",
|
|
154
|
-
"-args",
|
|
155
206
|
];
|
|
156
207
|
const encrypt = [
|
|
157
208
|
"-NoProfile",
|
|
158
209
|
"-NonInteractive",
|
|
159
210
|
"-Command",
|
|
160
|
-
|
|
211
|
+
UTF8_OUTPUT +
|
|
212
|
+
IMPORT_SECURITY +
|
|
213
|
+
READ_STDIN_UTF8 +
|
|
161
214
|
"$in | ConvertTo-SecureString -AsPlainText -Force |" +
|
|
162
|
-
|
|
163
|
-
"-args",
|
|
215
|
+
` ConvertFrom-SecureString | Set-Content $env:${PATH_VAR} -NoNewline`,
|
|
164
216
|
];
|
|
165
217
|
return {
|
|
166
218
|
label: "Windows DPAPI (user-encrypted file)",
|
|
167
219
|
read() {
|
|
168
220
|
if (!existsSync(path))
|
|
169
221
|
return undefined;
|
|
170
|
-
const res = run("powershell",
|
|
222
|
+
const res = run("powershell", decrypt, undefined, { [PATH_VAR]: path });
|
|
171
223
|
if (res.error || res.status !== 0)
|
|
172
224
|
return undefined;
|
|
173
225
|
return parse(res.stdout.trim());
|
|
174
226
|
},
|
|
175
227
|
write(values) {
|
|
176
228
|
mkdirSync(dir, { recursive: true });
|
|
177
|
-
const res = run("powershell",
|
|
229
|
+
const res = run("powershell", encrypt, JSON.stringify(values), { [PATH_VAR]: path });
|
|
178
230
|
if (res.error?.code === "ENOENT")
|
|
179
231
|
throw unavailable("powershell");
|
|
180
232
|
if (res.status !== 0)
|
|
@@ -18,6 +18,18 @@ export type HostState = {
|
|
|
18
18
|
cliAvailable: boolean;
|
|
19
19
|
/** Whether it already has a `jam` entry registered for this user. */
|
|
20
20
|
hasJamEntry: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* The launcher pin that entry actually runs, when the listing shows it.
|
|
23
|
+
*
|
|
24
|
+
* An entry existing is not the same as an entry being current: a pin written
|
|
25
|
+
* by an older release keeps running that release's server, which serves a
|
|
26
|
+
* different set of tools. Reading only the name made a stale registration
|
|
27
|
+
* indistinguishable from a good one, and setup then reported
|
|
28
|
+
* `already_configured` over it.
|
|
29
|
+
*/
|
|
30
|
+
entryVersion?: string;
|
|
31
|
+
/** The entry is present but does not run the launcher this release registers. */
|
|
32
|
+
entryStale?: boolean;
|
|
21
33
|
};
|
|
22
34
|
export type HostRunResult = {
|
|
23
35
|
status: number | null;
|
|
@@ -29,6 +41,8 @@ export type HostRunResult = {
|
|
|
29
41
|
export type HostRunner = (command: HostCommand) => HostRunResult;
|
|
30
42
|
export declare const defaultHostRunner: HostRunner;
|
|
31
43
|
export declare function hostRegistration(id: HostId): HostCommand | undefined;
|
|
44
|
+
/** The removal that has to precede re-registering an entry this host already has. */
|
|
45
|
+
export declare function hostUnregistration(id: HostId): HostCommand | undefined;
|
|
32
46
|
/**
|
|
33
47
|
* Is `jam` in this listing?
|
|
34
48
|
*
|
|
@@ -42,6 +56,19 @@ export declare function hostRegistration(id: HostId): HostCommand | undefined;
|
|
|
42
56
|
* `mcp add` on an existing entry writes the same launcher line back.
|
|
43
57
|
*/
|
|
44
58
|
export declare function listsJamEntry(stdout: string): boolean;
|
|
59
|
+
/** The listing line for `jam`, ANSI stripped, or null when there is none. */
|
|
60
|
+
export declare function jamEntryLine(stdout: string): string | null;
|
|
61
|
+
/** The launcher version a listing line runs, when the line names one. */
|
|
62
|
+
export declare function entryLauncherVersion(line: string): string | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* Does this entry run the launcher this release registers?
|
|
65
|
+
*
|
|
66
|
+
* Anything else - an older pin, an unpinned spec, a line whose command JAM
|
|
67
|
+
* cannot read - counts as stale. That direction is deliberate: `mcp add`
|
|
68
|
+
* rewrites the same entry, so a needless repair costs one command, while a
|
|
69
|
+
* missed one leaves the agent talking to a server nobody tested it against.
|
|
70
|
+
*/
|
|
71
|
+
export declare function isEntryStale(line: string): boolean;
|
|
45
72
|
/**
|
|
46
73
|
* Ask each host what it has, and whether it is there at all.
|
|
47
74
|
*
|
|
@@ -30,16 +30,22 @@ const ADAPTERS = [
|
|
|
30
30
|
// `-s user` is the whole point: registered for this user on this machine,
|
|
31
31
|
// not for whichever project happens to be open.
|
|
32
32
|
register: { command: "claude", args: ["mcp", "add", "jam", "-s", "user", ...LAUNCH] },
|
|
33
|
+
unregister: { command: "claude", args: ["mcp", "remove", "jam", "-s", "user"] },
|
|
33
34
|
},
|
|
34
35
|
{
|
|
35
36
|
id: "codex",
|
|
36
37
|
probe: { command: "codex", args: ["mcp", "list"] },
|
|
37
38
|
register: { command: "codex", args: ["mcp", "add", "jam", ...LAUNCH] },
|
|
39
|
+
unregister: { command: "codex", args: ["mcp", "remove", "jam"] },
|
|
38
40
|
},
|
|
39
41
|
];
|
|
40
42
|
export function hostRegistration(id) {
|
|
41
43
|
return ADAPTERS.find((a) => a.id === id)?.register;
|
|
42
44
|
}
|
|
45
|
+
/** The removal that has to precede re-registering an entry this host already has. */
|
|
46
|
+
export function hostUnregistration(id) {
|
|
47
|
+
return ADAPTERS.find((a) => a.id === id)?.unregister;
|
|
48
|
+
}
|
|
43
49
|
const ANSI = /\[[0-9;?]*[A-Za-z]/g;
|
|
44
50
|
/**
|
|
45
51
|
* Is `jam` in this listing?
|
|
@@ -54,11 +60,32 @@ const ANSI = /\[[0-9;?]*[A-Za-z]/g;
|
|
|
54
60
|
* `mcp add` on an existing entry writes the same launcher line back.
|
|
55
61
|
*/
|
|
56
62
|
export function listsJamEntry(stdout) {
|
|
57
|
-
return stdout
|
|
63
|
+
return jamEntryLine(stdout) !== null;
|
|
64
|
+
}
|
|
65
|
+
/** The listing line for `jam`, ANSI stripped, or null when there is none. */
|
|
66
|
+
export function jamEntryLine(stdout) {
|
|
67
|
+
const line = stdout
|
|
58
68
|
.replace(ANSI, "")
|
|
59
69
|
.split(/\r?\n/)
|
|
60
|
-
.
|
|
70
|
+
.find((candidate) => /^\s*jam(?=[\s:])/.test(candidate));
|
|
71
|
+
return line ?? null;
|
|
72
|
+
}
|
|
73
|
+
/** The launcher version a listing line runs, when the line names one. */
|
|
74
|
+
export function entryLauncherVersion(line) {
|
|
75
|
+
return /@jam-mcp\/launcher@([^\s"']+)/.exec(line)?.[1];
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Does this entry run the launcher this release registers?
|
|
79
|
+
*
|
|
80
|
+
* Anything else - an older pin, an unpinned spec, a line whose command JAM
|
|
81
|
+
* cannot read - counts as stale. That direction is deliberate: `mcp add`
|
|
82
|
+
* rewrites the same entry, so a needless repair costs one command, while a
|
|
83
|
+
* missed one leaves the agent talking to a server nobody tested it against.
|
|
84
|
+
*/
|
|
85
|
+
export function isEntryStale(line) {
|
|
86
|
+
return entryLauncherVersion(line) !== EXPECTED_LAUNCHER_VERSION;
|
|
61
87
|
}
|
|
88
|
+
const EXPECTED_LAUNCHER_VERSION = entryLauncherVersion(JAM_MCP_ENTRY.args.join(" "));
|
|
62
89
|
/**
|
|
63
90
|
* Ask each host what it has, and whether it is there at all.
|
|
64
91
|
*
|
|
@@ -85,10 +112,16 @@ function probeHosts(run) {
|
|
|
85
112
|
if (result.failed || result.status !== 0) {
|
|
86
113
|
return { id: adapter.id, cliAvailable: false, hasJamEntry: false };
|
|
87
114
|
}
|
|
115
|
+
const line = jamEntryLine(result.stdout);
|
|
116
|
+
if (!line)
|
|
117
|
+
return { id: adapter.id, cliAvailable: true, hasJamEntry: false };
|
|
118
|
+
const version = entryLauncherVersion(line);
|
|
88
119
|
return {
|
|
89
120
|
id: adapter.id,
|
|
90
121
|
cliAvailable: true,
|
|
91
|
-
hasJamEntry:
|
|
122
|
+
hasJamEntry: true,
|
|
123
|
+
...(version ? { entryVersion: version } : {}),
|
|
124
|
+
entryStale: isEntryStale(line),
|
|
92
125
|
};
|
|
93
126
|
});
|
|
94
127
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the registered entry actually serves.
|
|
3
|
+
*
|
|
4
|
+
* Counting the tools of the process doing the counting proves nothing about
|
|
5
|
+
* the agent's experience: the agent talks to whatever the host registration
|
|
6
|
+
* launches, which may be an older release with a different tool set. This asks
|
|
7
|
+
* that process directly, over the protocol the agent uses.
|
|
8
|
+
*/
|
|
9
|
+
export type LiveToolsetVerdict = "OK" | "LIVE_TOOLSET_MISMATCH" | "UNREACHABLE";
|
|
10
|
+
export type LiveToolsetResult = {
|
|
11
|
+
verdict: LiveToolsetVerdict;
|
|
12
|
+
expected: string[];
|
|
13
|
+
actual?: string[];
|
|
14
|
+
missing?: string[];
|
|
15
|
+
detail?: string;
|
|
16
|
+
};
|
|
17
|
+
export type ToolsetProbe = (argv: {
|
|
18
|
+
command: string;
|
|
19
|
+
args: string[];
|
|
20
|
+
}) => Promise<string[] | null>;
|
|
21
|
+
export declare const expectedTools: () => string[];
|
|
22
|
+
/**
|
|
23
|
+
* Speak just enough MCP to ask for the tool list: initialize, initialized,
|
|
24
|
+
* tools/list. A full client would pull in the SDK's transport machinery for
|
|
25
|
+
* one question that is three lines of JSON.
|
|
26
|
+
*/
|
|
27
|
+
export declare const defaultToolsetProbe: ToolsetProbe;
|
|
28
|
+
/**
|
|
29
|
+
* Compare what the registered command serves against what this release
|
|
30
|
+
* defines. A tool the agent cannot see is a tool it does not have, whatever
|
|
31
|
+
* the package on disk says.
|
|
32
|
+
*/
|
|
33
|
+
export declare function checkLiveToolset(argv: {
|
|
34
|
+
command: string;
|
|
35
|
+
args: string[];
|
|
36
|
+
}, probe?: ToolsetProbe): Promise<LiveToolsetResult>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { SERVER_VERSION } from "@jam-mcp/launcher";
|
|
3
|
+
import { TOOL_NAMES } from "../mcp/create-server.js";
|
|
4
|
+
const HANDSHAKE_TIMEOUT_MS = 30_000;
|
|
5
|
+
export const expectedTools = () => [...TOOL_NAMES].sort();
|
|
6
|
+
/**
|
|
7
|
+
* Speak just enough MCP to ask for the tool list: initialize, initialized,
|
|
8
|
+
* tools/list. A full client would pull in the SDK's transport machinery for
|
|
9
|
+
* one question that is three lines of JSON.
|
|
10
|
+
*/
|
|
11
|
+
export const defaultToolsetProbe = ({ command, args }) => new Promise((resolve) => {
|
|
12
|
+
const child = spawn(command, args, {
|
|
13
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
14
|
+
shell: process.platform === "win32",
|
|
15
|
+
});
|
|
16
|
+
let buffer = "";
|
|
17
|
+
let settled = false;
|
|
18
|
+
const done = (value) => {
|
|
19
|
+
if (settled)
|
|
20
|
+
return;
|
|
21
|
+
settled = true;
|
|
22
|
+
clearTimeout(timer);
|
|
23
|
+
child.stdin.end();
|
|
24
|
+
child.kill();
|
|
25
|
+
resolve(value);
|
|
26
|
+
};
|
|
27
|
+
const timer = setTimeout(() => done(null), HANDSHAKE_TIMEOUT_MS);
|
|
28
|
+
child.on("error", () => done(null));
|
|
29
|
+
child.on("exit", () => done(null));
|
|
30
|
+
child.stdout.on("data", (chunk) => {
|
|
31
|
+
buffer += chunk.toString("utf8");
|
|
32
|
+
let newline = buffer.indexOf("\n");
|
|
33
|
+
while (newline >= 0) {
|
|
34
|
+
const line = buffer.slice(0, newline).trim();
|
|
35
|
+
buffer = buffer.slice(newline + 1);
|
|
36
|
+
newline = buffer.indexOf("\n");
|
|
37
|
+
if (!line)
|
|
38
|
+
continue;
|
|
39
|
+
try {
|
|
40
|
+
const message = JSON.parse(line);
|
|
41
|
+
if (message.id === 2) {
|
|
42
|
+
done((message.result?.tools ?? []).map((tool) => tool.name).sort());
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Not our line. The server owns stdout for the protocol; anything
|
|
48
|
+
// unparseable is noise from a wrapper and is skipped rather than
|
|
49
|
+
// treated as a failure.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
const send = (payload) => {
|
|
54
|
+
child.stdin.write(`${JSON.stringify(payload)}\n`);
|
|
55
|
+
};
|
|
56
|
+
send({
|
|
57
|
+
jsonrpc: "2.0",
|
|
58
|
+
id: 1,
|
|
59
|
+
method: "initialize",
|
|
60
|
+
params: {
|
|
61
|
+
protocolVersion: "2024-11-05",
|
|
62
|
+
capabilities: {},
|
|
63
|
+
clientInfo: { name: "jam-doctor", version: SERVER_VERSION },
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
67
|
+
send({ jsonrpc: "2.0", id: 2, method: "tools/list" });
|
|
68
|
+
});
|
|
69
|
+
/**
|
|
70
|
+
* Compare what the registered command serves against what this release
|
|
71
|
+
* defines. A tool the agent cannot see is a tool it does not have, whatever
|
|
72
|
+
* the package on disk says.
|
|
73
|
+
*/
|
|
74
|
+
export async function checkLiveToolset(argv, probe = defaultToolsetProbe) {
|
|
75
|
+
const expected = expectedTools();
|
|
76
|
+
const actual = await probe(argv).catch(() => null);
|
|
77
|
+
if (actual === null) {
|
|
78
|
+
return { verdict: "UNREACHABLE", expected, detail: "the registered command did not answer tools/list" };
|
|
79
|
+
}
|
|
80
|
+
const missing = expected.filter((name) => !actual.includes(name));
|
|
81
|
+
if (missing.length > 0) {
|
|
82
|
+
return { verdict: "LIVE_TOOLSET_MISMATCH", expected, actual, missing };
|
|
83
|
+
}
|
|
84
|
+
return { verdict: "OK", expected, actual };
|
|
85
|
+
}
|
|
@@ -13,12 +13,19 @@ 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.
|
|
16
|
+
readonly args: readonly ["--yes", "@jam-mcp/launcher@1.4.1", "serve"];
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
|
-
* Recognise wiring from before the launcher existed: a hard-coded path
|
|
20
|
-
* machine's checkout
|
|
21
|
-
*
|
|
19
|
+
* Recognise wiring from before the launcher existed: a hard-coded `node` path
|
|
20
|
+
* to one machine's checkout. That works only where it was written, which is
|
|
21
|
+
* why `--migrate` exists.
|
|
22
|
+
*
|
|
23
|
+
* A bare `jam` is deliberately NOT legacy any more. It is what a persistent
|
|
24
|
+
* install (`npm install -g @jam-mcp/launcher@<exact>`) provides, and on a
|
|
25
|
+
* machine whose package runner is broken it is the entry that still works —
|
|
26
|
+
* a real Windows npm was seen failing to start `npx` children at all. Someone
|
|
27
|
+
* who registered it chose it; `--migrate` must not silently rewrite that
|
|
28
|
+
* choice back into the very path that fails there.
|
|
22
29
|
*/
|
|
23
30
|
export declare function isLegacyJamEntry(entry: unknown): boolean;
|
|
24
31
|
export type McpMergeResult = {
|
|
@@ -18,16 +18,21 @@ export const JAM_MCP_ENTRY = {
|
|
|
18
18
|
args: ["--yes", LAUNCHER_PACKAGE_SPEC, "serve"],
|
|
19
19
|
};
|
|
20
20
|
/**
|
|
21
|
-
* Recognise wiring from before the launcher existed: a hard-coded path
|
|
22
|
-
* machine's checkout
|
|
23
|
-
*
|
|
21
|
+
* Recognise wiring from before the launcher existed: a hard-coded `node` path
|
|
22
|
+
* to one machine's checkout. That works only where it was written, which is
|
|
23
|
+
* why `--migrate` exists.
|
|
24
|
+
*
|
|
25
|
+
* A bare `jam` is deliberately NOT legacy any more. It is what a persistent
|
|
26
|
+
* install (`npm install -g @jam-mcp/launcher@<exact>`) provides, and on a
|
|
27
|
+
* machine whose package runner is broken it is the entry that still works —
|
|
28
|
+
* a real Windows npm was seen failing to start `npx` children at all. Someone
|
|
29
|
+
* who registered it chose it; `--migrate` must not silently rewrite that
|
|
30
|
+
* choice back into the very path that fails there.
|
|
24
31
|
*/
|
|
25
32
|
export function isLegacyJamEntry(entry) {
|
|
26
33
|
if (!entry || typeof entry !== "object")
|
|
27
34
|
return false;
|
|
28
35
|
const { command, args } = entry;
|
|
29
|
-
if (command === "jam")
|
|
30
|
-
return true;
|
|
31
36
|
if (command === "node")
|
|
32
37
|
return true;
|
|
33
38
|
if (command === "npx" && Array.isArray(args)) {
|
|
@@ -36,6 +36,11 @@ export function applySetupPlan(plan, options = {}) {
|
|
|
36
36
|
// decides nothing. A failure is that host's failure, reported with the
|
|
37
37
|
// command that produced it - never retried against another host.
|
|
38
38
|
const run = options.runHost ?? defaultHostRunner;
|
|
39
|
+
// A repair removes the stale entry first. Its failure is not reported on
|
|
40
|
+
// its own: if the entry is already gone the removal fails harmlessly, and
|
|
41
|
+
// if it is still there the registration below fails and says so.
|
|
42
|
+
if (change.precede)
|
|
43
|
+
run({ command: change.precede.command, args: change.precede.args });
|
|
39
44
|
const result = run({ command: change.command, args: change.args });
|
|
40
45
|
if (result.failed || result.status !== 0) {
|
|
41
46
|
throw new Error(`Registering JAM with ${change.host} failed: ${change.command} ${change.args.join(" ")}`);
|
|
@@ -34,7 +34,7 @@ export type SetupChange = {
|
|
|
34
34
|
/** Present on a rebind, so the preview shows what is being replaced. */
|
|
35
35
|
previousKey?: string;
|
|
36
36
|
} | {
|
|
37
|
-
type: "create";
|
|
37
|
+
type: "create" | "replace";
|
|
38
38
|
target: "host-mcp";
|
|
39
39
|
host: HostId;
|
|
40
40
|
/**
|
|
@@ -44,6 +44,18 @@ export type SetupChange = {
|
|
|
44
44
|
*/
|
|
45
45
|
command: string;
|
|
46
46
|
args: string[];
|
|
47
|
+
/**
|
|
48
|
+
* Run before the registration, on a repair. The host CLI refuses to add an
|
|
49
|
+
* entry that already exists, so the stale one has to go first - and which
|
|
50
|
+
* command does that is decided here, not worked out during apply.
|
|
51
|
+
*/
|
|
52
|
+
precede?: {
|
|
53
|
+
command: string;
|
|
54
|
+
args: string[];
|
|
55
|
+
};
|
|
56
|
+
/** On a repair: the launcher pin the entry runs today, so the preview names it. */
|
|
57
|
+
previousVersion?: string;
|
|
58
|
+
reason?: "stale-registration";
|
|
47
59
|
};
|
|
48
60
|
export type SetupPlan = {
|
|
49
61
|
status: SetupStatus;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { CONFIG_RELATIVE_PATH } from "../config/load-config.js";
|
|
3
|
-
import { hostRegistration } from "./host-mcp.js";
|
|
3
|
+
import { hostRegistration, hostUnregistration } from "./host-mcp.js";
|
|
4
4
|
import { projectBindingsPath } from "./project-bindings.js";
|
|
5
5
|
import { decideProjectKey } from "./project-config-bootstrapper.js";
|
|
6
6
|
import { portableBootstrapCommand } from "@jam-mcp/launcher";
|
|
@@ -155,17 +155,25 @@ function finish(changes, state, project) {
|
|
|
155
155
|
function planHostChanges(state) {
|
|
156
156
|
const changes = [];
|
|
157
157
|
for (const host of state.hosts) {
|
|
158
|
-
if (!host.cliAvailable
|
|
158
|
+
if (!host.cliAvailable)
|
|
159
|
+
continue;
|
|
160
|
+
// An entry that exists but runs an older launcher is not "already set up":
|
|
161
|
+
// that pin decides which server, and so which tools, the agent actually gets.
|
|
162
|
+
if (host.hasJamEntry && host.entryStale !== true)
|
|
159
163
|
continue;
|
|
160
164
|
const registration = hostRegistration(host.id);
|
|
161
165
|
if (!registration)
|
|
162
166
|
continue;
|
|
167
|
+
const removal = hostUnregistration(host.id);
|
|
163
168
|
changes.push({
|
|
164
|
-
type: "create",
|
|
169
|
+
type: host.hasJamEntry ? "replace" : "create",
|
|
165
170
|
target: "host-mcp",
|
|
166
171
|
host: host.id,
|
|
167
172
|
command: registration.command,
|
|
168
173
|
args: registration.args,
|
|
174
|
+
...(host.hasJamEntry && removal ? { precede: { command: removal.command, args: removal.args } } : {}),
|
|
175
|
+
...(host.entryVersion ? { previousVersion: host.entryVersion } : {}),
|
|
176
|
+
...(host.hasJamEntry ? { reason: "stale-registration" } : {}),
|
|
169
177
|
});
|
|
170
178
|
}
|
|
171
179
|
return changes;
|
package/dist/cli/agent-api.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type MigrationTarget } from "../bootstrap/migration-target.js";
|
|
2
2
|
import type { CredentialPort } from "../ports/credentials.port.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type HostRunner } from "../bootstrap/host-mcp.js";
|
|
4
|
+
import { type ToolsetProbe } from "../bootstrap/live-toolset.js";
|
|
4
5
|
import type { GitRemoteFn } from "../bootstrap/workspace-identity.js";
|
|
5
6
|
/**
|
|
6
7
|
* The machine-readable half of setup.
|
|
@@ -27,6 +28,8 @@ export type AgentOptions = {
|
|
|
27
28
|
env?: NodeJS.ProcessEnv;
|
|
28
29
|
/** Injected by tests so a plan never shells out to npm to verify a migration target. */
|
|
29
30
|
migrationTarget?: MigrationTarget;
|
|
31
|
+
/** Injected by tests so doctor never launches a real MCP server to read its tools. */
|
|
32
|
+
toolsetProbe?: ToolsetProbe;
|
|
30
33
|
/** Injected by tests so identity never depends on the checkout under test. */
|
|
31
34
|
git?: GitRemoteFn;
|
|
32
35
|
/** Injected by tests so no test ever registers JAM with a real host. */
|
|
@@ -52,7 +55,15 @@ export declare function setupApplyCommand(options?: AgentOptions): Promise<numbe
|
|
|
52
55
|
* authenticating), and reports exactly how far it got.
|
|
53
56
|
*/
|
|
54
57
|
export declare function setupAgentCommand(options?: AgentOptions): Promise<number>;
|
|
55
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* `jam doctor --json`.
|
|
60
|
+
*
|
|
61
|
+
* Three axes, kept apart on purpose. The package on this machine being current
|
|
62
|
+
* says nothing about what the host registration launches, and neither says what
|
|
63
|
+
* the agent can actually call - a stale pin serves an older tool set while every
|
|
64
|
+
* local check passes. Reporting them as one verdict is how a broken setup was
|
|
65
|
+
* reported as ready.
|
|
66
|
+
*/
|
|
56
67
|
export declare function doctorJsonCommand(options?: AgentOptions): Promise<number>;
|
|
57
68
|
/**
|
|
58
69
|
* `jam auth status --json` - presence and origin only.
|
package/dist/cli/agent-api.js
CHANGED
|
@@ -5,6 +5,9 @@ import { applySetupPlan } from "../bootstrap/setup-apply.js";
|
|
|
5
5
|
import { detectSetupState } from "../bootstrap/setup-state.js";
|
|
6
6
|
import { buildDeps } from "../deps.js";
|
|
7
7
|
import { toJamError } from "../domain/errors.js";
|
|
8
|
+
import { hostRegistration } from "../bootstrap/host-mcp.js";
|
|
9
|
+
import { checkLiveToolset } from "../bootstrap/live-toolset.js";
|
|
10
|
+
import { SERVER_VERSION } from "@jam-mcp/launcher";
|
|
8
11
|
export function emitJson(payload) {
|
|
9
12
|
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
10
13
|
}
|
|
@@ -108,17 +111,74 @@ export async function setupAgentCommand(options = {}) {
|
|
|
108
111
|
});
|
|
109
112
|
return health.passed ? 0 : 1;
|
|
110
113
|
}
|
|
111
|
-
/**
|
|
114
|
+
/**
|
|
115
|
+
* `jam doctor --json`.
|
|
116
|
+
*
|
|
117
|
+
* Three axes, kept apart on purpose. The package on this machine being current
|
|
118
|
+
* says nothing about what the host registration launches, and neither says what
|
|
119
|
+
* the agent can actually call - a stale pin serves an older tool set while every
|
|
120
|
+
* local check passes. Reporting them as one verdict is how a broken setup was
|
|
121
|
+
* reported as ready.
|
|
122
|
+
*/
|
|
112
123
|
export async function doctorJsonCommand(options = {}) {
|
|
124
|
+
// detect() already probes the hosts for every non-shared path; doctor wants that.
|
|
113
125
|
const state = detect(options);
|
|
114
126
|
const health = await gateResult(state.project.root);
|
|
127
|
+
const axes = await inspectAxes(state, options);
|
|
128
|
+
// 등록이 아예 없는 것은 결함이 아니다 — 새 머신, 호스트 CLI 없는 CI, 아직 setup 을
|
|
129
|
+
// 안 한 사용자 모두 정상 상태다. 전체 판정을 무너뜨리는 것은 **거짓말하는 상태**뿐:
|
|
130
|
+
// 낡은 핀을 실행 중인 등록(STALE)과, 등록은 맞는데 실제 도구가 다른 경우(MISMATCH).
|
|
131
|
+
const axesOk = axes.registration !== "HOST_REGISTRATION_STALE" && axes.live !== "LIVE_TOOLSET_MISMATCH";
|
|
132
|
+
const passed = health.passed && axesOk;
|
|
115
133
|
emitJson({
|
|
116
|
-
status:
|
|
134
|
+
status: passed ? "ready" : "failed",
|
|
117
135
|
...(health.error ? { error: health.error } : {}),
|
|
118
136
|
project: { root: state.project.root, ...(state.project.key ? { key: state.project.key } : {}) },
|
|
137
|
+
axes,
|
|
119
138
|
checks: health.checks,
|
|
120
139
|
});
|
|
121
|
-
return
|
|
140
|
+
return passed ? 0 : 1;
|
|
141
|
+
}
|
|
142
|
+
async function inspectAxes(state, options) {
|
|
143
|
+
const packageVersion = state.runtime.version;
|
|
144
|
+
const axes = {
|
|
145
|
+
package: packageVersion === SERVER_VERSION ? "PACKAGE_READY" : "PACKAGE_NOT_READY",
|
|
146
|
+
...(packageVersion ? { packageVersion } : {}),
|
|
147
|
+
registration: "UNREGISTERED",
|
|
148
|
+
live: "UNCHECKED",
|
|
149
|
+
};
|
|
150
|
+
const hosts = state.hosts.filter((host) => host.cliAvailable);
|
|
151
|
+
if (state.hosts.length > 0 && hosts.length === 0) {
|
|
152
|
+
return { ...axes, registration: "HOST_UNREACHABLE", detail: "no host CLI answered" };
|
|
153
|
+
}
|
|
154
|
+
const registered = hosts.find((host) => host.hasJamEntry);
|
|
155
|
+
if (!registered)
|
|
156
|
+
return axes;
|
|
157
|
+
axes.registration = registered.entryStale ? "HOST_REGISTRATION_STALE" : "OK";
|
|
158
|
+
if (registered.entryVersion)
|
|
159
|
+
axes.registeredVersion = registered.entryVersion;
|
|
160
|
+
// A stale entry has already answered the question the live check would ask,
|
|
161
|
+
// and asking it means launching that older release. Repair first.
|
|
162
|
+
if (registered.entryStale)
|
|
163
|
+
return axes;
|
|
164
|
+
const registration = hostRegistration(registered.id);
|
|
165
|
+
const launch = registration ? launcherArgv(registration.args) : null;
|
|
166
|
+
if (!launch)
|
|
167
|
+
return { ...axes, live: "UNCHECKED", detail: "could not read the registered command" };
|
|
168
|
+
const result = await checkLiveToolset(launch, options.toolsetProbe);
|
|
169
|
+
axes.live = result.verdict === "OK" ? "OK" : result.verdict;
|
|
170
|
+
if (result.missing && result.missing.length > 0)
|
|
171
|
+
axes.missingTools = result.missing;
|
|
172
|
+
if (result.detail)
|
|
173
|
+
axes.detail = result.detail;
|
|
174
|
+
return axes;
|
|
175
|
+
}
|
|
176
|
+
/** The registration argv carries the launch command after `--`. */
|
|
177
|
+
function launcherArgv(args) {
|
|
178
|
+
const at = args.indexOf("--");
|
|
179
|
+
if (at < 0 || args.length <= at + 1)
|
|
180
|
+
return null;
|
|
181
|
+
return { command: args[at + 1], args: args.slice(at + 2) };
|
|
122
182
|
}
|
|
123
183
|
/**
|
|
124
184
|
* `jam auth status --json` - presence and origin only.
|
package/dist/cli/setup.js
CHANGED
|
@@ -108,7 +108,9 @@ function reportApplied(applied, plan) {
|
|
|
108
108
|
continue;
|
|
109
109
|
}
|
|
110
110
|
if (change.target === "host-mcp") {
|
|
111
|
-
line(
|
|
111
|
+
line(change.type === "replace"
|
|
112
|
+
? `[OK] ${change.host} - jam re-registered, replacing a stale launcher pin${change.previousVersion ? ` (${change.previousVersion})` : ""}`
|
|
113
|
+
: `[OK] ${change.host} - jam registered for this user`);
|
|
112
114
|
continue;
|
|
113
115
|
}
|
|
114
116
|
if (change.target === "project-config") {
|
package/dist/index.js
CHANGED
|
File without changes
|
|
@@ -2,6 +2,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import type { JamDeps } from "../deps.js";
|
|
3
3
|
export declare const SERVER_NAME = "jam";
|
|
4
4
|
export declare const TOOL_COUNT: number;
|
|
5
|
+
/**
|
|
6
|
+
* The same contract by name, for the checks that ask a *running* server what it
|
|
7
|
+
* serves. The registrars above cannot be introspected for their tool names, and
|
|
8
|
+
* counting is not enough: five tools with one renamed is still five.
|
|
9
|
+
* `tests/contract/tools.test.ts` holds this list to what the server registers.
|
|
10
|
+
*/
|
|
11
|
+
export declare const TOOL_NAMES: readonly ["jira_search", "jira_context", "jira_full", "jira_write_plan", "jira_write_apply"];
|
|
5
12
|
/**
|
|
6
13
|
* The external contract: three read tools and two write tools.
|
|
7
14
|
*
|
|
@@ -21,6 +21,19 @@ const REGISTER_TOOLS = [
|
|
|
21
21
|
registerJiraWriteApply,
|
|
22
22
|
];
|
|
23
23
|
export const TOOL_COUNT = REGISTER_TOOLS.length;
|
|
24
|
+
/**
|
|
25
|
+
* The same contract by name, for the checks that ask a *running* server what it
|
|
26
|
+
* serves. The registrars above cannot be introspected for their tool names, and
|
|
27
|
+
* counting is not enough: five tools with one renamed is still five.
|
|
28
|
+
* `tests/contract/tools.test.ts` holds this list to what the server registers.
|
|
29
|
+
*/
|
|
30
|
+
export const TOOL_NAMES = [
|
|
31
|
+
"jira_search",
|
|
32
|
+
"jira_context",
|
|
33
|
+
"jira_full",
|
|
34
|
+
"jira_write_plan",
|
|
35
|
+
"jira_write_apply",
|
|
36
|
+
];
|
|
24
37
|
/**
|
|
25
38
|
* The external contract: three read tools and two write tools.
|
|
26
39
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jam-mcp/server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
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.
|
|
44
|
+
"@jam-mcp/launcher": "1.4.1",
|
|
45
45
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
46
46
|
"yaml": "^2.9.0",
|
|
47
47
|
"zod": "^4.4.3"
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import type { EditFieldMetadata } from "../../domain/write.js";
|
|
2
|
-
import type { CredentialPort } from "../../ports/credentials.port.js";
|
|
3
|
-
import type { JiraEditMetadataPort } from "../../ports/jira-edit-metadata.port.js";
|
|
4
|
-
/**
|
|
5
|
-
* Jira Cloud REST v3 edit metadata for one issue.
|
|
6
|
-
*
|
|
7
|
-
* `GET /rest/api/3/issue/{key}/editmeta`, `retry: false`. Its answer decides
|
|
8
|
-
* whether a mutation may proceed and what shape it takes, so a
|
|
9
|
-
* retried-and-stale answer is worse than a failure - the same argument that
|
|
10
|
-
* keeps `getTransitions`, the create metadata calls and the assignability
|
|
11
|
-
* check on the non-retrying side.
|
|
12
|
-
*
|
|
13
|
-
* Jira keys the response by field id, and describes each field with a `schema`
|
|
14
|
-
* and a list of `operations`. Both travel, because both are what the decision
|
|
15
|
-
* is made in; the rest of the document does not.
|
|
16
|
-
*
|
|
17
|
-
* Anything JAM cannot read is dropped rather than half-understood. A field
|
|
18
|
-
* that survives here with the wrong shape would be a field JAM claims to
|
|
19
|
-
* understand well enough to write.
|
|
20
|
-
*/
|
|
21
|
-
export declare class JiraCloudEditMetadataAdapter implements JiraEditMetadataPort {
|
|
22
|
-
private readonly client;
|
|
23
|
-
constructor(credentials: CredentialPort, fetchImpl?: typeof fetch);
|
|
24
|
-
getEditableFields(issueKey: string): Promise<EditFieldMetadata[]>;
|
|
25
|
-
}
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { JiraClient } from "./jira-client.js";
|
|
2
|
-
/**
|
|
3
|
-
* Jira Cloud REST v3 edit metadata for one issue.
|
|
4
|
-
*
|
|
5
|
-
* `GET /rest/api/3/issue/{key}/editmeta`, `retry: false`. Its answer decides
|
|
6
|
-
* whether a mutation may proceed and what shape it takes, so a
|
|
7
|
-
* retried-and-stale answer is worse than a failure - the same argument that
|
|
8
|
-
* keeps `getTransitions`, the create metadata calls and the assignability
|
|
9
|
-
* check on the non-retrying side.
|
|
10
|
-
*
|
|
11
|
-
* Jira keys the response by field id, and describes each field with a `schema`
|
|
12
|
-
* and a list of `operations`. Both travel, because both are what the decision
|
|
13
|
-
* is made in; the rest of the document does not.
|
|
14
|
-
*
|
|
15
|
-
* Anything JAM cannot read is dropped rather than half-understood. A field
|
|
16
|
-
* that survives here with the wrong shape would be a field JAM claims to
|
|
17
|
-
* understand well enough to write.
|
|
18
|
-
*/
|
|
19
|
-
export class JiraCloudEditMetadataAdapter {
|
|
20
|
-
client;
|
|
21
|
-
constructor(credentials, fetchImpl) {
|
|
22
|
-
this.client = fetchImpl ? new JiraClient(credentials, fetchImpl) : new JiraClient(credentials);
|
|
23
|
-
}
|
|
24
|
-
async getEditableFields(issueKey) {
|
|
25
|
-
const { data } = await this.client.request({
|
|
26
|
-
path: `rest/api/3/issue/${encodeURIComponent(issueKey)}/editmeta`,
|
|
27
|
-
retry: false,
|
|
28
|
-
});
|
|
29
|
-
const fields = data?.fields;
|
|
30
|
-
if (!fields || typeof fields !== "object")
|
|
31
|
-
return [];
|
|
32
|
-
return Object.entries(fields)
|
|
33
|
-
.map(([id, raw]) => toEditField(id, raw))
|
|
34
|
-
.filter((f) => f !== undefined);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
function toEditField(id, raw) {
|
|
38
|
-
if (!raw || typeof raw !== "object")
|
|
39
|
-
return undefined;
|
|
40
|
-
// A field with no schema type is a field JAM cannot classify, and an
|
|
41
|
-
// unclassifiable field is one it must not decide it can write.
|
|
42
|
-
const type = typeof raw.schema?.type === "string" ? raw.schema.type : undefined;
|
|
43
|
-
if (!type)
|
|
44
|
-
return undefined;
|
|
45
|
-
const allowed = toOptions(raw.allowedValues);
|
|
46
|
-
return {
|
|
47
|
-
id,
|
|
48
|
-
name: typeof raw.name === "string" ? raw.name : id,
|
|
49
|
-
required: raw.required === true,
|
|
50
|
-
operations: Array.isArray(raw.operations)
|
|
51
|
-
? raw.operations.filter((op) => typeof op === "string")
|
|
52
|
-
: [],
|
|
53
|
-
schema: {
|
|
54
|
-
type,
|
|
55
|
-
...(typeof raw.schema?.items === "string" ? { items: raw.schema.items } : {}),
|
|
56
|
-
...(typeof raw.schema?.custom === "string" ? { custom: raw.schema.custom } : {}),
|
|
57
|
-
...(typeof raw.schema?.customId === "number" ? { customId: raw.schema.customId } : {}),
|
|
58
|
-
},
|
|
59
|
-
...(allowed ? { allowedValues: allowed } : {}),
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* The options Jira offers, when it constrains the field at all.
|
|
64
|
-
*
|
|
65
|
-
* Absent and empty mean different things and stay apart: absent is "Jira did
|
|
66
|
-
* not constrain this", empty is "Jira constrains it and offers nothing". The
|
|
67
|
-
* first permits a free value, the second permits none.
|
|
68
|
-
*
|
|
69
|
-
* Jira labels an option `value` on a select and `name` on some other pickers.
|
|
70
|
-
* Both are read; an option with neither an id nor a label is dropped, because
|
|
71
|
-
* it can be neither chosen nor recognised afterwards.
|
|
72
|
-
*/
|
|
73
|
-
function toOptions(raw) {
|
|
74
|
-
if (!Array.isArray(raw))
|
|
75
|
-
return undefined;
|
|
76
|
-
return raw
|
|
77
|
-
.map((entry) => {
|
|
78
|
-
const o = entry;
|
|
79
|
-
const id = typeof o?.id === "string" ? o.id : typeof o?.id === "number" ? String(o.id) : undefined;
|
|
80
|
-
const label = typeof o?.value === "string" ? o.value : typeof o?.name === "string" ? o.name : undefined;
|
|
81
|
-
return id && label ? { id, label } : undefined;
|
|
82
|
-
})
|
|
83
|
-
.filter((o) => o !== undefined);
|
|
84
|
-
}
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import type { ProjectConfig } from "../config/schema.js";
|
|
2
|
-
import type { CustomFieldKind, CustomFieldRequirements, CustomFieldUpdateInput, CustomFieldValueView, EditFieldMetadata, EditFieldOption } from "../domain/write.js";
|
|
3
|
-
/**
|
|
4
|
-
* What JAM will write to a custom field, and everything that has to be true
|
|
5
|
-
* first.
|
|
6
|
-
*
|
|
7
|
-
* Three separate permissions have to line up, and none of them implies
|
|
8
|
-
* another:
|
|
9
|
-
*
|
|
10
|
-
* 1. **The team said so.** The field's exact id is in the project's whitelist
|
|
11
|
-
* with `writable: true`. Being readable is not being writable - reading a
|
|
12
|
-
* field and letting an agent change it are different decisions, and a
|
|
13
|
-
* config written before JAM could write must not start granting writes
|
|
14
|
-
* because JAM learned how.
|
|
15
|
-
* 2. **Jira allows it here and now.** The field is on this issue's edit
|
|
16
|
-
* screen for this account, and Jira lists `set` among its operations.
|
|
17
|
-
* Asked, never modelled: applicability depends on project, issue type,
|
|
18
|
-
* field contexts, screens and permissions, and JAM does not carry a copy
|
|
19
|
-
* of any of that.
|
|
20
|
-
* 3. **JAM knows the shape.** The field's type is one of four families whose
|
|
21
|
-
* wire form JAM can produce from a plain value and compare afterwards.
|
|
22
|
-
* Anything else is refused rather than posted to find out.
|
|
23
|
-
*/
|
|
24
|
-
type WritableField = {
|
|
25
|
-
id: string;
|
|
26
|
-
name: string;
|
|
27
|
-
};
|
|
28
|
-
/**
|
|
29
|
-
* Which configured field this selector names.
|
|
30
|
-
*
|
|
31
|
-
* The id is the identity; the name is an alias for people. Resolution is exact
|
|
32
|
-
* on either - no substring, no fuzz - because the alternative is an agent's
|
|
33
|
-
* approximate word choosing which field on somebody's board gets rewritten.
|
|
34
|
-
*
|
|
35
|
-
* Only `writable: true` entries are candidates, including for the refusal
|
|
36
|
-
* message: naming a read-only field as an alternative would suggest it is one
|
|
37
|
-
* selector away from being written.
|
|
38
|
-
*/
|
|
39
|
-
export declare function resolveWritableField(config: ProjectConfig, requested: string): WritableField;
|
|
40
|
-
/**
|
|
41
|
-
* The field as Jira currently offers it on this issue, or a refusal.
|
|
42
|
-
*
|
|
43
|
-
* Absent from the edit metadata and present-but-not-settable are different
|
|
44
|
-
* situations with the same answer for the caller, so they share a code and
|
|
45
|
-
* differ in the detail: one means the field is not on this screen, the other
|
|
46
|
-
* that Jira will not let this account set it.
|
|
47
|
-
*/
|
|
48
|
-
export declare function assertEditable(issueKey: string, field: WritableField, metadata: EditFieldMetadata[]): EditFieldMetadata;
|
|
49
|
-
/**
|
|
50
|
-
* Which of the four families this field belongs to, if any.
|
|
51
|
-
*
|
|
52
|
-
* Classified from Jira's own `schema`, which is the vocabulary Jira answers
|
|
53
|
-
* in. The implementation key (`schema.custom`) deliberately does not decide
|
|
54
|
-
* it: there are hundreds of them, they are app-specific, and a field's wire
|
|
55
|
-
* shape follows its type rather than its plugin.
|
|
56
|
-
*
|
|
57
|
-
* Anything unclassified is refused. Posting an unknown type to see what
|
|
58
|
-
* happens would use a Jira 400 as schema discovery, and on the occasions it
|
|
59
|
-
* did not 400 it would write something nobody described.
|
|
60
|
-
*/
|
|
61
|
-
export declare function classifyKind(field: EditFieldMetadata): CustomFieldKind;
|
|
62
|
-
/**
|
|
63
|
-
* The value, checked against the family and turned into what Jira expects.
|
|
64
|
-
*
|
|
65
|
-
* Types are never coerced. `"5"` is not `5`: a caller that meant a number can
|
|
66
|
-
* say so, and silently converting would make JAM's idea of the value differ
|
|
67
|
-
* from the caller's in exactly the cases where it matters.
|
|
68
|
-
*
|
|
69
|
-
* Nothing here clears a field. Empty strings, empty arrays and null are
|
|
70
|
-
* refused rather than treated as "unset" - removing a value is a different
|
|
71
|
-
* intent from setting one, and it is not in this version.
|
|
72
|
-
*/
|
|
73
|
-
export declare function resolveCustomFieldValue(field: EditFieldMetadata, kind: CustomFieldKind, input: CustomFieldUpdateInput): {
|
|
74
|
-
jiraValue: unknown;
|
|
75
|
-
view: CustomFieldValueView;
|
|
76
|
-
resolvedOptions?: EditFieldOption[];
|
|
77
|
-
};
|
|
78
|
-
/**
|
|
79
|
-
* Do this plan's premises still hold?
|
|
80
|
-
*
|
|
81
|
-
* Semantic, like the create schema check and for the same reason: comparing
|
|
82
|
-
* whole metadata documents would invalidate every outstanding plan whenever an
|
|
83
|
-
* unrelated field appeared on the screen. What is compared is what the plan
|
|
84
|
-
* actually rested on - the field is still settable, still the same family,
|
|
85
|
-
* still the same schema, and every option it chose is still offered under the
|
|
86
|
-
* same label.
|
|
87
|
-
*
|
|
88
|
-
* A renamed option is treated as a changed one. The id is the identity, but a
|
|
89
|
-
* label is what the plan showed a human before they agreed to it, and "Backend"
|
|
90
|
-
* becoming "Platform" is a different statement about the issue.
|
|
91
|
-
*/
|
|
92
|
-
export declare function assertCustomFieldUnchanged(issueKey: string, requirements: CustomFieldRequirements, metadata: EditFieldMetadata[]): void;
|
|
93
|
-
export {};
|
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
import { JamError } from "../domain/errors.js";
|
|
2
|
-
/**
|
|
3
|
-
* Which configured field this selector names.
|
|
4
|
-
*
|
|
5
|
-
* The id is the identity; the name is an alias for people. Resolution is exact
|
|
6
|
-
* on either - no substring, no fuzz - because the alternative is an agent's
|
|
7
|
-
* approximate word choosing which field on somebody's board gets rewritten.
|
|
8
|
-
*
|
|
9
|
-
* Only `writable: true` entries are candidates, including for the refusal
|
|
10
|
-
* message: naming a read-only field as an alternative would suggest it is one
|
|
11
|
-
* selector away from being written.
|
|
12
|
-
*/
|
|
13
|
-
export function resolveWritableField(config, requested) {
|
|
14
|
-
const wanted = requested.trim();
|
|
15
|
-
if (wanted.length === 0) {
|
|
16
|
-
throw new JamError("JAM_WRITE_OPERATION_NOT_ALLOWED", "custom-field.update needs a non-empty `input.field`.", { operation: "custom-field.update" });
|
|
17
|
-
}
|
|
18
|
-
const writable = config.customFields.filter((f) => f.writable);
|
|
19
|
-
const match = writable.find((f) => f.id.toLowerCase() === wanted.toLowerCase()) ??
|
|
20
|
-
writable.find((f) => f.name.trim().toLowerCase() === wanted.toLowerCase());
|
|
21
|
-
if (!match) {
|
|
22
|
-
throw new JamError("JAM_WRITE_FIELD_NOT_ALLOWED", writable.length === 0
|
|
23
|
-
? `No custom field in this project is writable. A team opts one in by adding \`writable: true\` to its entry in .jira-agent/project.yaml; being readable does not make a field writable.`
|
|
24
|
-
: `"${requested}" is not a writable custom field in this project. JAM writes only the exact ids a team has opted in.`, {
|
|
25
|
-
requested,
|
|
26
|
-
writableCustomFields: writable.map((f) => ({ id: f.id, name: f.name })),
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
return { id: match.id, name: match.name };
|
|
30
|
-
}
|
|
31
|
-
/**
|
|
32
|
-
* The field as Jira currently offers it on this issue, or a refusal.
|
|
33
|
-
*
|
|
34
|
-
* Absent from the edit metadata and present-but-not-settable are different
|
|
35
|
-
* situations with the same answer for the caller, so they share a code and
|
|
36
|
-
* differ in the detail: one means the field is not on this screen, the other
|
|
37
|
-
* that Jira will not let this account set it.
|
|
38
|
-
*/
|
|
39
|
-
export function assertEditable(issueKey, field, metadata) {
|
|
40
|
-
const found = metadata.find((f) => f.id === field.id);
|
|
41
|
-
if (!found) {
|
|
42
|
-
throw new JamError("JAM_WRITE_CUSTOM_FIELD_NOT_EDITABLE", `Jira does not offer ${field.name} (${field.id}) on ${issueKey}'s edit screen for this account. The field may not apply to this project or issue type, or this account may not be able to edit it.`, { issueKey, fieldId: field.id, fieldName: field.name, reason: "NOT_ON_EDIT_SCREEN" });
|
|
43
|
-
}
|
|
44
|
-
if (!found.operations.includes("set")) {
|
|
45
|
-
throw new JamError("JAM_WRITE_CUSTOM_FIELD_NOT_EDITABLE", `Jira lists ${field.name} (${field.id}) on ${issueKey} but does not offer "set" for it${found.operations.length > 0 ? ` - only ${found.operations.join(", ")}` : ""}. JAM only sets a value; it does not add to or remove from one.`, {
|
|
46
|
-
issueKey,
|
|
47
|
-
fieldId: field.id,
|
|
48
|
-
fieldName: field.name,
|
|
49
|
-
operations: found.operations,
|
|
50
|
-
reason: "SET_NOT_OFFERED",
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
return found;
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Which of the four families this field belongs to, if any.
|
|
57
|
-
*
|
|
58
|
-
* Classified from Jira's own `schema`, which is the vocabulary Jira answers
|
|
59
|
-
* in. The implementation key (`schema.custom`) deliberately does not decide
|
|
60
|
-
* it: there are hundreds of them, they are app-specific, and a field's wire
|
|
61
|
-
* shape follows its type rather than its plugin.
|
|
62
|
-
*
|
|
63
|
-
* Anything unclassified is refused. Posting an unknown type to see what
|
|
64
|
-
* happens would use a Jira 400 as schema discovery, and on the occasions it
|
|
65
|
-
* did not 400 it would write something nobody described.
|
|
66
|
-
*/
|
|
67
|
-
export function classifyKind(field) {
|
|
68
|
-
const { type, items } = field.schema;
|
|
69
|
-
if (type === "string" && !items)
|
|
70
|
-
return "text";
|
|
71
|
-
if (type === "number" && !items)
|
|
72
|
-
return "number";
|
|
73
|
-
if (type === "option" && !items)
|
|
74
|
-
return "single-option";
|
|
75
|
-
if (type === "array" && items === "option")
|
|
76
|
-
return "multi-option";
|
|
77
|
-
throw new JamError("JAM_WRITE_CUSTOM_FIELD_TYPE_UNSUPPORTED", `${field.name} (${field.id}) is a ${describeType(field)} field, and JAM does not know how to write one safely yet. Supported: single-line text, number, single-select and multi-select.`, {
|
|
78
|
-
fieldId: field.id,
|
|
79
|
-
fieldName: field.name,
|
|
80
|
-
schema: field.schema,
|
|
81
|
-
supported: ["text", "number", "single-option", "multi-option"],
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
function describeType(field) {
|
|
85
|
-
const { type, items } = field.schema;
|
|
86
|
-
return items ? `${type} of ${items}` : type;
|
|
87
|
-
}
|
|
88
|
-
/**
|
|
89
|
-
* The value, checked against the family and turned into what Jira expects.
|
|
90
|
-
*
|
|
91
|
-
* Types are never coerced. `"5"` is not `5`: a caller that meant a number can
|
|
92
|
-
* say so, and silently converting would make JAM's idea of the value differ
|
|
93
|
-
* from the caller's in exactly the cases where it matters.
|
|
94
|
-
*
|
|
95
|
-
* Nothing here clears a field. Empty strings, empty arrays and null are
|
|
96
|
-
* refused rather than treated as "unset" - removing a value is a different
|
|
97
|
-
* intent from setting one, and it is not in this version.
|
|
98
|
-
*/
|
|
99
|
-
export function resolveCustomFieldValue(field, kind, input) {
|
|
100
|
-
const { value } = input;
|
|
101
|
-
const named = { id: field.id, name: field.name };
|
|
102
|
-
switch (kind) {
|
|
103
|
-
case "text": {
|
|
104
|
-
if (typeof value !== "string")
|
|
105
|
-
throw wrongType(field, kind, value);
|
|
106
|
-
const text = value.trim();
|
|
107
|
-
if (text.length === 0)
|
|
108
|
-
throw refuseClear(field);
|
|
109
|
-
return { jiraValue: text, view: { ...named, value: text } };
|
|
110
|
-
}
|
|
111
|
-
case "number": {
|
|
112
|
-
if (typeof value !== "number" || !Number.isFinite(value))
|
|
113
|
-
throw wrongType(field, kind, value);
|
|
114
|
-
return { jiraValue: value, view: { ...named, value } };
|
|
115
|
-
}
|
|
116
|
-
case "single-option": {
|
|
117
|
-
if (typeof value !== "string")
|
|
118
|
-
throw wrongType(field, kind, value);
|
|
119
|
-
const option = resolveOption(field, value);
|
|
120
|
-
// Jira takes the option by id. The label is what a person reads, and two
|
|
121
|
-
// options could carry the same one.
|
|
122
|
-
return {
|
|
123
|
-
jiraValue: { id: option.id },
|
|
124
|
-
view: { ...named, value: option },
|
|
125
|
-
resolvedOptions: [option],
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
case "multi-option": {
|
|
129
|
-
if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
|
|
130
|
-
throw wrongType(field, kind, value);
|
|
131
|
-
}
|
|
132
|
-
if (value.length === 0)
|
|
133
|
-
throw refuseClear(field);
|
|
134
|
-
const seen = new Set();
|
|
135
|
-
for (const raw of value) {
|
|
136
|
-
const key = raw.trim().toLowerCase();
|
|
137
|
-
if (seen.has(key)) {
|
|
138
|
-
throw new JamError("JAM_WRITE_VALUE_NOT_ALLOWED", `"${raw}" appears more than once in the value for ${field.name}. JAM does not quietly drop the repeat - say each option once.`, { fieldId: field.id, repeated: raw });
|
|
139
|
-
}
|
|
140
|
-
seen.add(key);
|
|
141
|
-
}
|
|
142
|
-
// Every option resolves, or none is written. A partly-applied selection
|
|
143
|
-
// is a selection nobody asked for.
|
|
144
|
-
const options = value.map((raw) => resolveOption(field, raw));
|
|
145
|
-
return {
|
|
146
|
-
jiraValue: options.map((o) => ({ id: o.id })),
|
|
147
|
-
view: { ...named, value: options },
|
|
148
|
-
resolvedOptions: options,
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
/**
|
|
154
|
-
* Which option Jira offers under this name, if exactly one does.
|
|
155
|
-
*
|
|
156
|
-
* An option id wins outright, then an exact label ignoring case and space.
|
|
157
|
-
* Nothing partial: Jira's option lists are short and a caller can name one
|
|
158
|
-
* exactly, so a near miss is a question rather than a guess.
|
|
159
|
-
*/
|
|
160
|
-
function resolveOption(field, requested) {
|
|
161
|
-
const allowed = field.allowedValues;
|
|
162
|
-
if (!allowed) {
|
|
163
|
-
throw new JamError("JAM_WRITE_CUSTOM_FIELD_TYPE_UNSUPPORTED", `${field.name} (${field.id}) is a select field, but Jira did not say which options it offers, so JAM cannot resolve "${requested}" to one.`, { fieldId: field.id, fieldName: field.name, schema: field.schema });
|
|
164
|
-
}
|
|
165
|
-
const wanted = requested.trim();
|
|
166
|
-
const byId = allowed.filter((o) => o.id === wanted);
|
|
167
|
-
const matches = byId.length > 0
|
|
168
|
-
? byId
|
|
169
|
-
: allowed.filter((o) => o.label.trim().toLowerCase() === wanted.toLowerCase());
|
|
170
|
-
if (matches.length === 0) {
|
|
171
|
-
throw new JamError("JAM_WRITE_VALUE_NOT_ALLOWED", allowed.length === 0
|
|
172
|
-
? `Jira offers no options for ${field.name} on this issue, so "${requested}" cannot be set.`
|
|
173
|
-
: `"${requested}" is not an option Jira offers for ${field.name}. Allowed: ${allowed.map((o) => o.label).join(", ")}.`, { fieldId: field.id, requested, allowed });
|
|
174
|
-
}
|
|
175
|
-
if (matches.length > 1) {
|
|
176
|
-
throw new JamError("JAM_WRITE_VALUE_NOT_ALLOWED", `"${requested}" matches ${matches.length} options for ${field.name}. Pass the option id of the one you mean.`, { fieldId: field.id, requested, candidates: matches });
|
|
177
|
-
}
|
|
178
|
-
return matches[0];
|
|
179
|
-
}
|
|
180
|
-
/**
|
|
181
|
-
* Do this plan's premises still hold?
|
|
182
|
-
*
|
|
183
|
-
* Semantic, like the create schema check and for the same reason: comparing
|
|
184
|
-
* whole metadata documents would invalidate every outstanding plan whenever an
|
|
185
|
-
* unrelated field appeared on the screen. What is compared is what the plan
|
|
186
|
-
* actually rested on - the field is still settable, still the same family,
|
|
187
|
-
* still the same schema, and every option it chose is still offered under the
|
|
188
|
-
* same label.
|
|
189
|
-
*
|
|
190
|
-
* A renamed option is treated as a changed one. The id is the identity, but a
|
|
191
|
-
* label is what the plan showed a human before they agreed to it, and "Backend"
|
|
192
|
-
* becoming "Platform" is a different statement about the issue.
|
|
193
|
-
*/
|
|
194
|
-
export function assertCustomFieldUnchanged(issueKey, requirements, metadata) {
|
|
195
|
-
const field = metadata.find((f) => f.id === requirements.fieldId);
|
|
196
|
-
if (!field) {
|
|
197
|
-
throw schemaChanged(`${requirements.fieldName} (${requirements.fieldId}) is no longer on ${issueKey}'s edit screen for this account.`, { issueKey, fieldId: requirements.fieldId });
|
|
198
|
-
}
|
|
199
|
-
if (!field.operations.includes("set")) {
|
|
200
|
-
throw schemaChanged(`Jira no longer offers "set" for ${requirements.fieldName} on ${issueKey}.`, { issueKey, fieldId: field.id, operations: field.operations });
|
|
201
|
-
}
|
|
202
|
-
if (field.schema.type !== requirements.schema.type ||
|
|
203
|
-
field.schema.items !== requirements.schema.items) {
|
|
204
|
-
throw schemaChanged(`${requirements.fieldName} is no longer a ${requirements.kind} field.`, { issueKey, fieldId: field.id, planned: requirements.schema, current: field.schema });
|
|
205
|
-
}
|
|
206
|
-
for (const planned of requirements.resolvedOptions ?? []) {
|
|
207
|
-
const current = field.allowedValues?.find((o) => o.id === planned.id);
|
|
208
|
-
if (!current) {
|
|
209
|
-
throw schemaChanged(`Option "${planned.label}" is no longer offered for ${requirements.fieldName}.`, { issueKey, fieldId: field.id, option: planned });
|
|
210
|
-
}
|
|
211
|
-
if (current.label !== planned.label) {
|
|
212
|
-
throw schemaChanged(`Option "${planned.label}" has been renamed to "${current.label}", so this plan no longer describes the change it showed.`, { issueKey, fieldId: field.id, planned, current });
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
function schemaChanged(what, details) {
|
|
217
|
-
return new JamError("JAM_WRITE_SCHEMA_CHANGED", `${what} This plan was built on the field's configuration as it was, so it no longer describes a change JAM can make. Nothing was written - plan again.`, details);
|
|
218
|
-
}
|
|
219
|
-
function wrongType(field, kind, value) {
|
|
220
|
-
const wanted = {
|
|
221
|
-
text: "a string",
|
|
222
|
-
number: "a number",
|
|
223
|
-
"single-option": "a string naming one option",
|
|
224
|
-
"multi-option": "an array of strings naming options",
|
|
225
|
-
}[kind];
|
|
226
|
-
return new JamError("JAM_WRITE_VALUE_NOT_ALLOWED", `${field.name} (${field.id}) is a ${kind} field and needs ${wanted}. JAM does not convert between types - "5" and 5 are different values, and guessing which was meant is not JAM's to do.`, { fieldId: field.id, kind, received: typeof value });
|
|
227
|
-
}
|
|
228
|
-
function refuseClear(field) {
|
|
229
|
-
return new JamError("JAM_WRITE_VALUE_NOT_ALLOWED", `custom-field.update sets a value; it does not clear one. ${field.name} cannot be set to an empty value in this version.`, { fieldId: field.id, reason: "CLEAR_NOT_SUPPORTED" });
|
|
230
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import type { EditFieldMetadata } from "../domain/write.js";
|
|
2
|
-
/**
|
|
3
|
-
* What Jira will let this account change on one issue, right now.
|
|
4
|
-
*
|
|
5
|
-
* `GET /rest/api/3/issue/{key}/editmeta` is the authority, and it is asked
|
|
6
|
-
* rather than reconstructed. A custom field's applicability depends on the
|
|
7
|
-
* project, the issue type, the field's contexts, the screen it is on and the
|
|
8
|
-
* permissions of whoever is asking - JAM does not carry a copy of that model,
|
|
9
|
-
* and the field-context APIs that would let it try need administrator rights
|
|
10
|
-
* most tokens do not have. So the question is put to Jira in the form it can
|
|
11
|
-
* answer exactly: on this issue, for this account, what is editable and how.
|
|
12
|
-
*
|
|
13
|
-
* The same shape as the other read-shaped ports, for the same reasons: it
|
|
14
|
-
* mutates nothing, so it does not belong behind the write port's no-retry
|
|
15
|
-
* contract, and it answers a question about a configuration rather than about
|
|
16
|
-
* an issue, so the read port's completeness semantics would mean nothing here.
|
|
17
|
-
*
|
|
18
|
-
* It does not retry. Its answer decides a mutation.
|
|
19
|
-
*/
|
|
20
|
-
export interface JiraEditMetadataPort {
|
|
21
|
-
getEditableFields(issueKey: string): Promise<EditFieldMetadata[]>;
|
|
22
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|