@atlassian-dc-mcp/common 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/build/__tests__/describe-error.test.d.ts +2 -0
  3. package/build/__tests__/describe-error.test.d.ts.map +1 -0
  4. package/build/__tests__/describe-error.test.js +54 -0
  5. package/build/__tests__/describe-error.test.js.map +1 -0
  6. package/build/__tests__/setup-cli.test.js +151 -44
  7. package/build/__tests__/setup-cli.test.js.map +1 -1
  8. package/build/config/__tests__/resolve-base.test.d.ts +2 -0
  9. package/build/config/__tests__/resolve-base.test.d.ts.map +1 -0
  10. package/build/config/__tests__/resolve-base.test.js +89 -0
  11. package/build/config/__tests__/resolve-base.test.js.map +1 -0
  12. package/build/config/index.d.ts +1 -0
  13. package/build/config/index.d.ts.map +1 -1
  14. package/build/config/index.js +1 -0
  15. package/build/config/index.js.map +1 -1
  16. package/build/config/resolve-base.d.ts +8 -0
  17. package/build/config/resolve-base.d.ts.map +1 -0
  18. package/build/config/resolve-base.js +31 -0
  19. package/build/config/resolve-base.js.map +1 -0
  20. package/build/config/source.d.ts +1 -0
  21. package/build/config/source.d.ts.map +1 -1
  22. package/build/config/source.js.map +1 -1
  23. package/build/index.d.ts +1 -0
  24. package/build/index.d.ts.map +1 -1
  25. package/build/index.js +1 -0
  26. package/build/index.js.map +1 -1
  27. package/build/setup/describe-error.d.ts +2 -0
  28. package/build/setup/describe-error.d.ts.map +1 -0
  29. package/build/setup/describe-error.js +50 -0
  30. package/build/setup/describe-error.js.map +1 -0
  31. package/build/setup/value-validator.d.ts +8 -0
  32. package/build/setup/value-validator.d.ts.map +1 -0
  33. package/build/setup/value-validator.js +57 -0
  34. package/build/setup/value-validator.js.map +1 -0
  35. package/build/setup-cli.d.ts +15 -0
  36. package/build/setup-cli.d.ts.map +1 -1
  37. package/build/setup-cli.js +140 -27
  38. package/build/setup-cli.js.map +1 -1
  39. package/jest.config.js +1 -0
  40. package/package.json +2 -2
  41. package/src/__tests__/describe-error.test.ts +70 -0
  42. package/src/__tests__/setup-cli.test.ts +181 -38
  43. package/src/config/__tests__/resolve-base.test.ts +101 -0
  44. package/src/config/index.ts +1 -0
  45. package/src/config/resolve-base.ts +43 -0
  46. package/src/config/source.ts +1 -0
  47. package/src/index.ts +1 -0
  48. package/src/setup/describe-error.ts +67 -0
  49. package/src/setup/value-validator.ts +59 -0
  50. package/src/setup-cli.ts +191 -30
  51. package/tsconfig.tsbuildinfo +1 -1
package/src/setup-cli.ts CHANGED
@@ -8,19 +8,45 @@ import type {
8
8
  } from './config/source.js';
9
9
  import { HomeFileSource, getHomeFilePath } from './config/sources/home-file.js';
10
10
  import { MacosKeychainSource } from './config/sources/macos-keychain.js';
11
+ import { SetupValueValidator } from './setup/value-validator.js';
11
12
 
12
13
  const FALLBACK_PAGE_SIZE = 25;
14
+ const MAX_VALIDATION_ATTEMPTS = 3;
15
+
16
+ type PromptDefaults = {
17
+ host?: string;
18
+ apiBasePath?: string;
19
+ token?: string;
20
+ defaultPageSize?: number;
21
+ };
13
22
 
