@atlassian-dc-mcp/common 0.16.0 → 0.17.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 +11 -0
- package/build/__tests__/runtime-config.test.js +65 -14
- package/build/__tests__/runtime-config.test.js.map +1 -1
- package/build/__tests__/setup-cli.test.d.ts +2 -0
- package/build/__tests__/setup-cli.test.d.ts.map +1 -0
- package/build/__tests__/setup-cli.test.js +166 -0
- package/build/__tests__/setup-cli.test.js.map +1 -0
- package/build/config/__tests__/registry.test.d.ts +2 -0
- package/build/config/__tests__/registry.test.d.ts.map +1 -0
- package/build/config/__tests__/registry.test.js +105 -0
- package/build/config/__tests__/registry.test.js.map +1 -0
- package/build/config/__tests__/sources/env-file.test.d.ts +2 -0
- package/build/config/__tests__/sources/env-file.test.d.ts.map +1 -0
- package/build/config/__tests__/sources/env-file.test.js +93 -0
- package/build/config/__tests__/sources/env-file.test.js.map +1 -0
- package/build/config/__tests__/sources/home-file.test.d.ts +2 -0
- package/build/config/__tests__/sources/home-file.test.d.ts.map +1 -0
- package/build/config/__tests__/sources/home-file.test.js +114 -0
- package/build/config/__tests__/sources/home-file.test.js.map +1 -0
- package/build/config/__tests__/sources/macos-keychain.test.d.ts +2 -0
- package/build/config/__tests__/sources/macos-keychain.test.d.ts.map +1 -0
- package/build/config/__tests__/sources/macos-keychain.test.js +109 -0
- package/build/config/__tests__/sources/macos-keychain.test.js.map +1 -0
- package/build/config/__tests__/sources/process-env.test.d.ts +2 -0
- package/build/config/__tests__/sources/process-env.test.d.ts.map +1 -0
- package/build/config/__tests__/sources/process-env.test.js +51 -0
- package/build/config/__tests__/sources/process-env.test.js.map +1 -0
- package/build/config/index.d.ts +8 -0
- package/build/config/index.d.ts.map +1 -0
- package/build/config/index.js +8 -0
- package/build/config/index.js.map +1 -0
- package/build/config/registry.d.ts +26 -0
- package/build/config/registry.d.ts.map +1 -0
- package/build/config/registry.js +51 -0
- package/build/config/registry.js.map +1 -0
- package/build/config/runtime-config.d.ts +14 -0
- package/build/config/runtime-config.d.ts.map +1 -0
- package/build/config/runtime-config.js +41 -0
- package/build/config/runtime-config.js.map +1 -0
- package/build/config/source.d.ts +28 -0
- package/build/config/source.d.ts.map +1 -0
- package/build/config/source.js +11 -0
- package/build/config/source.js.map +1 -0
- package/build/config/sources/dotenv-file-cache.d.ts +12 -0
- package/build/config/sources/dotenv-file-cache.d.ts.map +1 -0
- package/build/config/sources/dotenv-file-cache.js +41 -0
- package/build/config/sources/dotenv-file-cache.js.map +1 -0
- package/build/config/sources/env-file.d.ts +16 -0
- package/build/config/sources/env-file.d.ts.map +1 -0
- package/build/config/sources/env-file.js +48 -0
- package/build/config/sources/env-file.js.map +1 -0
- package/build/config/sources/home-file.d.ts +19 -0
- package/build/config/sources/home-file.d.ts.map +1 -0
- package/build/config/sources/home-file.js +102 -0
- package/build/config/sources/home-file.js.map +1 -0
- package/build/config/sources/macos-keychain.d.ts +26 -0
- package/build/config/sources/macos-keychain.d.ts.map +1 -0
- package/build/config/sources/macos-keychain.js +88 -0
- package/build/config/sources/macos-keychain.js.map +1 -0
- package/build/config/sources/process-env.d.ts +8 -0
- package/build/config/sources/process-env.d.ts.map +1 -0
- package/build/config/sources/process-env.js +12 -0
- package/build/config/sources/process-env.js.map +1 -0
- package/build/index.d.ts +2 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -1
- package/build/index.js.map +1 -1
- package/build/setup-cli.d.ts +25 -0
- package/build/setup-cli.d.ts.map +1 -0
- package/build/setup-cli.js +215 -0
- package/build/setup-cli.js.map +1 -0
- package/package.json +5 -4
- package/src/__tests__/runtime-config.test.ts +75 -27
- package/src/__tests__/setup-cli.test.ts +175 -0
- package/src/config/__tests__/registry.test.ts +118 -0
- package/src/config/__tests__/sources/env-file.test.ts +113 -0
- package/src/config/__tests__/sources/home-file.test.ts +129 -0
- package/src/config/__tests__/sources/macos-keychain.test.ts +136 -0
- package/src/config/__tests__/sources/process-env.test.ts +59 -0
- package/src/config/index.ts +7 -0
- package/src/config/registry.ts +79 -0
- package/src/config/runtime-config.ts +60 -0
- package/src/config/source.ts +37 -0
- package/src/config/sources/dotenv-file-cache.ts +54 -0
- package/src/config/sources/env-file.ts +61 -0
- package/src/config/sources/home-file.ts +121 -0
- package/src/config/sources/macos-keychain.ts +117 -0
- package/src/config/sources/process-env.ts +15 -0
- package/src/index.ts +2 -1
- package/src/setup-cli.ts +308 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/build/runtime-config.d.ts +0 -14
- package/build/runtime-config.d.ts.map +0 -1
- package/build/runtime-config.js +0 -123
- package/build/runtime-config.js.map +0 -1
- package/src/runtime-config.ts +0 -176
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import type { ConfigKey, ProductDefinition, SourceId, WritableSource } from '../source.js';
|
|
6
|
+
import { getNonEmptyValue } from '../source.js';
|
|
7
|
+
import {
|
|
8
|
+
DotenvFileCache,
|
|
9
|
+
DotenvFileNotFoundError,
|
|
10
|
+
type ParsedEnvironment,
|
|
11
|
+
} from './dotenv-file-cache.js';
|
|
12
|
+
|
|
13
|
+
const HOME_DIR_NAME = '.atlassian-dc-mcp';
|
|
14
|
+
|
|
15
|
+
function getHomeFilePath(product: ProductDefinition | string): string {
|
|
16
|
+
const id = typeof product === 'string' ? product : product.id;
|
|
17
|
+
return path.join(os.homedir(), HOME_DIR_NAME, `${id}.env`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function needsQuoting(value: string): boolean {
|
|
21
|
+
return /[\s#'"]/.test(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function serializeValue(value: string): string {
|
|
25
|
+
if (!needsQuoting(value)) {
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function serialize(values: ParsedEnvironment): string {
|
|
32
|
+
const lines: string[] = [];
|
|
33
|
+
for (const [key, raw] of Object.entries(values)) {
|
|
34
|
+
lines.push(`${key}=${serializeValue(raw)}`);
|
|
35
|
+
}
|
|
36
|
+
return lines.length > 0 ? `${lines.join('\n')}\n` : '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class HomeFileSource implements WritableSource {
|
|
40
|
+
readonly id: SourceId = 'home-file';
|
|
41
|
+
readonly priority = 60;
|
|
42
|
+
|
|
43
|
+
private cache = new DotenvFileCache();
|
|
44
|
+
|
|
45
|
+
isAvailable(): boolean {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
read(product: ProductDefinition, key: ConfigKey): string | undefined {
|
|
50
|
+
const values = this.loadFile(product);
|
|
51
|
+
return getNonEmptyValue(values[product.envVars[key]]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe(): string {
|
|
55
|
+
return `home-file (~/${HOME_DIR_NAME}/<product>.env)`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describeForProduct(product: ProductDefinition): string {
|
|
59
|
+
return `home-file (${getHomeFilePath(product)})`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
write(product: ProductDefinition, key: ConfigKey, value: string): void {
|
|
63
|
+
const envVar = product.envVars[key];
|
|
64
|
+
const existing = this.readFileIgnoringErrors(product);
|
|
65
|
+
existing[envVar] = value;
|
|
66
|
+
this.persist(product, existing);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
clear(product: ProductDefinition, key: ConfigKey): void {
|
|
70
|
+
const envVar = product.envVars[key];
|
|
71
|
+
const existing = this.readFileIgnoringErrors(product);
|
|
72
|
+
if (!(envVar in existing)) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
delete existing[envVar];
|
|
76
|
+
this.persist(product, existing);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private loadFile(product: ProductDefinition): ParsedEnvironment {
|
|
80
|
+
const filePath = getHomeFilePath(product);
|
|
81
|
+
try {
|
|
82
|
+
return this.cache.load(filePath);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error instanceof DotenvFileNotFoundError) {
|
|
85
|
+
return {};
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private readFileIgnoringErrors(product: ProductDefinition): ParsedEnvironment {
|
|
92
|
+
try {
|
|
93
|
+
return { ...this.loadFile(product) };
|
|
94
|
+
} catch {
|
|
95
|
+
return {};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private persist(product: ProductDefinition, values: ParsedEnvironment): void {
|
|
100
|
+
const filePath = getHomeFilePath(product);
|
|
101
|
+
const dir = path.dirname(filePath);
|
|
102
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
103
|
+
|
|
104
|
+
const tmpPath = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
|
|
105
|
+
try {
|
|
106
|
+
fs.writeFileSync(tmpPath, serialize(values), { mode: 0o600 });
|
|
107
|
+
fs.renameSync(tmpPath, filePath);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
try {
|
|
110
|
+
fs.unlinkSync(tmpPath);
|
|
111
|
+
} catch {
|
|
112
|
+
// best-effort cleanup
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this.cache.invalidate();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export { getHomeFilePath, HOME_DIR_NAME };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { execFileSync as nodeExecFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync as nodeExistsSync } from 'node:fs';
|
|
3
|
+
import type { ConfigKey, ProductDefinition, SourceId, WritableSource } from '../source.js';
|
|
4
|
+
|
|
5
|
+
// We pass the token via `-w <value>` on write. `security add-generic-password`
|
|
6
|
+
// has no stdin-password mode, so the token is visible in argv to same-user
|
|
7
|
+
// processes. A same-user attacker already has keychain access via `security`
|
|
8
|
+
// itself; this does not widen the attack surface.
|
|
9
|
+
|
|
10
|
+
export const SECURITY_BINARY = '/usr/bin/security';
|
|
11
|
+
export const KEYCHAIN_SERVICE = 'atlassian-dc-mcp';
|
|
12
|
+
const KEYCHAIN_TIMEOUT_MS = 5000;
|
|
13
|
+
export const NOT_FOUND_STATUS = 44;
|
|
14
|
+
|
|
15
|
+
export type KeychainDeps = {
|
|
16
|
+
execFileSync: typeof nodeExecFileSync;
|
|
17
|
+
existsSync: typeof nodeExistsSync;
|
|
18
|
+
getPlatform: () => NodeJS.Platform;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function accountFor(product: ProductDefinition): string {
|
|
22
|
+
return `${product.id}-token`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_DEPS: KeychainDeps = {
|
|
26
|
+
execFileSync: nodeExecFileSync,
|
|
27
|
+
existsSync: nodeExistsSync,
|
|
28
|
+
getPlatform: () => process.platform,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export class MacosKeychainSource implements WritableSource {
|
|
32
|
+
readonly id: SourceId = 'macos-keychain';
|
|
33
|
+
readonly priority = 40;
|
|
34
|
+
|
|
35
|
+
private cache = new Map<string, string | undefined>();
|
|
36
|
+
private cacheWarmed = new Set<string>();
|
|
37
|
+
private readonly deps: KeychainDeps;
|
|
38
|
+
|
|
39
|
+
constructor(deps: Partial<KeychainDeps> = {}) {
|
|
40
|
+
this.deps = { ...DEFAULT_DEPS, ...deps };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
isAvailable(): boolean {
|
|
44
|
+
return this.deps.getPlatform() === 'darwin' && this.deps.existsSync(SECURITY_BINARY);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
read(product: ProductDefinition, key: ConfigKey): string | undefined {
|
|
48
|
+
if (key !== 'token' || !this.isAvailable()) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
if (this.cacheWarmed.has(product.id)) {
|
|
52
|
+
return this.cache.get(product.id);
|
|
53
|
+
}
|
|
54
|
+
const value = this.findPassword(product);
|
|
55
|
+
this.cache.set(product.id, value);
|
|
56
|
+
this.cacheWarmed.add(product.id);
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe(): string {
|
|
61
|
+
return 'macOS Keychain';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
write(product: ProductDefinition, key: ConfigKey, value: string): void {
|
|
65
|
+
if (key !== 'token') {
|
|
66
|
+
throw new Error('macOS Keychain only stores the token key');
|
|
67
|
+
}
|
|
68
|
+
if (!this.isAvailable()) {
|
|
69
|
+
throw new Error('macOS Keychain is not available on this platform');
|
|
70
|
+
}
|
|
71
|
+
this.deps.execFileSync(
|
|
72
|
+
SECURITY_BINARY,
|
|
73
|
+
[
|
|
74
|
+
'add-generic-password',
|
|
75
|
+
'-U',
|
|
76
|
+
'-s', KEYCHAIN_SERVICE,
|
|
77
|
+
'-a', accountFor(product),
|
|
78
|
+
'-w', value,
|
|
79
|
+
],
|
|
80
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', timeout: KEYCHAIN_TIMEOUT_MS },
|
|
81
|
+
);
|
|
82
|
+
this.cache.set(product.id, value);
|
|
83
|
+
this.cacheWarmed.add(product.id);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
clear(product: ProductDefinition, key: ConfigKey): void {
|
|
87
|
+
if (key !== 'token' || !this.isAvailable()) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
this.deps.execFileSync(
|
|
92
|
+
SECURITY_BINARY,
|
|
93
|
+
['delete-generic-password', '-s', KEYCHAIN_SERVICE, '-a', accountFor(product)],
|
|
94
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', timeout: KEYCHAIN_TIMEOUT_MS },
|
|
95
|
+
);
|
|
96
|
+
} catch {
|
|
97
|
+
// absent / already cleared — non-fatal
|
|
98
|
+
}
|
|
99
|
+
this.cache.set(product.id, undefined);
|
|
100
|
+
this.cacheWarmed.add(product.id);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private findPassword(product: ProductDefinition): string | undefined {
|
|
104
|
+
try {
|
|
105
|
+
const stdout = this.deps.execFileSync(
|
|
106
|
+
SECURITY_BINARY,
|
|
107
|
+
['find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', accountFor(product), '-w'],
|
|
108
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', timeout: KEYCHAIN_TIMEOUT_MS },
|
|
109
|
+
);
|
|
110
|
+
return String(stdout).replace(/\n$/, '');
|
|
111
|
+
} catch {
|
|
112
|
+
// exit 44 = not found; any other failure (locked keychain, etc.) is
|
|
113
|
+
// treated as absent so the resolver continues down the chain.
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ConfigKey, ProductDefinition, ReadableSource, SourceId } from '../source.js';
|
|
2
|
+
import { getNonEmptyValue } from '../source.js';
|
|
3
|
+
|
|
4
|
+
export class ProcessEnvSource implements ReadableSource {
|
|
5
|
+
readonly id: SourceId = 'process-env';
|
|
6
|
+
readonly priority = 100;
|
|
7
|
+
|
|
8
|
+
read(product: ProductDefinition, key: ConfigKey): string | undefined {
|
|
9
|
+
return getNonEmptyValue(process.env[product.envVars[key]]);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe(): string {
|
|
13
|
+
return 'process env';
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
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
|
-
export * from './
|
|
5
|
+
export * from './config/index.js';
|
|
6
|
+
export { runSetup } from './setup-cli.js';
|
|
6
7
|
|
|
7
8
|
// Helper function to format tool responses
|
|
8
9
|
export const formatToolResponse = (result: unknown) => ({
|
package/src/setup-cli.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { confirm as inquirerConfirm, input as inquirerInput, password as inquirerPassword } from '@inquirer/prompts';
|
|
2
|
+
import { buildDefaultRegistry, type ConfigRegistry } from './config/registry.js';
|
|
3
|
+
import { getProductRuntimeConfig } from './config/runtime-config.js';
|
|
4
|
+
import type {
|
|
5
|
+
ConfigKey,
|
|
6
|
+
ProductDefinition,
|
|
7
|
+
WritableSource,
|
|
8
|
+
} from './config/source.js';
|
|
9
|
+
import { HomeFileSource, getHomeFilePath } from './config/sources/home-file.js';
|
|
10
|
+
import { MacosKeychainSource } from './config/sources/macos-keychain.js';
|
|
11
|
+
|
|
12
|
+
const FALLBACK_PAGE_SIZE = 25;
|
|
13
|
+
|
|
14
|
+
type PromptResult = {
|
|
15
|
+
host: string;
|
|
16
|
+
apiBasePath: string;
|
|
17
|
+
defaultPageSize: string;
|
|
18
|
+
token: string | undefined;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type SetupPrompts = {
|
|
22
|
+
input: (opts: { message: string; default?: string; validate?: (raw: string) => true | string }) => Promise<string>;
|
|
23
|
+
password: (opts: { message: string; mask?: string }) => Promise<string>;
|
|
24
|
+
confirm: (opts: { message: string; default?: boolean }) => Promise<boolean>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type SetupDeps = {
|
|
28
|
+
registry?: ConfigRegistry;
|
|
29
|
+
log?: (message: string) => void;
|
|
30
|
+
exit?: (code: number) => void;
|
|
31
|
+
prompts?: SetupPrompts;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const DEFAULT_PROMPTS: SetupPrompts = {
|
|
35
|
+
input: (opts) => inquirerInput(opts as any),
|
|
36
|
+
password: (opts) => inquirerPassword(opts as any),
|
|
37
|
+
confirm: (opts) => inquirerConfirm(opts as any),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export async function runSetup(product: ProductDefinition, deps: SetupDeps = {}): Promise<void> {
|
|
41
|
+
const registry = deps.registry ?? buildDefaultRegistry();
|
|
42
|
+
const log = deps.log ?? ((message: string) => process.stdout.write(`${message}\n`));
|
|
43
|
+
const exit = deps.exit ?? ((code: number) => { process.exit(code); });
|
|
44
|
+
const prompts = deps.prompts ?? DEFAULT_PROMPTS;
|
|
45
|
+
|
|
46
|
+
registry.initialize();
|
|
47
|
+
|
|
48
|
+
log(`Atlassian DC MCP setup — ${product.id}`);
|
|
49
|
+
log('');
|
|
50
|
+
|
|
51
|
+
const current = getProductRuntimeConfig(product);
|
|
52
|
+
printCurrent(log, registry, product, current);
|
|
53
|
+
|
|
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;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const homeFile = requireHomeFile(registry);
|
|
66
|
+
writeNonSecretFields(registry, product, answers, homeFile, log);
|
|
67
|
+
const tokenWriter = await writeToken(registry, product, answers.token, homeFile, log, prompts);
|
|
68
|
+
printSummary(log, product, answers, tokenWriter);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function requireHomeFile(registry: ConfigRegistry): HomeFileSource {
|
|
72
|
+
const homeFile = registry.getWritableSource(
|
|
73
|
+
(s): s is HomeFileSource => s instanceof HomeFileSource,
|
|
74
|
+
);
|
|
75
|
+
if (!homeFile) {
|
|
76
|
+
throw new Error('No writable source available for non-secret fields');
|
|
77
|
+
}
|
|
78
|
+
return homeFile;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function printCurrent(
|
|
82
|
+
log: (message: string) => void,
|
|
83
|
+
registry: ConfigRegistry,
|
|
84
|
+
product: ProductDefinition,
|
|
85
|
+
current: ReturnType<typeof getProductRuntimeConfig>,
|
|
86
|
+
): void {
|
|
87
|
+
const keys: ConfigKey[] = ['host', 'apiBasePath', 'token', 'defaultPageSize'];
|
|
88
|
+
for (const key of keys) {
|
|
89
|
+
const primary = registry.locate(product, key)[0];
|
|
90
|
+
const label = displayLabel(key);
|
|
91
|
+
const displayValue = key === 'token' ? maskToken(current.token) : readable(current[key]);
|
|
92
|
+
const origin = primary ? `from ${primary.detail ?? primary.sourceId}` : 'not set';
|
|
93
|
+
log(`Current ${label}: ${displayValue} (${origin})`);
|
|
94
|
+
}
|
|
95
|
+
log('');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function promptForValues(
|
|
99
|
+
prompts: SetupPrompts,
|
|
100
|
+
product: ProductDefinition,
|
|
101
|
+
current: ReturnType<typeof getProductRuntimeConfig>,
|
|
102
|
+
): Promise<PromptResult> {
|
|
103
|
+
const host = await prompts.input({
|
|
104
|
+
message: 'Host (e.g. jira.example.com):',
|
|
105
|
+
default: current.host ?? '',
|
|
106
|
+
});
|
|
107
|
+
const apiBasePath = await prompts.input({
|
|
108
|
+
message: 'API base path:',
|
|
109
|
+
default: current.apiBasePath ?? product.defaultApiBasePath ?? '',
|
|
110
|
+
});
|
|
111
|
+
const defaultPageSize = await prompts.input({
|
|
112
|
+
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',
|
|
118
|
+
});
|
|
119
|
+
const token = await promptForToken(prompts, current.token);
|
|
120
|
+
return { host: host.trim(), apiBasePath: apiBasePath.trim(), defaultPageSize: defaultPageSize.trim(), token };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function promptForToken(
|
|
124
|
+
prompts: SetupPrompts,
|
|
125
|
+
existing: string | undefined,
|
|
126
|
+
): Promise<string | undefined> {
|
|
127
|
+
const entered = await prompts.password({ message: 'API token:', mask: '*' });
|
|
128
|
+
const trimmed = entered.trim();
|
|
129
|
+
if (trimmed.length > 0) {
|
|
130
|
+
return trimmed;
|
|
131
|
+
}
|
|
132
|
+
if (!existing) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
await prompts.confirm({ message: 'Keep existing token?', default: true });
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function writeNonSecretFields(
|
|
140
|
+
registry: ConfigRegistry,
|
|
141
|
+
product: ProductDefinition,
|
|
142
|
+
answers: PromptResult,
|
|
143
|
+
homeFile: HomeFileSource,
|
|
144
|
+
log: (message: string) => void,
|
|
145
|
+
): void {
|
|
146
|
+
for (const key of ['host', 'apiBasePath', 'defaultPageSize'] as const) {
|
|
147
|
+
warnIfShadowed(registry, product, key, homeFile, log);
|
|
148
|
+
const value = answers[key];
|
|
149
|
+
if (value.length > 0) {
|
|
150
|
+
homeFile.write(product, key, value);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function writeToken(
|
|
156
|
+
registry: ConfigRegistry,
|
|
157
|
+
product: ProductDefinition,
|
|
158
|
+
token: string | undefined,
|
|
159
|
+
homeFile: HomeFileSource,
|
|
160
|
+
log: (message: string) => void,
|
|
161
|
+
prompts: SetupPrompts,
|
|
162
|
+
): Promise<WritableSource | undefined> {
|
|
163
|
+
if (!token) {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const keychain = registry.getWritableSource(
|
|
168
|
+
(s): s is MacosKeychainSource => s instanceof MacosKeychainSource,
|
|
169
|
+
);
|
|
170
|
+
const candidates: WritableSource[] = [];
|
|
171
|
+
if (keychain?.isAvailable()) {
|
|
172
|
+
candidates.push(keychain);
|
|
173
|
+
}
|
|
174
|
+
candidates.push(homeFile);
|
|
175
|
+
|
|
176
|
+
warnIfShadowed(registry, product, 'token', candidates[0], log);
|
|
177
|
+
|
|
178
|
+
for (const writer of candidates) {
|
|
179
|
+
const written = await tryWrite(writer, product, token, log, prompts);
|
|
180
|
+
if (written) {
|
|
181
|
+
if (writer instanceof MacosKeychainSource) {
|
|
182
|
+
homeFile.clear(product, 'token');
|
|
183
|
+
}
|
|
184
|
+
return writer;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
throw new Error('No writable source succeeded for token');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function tryWrite(
|
|
191
|
+
writer: WritableSource,
|
|
192
|
+
product: ProductDefinition,
|
|
193
|
+
token: string,
|
|
194
|
+
log: (message: string) => void,
|
|
195
|
+
prompts: SetupPrompts,
|
|
196
|
+
): Promise<boolean> {
|
|
197
|
+
try {
|
|
198
|
+
writer.write(product, 'token', token);
|
|
199
|
+
return true;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
const stderr = (error as { stderr?: string | Buffer }).stderr?.toString() ?? '';
|
|
202
|
+
log(`Failed to write token to ${writer.describe()}: ${(error as Error).message}`);
|
|
203
|
+
if (stderr) {
|
|
204
|
+
log(stderr.trim());
|
|
205
|
+
}
|
|
206
|
+
if (writer instanceof MacosKeychainSource) {
|
|
207
|
+
const fallback = await prompts.confirm({
|
|
208
|
+
message: 'Fall back to plaintext home file with mode 0600?',
|
|
209
|
+
default: false,
|
|
210
|
+
});
|
|
211
|
+
return !fallback;
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function warnIfShadowed(
|
|
218
|
+
registry: ConfigRegistry,
|
|
219
|
+
product: ProductDefinition,
|
|
220
|
+
key: ConfigKey,
|
|
221
|
+
target: WritableSource,
|
|
222
|
+
log: (message: string) => void,
|
|
223
|
+
): void {
|
|
224
|
+
const shadowing = registry
|
|
225
|
+
.locate(product, key)
|
|
226
|
+
.filter((loc) => priorityOf(registry, loc.sourceId) > target.priority);
|
|
227
|
+
if (shadowing.length === 0) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const envVar = product.envVars[key];
|
|
231
|
+
for (const loc of shadowing) {
|
|
232
|
+
if (loc.sourceId === 'process-env') {
|
|
233
|
+
log(`Warning: process.env[${envVar}] is set — runtime will prefer it over the value you're saving to ${target.describe()}. Unset it to use the new value.`);
|
|
234
|
+
} else {
|
|
235
|
+
log(`Warning: ${loc.detail ?? loc.sourceId} provides ${envVar} — runtime will prefer it over the value you're saving to ${target.describe()}.`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function priorityOf(registry: ConfigRegistry, sourceId: string): number {
|
|
241
|
+
const match = registry.sources.find((s) => s.id === sourceId);
|
|
242
|
+
return match?.priority ?? 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function printSummary(
|
|
246
|
+
log: (message: string) => void,
|
|
247
|
+
product: ProductDefinition,
|
|
248
|
+
answers: PromptResult,
|
|
249
|
+
tokenWriter: WritableSource | undefined,
|
|
250
|
+
): void {
|
|
251
|
+
log('');
|
|
252
|
+
log('Saved configuration:');
|
|
253
|
+
log(` host: ${answers.host || '(unchanged)'}`);
|
|
254
|
+
log(` apiBasePath: ${answers.apiBasePath || '(unchanged)'}`);
|
|
255
|
+
log(` defaultPageSize: ${answers.defaultPageSize || '(unchanged)'}`);
|
|
256
|
+
if (tokenWriter) {
|
|
257
|
+
log(` token: ${maskToken(answers.token)} (stored in ${describeWriter(tokenWriter, product)})`);
|
|
258
|
+
} else {
|
|
259
|
+
log(' token: (unchanged)');
|
|
260
|
+
}
|
|
261
|
+
log(`Home file: ${getHomeFilePath(product)}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function describeWriter(writer: WritableSource, product: ProductDefinition): string {
|
|
265
|
+
if (writer instanceof MacosKeychainSource) {
|
|
266
|
+
return `macOS Keychain (service atlassian-dc-mcp, account ${product.id}-token)`;
|
|
267
|
+
}
|
|
268
|
+
if (writer instanceof HomeFileSource) {
|
|
269
|
+
return writer.describeForProduct(product);
|
|
270
|
+
}
|
|
271
|
+
return writer.describe();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function displayLabel(key: ConfigKey): string {
|
|
275
|
+
switch (key) {
|
|
276
|
+
case 'host':
|
|
277
|
+
return 'host';
|
|
278
|
+
case 'apiBasePath':
|
|
279
|
+
return 'API base path';
|
|
280
|
+
case 'token':
|
|
281
|
+
return 'token';
|
|
282
|
+
case 'defaultPageSize':
|
|
283
|
+
return 'page size';
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function readable(value: string | number | undefined): string {
|
|
288
|
+
if (value === undefined || value === '') {
|
|
289
|
+
return '(not set)';
|
|
290
|
+
}
|
|
291
|
+
return String(value);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function maskToken(token: string | undefined): string {
|
|
295
|
+
if (!token) {
|
|
296
|
+
return '(not set)';
|
|
297
|
+
}
|
|
298
|
+
const last4 = token.slice(-4);
|
|
299
|
+
return `••••${last4}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function isUserCancel(error: unknown): boolean {
|
|
303
|
+
if (!error || typeof error !== 'object') {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
const err = error as { name?: string; message?: string };
|
|
307
|
+
return err.name === 'ExitPromptError' || /force closed/i.test(err.message ?? '');
|
|
308
|
+
}
|