@atlassian-dc-mcp/common 0.17.1 → 0.19.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/CHANGELOG.md +22 -0
- package/build/__tests__/args.test.d.ts +2 -0
- package/build/__tests__/args.test.d.ts.map +1 -0
- package/build/__tests__/args.test.js +117 -0
- package/build/__tests__/args.test.js.map +1 -0
- package/build/__tests__/describe-error.test.d.ts +2 -0
- package/build/__tests__/describe-error.test.d.ts.map +1 -0
- package/build/__tests__/describe-error.test.js +54 -0
- package/build/__tests__/describe-error.test.js.map +1 -0
- package/build/__tests__/setup-cli.test.js +337 -44
- package/build/__tests__/setup-cli.test.js.map +1 -1
- package/build/config/source.d.ts +1 -0
- package/build/config/source.d.ts.map +1 -1
- package/build/config/source.js.map +1 -1
- package/build/index.d.ts +3 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -1
- package/build/index.js.map +1 -1
- package/build/setup/args.d.ts +14 -0
- package/build/setup/args.d.ts.map +1 -0
- package/build/setup/args.js +66 -0
- package/build/setup/args.js.map +1 -0
- package/build/setup/describe-error.d.ts +2 -0
- package/build/setup/describe-error.d.ts.map +1 -0
- package/build/setup/describe-error.js +50 -0
- package/build/setup/describe-error.js.map +1 -0
- package/build/setup/value-validator.d.ts +8 -0
- package/build/setup/value-validator.d.ts.map +1 -0
- package/build/setup/value-validator.js +57 -0
- package/build/setup/value-validator.js.map +1 -0
- package/build/setup-cli.d.ts +18 -0
- package/build/setup-cli.d.ts.map +1 -1
- package/build/setup-cli.js +241 -33
- package/build/setup-cli.js.map +1 -1
- package/jest.config.js +1 -0
- package/package.json +2 -2
- package/src/__tests__/args.test.ts +132 -0
- package/src/__tests__/describe-error.test.ts +70 -0
- package/src/__tests__/setup-cli.test.ts +393 -38
- package/src/config/source.ts +1 -0
- package/src/index.ts +3 -1
- package/src/setup/args.ts +78 -0
- package/src/setup/describe-error.ts +67 -0
- package/src/setup/value-validator.ts +59 -0
- package/src/setup-cli.ts +318 -36
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -2,9 +2,28 @@ import { DefaultConfigRegistry } from '../config/registry.js';
|
|
|
2
2
|
import { HomeFileSource } from '../config/sources/home-file.js';
|
|
3
3
|
import { MacosKeychainSource, type KeychainDeps } from '../config/sources/macos-keychain.js';
|
|
4
4
|
import { ProcessEnvSource } from '../config/sources/process-env.js';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
runSetup,
|
|
7
|
+
type CredentialValidationContext,
|
|
8
|
+
type CredentialValidationResult,
|
|
9
|
+
type SetupPrompts,
|
|
10
|
+
type ValidateCredentials,
|
|
11
|
+
} from '../setup-cli.js';
|
|
12
|
+
import type { ParsedSetupArgs } from '../setup/args.js';
|
|
6
13
|
import type { ConfigKey, ProductDefinition } from '../config/source.js';
|
|
7
14
|
|
|
15
|
+
function makeArgs(overrides: Partial<ParsedSetupArgs> = {}): ParsedSetupArgs {
|
|
16
|
+
return {
|
|
17
|
+
host: undefined,
|
|
18
|
+
apiBasePath: undefined,
|
|
19
|
+
token: undefined,
|
|
20
|
+
defaultPageSize: undefined,
|
|
21
|
+
nonInteractive: false,
|
|
22
|
+
help: false,
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
8
27
|
const JIRA: ProductDefinition = {
|
|
9
28
|
id: 'jira',
|
|
10
29
|
envVars: {
|
|
@@ -72,15 +91,69 @@ class FakeHomeFile extends HomeFileSource {
|
|
|
72
91
|
}
|
|
73
92
|
}
|
|
74
93
|
|
|
94
|
+
class StubCredentialValidator {
|
|
95
|
+
readonly calls: CredentialValidationContext[] = [];
|
|
96
|
+
private readonly queue: CredentialValidationResult[] = [];
|
|
97
|
+
private fallback: CredentialValidationResult = { ok: true };
|
|
98
|
+
|
|
99
|
+
returns(result: CredentialValidationResult): this {
|
|
100
|
+
this.fallback = result;
|
|
101
|
+
return this;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
reject(message: string): this {
|
|
105
|
+
return this.returns({ ok: false, message });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
enqueue(...results: CredentialValidationResult[]): this {
|
|
109
|
+
this.queue.push(...results);
|
|
110
|
+
return this;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
asFn(): ValidateCredentials {
|
|
114
|
+
return async (ctx) => {
|
|
115
|
+
this.calls.push(ctx);
|
|
116
|
+
return this.queue.shift() ?? this.fallback;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
type ConfirmStub = (message: string, fallbackDefault?: boolean) => boolean;
|
|
122
|
+
|
|
123
|
+
function scriptedConfirms(script: Record<string, boolean>): ConfirmStub {
|
|
124
|
+
return (message, fallbackDefault) => {
|
|
125
|
+
for (const [prefix, answer] of Object.entries(script)) {
|
|
126
|
+
if (message.startsWith(prefix)) return answer;
|
|
127
|
+
}
|
|
128
|
+
return fallbackDefault ?? false;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
75
132
|
function makeRegistry(keychain: FakeKeychain, home: FakeHomeFile) {
|
|
76
133
|
return new DefaultConfigRegistry([new ProcessEnvSource(), home, keychain]);
|
|
77
134
|
}
|
|
78
135
|
|
|
79
|
-
|
|
136
|
+
const standardAnswers = {
|
|
137
|
+
host: 'j-host',
|
|
138
|
+
apiBasePath: '/rest/api/2',
|
|
139
|
+
pageSize: '25',
|
|
140
|
+
token: 'secret',
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
function makePrompts(
|
|
144
|
+
overrides: Partial<SetupPrompts> = {},
|
|
145
|
+
answers: Partial<typeof standardAnswers> = {},
|
|
146
|
+
confirms?: ConfirmStub,
|
|
147
|
+
): SetupPrompts {
|
|
148
|
+
const filled = { ...standardAnswers, ...answers };
|
|
80
149
|
return {
|
|
81
|
-
input: async (opts) =>
|
|
82
|
-
|
|
83
|
-
|
|
150
|
+
input: async (opts) => {
|
|
151
|
+
if (opts.message.startsWith('Host')) return filled.host;
|
|
152
|
+
if (opts.message.startsWith('API base path')) return filled.apiBasePath;
|
|
153
|
+
return filled.pageSize;
|
|
154
|
+
},
|
|
155
|
+
password: async () => filled.token,
|
|
156
|
+
confirm: async (opts) => (confirms ?? ((_m, d) => d ?? false))(opts.message, opts.default),
|
|
84
157
|
...overrides,
|
|
85
158
|
};
|
|
86
159
|
}
|
|
@@ -105,16 +178,13 @@ describe('runSetup', () => {
|
|
|
105
178
|
});
|
|
106
179
|
|
|
107
180
|
it('writes the token to keychain first on darwin and clears home file token', async () => {
|
|
108
|
-
const prompts = makePrompts({
|
|
109
|
-
input: async (opts) => {
|
|
110
|
-
if (opts.message.startsWith('Host')) return 'j-host';
|
|
111
|
-
if (opts.message.startsWith('API base path')) return '/rest/api/2';
|
|
112
|
-
return '25';
|
|
113
|
-
},
|
|
114
|
-
password: async () => 'secret',
|
|
115
|
-
});
|
|
116
181
|
const registry = makeRegistry(keychain, home);
|
|
117
|
-
await runSetup(JIRA, {
|
|
182
|
+
await runSetup(JIRA, {
|
|
183
|
+
registry,
|
|
184
|
+
log: (m) => logs.push(m),
|
|
185
|
+
exit: () => undefined,
|
|
186
|
+
prompts: makePrompts(),
|
|
187
|
+
});
|
|
118
188
|
|
|
119
189
|
expect(keychain.writeCalls).toBe(1);
|
|
120
190
|
expect(keychain.store).toBe('secret');
|
|
@@ -124,16 +194,13 @@ describe('runSetup', () => {
|
|
|
124
194
|
|
|
125
195
|
it('falls back to home file when keychain is unavailable', async () => {
|
|
126
196
|
keychain.available = false;
|
|
127
|
-
const prompts = makePrompts({
|
|
128
|
-
input: async (opts) => {
|
|
129
|
-
if (opts.message.startsWith('Host')) return 'j-host';
|
|
130
|
-
if (opts.message.startsWith('API base path')) return '/rest/api/2';
|
|
131
|
-
return '25';
|
|
132
|
-
},
|
|
133
|
-
password: async () => 'secret',
|
|
134
|
-
});
|
|
135
197
|
const registry = makeRegistry(keychain, home);
|
|
136
|
-
await runSetup(JIRA, {
|
|
198
|
+
await runSetup(JIRA, {
|
|
199
|
+
registry,
|
|
200
|
+
log: (m) => logs.push(m),
|
|
201
|
+
exit: () => undefined,
|
|
202
|
+
prompts: makePrompts(),
|
|
203
|
+
});
|
|
137
204
|
|
|
138
205
|
expect(keychain.writeCalls).toBe(0);
|
|
139
206
|
const tokenWrites = home.writes.filter(([, k]) => k === 'token');
|
|
@@ -142,34 +209,322 @@ describe('runSetup', () => {
|
|
|
142
209
|
|
|
143
210
|
it('prints shadowing warning when env var is set higher than target', async () => {
|
|
144
211
|
process.env.JIRA_API_TOKEN = 'shadow';
|
|
145
|
-
const prompts = makePrompts({
|
|
146
|
-
input: async (opts) => {
|
|
147
|
-
if (opts.message.startsWith('Host')) return 'j-host';
|
|
148
|
-
if (opts.message.startsWith('API base path')) return '/rest/api/2';
|
|
149
|
-
return '25';
|
|
150
|
-
},
|
|
151
|
-
password: async () => 'secret',
|
|
152
|
-
});
|
|
153
212
|
const registry = makeRegistry(keychain, home);
|
|
154
|
-
await runSetup(JIRA, {
|
|
213
|
+
await runSetup(JIRA, {
|
|
214
|
+
registry,
|
|
215
|
+
log: (m) => logs.push(m),
|
|
216
|
+
exit: () => undefined,
|
|
217
|
+
prompts: makePrompts(),
|
|
218
|
+
});
|
|
155
219
|
|
|
156
220
|
expect(logs.some((l) => l.includes('JIRA_API_TOKEN') && l.includes('Warning'))).toBe(true);
|
|
157
221
|
});
|
|
158
222
|
|
|
223
|
+
it('calls validateCredentials with the entered values before saving', async () => {
|
|
224
|
+
const validator = new StubCredentialValidator();
|
|
225
|
+
const registry = makeRegistry(keychain, home);
|
|
226
|
+
|
|
227
|
+
await runSetup(JIRA, {
|
|
228
|
+
registry,
|
|
229
|
+
log: (m) => logs.push(m),
|
|
230
|
+
exit: () => undefined,
|
|
231
|
+
prompts: makePrompts(),
|
|
232
|
+
validateCredentials: validator.asFn(),
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
expect(validator.calls).toEqual([
|
|
236
|
+
{ host: 'j-host', apiBasePath: '/rest/api/2', token: 'secret' },
|
|
237
|
+
]);
|
|
238
|
+
expect(keychain.writeCalls).toBe(1);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('exits without writing when validation fails and user declines retry or save-anyway', async () => {
|
|
242
|
+
const validator = new StubCredentialValidator().reject('401 Unauthorized');
|
|
243
|
+
const exitFn = jest.fn();
|
|
244
|
+
const registry = makeRegistry(keychain, home);
|
|
245
|
+
|
|
246
|
+
await runSetup(JIRA, {
|
|
247
|
+
registry,
|
|
248
|
+
log: (m) => logs.push(m),
|
|
249
|
+
exit: exitFn,
|
|
250
|
+
prompts: makePrompts({}, { token: 'bad-token' }, scriptedConfirms({
|
|
251
|
+
'Try again': false,
|
|
252
|
+
'Save configuration anyway': false,
|
|
253
|
+
})),
|
|
254
|
+
validateCredentials: validator.asFn(),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(validator.calls).toHaveLength(1);
|
|
258
|
+
expect(exitFn).toHaveBeenCalledWith(1);
|
|
259
|
+
expect(keychain.writeCalls).toBe(0);
|
|
260
|
+
expect(home.writes).toHaveLength(0);
|
|
261
|
+
expect(logs.some((l) => l.includes('401 Unauthorized'))).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('retries validation after a failure and writes once it succeeds', async () => {
|
|
265
|
+
const validator = new StubCredentialValidator().enqueue(
|
|
266
|
+
{ ok: false, message: '401 Unauthorized' },
|
|
267
|
+
{ ok: true },
|
|
268
|
+
);
|
|
269
|
+
const registry = makeRegistry(keychain, home);
|
|
270
|
+
|
|
271
|
+
await runSetup(JIRA, {
|
|
272
|
+
registry,
|
|
273
|
+
log: (m) => logs.push(m),
|
|
274
|
+
exit: () => undefined,
|
|
275
|
+
prompts: makePrompts({}, {}, scriptedConfirms({ 'Try again': true })),
|
|
276
|
+
validateCredentials: validator.asFn(),
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(validator.calls).toHaveLength(2);
|
|
280
|
+
expect(keychain.writeCalls).toBe(1);
|
|
281
|
+
expect(logs.some((l) => l.includes('401 Unauthorized'))).toBe(true);
|
|
282
|
+
expect(logs.some((l) => l.startsWith('Validation succeeded'))).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('stops offering retry after the third failure and lets the user save anyway', async () => {
|
|
286
|
+
const validator = new StubCredentialValidator().reject('401 Unauthorized');
|
|
287
|
+
let retryPromptsShown = 0;
|
|
288
|
+
const confirms: ConfirmStub = (message) => {
|
|
289
|
+
if (message.startsWith('Try again')) {
|
|
290
|
+
retryPromptsShown++;
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
if (message.startsWith('Save configuration anyway')) return true;
|
|
294
|
+
return false;
|
|
295
|
+
};
|
|
296
|
+
const registry = makeRegistry(keychain, home);
|
|
297
|
+
|
|
298
|
+
await runSetup(JIRA, {
|
|
299
|
+
registry,
|
|
300
|
+
log: (m) => logs.push(m),
|
|
301
|
+
exit: () => undefined,
|
|
302
|
+
prompts: makePrompts({}, {}, confirms),
|
|
303
|
+
validateCredentials: validator.asFn(),
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
expect(validator.calls).toHaveLength(3);
|
|
307
|
+
expect(retryPromptsShown).toBe(2);
|
|
308
|
+
expect(keychain.writeCalls).toBe(1);
|
|
309
|
+
});
|
|
310
|
+
|
|
159
311
|
it('exits 130 on SIGINT cancellation before any write', async () => {
|
|
160
312
|
const exitErr: Error & { name: string } = Object.assign(new Error('closed'), {
|
|
161
313
|
name: 'ExitPromptError',
|
|
162
314
|
});
|
|
163
|
-
const prompts = makePrompts({
|
|
164
|
-
input: async () => {
|
|
165
|
-
throw exitErr;
|
|
166
|
-
},
|
|
167
|
-
});
|
|
168
315
|
const exitFn = jest.fn();
|
|
169
316
|
const registry = makeRegistry(keychain, home);
|
|
170
|
-
await runSetup(JIRA, {
|
|
317
|
+
await runSetup(JIRA, {
|
|
318
|
+
registry,
|
|
319
|
+
log: (m) => logs.push(m),
|
|
320
|
+
exit: exitFn,
|
|
321
|
+
prompts: makePrompts({
|
|
322
|
+
input: async () => {
|
|
323
|
+
throw exitErr;
|
|
324
|
+
},
|
|
325
|
+
}),
|
|
326
|
+
});
|
|
171
327
|
expect(exitFn).toHaveBeenCalledWith(130);
|
|
172
328
|
expect(keychain.writeCalls).toBe(0);
|
|
173
329
|
expect(home.writes).toHaveLength(0);
|
|
174
330
|
});
|
|
331
|
+
|
|
332
|
+
it('skips the host prompt when --host is passed and still prompts for the rest', async () => {
|
|
333
|
+
const inputCalls: string[] = [];
|
|
334
|
+
const passwordCalls = jest.fn(async () => 'secret');
|
|
335
|
+
const registry = makeRegistry(keychain, home);
|
|
336
|
+
|
|
337
|
+
await runSetup(JIRA, {
|
|
338
|
+
registry,
|
|
339
|
+
log: (m) => logs.push(m),
|
|
340
|
+
exit: () => undefined,
|
|
341
|
+
args: makeArgs({ host: 'cli-host.example.com' }),
|
|
342
|
+
prompts: {
|
|
343
|
+
input: async (opts) => {
|
|
344
|
+
inputCalls.push(opts.message);
|
|
345
|
+
if (opts.message.startsWith('API base path')) return '/rest/api/2';
|
|
346
|
+
return '25';
|
|
347
|
+
},
|
|
348
|
+
password: passwordCalls,
|
|
349
|
+
confirm: async (opts) => opts.default ?? false,
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
expect(inputCalls.some((m) => m.startsWith('Host'))).toBe(false);
|
|
354
|
+
expect(inputCalls.some((m) => m.startsWith('API base path'))).toBe(true);
|
|
355
|
+
expect(passwordCalls).toHaveBeenCalledTimes(1);
|
|
356
|
+
expect(home.values.jira.host).toBe('cli-host.example.com');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
describe('non-interactive mode', () => {
|
|
360
|
+
function nonInteractivePrompts(): SetupPrompts & { inputCalls: string[]; passwordCalls: number } {
|
|
361
|
+
const inputCalls: string[] = [];
|
|
362
|
+
let passwordCalls = 0;
|
|
363
|
+
return Object.assign(
|
|
364
|
+
{
|
|
365
|
+
input: async (opts: { message: string }) => {
|
|
366
|
+
inputCalls.push(opts.message);
|
|
367
|
+
return '';
|
|
368
|
+
},
|
|
369
|
+
password: async () => {
|
|
370
|
+
passwordCalls++;
|
|
371
|
+
return '';
|
|
372
|
+
},
|
|
373
|
+
confirm: async () => false,
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
inputCalls,
|
|
377
|
+
get passwordCalls() {
|
|
378
|
+
return passwordCalls;
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
) as SetupPrompts & { inputCalls: string[]; passwordCalls: number };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
it('writes everything without prompts when all required fields come from CLI args', async () => {
|
|
385
|
+
const validator = new StubCredentialValidator();
|
|
386
|
+
const exitFn = jest.fn();
|
|
387
|
+
const registry = makeRegistry(keychain, home);
|
|
388
|
+
const prompts = nonInteractivePrompts();
|
|
389
|
+
|
|
390
|
+
await runSetup(JIRA, {
|
|
391
|
+
registry,
|
|
392
|
+
log: (m) => logs.push(m),
|
|
393
|
+
exit: exitFn,
|
|
394
|
+
prompts,
|
|
395
|
+
validateCredentials: validator.asFn(),
|
|
396
|
+
args: makeArgs({
|
|
397
|
+
host: 'cli-host.example.com',
|
|
398
|
+
token: 'cli-token',
|
|
399
|
+
nonInteractive: true,
|
|
400
|
+
}),
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
expect(prompts.inputCalls).toHaveLength(0);
|
|
404
|
+
expect(prompts.passwordCalls).toBe(0);
|
|
405
|
+
expect(exitFn).not.toHaveBeenCalled();
|
|
406
|
+
expect(validator.calls).toEqual([
|
|
407
|
+
{ host: 'cli-host.example.com', apiBasePath: '/rest/api/2', token: 'cli-token' },
|
|
408
|
+
]);
|
|
409
|
+
expect(home.values.jira.host).toBe('cli-host.example.com');
|
|
410
|
+
expect(home.values.jira.apiBasePath).toBe('/rest/api/2');
|
|
411
|
+
expect(keychain.store).toBe('cli-token');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('exits 1 when --token is missing and there is no existing token', async () => {
|
|
415
|
+
const validator = new StubCredentialValidator();
|
|
416
|
+
const exitFn = jest.fn();
|
|
417
|
+
const registry = makeRegistry(keychain, home);
|
|
418
|
+
|
|
419
|
+
await runSetup(JIRA, {
|
|
420
|
+
registry,
|
|
421
|
+
log: (m) => logs.push(m),
|
|
422
|
+
exit: exitFn,
|
|
423
|
+
prompts: nonInteractivePrompts(),
|
|
424
|
+
validateCredentials: validator.asFn(),
|
|
425
|
+
args: makeArgs({ host: 'cli-host.example.com', nonInteractive: true }),
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
expect(exitFn).toHaveBeenCalledWith(1);
|
|
429
|
+
expect(validator.calls).toHaveLength(0);
|
|
430
|
+
expect(keychain.writeCalls).toBe(0);
|
|
431
|
+
expect(home.writes).toHaveLength(0);
|
|
432
|
+
expect(logs.some((l) => l.includes('JIRA_API_TOKEN'))).toBe(true);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('reuses an existing keychain token without rewriting it when --token is omitted', async () => {
|
|
436
|
+
keychain.store = 'kept-token';
|
|
437
|
+
const validator = new StubCredentialValidator();
|
|
438
|
+
const exitFn = jest.fn();
|
|
439
|
+
const registry = makeRegistry(keychain, home);
|
|
440
|
+
|
|
441
|
+
await runSetup(JIRA, {
|
|
442
|
+
registry,
|
|
443
|
+
log: (m) => logs.push(m),
|
|
444
|
+
exit: exitFn,
|
|
445
|
+
prompts: nonInteractivePrompts(),
|
|
446
|
+
validateCredentials: validator.asFn(),
|
|
447
|
+
args: makeArgs({ host: 'cli-host.example.com', nonInteractive: true }),
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
expect(exitFn).not.toHaveBeenCalled();
|
|
451
|
+
expect(validator.calls).toEqual([
|
|
452
|
+
{ host: 'cli-host.example.com', apiBasePath: '/rest/api/2', token: 'kept-token' },
|
|
453
|
+
]);
|
|
454
|
+
expect(keychain.writeCalls).toBe(0);
|
|
455
|
+
expect(keychain.store).toBe('kept-token');
|
|
456
|
+
const tokenWrites = home.writes.filter(([, k]) => k === 'token');
|
|
457
|
+
expect(tokenWrites).toHaveLength(0);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('exits 1 with a format error when --default-page-size is not a positive integer', async () => {
|
|
461
|
+
const exitFn = jest.fn();
|
|
462
|
+
const registry = makeRegistry(keychain, home);
|
|
463
|
+
|
|
464
|
+
await runSetup(JIRA, {
|
|
465
|
+
registry,
|
|
466
|
+
log: (m) => logs.push(m),
|
|
467
|
+
exit: exitFn,
|
|
468
|
+
prompts: nonInteractivePrompts(),
|
|
469
|
+
args: makeArgs({
|
|
470
|
+
host: 'cli-host.example.com',
|
|
471
|
+
token: 'cli-token',
|
|
472
|
+
defaultPageSize: 'abc',
|
|
473
|
+
nonInteractive: true,
|
|
474
|
+
}),
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
expect(exitFn).toHaveBeenCalledWith(1);
|
|
478
|
+
expect(home.writes).toHaveLength(0);
|
|
479
|
+
expect(keychain.writeCalls).toBe(0);
|
|
480
|
+
expect(logs.some((l) => l.includes('default page size'))).toBe(true);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('exits 1 on credential rejection without retrying or asking to save anyway', async () => {
|
|
484
|
+
const validator = new StubCredentialValidator().reject('401 Unauthorized');
|
|
485
|
+
const exitFn = jest.fn();
|
|
486
|
+
const confirmFn = jest.fn(async () => false);
|
|
487
|
+
const registry = makeRegistry(keychain, home);
|
|
488
|
+
|
|
489
|
+
await runSetup(JIRA, {
|
|
490
|
+
registry,
|
|
491
|
+
log: (m) => logs.push(m),
|
|
492
|
+
exit: exitFn,
|
|
493
|
+
prompts: { ...nonInteractivePrompts(), confirm: confirmFn },
|
|
494
|
+
validateCredentials: validator.asFn(),
|
|
495
|
+
args: makeArgs({
|
|
496
|
+
host: 'cli-host.example.com',
|
|
497
|
+
token: 'cli-token',
|
|
498
|
+
nonInteractive: true,
|
|
499
|
+
}),
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
expect(validator.calls).toHaveLength(1);
|
|
503
|
+
expect(exitFn).toHaveBeenCalledWith(1);
|
|
504
|
+
expect(confirmFn).not.toHaveBeenCalled();
|
|
505
|
+
expect(keychain.writeCalls).toBe(0);
|
|
506
|
+
expect(home.writes).toHaveLength(0);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('falls back to product.defaultApiBasePath and FALLBACK_PAGE_SIZE for unspecified optional fields', async () => {
|
|
510
|
+
const validator = new StubCredentialValidator();
|
|
511
|
+
const registry = makeRegistry(keychain, home);
|
|
512
|
+
|
|
513
|
+
await runSetup(JIRA, {
|
|
514
|
+
registry,
|
|
515
|
+
log: (m) => logs.push(m),
|
|
516
|
+
exit: () => undefined,
|
|
517
|
+
prompts: nonInteractivePrompts(),
|
|
518
|
+
validateCredentials: validator.asFn(),
|
|
519
|
+
args: makeArgs({
|
|
520
|
+
host: 'cli-host.example.com',
|
|
521
|
+
token: 'cli-token',
|
|
522
|
+
nonInteractive: true,
|
|
523
|
+
}),
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
expect(home.values.jira.apiBasePath).toBe('/rest/api/2');
|
|
527
|
+
expect(home.values.jira.defaultPageSize).toBe('25');
|
|
528
|
+
});
|
|
529
|
+
});
|
|
175
530
|
});
|
package/src/config/source.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -3,7 +3,9 @@ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
export * from './api-error-handler.js'
|
|
5
5
|
export * from './config/index.js';
|
|
6
|
-
export { runSetup } from './setup-cli.js';
|
|
6
|
+
export { runSetup, runSetupCli } from './setup-cli.js';
|
|
7
|
+
export { describeValidationError } from './setup/describe-error.js';
|
|
8
|
+
export { parseSetupArgs, printSetupHelp, SetupArgsError, type ParsedSetupArgs } from './setup/args.js';
|
|
7
9
|
|
|
8
10
|
// Helper function to format tool responses
|
|
9
11
|
export const formatToolResponse = (result: unknown) => ({
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
|
|
3
|
+
export type ParsedSetupArgs = {
|
|
4
|
+
host?: string;
|
|
5
|
+
apiBasePath?: string;
|
|
6
|
+
token?: string;
|
|
7
|
+
defaultPageSize?: string;
|
|
8
|
+
nonInteractive: boolean;
|
|
9
|
+
help: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export class SetupArgsError extends Error {
|
|
13
|
+
constructor(message: string) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'SetupArgsError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseSetupArgs(argv: readonly string[]): ParsedSetupArgs {
|
|
20
|
+
let values;
|
|
21
|
+
try {
|
|
22
|
+
({ values } = parseArgs({
|
|
23
|
+
args: [...argv],
|
|
24
|
+
options: {
|
|
25
|
+
host: { type: 'string', short: 'H' },
|
|
26
|
+
'api-base-path': { type: 'string', short: 'b' },
|
|
27
|
+
token: { type: 'string', short: 't' },
|
|
28
|
+
'default-page-size': { type: 'string', short: 's' },
|
|
29
|
+
'non-interactive': { type: 'boolean', short: 'n', default: false },
|
|
30
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
31
|
+
},
|
|
32
|
+
strict: true,
|
|
33
|
+
allowPositionals: false,
|
|
34
|
+
}));
|
|
35
|
+
} catch (error) {
|
|
36
|
+
throw new SetupArgsError((error as Error).message);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
host: trimToUndefined(values.host),
|
|
41
|
+
apiBasePath: trimToUndefined(values['api-base-path']),
|
|
42
|
+
token: trimToUndefined(values.token),
|
|
43
|
+
defaultPageSize: trimToUndefined(values['default-page-size']),
|
|
44
|
+
nonInteractive: values['non-interactive'] === true,
|
|
45
|
+
help: values.help === true,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function trimToUndefined(value: string | undefined): string | undefined {
|
|
50
|
+
if (value === undefined) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
const trimmed = value.trim();
|
|
54
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function printSetupHelp(productId: string, log: (message: string) => void): void {
|
|
58
|
+
const lines = [
|
|
59
|
+
`Usage: @atlassian-dc-mcp/${productId} setup [options]`,
|
|
60
|
+
'',
|
|
61
|
+
'Options:',
|
|
62
|
+
' -H, --host <value> Host (e.g. jira.example.com)',
|
|
63
|
+
' -b, --api-base-path <value> API base path or full URL',
|
|
64
|
+
' -t, --token <value> API token',
|
|
65
|
+
' -s, --default-page-size <n> Default page size (positive integer)',
|
|
66
|
+
' -n, --non-interactive Skip prompts; fail if a required value is missing',
|
|
67
|
+
' -h, --help Show this help and exit',
|
|
68
|
+
'',
|
|
69
|
+
'In interactive mode (default), any value not passed as a flag is collected via prompts.',
|
|
70
|
+
'In --non-interactive mode, missing values fall back to existing configuration',
|
|
71
|
+
`(process env, ~/.atlassian-dc-mcp/${productId}.env, or macOS Keychain), and the run`,
|
|
72
|
+
'fails if a host (or full-URL --api-base-path) and token cannot be resolved.',
|
|
73
|
+
'An existing token is reused when --token is omitted.',
|
|
74
|
+
];
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
log(line);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const NETWORK_CODE_HINTS: Record<string, string> = {
|
|
2
|
+
ENOTFOUND: 'could not resolve host',
|
|
3
|
+
EAI_AGAIN: 'DNS lookup temporarily failed',
|
|
4
|
+
ECONNREFUSED: 'connection refused',
|
|
5
|
+
ECONNRESET: 'connection reset by peer',
|
|
6
|
+
ETIMEDOUT: 'connection timed out',
|
|
7
|
+
ECONNABORTED: 'connection aborted',
|
|
8
|
+
EHOSTUNREACH: 'host is unreachable',
|
|
9
|
+
ENETUNREACH: 'network is unreachable',
|
|
10
|
+
EPROTO: 'TLS protocol error',
|
|
11
|
+
CERT_HAS_EXPIRED: 'server TLS certificate has expired',
|
|
12
|
+
DEPTH_ZERO_SELF_SIGNED_CERT: 'server uses a self-signed TLS certificate',
|
|
13
|
+
SELF_SIGNED_CERT_IN_CHAIN: 'server TLS certificate chain is self-signed',
|
|
14
|
+
UNABLE_TO_VERIFY_LEAF_SIGNATURE: 'server TLS certificate could not be verified',
|
|
15
|
+
ERR_TLS_CERT_ALTNAME_INVALID: 'server TLS certificate does not match the host name',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type ApiLikeError = {
|
|
19
|
+
status: number;
|
|
20
|
+
statusText?: string;
|
|
21
|
+
url?: string;
|
|
22
|
+
body?: unknown;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type CauseLikeError = {
|
|
26
|
+
code?: string;
|
|
27
|
+
message?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function describeValidationError(error: unknown): string {
|
|
31
|
+
if (isApiLikeError(error)) {
|
|
32
|
+
return formatApiError(error);
|
|
33
|
+
}
|
|
34
|
+
if (isAbortError(error)) {
|
|
35
|
+
return 'request was aborted (possibly timed out)';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const err = error as { message?: string; cause?: CauseLikeError } | undefined;
|
|
39
|
+
const cause = err?.cause;
|
|
40
|
+
if (cause?.code) {
|
|
41
|
+
const hint = NETWORK_CODE_HINTS[cause.code] ?? 'network error';
|
|
42
|
+
const detail = cause.message ?? err?.message ?? 'unknown';
|
|
43
|
+
return `${hint} (${cause.code}: ${detail})`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return err?.message ?? String(error);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatApiError(error: ApiLikeError): string {
|
|
50
|
+
const status = `${error.status}${error.statusText ? ` ${error.statusText}` : ''}`.trim();
|
|
51
|
+
const url = error.url ? `${error.url} ` : '';
|
|
52
|
+
const hint = status.startsWith('401') || status.startsWith('403')
|
|
53
|
+
? ' Check the host, API base path, and API token.'
|
|
54
|
+
: '';
|
|
55
|
+
return `${url}returned ${status}.${hint}`.trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isApiLikeError(error: unknown): error is ApiLikeError {
|
|
59
|
+
return Boolean(error)
|
|
60
|
+
&& typeof error === 'object'
|
|
61
|
+
&& 'status' in (error as object)
|
|
62
|
+
&& typeof (error as ApiLikeError).status === 'number';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isAbortError(error: unknown): boolean {
|
|
66
|
+
return Boolean(error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError');
|
|
67
|
+
}
|