14
23
  type PromptResult = {
15
24
  host: string;
16
25
  apiBasePath: string;
17
26
  defaultPageSize: string;
18
- token: string | undefined;
27
+ tokenToWrite: string | undefined;
28
+ tokenForValidation: string | undefined;
29
+ };
30
+
31
+ type TokenPromptResult = Pick<PromptResult, 'tokenToWrite' | 'tokenForValidation'>;
32
+
33
+ export type CredentialValidationContext = {
34
+ host: string;
35
+ apiBasePath: string;
36
+ token: string;
19
37
  };
20
38
 
39
+ export type CredentialValidationResult =
40
+ | { ok: true; detail?: string }
41
+ | { ok: false; message: string };
42
+
43
+ export type ValidateCredentials = (
44
+ context: CredentialValidationContext,
45
+ ) => Promise<CredentialValidationResult>;
46
+
21
47
  export type SetupPrompts = {
22
48
  input: (opts: { message: string; default?: string; validate?: (raw: string) => true | string }) => Promise<string>;
23
- password: (opts: { message: string; mask?: string }) => Promise<string>;
49
+ password: (opts: { message: string; mask?: string; validate?: (raw: string) => true | string }) => Promise<string>;
24
50
  confirm: (opts: { message: string; default?: boolean }) => Promise<boolean>;
25
51
  };
26
52
 
@@ -29,6 +55,7 @@ export type SetupDeps = {
29
55
  log?: (message: string) => void;
30
56
  exit?: (code: number) => void;
31
57
  prompts?: SetupPrompts;
58
+ validateCredentials?: ValidateCredentials;
32
59
  };
33
60
 
