@atlassian-dc-mcp/common 0.18.0 → 0.19.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.
@@ -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
+ }
package/src/setup-cli.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { confirm as inquirerConfirm, input as inquirerInput, password as inquirerPassword } from '@inquirer/prompts';
2
2
  import { buildDefaultRegistry, type ConfigRegistry } from './config/registry.js';
3
- import { getProductRuntimeConfig } from './config/runtime-config.js';
3
+ import type { ProductRuntimeConfig } from './config/runtime-config.js';
4
4
  import type {
5
5
  ConfigKey,
6
6
  ProductDefinition,
@@ -8,6 +8,7 @@ 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 { parseSetupArgs, printSetupHelp, SetupArgsError, type ParsedSetupArgs } from './setup/args.js';
11
12
  import { SetupValueValidator } from './setup/value-validator.js';
12
13
 
13
14
  const FALLBACK_PAGE_SIZE = 25;
@@ -56,6 +57,7 @@ export type SetupDeps = {
56
57
  exit?: (code: number) => void;
57
58
  prompts?: SetupPrompts;
58
59
  validateCredentials?: ValidateCredentials;
60
+ args?: ParsedSetupArgs;
59
61
  };
60
62
 
61
63
  const DEFAULT_PROMPTS: SetupPrompts = {
@@ -64,6 +66,36 @@ const DEFAULT_PROMPTS: SetupPrompts = {
64
66
  confirm: (opts) => inquirerConfirm(opts as any),
65
67
  };
66
68
 
69
+ export async function runSetupCli(
70
+ product: ProductDefinition,
71
+ deps: Omit<SetupDeps, 'args'> = {},
72
+ ): Promise<void> {
73
+ const rawArgv = process.argv.slice(2);
74
+ const argv = rawArgv[0] === 'setup' ? rawArgv.slice(1) : rawArgv;
75
+ const exit = deps.exit ?? ((code: number) => { process.exit(code); });
76
+
77
+ let args: ParsedSetupArgs;
78
+ try {
79
+ args = parseSetupArgs(argv);
80
+ } catch (error) {
81
+ if (error instanceof SetupArgsError) {
82
+ process.stderr.write(`${error.message}\n\n`);
83
+ printSetupHelp(product.id, (m) => process.stderr.write(`${m}\n`));
84
+ exit(1);
85
+ return;
86
+ }
87
+ throw error;
88
+ }
89
+
90
+ if (args.help) {
91
+ printSetupHelp(product.id, (m) => process.stdout.write(`${m}\n`));
92
+ exit(0);
93
+ return;
94
+ }
95
+
96
+ await runSetup(product, { ...deps, args });
97
+ }
98
+
67
99
  export async function runSetup(product: ProductDefinition, deps: SetupDeps = {}): Promise<void> {
68
100
  const registry = deps.registry ?? buildDefaultRegistry();
69
101
  const log = deps.log ?? ((message: string) => process.stdout.write(`${message}\n`));
@@ -75,10 +107,12 @@ export async function runSetup(product: ProductDefinition, deps: SetupDeps = {})
75
107
  log(`Atlassian DC MCP setup — ${product.id}`);
76
108
  log('');
77
109
 
78
- const current = getProductRuntimeConfig(product);
110
+ const current = readCurrentConfig(registry, product);
79
111
  printCurrent(log, registry, product, current);
80
112
 
81
- const answers = await collectAnswersWithValidation(product, deps, prompts, current, log, exit);
113
+ const answers = deps.args?.nonInteractive
114
+ ? await runNonInteractive(product, deps, current, log, exit, deps.args)
115
+ : await collectAnswersWithValidation(product, deps, prompts, current, log, exit, deps.args);
82
116
  if (!answers) {
83
117
  return;
84
118
  }
@@ -93,16 +127,17 @@ async function collectAnswersWithValidation(
93
127
  product: ProductDefinition,
94
128
  deps: SetupDeps,
95
129
  prompts: SetupPrompts,
96
- current: ReturnType<typeof getProductRuntimeConfig>,
130
+ current: ProductRuntimeConfig,
97
131
  log: (message: string) => void,
98
132
  exit: (code: number) => void,
133
+ args: ParsedSetupArgs | undefined,
99
134
  ): Promise<PromptResult | undefined> {
100
135
  let defaults: PromptDefaults = current;
101
136
 
102
137
  for (let attempt = 1; ; attempt++) {
103
138
  let answers: PromptResult;
104
139
  try {
105
- answers = await promptForValues(prompts, product, defaults);
140
+ answers = await promptForValues(prompts, product, defaults, args);
106
141
  } catch (error) {
107
142
  if (isUserCancel(error)) {
108
143
  exit(130);
@@ -153,6 +188,57 @@ async function collectAnswersWithValidation(
153
188
  }
154
189
  }
155
190
 
191
+ async function runNonInteractive(
192
+ product: ProductDefinition,
193
+ deps: SetupDeps,
194
+ current: ProductRuntimeConfig,
195
+ log: (message: string) => void,
196
+ exit: (code: number) => void,
197
+ args: ParsedSetupArgs,
198
+ ): Promise<PromptResult | undefined> {
199
+ let tokenToWrite: string | undefined;
200
+ let tokenForValidation: string | undefined;
201
+ if (args.token) {
202
+ tokenToWrite = args.token;
203
+ tokenForValidation = args.token;
204
+ } else if (current.token) {
205
+ tokenForValidation = current.token;
206
+ }
207
+
208
+ const answers: PromptResult = {
209
+ host: args.host ?? current.host ?? '',
210
+ apiBasePath: args.apiBasePath ?? current.apiBasePath ?? product.defaultApiBasePath ?? '',
211
+ defaultPageSize: args.defaultPageSize ?? String(current.defaultPageSize ?? FALLBACK_PAGE_SIZE),
212
+ tokenToWrite,
213
+ tokenForValidation,
214
+ };
215
+
216
+ const formatErrors = validateAnswers(product, answers);
217
+ if (formatErrors.length > 0) {
218
+ for (const message of formatErrors) {
219
+ log(`Validation failed: ${message}`);
220
+ }
221
+ exit(1);
222
+ return undefined;
223
+ }
224
+
225
+ if (deps.validateCredentials && answers.tokenForValidation) {
226
+ const result = await deps.validateCredentials({
227
+ host: answers.host,
228
+ apiBasePath: answers.apiBasePath,
229
+ token: answers.tokenForValidation,
230
+ });
231
+ if (!result.ok) {
232
+ log(`Validation failed: ${result.message}`);
233
+ exit(1);
234
+ return undefined;
235
+ }
236
+ log(result.detail ? `Validation succeeded: ${result.detail}` : 'Validation succeeded.');
237
+ }
238
+
239
+ return answers;
240
+ }
241
+
156
242
  function answersAsDefaults(answers: PromptResult): PromptDefaults {
157
243
  const pageSize = Number.parseInt(answers.defaultPageSize, 10);
158
244
  return {
@@ -184,6 +270,32 @@ async function offerRetryAfterFailure(
184
270
  return saveAnyway ? 'save-anyway' : 'abort';
185
271
  }
186
272
 
273
+ function readCurrentConfig(
274
+ registry: ConfigRegistry,
275
+ product: ProductDefinition,
276
+ ): ProductRuntimeConfig {
277
+ const pageSizeRaw = registry.resolve(product, 'defaultPageSize').value;
278
+ const pageSize = parsePositiveInteger(pageSizeRaw) ?? FALLBACK_PAGE_SIZE;
279
+ return {
280
+ host: registry.resolve(product, 'host').value,
281
+ apiBasePath: registry.resolve(product, 'apiBasePath').value,
282
+ token: registry.resolve(product, 'token').value,
283
+ defaultPageSize: pageSize,
284
+ };
285
+ }
286
+
287
+ function parsePositiveInteger(value: string | undefined): number | undefined {
288
+ if (!value) {
289
+ return undefined;
290
+ }
291
+ const trimmed = value.trim();
292
+ if (!/^\d+$/.test(trimmed)) {
293
+ return undefined;
294
+ }
295
+ const parsed = Number.parseInt(trimmed, 10);
296
+ return parsed > 0 ? parsed : undefined;
297
+ }
298
+
187
299
  function requireHomeFile(registry: ConfigRegistry): HomeFileSource {
188
300
  const homeFile = registry.getWritableSource(
189
301
  (s): s is HomeFileSource => s instanceof HomeFileSource,
@@ -198,7 +310,7 @@ function printCurrent(
198
310
  log: (message: string) => void,
199
311
  registry: ConfigRegistry,
200
312
  product: ProductDefinition,
201
- current: ReturnType<typeof getProductRuntimeConfig>,
313
+ current: ProductRuntimeConfig,
202
314
  ): void {
203
315
  const keys: ConfigKey[] = ['host', 'apiBasePath', 'token', 'defaultPageSize'];
204
316
  for (const key of keys) {
@@ -215,23 +327,24 @@ async function promptForValues(
215
327
  prompts: SetupPrompts,
216
328
  product: ProductDefinition,
217
329
  defaults: PromptDefaults,
330
+ args: ParsedSetupArgs | undefined,
218
331
  ): Promise<PromptResult> {
219
- const host = await prompts.input({
332
+ const host = args?.host ?? await prompts.input({
220
333
  message: 'Host (e.g. jira.example.com):',
221
334
  default: defaults.host ?? '',
222
335
  validate: SetupValueValidator.host,
223
336
  });
224
- const apiBasePath = await prompts.input({
337
+ const apiBasePath = args?.apiBasePath ?? await prompts.input({
225
338
  message: 'API base path:',
226
339
  default: defaults.apiBasePath ?? product.defaultApiBasePath ?? '',
227
340
  validate: SetupValueValidator.apiBasePath,
228
341
  });
229
- const defaultPageSize = await prompts.input({
342
+ const defaultPageSize = args?.defaultPageSize ?? await prompts.input({
230
343
  message: 'Default page size:',
231
344
  default: String(defaults.defaultPageSize ?? FALLBACK_PAGE_SIZE),
232
345
  validate: SetupValueValidator.pageSize,
233
346
  });
234
- const token = await promptForToken(prompts, defaults.token);
347
+ const token = await promptForToken(prompts, defaults.token, args?.token);
235
348
  return {
236
349
  host: host.trim(),
237
350
  apiBasePath: apiBasePath.trim(),
@@ -243,7 +356,11 @@ async function promptForValues(
243
356
  async function promptForToken(
244
357
  prompts: SetupPrompts,
245
358
  existing: string | undefined,
359
+ fromArgs: string | undefined,
246
360
  ): Promise<TokenPromptResult> {
361
+ if (fromArgs) {
362
+ return { tokenToWrite: fromArgs, tokenForValidation: fromArgs };
363
+ }
247
364
  const entered = await prompts.password({
248
365
  message: 'API token:',
249
366
  mask: '*',
@@ -268,7 +385,6 @@ function validateAnswers(product: ProductDefinition, answers: PromptResult): str
268
385
  for (const [label, value, validator] of [
269
386
  ['host', answers.host, SetupValueValidator.host],
270
387
  ['API base path', answers.apiBasePath, SetupValueValidator.apiBasePath],
271
- ['API token', answers.tokenForValidation ?? '', SetupValueValidator.token],
272
388
  ] as const) {
273
389
  const result = validator(value);
274
390
  if (result !== true) {
@@ -283,6 +399,11 @@ function validateAnswers(product: ProductDefinition, answers: PromptResult): str
283
399
 
284
400
  if (!answers.tokenForValidation) {
285
401
  errors.push(`API token is required (${product.envVars.token}).`);
402
+ } else {
403
+ const tokenResult = SetupValueValidator.token(answers.tokenForValidation);
404
+ if (tokenResult !== true) {
405
+ errors.push(`API token: ${tokenResult}`);
406
+ }
286
407
  }
287
408
 
288
409
  const hasHost = answers.host.length > 0;