34
61
  const DEFAULT_PROMPTS: SetupPrompts = {
@@ -51,23 +78,112 @@ export async function runSetup(product: ProductDefinition, deps: SetupDeps = {})
51
78
  const current = getProductRuntimeConfig(product);
52
79
  printCurrent(log, registry, product, current);
53
80
 
54
- let answers: PromptResult;
55
- try {
56
- answers = await promptForValues(prompts, product, current);
57
- } catch (error) {
58
- if (isUserCancel(error)) {
59
- exit(130);
60
- return;
61
- }
62
- throw error;
81
+ const answers = await collectAnswersWithValidation(product, deps, prompts, current, log, exit);
82
+ if (!answers) {
83
+ return;
63
84
  }
64
85
 
65
86
  const homeFile = requireHomeFile(registry);
66
87
  writeNonSecretFields(registry, product, answers, homeFile, log);
67
- const tokenWriter = await writeToken(registry, product, answers.token, homeFile, log, prompts);
88
+ const tokenWriter = await writeToken(registry, product, answers.tokenToWrite, homeFile, log, prompts);
68
89
  printSummary(log, product, answers, tokenWriter);
69
90
  }
70
91
 
92
+ async function collectAnswersWithValidation(
93
+ product: ProductDefinition,
94
+ deps: SetupDeps,
95
+ prompts: SetupPrompts,
96
+ current: ReturnType<typeof getProductRuntimeConfig>,
97
+ log: (message: string) => void,
98
+ exit: (code: number) => void,
99
+ ): Promise<PromptResult | undefined> {
100
+ let defaults: PromptDefaults = current;
101
+
102
+ for (let attempt = 1; ; attempt++) {
103
+ let answers: PromptResult;
104
+ try {
105
+ answers = await promptForValues(prompts, product, defaults);
106
+ } catch (error) {
107
+ if (isUserCancel(error)) {
108
+ exit(130);
109
+ return undefined;
110
+ }
111
+ throw error;
112
+ }
113
+
114
+ const answerErrors = validateAnswers(product, answers);
115
+ if (answerErrors.length > 0) {
116
+ for (const message of answerErrors) {
117
+ log(`Validation failed: ${message}`);
118
+ }
119
+ const retry = await confirmRetry(prompts, 'Try again?');
120
+ if (retry) {
121
+ defaults = answersAsDefaults(answers);
122
+ continue;
123
+ }
124
+ exit(1);
125
+ return undefined;
126
+ }
127
+
128
+ if (!deps.validateCredentials || !answers.tokenForValidation) {
129
+ return answers;
130
+ }
131
+
132
+ const result = await deps.validateCredentials({
133
+ host: answers.host,
134
+ apiBasePath: answers.apiBasePath,
135
+ token: answers.tokenForValidation,
136
+ });
137
+ if (result.ok) {
138
+ log(result.detail ? `Validation succeeded: ${result.detail}` : 'Validation succeeded.');
139
+ return answers;
140
+ }
141
+
142
+ log(`Validation failed: ${result.message}`);
143
+ const outcome = await offerRetryAfterFailure(prompts, attempt);
144
+ if (outcome === 'retry') {
145
+ defaults = answersAsDefaults(answers);
146
+ continue;
147
+ }
148
+ if (outcome === 'save-anyway') {
149
+ return answers;
150
+ }
151
+ exit(1);
152
+ return undefined;
153
+ }
154
+ }
155
+
156
+ function answersAsDefaults(answers: PromptResult): PromptDefaults {
157
+ const pageSize = Number.parseInt(answers.defaultPageSize, 10);
158
+ return {
159
+ host: answers.host,
160
+ apiBasePath: answers.apiBasePath,
161
+ token: answers.tokenForValidation,
162
+ defaultPageSize: Number.isFinite(pageSize) && pageSize > 0 ? pageSize : undefined,
163
+ };
164
+ }
165
+
166
+ async function confirmRetry(prompts: SetupPrompts, message: string): Promise<boolean> {
167
+ return prompts.confirm({ message, default: true });
168
+ }
169
+
170
+ async function offerRetryAfterFailure(
171
+ prompts: SetupPrompts,
172
+ attempt: number,
173
+ ): Promise<'retry' | 'save-anyway' | 'abort'> {
174
+ if (attempt < MAX_VALIDATION_ATTEMPTS) {
175
+ const retry = await confirmRetry(prompts, 'Try again with different values?');
176
+ if (retry) {
177
+ return 'retry';
178
+ }
179
+ }
180
+ const saveAnyway = await prompts.confirm({
181
+ message: 'Save configuration anyway?',
182
+ default: false,
183
+ });
184
+ return saveAnyway ? 'save-anyway' : 'abort';
185
+ }
186
+
71
187
  function requireHomeFile(registry: ConfigRegistry): HomeFileSource {
72
188
  const homeFile = registry.getWritableSource(
73
189
  (s): s is HomeFileSource => s instanceof HomeFileSource,
@@ -98,42 +214,84 @@ function printCurrent(
98
214
  async function promptForValues(
99
215
  prompts: SetupPrompts,
100
216
  product: ProductDefinition,
101
- current: ReturnType<typeof getProductRuntimeConfig>,
217
+ defaults: PromptDefaults,
102
218
  ): Promise<PromptResult> {
103
219
  const host = await prompts.input({
104
220
  message: 'Host (e.g. jira.example.com):',
105
- default: current.host ?? '',
221
+ default: defaults.host ?? '',
222
+ validate: SetupValueValidator.host,
106
223
  });
107
224
  const apiBasePath = await prompts.input({
108
225
  message: 'API base path:',
109
- default: current.apiBasePath ?? product.defaultApiBasePath ?? '',
226
+ default: defaults.apiBasePath ?? product.defaultApiBasePath ?? '',
227
+ validate: SetupValueValidator.apiBasePath,
110
228
  });
111
229
  const defaultPageSize = await prompts.input({
112
230
  message: 'Default page size:',
113
- default: String(current.defaultPageSize ?? FALLBACK_PAGE_SIZE),
114
- validate: (raw) =>
115
- /^\d+$/.test(raw.trim()) && Number.parseInt(raw.trim(), 10) > 0
116
- ? true
117
- : 'Enter a positive integer',
231
+ default: String(defaults.defaultPageSize ?? FALLBACK_PAGE_SIZE),
232
+ validate: SetupValueValidator.pageSize,
118
233
  });
119
- const token = await promptForToken(prompts, current.token);
120
- return { host: host.trim(), apiBasePath: apiBasePath.trim(), defaultPageSize: defaultPageSize.trim(), token };
234
+ const token = await promptForToken(prompts, defaults.token);
235
+ return {
236
+ host: host.trim(),
237
+ apiBasePath: apiBasePath.trim(),
238
+ defaultPageSize: defaultPageSize.trim(),
239
+ ...token,
240
+ };
121
241
  }
122
242
 
123
243
  async function promptForToken(
124
244
  prompts: SetupPrompts,
125
245
  existing: string | undefined,
126
- ): Promise<string | undefined> {
127
- const entered = await prompts.password({ message: 'API token:', mask: '*' });
246
+ ): Promise<TokenPromptResult> {
247
+ const entered = await prompts.password({
248
+ message: 'API token:',
249
+ mask: '*',
250
+ validate: SetupValueValidator.token,
251
+ });
128
252
  const trimmed = entered.trim();
129
253
  if (trimmed.length > 0) {
130
- return trimmed;
254
+ return { tokenToWrite: trimmed, tokenForValidation: trimmed };
131
255
  }
132
256
  if (!existing) {
133
- return undefined;
257
+ return { tokenToWrite: undefined, tokenForValidation: undefined };
134
258
  }
135
- await prompts.confirm({ message: 'Keep existing token?', default: true });
136
- return undefined;
259
+ const keepExisting = await prompts.confirm({ message: 'Keep existing token?', default: true });
260
+ return {
261
+ tokenToWrite: undefined,
262
+ tokenForValidation: keepExisting ? existing : undefined,
263
+ };
264
+ }
265
+
266
+ function validateAnswers(product: ProductDefinition, answers: PromptResult): string[] {
267
+ const errors: string[] = [];
268
+ for (const [label, value, validator] of [
269
+ ['host', answers.host, SetupValueValidator.host],
270
+ ['API base path', answers.apiBasePath, SetupValueValidator.apiBasePath],
271
+ ['API token', answers.tokenForValidation ?? '', SetupValueValidator.token],
272
+ ] as const) {
273
+ const result = validator(value);
274
+ if (result !== true) {
275
+ errors.push(`${label}: ${result}`);
276
+ }
277
+ }
278
+
279
+ const pageSize = SetupValueValidator.pageSize(answers.defaultPageSize);
280
+ if (pageSize !== true) {
281
+ errors.push(`default page size: ${pageSize}`);
282
+ }
283
+
284
+ if (!answers.tokenForValidation) {
285
+ errors.push(`API token is required (${product.envVars.token}).`);
286
+ }
287
+
288
+ const hasHost = answers.host.length > 0;
289
+ const hasFullApiBasePath = /^https?:\/\//i.test(answers.apiBasePath);
290
+ if (!hasHost && !hasFullApiBasePath) {
291
+ errors.push(`Enter ${product.envVars.host}, or enter a full URL for ${product.envVars.apiBasePath}.`);
292
+ }
293
+
294
+ return errors;
137
295
  }
138
296
 
139
297
  function writeNonSecretFields(
@@ -208,7 +366,10 @@ async function tryWrite(
208
366
  message: 'Fall back to plaintext home file with mode 0600?',
209
367
  default: false,
210
368
  });
211
- return !fallback;
369
+ if (fallback) {
370
+ return false;
371
+ }
372
+ throw new Error('Token was not saved because keychain write failed and plaintext fallback was declined');
212
373
  }
213
374
  return false;
214
375
  }
@@ -254,7 +415,7 @@ function printSummary(
254
415
  log(` apiBasePath: ${answers.apiBasePath || '(unchanged)'}`);
255
416
  log(` defaultPageSize: ${answers.defaultPageSize || '(unchanged)'}`);
256
417
  if (tokenWriter) {
257
- log(` token: ${maskToken(answers.token)} (stored in ${describeWriter(tokenWriter, product)})`);
418
+ log(` token: ${maskToken(answers.tokenToWrite)} (stored in ${describeWriter(tokenWriter, product)})`);
258
419
  } else {
259
420
  log(' token: (unchanged)');
260
421
  }