@atlassian-dc-mcp/common 0.16.0 → 0.17.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.
Files changed (106) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/build/__tests__/runtime-config.test.js +65 -14
  3. package/build/__tests__/runtime-config.test.js.map +1 -1
  4. package/build/__tests__/setup-cli.test.d.ts +2 -0
  5. package/build/__tests__/setup-cli.test.d.ts.map +1 -0
  6. package/build/__tests__/setup-cli.test.js +166 -0
  7. package/build/__tests__/setup-cli.test.js.map +1 -0
  8. package/build/config/__tests__/registry.test.d.ts +2 -0
  9. package/build/config/__tests__/registry.test.d.ts.map +1 -0
  10. package/build/config/__tests__/registry.test.js +105 -0
  11. package/build/config/__tests__/registry.test.js.map +1 -0
  12. package/build/config/__tests__/resolve-base.test.d.ts +2 -0
  13. package/build/config/__tests__/resolve-base.test.d.ts.map +1 -0
  14. package/build/config/__tests__/resolve-base.test.js +89 -0
  15. package/build/config/__tests__/resolve-base.test.js.map +1 -0
  16. package/build/config/__tests__/sources/env-file.test.d.ts +2 -0
  17. package/build/config/__tests__/sources/env-file.test.d.ts.map +1 -0
  18. package/build/config/__tests__/sources/env-file.test.js +93 -0
  19. package/build/config/__tests__/sources/env-file.test.js.map +1 -0
  20. package/build/config/__tests__/sources/home-file.test.d.ts +2 -0
  21. package/build/config/__tests__/sources/home-file.test.d.ts.map +1 -0
  22. package/build/config/__tests__/sources/home-file.test.js +114 -0
  23. package/build/config/__tests__/sources/home-file.test.js.map +1 -0
  24. package/build/config/__tests__/sources/macos-keychain.test.d.ts +2 -0
  25. package/build/config/__tests__/sources/macos-keychain.test.d.ts.map +1 -0
  26. package/build/config/__tests__/sources/macos-keychain.test.js +109 -0
  27. package/build/config/__tests__/sources/macos-keychain.test.js.map +1 -0
  28. package/build/config/__tests__/sources/process-env.test.d.ts +2 -0
  29. package/build/config/__tests__/sources/process-env.test.d.ts.map +1 -0
  30. package/build/config/__tests__/sources/process-env.test.js +51 -0
  31. package/build/config/__tests__/sources/process-env.test.js.map +1 -0
  32. package/build/config/index.d.ts +9 -0
  33. package/build/config/index.d.ts.map +1 -0
  34. package/build/config/index.js +9 -0
  35. package/build/config/index.js.map +1 -0
  36. package/build/config/registry.d.ts +26 -0
  37. package/build/config/registry.d.ts.map +1 -0
  38. package/build/config/registry.js +51 -0
  39. package/build/config/registry.js.map +1 -0
  40. package/build/config/resolve-base.d.ts +8 -0
  41. package/build/config/resolve-base.d.ts.map +1 -0
  42. package/build/config/resolve-base.js +31 -0
  43. package/build/config/resolve-base.js.map +1 -0
  44. package/build/config/runtime-config.d.ts +14 -0
  45. package/build/config/runtime-config.d.ts.map +1 -0
  46. package/build/config/runtime-config.js +41 -0
  47. package/build/config/runtime-config.js.map +1 -0
  48. package/build/config/source.d.ts +28 -0
  49. package/build/config/source.d.ts.map +1 -0
  50. package/build/config/source.js +11 -0
  51. package/build/config/source.js.map +1 -0
  52. package/build/config/sources/dotenv-file-cache.d.ts +12 -0
  53. package/build/config/sources/dotenv-file-cache.d.ts.map +1 -0
  54. package/build/config/sources/dotenv-file-cache.js +41 -0
  55. package/build/config/sources/dotenv-file-cache.js.map +1 -0
  56. package/build/config/sources/env-file.d.ts +16 -0
  57. package/build/config/sources/env-file.d.ts.map +1 -0
  58. package/build/config/sources/env-file.js +48 -0
  59. package/build/config/sources/env-file.js.map +1 -0
  60. package/build/config/sources/home-file.d.ts +19 -0
  61. package/build/config/sources/home-file.d.ts.map +1 -0
  62. package/build/config/sources/home-file.js +102 -0
  63. package/build/config/sources/home-file.js.map +1 -0
  64. package/build/config/sources/macos-keychain.d.ts +26 -0
  65. package/build/config/sources/macos-keychain.d.ts.map +1 -0
  66. package/build/config/sources/macos-keychain.js +88 -0
  67. package/build/config/sources/macos-keychain.js.map +1 -0
  68. package/build/config/sources/process-env.d.ts +8 -0
  69. package/build/config/sources/process-env.d.ts.map +1 -0
  70. package/build/config/sources/process-env.js +12 -0
  71. package/build/config/sources/process-env.js.map +1 -0
  72. package/build/index.d.ts +2 -1
  73. package/build/index.d.ts.map +1 -1
  74. package/build/index.js +2 -1
  75. package/build/index.js.map +1 -1
  76. package/build/setup-cli.d.ts +25 -0
  77. package/build/setup-cli.d.ts.map +1 -0
  78. package/build/setup-cli.js +215 -0
  79. package/build/setup-cli.js.map +1 -0
  80. package/package.json +5 -4
  81. package/src/__tests__/runtime-config.test.ts +75 -27
  82. package/src/__tests__/setup-cli.test.ts +175 -0
  83. package/src/config/__tests__/registry.test.ts +118 -0
  84. package/src/config/__tests__/resolve-base.test.ts +101 -0
  85. package/src/config/__tests__/sources/env-file.test.ts +113 -0
  86. package/src/config/__tests__/sources/home-file.test.ts +129 -0
  87. package/src/config/__tests__/sources/macos-keychain.test.ts +136 -0
  88. package/src/config/__tests__/sources/process-env.test.ts +59 -0
  89. package/src/config/index.ts +8 -0
  90. package/src/config/registry.ts +79 -0
  91. package/src/config/resolve-base.ts +43 -0
  92. package/src/config/runtime-config.ts +60 -0
  93. package/src/config/source.ts +37 -0
  94. package/src/config/sources/dotenv-file-cache.ts +54 -0
  95. package/src/config/sources/env-file.ts +61 -0
  96. package/src/config/sources/home-file.ts +121 -0
  97. package/src/config/sources/macos-keychain.ts +117 -0
  98. package/src/config/sources/process-env.ts +15 -0
  99. package/src/index.ts +2 -1
  100. package/src/setup-cli.ts +308 -0
  101. package/tsconfig.tsbuildinfo +1 -1
  102. package/build/runtime-config.d.ts +0 -14
  103. package/build/runtime-config.d.ts.map +0 -1
  104. package/build/runtime-config.js +0 -123
  105. package/build/runtime-config.js.map +0 -1
  106. package/src/runtime-config.ts +0 -176
@@ -0,0 +1,118 @@
1
+ import { DefaultConfigRegistry } from '../registry.js';
2
+ import type {
3
+ ConfigKey,
4
+ ProductDefinition,
5
+ ReadableSource,
6
+ SourceId,
7
+ WritableSource,
8
+ } from '../source.js';
9
+
10
+ const PRODUCT: ProductDefinition = {
11
+ id: 'jira',
12
+ envVars: {
13
+ host: 'JIRA_HOST',
14
+ apiBasePath: 'JIRA_API_BASE_PATH',
15
+ token: 'JIRA_API_TOKEN',
16
+ defaultPageSize: 'JIRA_DEFAULT_PAGE_SIZE',
17
+ },
18
+ };
19
+
20
+ class ReadOnlySource implements ReadableSource {
21
+ constructor(
22
+ public readonly id: SourceId,
23
+ public readonly priority: number,
24
+ public readonly values: Partial<Record<ConfigKey, string>> = {},
25
+ ) {}
26
+ read(_p: ProductDefinition, key: ConfigKey): string | undefined {
27
+ return this.values[key];
28
+ }
29
+ describe(): string {
30
+ return this.id;
31
+ }
32
+ }
33
+
34
+ class FakeWritableSource implements WritableSource {
35
+ calls: string[] = [];
36
+ available = true;
37
+ constructor(
38
+ public readonly id: SourceId,
39
+ public readonly priority: number,
40
+ ) {}
41
+ read(): string | undefined {
42
+ return undefined;
43
+ }
44
+ describe(): string {
45
+ return this.id;
46
+ }
47
+ isAvailable(): boolean {
48
+ return this.available;
49
+ }
50
+ write(_p: ProductDefinition, key: ConfigKey, value: string): void {
51
+ this.calls.push(`write:${key}:${value}`);
52
+ }
53
+ clear(_p: ProductDefinition, key: ConfigKey): void {
54
+ this.calls.push(`clear:${key}`);
55
+ }
56
+ }
57
+
58
+ describe('DefaultConfigRegistry', () => {
59
+ it('sorts sources by priority descending', () => {
60
+ const a = new ReadOnlySource('home-file', 60);
61
+ const b = new ReadOnlySource('process-env', 100);
62
+ const c = new ReadOnlySource('env-file', 80);
63
+ const registry = new DefaultConfigRegistry([a, b, c]);
64
+ expect(registry.sources.map((s) => s.id)).toEqual(['process-env', 'env-file', 'home-file']);
65
+ });
66
+
67
+ it('resolve returns the first non-empty value in priority order', () => {
68
+ const envFile = new ReadOnlySource('env-file', 80, { token: 'file-token' });
69
+ const processEnv = new ReadOnlySource('process-env', 100, { token: 'env-token' });
70
+ const registry = new DefaultConfigRegistry([envFile, processEnv]);
71
+ const resolved = registry.resolve(PRODUCT, 'token');
72
+ expect(resolved.value).toBe('env-token');
73
+ expect(resolved.from?.sourceId).toBe('process-env');
74
+ });
75
+
76
+ it('resolve falls through when a higher-priority source is empty', () => {
77
+ const envFile = new ReadOnlySource('env-file', 80, { token: 'file-token' });
78
+ const processEnv = new ReadOnlySource('process-env', 100, {});
79
+ const registry = new DefaultConfigRegistry([envFile, processEnv]);
80
+ expect(registry.resolve(PRODUCT, 'token').value).toBe('file-token');
81
+ });
82
+
83
+ it('locate returns all sources with values, priority order', () => {
84
+ const home = new ReadOnlySource('home-file', 60, { token: 'home-token' });
85
+ const env = new ReadOnlySource('process-env', 100, { token: 'env-token' });
86
+ const envFile = new ReadOnlySource('env-file', 80, {});
87
+ const registry = new DefaultConfigRegistry([home, env, envFile]);
88
+ const locs = registry.locate(PRODUCT, 'token');
89
+ expect(locs.map((l) => l.sourceId)).toEqual(['process-env', 'home-file']);
90
+ });
91
+
92
+ it('getWritableSource returns the first source matching the predicate', () => {
93
+ const a = new FakeWritableSource('home-file', 60);
94
+ const b = new FakeWritableSource('macos-keychain', 40);
95
+ const registry = new DefaultConfigRegistry([a, b]);
96
+ const found = registry.getWritableSource(
97
+ (s): s is FakeWritableSource => s instanceof FakeWritableSource && s.id === 'macos-keychain',
98
+ );
99
+ expect(found).toBe(b);
100
+ });
101
+
102
+ it('calls initialize on all sources when registry.initialize runs', () => {
103
+ const calls: string[] = [];
104
+ const makeSource = (id: SourceId, priority: number) =>
105
+ ({
106
+ id,
107
+ priority,
108
+ read: () => undefined,
109
+ describe: () => id,
110
+ initialize: (opts?: { cwd?: string }) => {
111
+ calls.push(`${id}:${opts?.cwd ?? ''}`);
112
+ },
113
+ }) satisfies ReadableSource;
114
+ const registry = new DefaultConfigRegistry([makeSource('env-file', 80), makeSource('process-env', 100)]);
115
+ registry.initialize({ cwd: '/tmp' });
116
+ expect(calls.sort()).toEqual(['env-file:/tmp', 'process-env:/tmp']);
117
+ });
118
+ });
@@ -0,0 +1,101 @@
1
+ import { resolveOpenApiBase } from '../resolve-base.js';
2
+
3
+ describe('resolveOpenApiBase', () => {
4
+ it('combines bare host with default base path', () => {
5
+ const base = resolveOpenApiBase({
6
+ host: 'jira.example.com',
7
+ defaultBasePath: '/rest',
8
+ });
9
+ expect(base).toBe('https://jira.example.com/rest');
10
+ });
11
+
12
+ it('uses an explicit apiBasePath when it is a path', () => {
13
+ const base = resolveOpenApiBase({
14
+ host: 'jira.example.com',
15
+ apiBasePath: '/custom',
16
+ defaultBasePath: '/rest',
17
+ });
18
+ expect(base).toBe('https://jira.example.com/custom');
19
+ });
20
+
21
+ it('uses an empty default for confluence-style generated paths', () => {
22
+ const base = resolveOpenApiBase({
23
+ host: 'wiki.example.com',
24
+ defaultBasePath: '',
25
+ });
26
+ expect(base).toBe('https://wiki.example.com');
27
+ });
28
+
29
+ it('accepts a fully-qualified apiBasePath as a full URL override', () => {
30
+ const base = resolveOpenApiBase({
31
+ host: 'ignored.example.com',
32
+ apiBasePath: 'https://real.example.com/rest',
33
+ defaultBasePath: '/rest',
34
+ });
35
+ expect(base).toBe('https://real.example.com/rest');
36
+ });
37
+
38
+ it('strips known generated suffix from a fully-qualified apiBasePath', () => {
39
+ const base = resolveOpenApiBase({
40
+ host: 'ignored.example.com',
41
+ apiBasePath: 'https://real.example.com/rest/api/2',
42
+ defaultBasePath: '/rest',
43
+ strippableSuffixes: ['/api/2'],
44
+ });
45
+ expect(base).toBe('https://real.example.com/rest');
46
+ });
47
+
48
+ it('strips a confluence generated suffix from a fully-qualified apiBasePath with a context path', () => {
49
+ const base = resolveOpenApiBase({
50
+ host: 'ignored.example.com',
51
+ apiBasePath: 'https://wiki.example.com/confluence/rest/api',
52
+ defaultBasePath: '',
53
+ strippableSuffixes: ['/rest/api', '/rest'],
54
+ });
55
+ expect(base).toBe('https://wiki.example.com/confluence');
56
+ });
57
+
58
+ it('strips known generated suffix included by mistake (jira)', () => {
59
+ const base = resolveOpenApiBase({
60
+ host: 'jira.example.com',
61
+ apiBasePath: '/rest/api/2',
62
+ defaultBasePath: '/rest',
63
+ strippableSuffixes: ['/api/2'],
64
+ });
65
+ expect(base).toBe('https://jira.example.com/rest');
66
+ });
67
+
68
+ it('strips known generated suffix included by mistake (bitbucket)', () => {
69
+ const base = resolveOpenApiBase({
70
+ host: 'bb.example.com',
71
+ apiBasePath: '/rest/api/1.0',
72
+ defaultBasePath: '/rest',
73
+ strippableSuffixes: ['/api/1.0', '/api/latest'],
74
+ });
75
+ expect(base).toBe('https://bb.example.com/rest');
76
+ });
77
+
78
+ it('strips known generated suffix included by mistake (confluence)', () => {
79
+ const base = resolveOpenApiBase({
80
+ host: 'wiki.example.com',
81
+ apiBasePath: '/rest/api',
82
+ defaultBasePath: '',
83
+ strippableSuffixes: ['/rest/api', '/rest'],
84
+ });
85
+ expect(base).toBe('https://wiki.example.com');
86
+ });
87
+
88
+ it('preserves a host that already includes a scheme', () => {
89
+ const base = resolveOpenApiBase({
90
+ host: 'https://jira.example.com/',
91
+ defaultBasePath: '/rest',
92
+ });
93
+ expect(base).toBe('https://jira.example.com/rest');
94
+ });
95
+
96
+ it('throws when host and apiBasePath are both missing', () => {
97
+ expect(() =>
98
+ resolveOpenApiBase({ defaultBasePath: '/rest' }),
99
+ ).toThrow('host or apiBasePath must be provided');
100
+ });
101
+ });
@@ -0,0 +1,113 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { EnvFileSource, ATLASSIAN_DC_MCP_CONFIG_FILE_ENV_VAR } from '../../sources/env-file.js';
5
+ import type { ProductDefinition } from '../../source.js';
6
+
7
+ const JIRA: ProductDefinition = {
8
+ id: 'jira',
9
+ envVars: {
10
+ host: 'JIRA_HOST',
11
+ apiBasePath: 'JIRA_API_BASE_PATH',
12
+ token: 'JIRA_API_TOKEN',
13
+ defaultPageSize: 'JIRA_DEFAULT_PAGE_SIZE',
14
+ },
15
+ };
16
+
17
+ const BITBUCKET: ProductDefinition = {
18
+ id: 'bitbucket',
19
+ envVars: {
20
+ host: 'BITBUCKET_HOST',
21
+ apiBasePath: 'BITBUCKET_API_BASE_PATH',
22
+ token: 'BITBUCKET_API_TOKEN',
23
+ defaultPageSize: 'BITBUCKET_DEFAULT_PAGE_SIZE',
24
+ },
25
+ };
26
+
27
+ describe('EnvFileSource', () => {
28
+ const originalEnv = process.env;
29
+ let tempDir: string;
30
+
31
+ beforeEach(() => {
32
+ process.env = { ...originalEnv };
33
+ delete process.env[ATLASSIAN_DC_MCP_CONFIG_FILE_ENV_VAR];
34
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'env-file-src-'));
35
+ });
36
+
37
+ afterEach(() => {
38
+ fs.rmSync(tempDir, { recursive: true, force: true });
39
+ });
40
+
41
+ afterAll(() => {
42
+ process.env = originalEnv;
43
+ });
44
+
45
+ it('reads an explicit shared config file', () => {
46
+ const filePath = path.join(tempDir, 'shared.env');
47
+ fs.writeFileSync(filePath, 'JIRA_HOST=file-host\nJIRA_API_TOKEN=file-token\n');
48
+ process.env[ATLASSIAN_DC_MCP_CONFIG_FILE_ENV_VAR] = filePath;
49
+ const source = new EnvFileSource();
50
+ source.initialize({ cwd: tempDir });
51
+ expect(source.read(JIRA, 'host')).toBe('file-host');
52
+ expect(source.read(JIRA, 'token')).toBe('file-token');
53
+ });
54
+
55
+ it('reads cwd/.env when no explicit file is configured', () => {
56
+ fs.writeFileSync(path.join(tempDir, '.env'), 'BITBUCKET_HOST=cwd-host\n');
57
+ const source = new EnvFileSource();
58
+ source.initialize({ cwd: tempDir });
59
+ expect(source.read(BITBUCKET, 'host')).toBe('cwd-host');
60
+ });
61
+
62
+ it('throws when explicit file is relative', () => {
63
+ process.env[ATLASSIAN_DC_MCP_CONFIG_FILE_ENV_VAR] = 'relative/shared.env';
64
+ const source = new EnvFileSource();
65
+ expect(() => source.initialize({ cwd: tempDir })).toThrow(/must be an absolute path/);
66
+ });
67
+
68
+ it('throws when explicit file is missing', () => {
69
+ const missing = path.join(tempDir, 'missing.env');
70
+ process.env[ATLASSIAN_DC_MCP_CONFIG_FILE_ENV_VAR] = missing;
71
+ const source = new EnvFileSource();
72
+ expect(() => source.initialize({ cwd: tempDir })).toThrow(
73
+ `${ATLASSIAN_DC_MCP_CONFIG_FILE_ENV_VAR} points to a missing file: ${missing}`,
74
+ );
75
+ });
76
+
77
+ it('revalidates cache when mtime changes', () => {
78
+ const filePath = path.join(tempDir, 'shared.env');
79
+ fs.writeFileSync(filePath, 'JIRA_API_TOKEN=token-a\n');
80
+ const initial = new Date('2026-01-01T00:00:00.000Z');
81
+ fs.utimesSync(filePath, initial, initial);
82
+ process.env[ATLASSIAN_DC_MCP_CONFIG_FILE_ENV_VAR] = filePath;
83
+
84
+ const source = new EnvFileSource();
85
+ source.initialize({ cwd: tempDir });
86
+ expect(source.read(JIRA, 'token')).toBe('token-a');
87
+
88
+ fs.writeFileSync(filePath, 'JIRA_API_TOKEN=token-b\n');
89
+ const updated = new Date('2026-01-01T00:00:01.000Z');
90
+ fs.utimesSync(filePath, updated, updated);
91
+ expect(source.read(JIRA, 'token')).toBe('token-b');
92
+ });
93
+
94
+ it('returns empty object when cwd/.env is missing', () => {
95
+ const source = new EnvFileSource();
96
+ source.initialize({ cwd: tempDir });
97
+ expect(source.read(JIRA, 'host')).toBeUndefined();
98
+ });
99
+
100
+ it('evicts cache when an explicit file disappears after a successful read', () => {
101
+ const filePath = path.join(tempDir, 'shared.env');
102
+ fs.writeFileSync(filePath, 'JIRA_API_TOKEN=token-a\n');
103
+ process.env[ATLASSIAN_DC_MCP_CONFIG_FILE_ENV_VAR] = filePath;
104
+ const source = new EnvFileSource();
105
+ source.initialize({ cwd: tempDir });
106
+ expect(source.read(JIRA, 'token')).toBe('token-a');
107
+
108
+ fs.unlinkSync(filePath);
109
+ expect(() => source.read(JIRA, 'token')).toThrow(
110
+ `${ATLASSIAN_DC_MCP_CONFIG_FILE_ENV_VAR} points to a missing file: ${filePath}`,
111
+ );
112
+ });
113
+ });
@@ -0,0 +1,129 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { HomeFileSource, getHomeFilePath, HOME_DIR_NAME } from '../../sources/home-file.js';
5
+ import type { ProductDefinition } from '../../source.js';
6
+
7
+ const JIRA: ProductDefinition = {
8
+ id: 'jira',
9
+ envVars: {
10
+ host: 'JIRA_HOST',
11
+ apiBasePath: 'JIRA_API_BASE_PATH',
12
+ token: 'JIRA_API_TOKEN',
13
+ defaultPageSize: 'JIRA_DEFAULT_PAGE_SIZE',
14
+ },
15
+ };
16
+
17
+ const CONFLUENCE: ProductDefinition = {
18
+ id: 'confluence',
19
+ envVars: {
20
+ host: 'CONFLUENCE_HOST',
21
+ apiBasePath: 'CONFLUENCE_API_BASE_PATH',
22
+ token: 'CONFLUENCE_API_TOKEN',
23
+ defaultPageSize: 'CONFLUENCE_DEFAULT_PAGE_SIZE',
24
+ },
25
+ };
26
+
27
+ describe('HomeFileSource', () => {
28
+ let tempHome: string;
29
+ let homedirSpy: jest.SpyInstance;
30
+
31
+ beforeEach(() => {
32
+ tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'home-file-src-'));
33
+ homedirSpy = jest.spyOn(os, 'homedir').mockReturnValue(tempHome);
34
+ });
35
+
36
+ afterEach(() => {
37
+ homedirSpy.mockRestore();
38
+ fs.rmSync(tempHome, { recursive: true, force: true });
39
+ });
40
+
41
+ it('returns undefined when the home file does not exist', () => {
42
+ expect(new HomeFileSource().read(JIRA, 'host')).toBeUndefined();
43
+ });
44
+
45
+ it('writes values and reads them back', () => {
46
+ const source = new HomeFileSource();
47
+ source.write(JIRA, 'host', 'j-host');
48
+ source.write(JIRA, 'token', 'j-token');
49
+ expect(source.read(JIRA, 'host')).toBe('j-host');
50
+ expect(source.read(JIRA, 'token')).toBe('j-token');
51
+ });
52
+
53
+ it('writes files with mode 0600 on POSIX', () => {
54
+ if (process.platform === 'win32') {
55
+ return;
56
+ }
57
+ const source = new HomeFileSource();
58
+ source.write(JIRA, 'host', 'j-host');
59
+ const stat = fs.statSync(getHomeFilePath(JIRA));
60
+ expect(stat.mode & 0o777).toBe(0o600);
61
+ });
62
+
63
+ it('merge preserves unrelated keys', () => {
64
+ const filePath = getHomeFilePath(JIRA);
65
+ fs.mkdirSync(path.join(tempHome, HOME_DIR_NAME), { recursive: true });
66
+ fs.writeFileSync(filePath, 'JIRA_HOST=old\nUNRELATED_KEY=keep-me\n', { mode: 0o600 });
67
+ const source = new HomeFileSource();
68
+ source.write(JIRA, 'host', 'new');
69
+ const contents = fs.readFileSync(filePath, 'utf8');
70
+ expect(contents).toContain('JIRA_HOST=new');
71
+ expect(contents).toContain('UNRELATED_KEY=keep-me');
72
+ });
73
+
74
+ it('clear removes only the specified key', () => {
75
+ const source = new HomeFileSource();
76
+ source.write(JIRA, 'host', 'j-host');
77
+ source.write(JIRA, 'token', 'j-token');
78
+ source.clear(JIRA, 'token');
79
+ expect(source.read(JIRA, 'host')).toBe('j-host');
80
+ expect(source.read(JIRA, 'token')).toBeUndefined();
81
+ });
82
+
83
+ it('quotes values that contain whitespace', () => {
84
+ const source = new HomeFileSource();
85
+ source.write(JIRA, 'host', 'value with spaces');
86
+ const contents = fs.readFileSync(getHomeFilePath(JIRA), 'utf8');
87
+ expect(contents).toMatch(/JIRA_HOST="value with spaces"/);
88
+ expect(source.read(JIRA, 'host')).toBe('value with spaces');
89
+ });
90
+
91
+ it('stores each product in its own file', () => {
92
+ const source = new HomeFileSource();
93
+ source.write(JIRA, 'host', 'j-host');
94
+ source.write(CONFLUENCE, 'host', 'c-host');
95
+ expect(fs.existsSync(getHomeFilePath(JIRA))).toBe(true);
96
+ expect(fs.existsSync(getHomeFilePath(CONFLUENCE))).toBe(true);
97
+ expect(source.read(JIRA, 'host')).toBe('j-host');
98
+ expect(source.read(CONFLUENCE, 'host')).toBe('c-host');
99
+ });
100
+
101
+ it('evicts cache when the home file is deleted externally so writes do not merge stale values', () => {
102
+ const source = new HomeFileSource();
103
+ source.write(JIRA, 'host', 'stale-host');
104
+ expect(source.read(JIRA, 'host')).toBe('stale-host');
105
+
106
+ fs.unlinkSync(getHomeFilePath(JIRA));
107
+ expect(source.read(JIRA, 'host')).toBeUndefined();
108
+
109
+ source.write(JIRA, 'token', 'fresh-token');
110
+ const contents = fs.readFileSync(getHomeFilePath(JIRA), 'utf8');
111
+ expect(contents).not.toContain('stale-host');
112
+ expect(contents).toContain('JIRA_API_TOKEN=fresh-token');
113
+ });
114
+
115
+ it('removes the temp file if rename fails', () => {
116
+ const source = new HomeFileSource();
117
+ const renameSpy = jest.spyOn(fs, 'renameSync').mockImplementation(() => {
118
+ throw new Error('boom');
119
+ });
120
+ try {
121
+ expect(() => source.write(JIRA, 'host', 'x')).toThrow('boom');
122
+ const dir = path.join(tempHome, HOME_DIR_NAME);
123
+ const entries = fs.existsSync(dir) ? fs.readdirSync(dir) : [];
124
+ expect(entries.filter((e) => e.includes('.tmp-'))).toEqual([]);
125
+ } finally {
126
+ renameSpy.mockRestore();
127
+ }
128
+ });
129
+ });
@@ -0,0 +1,136 @@
1
+ import { MacosKeychainSource, SECURITY_BINARY, type KeychainDeps } from '../../sources/macos-keychain.js';
2
+ import type { ProductDefinition } from '../../source.js';
3
+
4
+ const JIRA: ProductDefinition = {
5
+ id: 'jira',
6
+ envVars: {
7
+ host: 'JIRA_HOST',
8
+ apiBasePath: 'JIRA_API_BASE_PATH',
9
+ token: 'JIRA_API_TOKEN',
10
+ defaultPageSize: 'JIRA_DEFAULT_PAGE_SIZE',
11
+ },
12
+ };
13
+
14
+ function makeDeps(overrides: Partial<KeychainDeps> = {}) {
15
+ return {
16
+ execFileSync: jest.fn(),
17
+ existsSync: jest.fn(() => true),
18
+ getPlatform: () => 'darwin' as NodeJS.Platform,
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ describe('MacosKeychainSource', () => {
24
+ it('isAvailable is true on darwin when binary exists', () => {
25
+ expect(new MacosKeychainSource(makeDeps()).isAvailable()).toBe(true);
26
+ });
27
+
28
+ it('isAvailable is false off darwin', () => {
29
+ const deps = makeDeps({ getPlatform: () => 'linux' as NodeJS.Platform });
30
+ expect(new MacosKeychainSource(deps).isAvailable()).toBe(false);
31
+ });
32
+
33
+ it('isAvailable is false when binary is missing', () => {
34
+ const deps = makeDeps({ existsSync: jest.fn(() => false) });
35
+ expect(new MacosKeychainSource(deps).isAvailable()).toBe(false);
36
+ });
37
+
38
+ it('read returns token on success and strips trailing newline', () => {
39
+ const execFileSync = jest.fn().mockReturnValueOnce('secret-token\n');
40
+ const deps = makeDeps({ execFileSync: execFileSync as unknown as KeychainDeps['execFileSync'] });
41
+ const source = new MacosKeychainSource(deps);
42
+ expect(source.read(JIRA, 'token')).toBe('secret-token');
43
+ expect(execFileSync).toHaveBeenCalledWith(
44
+ SECURITY_BINARY,
45
+ ['find-generic-password', '-s', 'atlassian-dc-mcp', '-a', 'jira-token', '-w'],
46
+ expect.objectContaining({ encoding: 'utf8', timeout: 5000 }),
47
+ );
48
+ });
49
+
50
+ it('read returns undefined when security exits non-zero (not found)', () => {
51
+ const execFileSync = jest.fn().mockImplementationOnce(() => {
52
+ const err: NodeJS.ErrnoException & { status?: number } = new Error('not found');
53
+ err.status = 44;
54
+ throw err;
55
+ });
56
+ const source = new MacosKeychainSource(makeDeps({ execFileSync: execFileSync as any }));
57
+ expect(source.read(JIRA, 'token')).toBeUndefined();
58
+ });
59
+
60
+ it('read returns undefined when keychain is locked (non-44)', () => {
61
+ const execFileSync = jest.fn().mockImplementationOnce(() => {
62
+ const err: NodeJS.ErrnoException & { status?: number } = new Error('auth failed');
63
+ err.status = 128;
64
+ throw err;
65
+ });
66
+ const source = new MacosKeychainSource(makeDeps({ execFileSync: execFileSync as any }));
67
+ expect(source.read(JIRA, 'token')).toBeUndefined();
68
+ });
69
+
70
+ it('read returns undefined for non-token keys without invoking security', () => {
71
+ const execFileSync = jest.fn();
72
+ const source = new MacosKeychainSource(makeDeps({ execFileSync: execFileSync as any }));
73
+ expect(source.read(JIRA, 'host')).toBeUndefined();
74
+ expect(execFileSync).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it('caches reads', () => {
78
+ const execFileSync = jest.fn().mockReturnValueOnce('cached\n');
79
+ const source = new MacosKeychainSource(makeDeps({ execFileSync: execFileSync as any }));
80
+ source.read(JIRA, 'token');
81
+ source.read(JIRA, 'token');
82
+ source.read(JIRA, 'token');
83
+ expect(execFileSync).toHaveBeenCalledTimes(1);
84
+ });
85
+
86
+ it('write uses -U flag', () => {
87
+ const execFileSync = jest.fn().mockReturnValueOnce('');
88
+ const source = new MacosKeychainSource(makeDeps({ execFileSync: execFileSync as any }));
89
+ source.write(JIRA, 'token', 'abc');
90
+ expect(execFileSync).toHaveBeenCalledWith(
91
+ SECURITY_BINARY,
92
+ ['add-generic-password', '-U', '-s', 'atlassian-dc-mcp', '-a', 'jira-token', '-w', 'abc'],
93
+ expect.objectContaining({ encoding: 'utf8' }),
94
+ );
95
+ });
96
+
97
+ it('write throws when the key is not token', () => {
98
+ const execFileSync = jest.fn();
99
+ const source = new MacosKeychainSource(makeDeps({ execFileSync: execFileSync as any }));
100
+ expect(() => source.write(JIRA, 'host', 'x')).toThrow(/only stores the token/);
101
+ expect(execFileSync).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it('round-trips a token containing shell metacharacters', () => {
105
+ const tricky = 'a\'b"c$d`e';
106
+ const execFileSync = jest
107
+ .fn()
108
+ .mockImplementationOnce(() => '')
109
+ .mockImplementationOnce(() => `${tricky}\n`);
110
+ const source = new MacosKeychainSource(makeDeps({ execFileSync: execFileSync as any }));
111
+ source.write(JIRA, 'token', tricky);
112
+ expect(source.read(JIRA, 'token')).toBe(tricky);
113
+ const writeArgs = execFileSync.mock.calls[0][1] as string[];
114
+ expect(writeArgs[writeArgs.length - 1]).toBe(tricky);
115
+ });
116
+
117
+ it('clear invokes delete-generic-password and caches undefined', () => {
118
+ const execFileSync = jest.fn().mockImplementationOnce(() => '');
119
+ const source = new MacosKeychainSource(makeDeps({ execFileSync: execFileSync as any }));
120
+ source.clear(JIRA, 'token');
121
+ expect(execFileSync).toHaveBeenCalledWith(
122
+ SECURITY_BINARY,
123
+ ['delete-generic-password', '-s', 'atlassian-dc-mcp', '-a', 'jira-token'],
124
+ expect.any(Object),
125
+ );
126
+ expect(source.read(JIRA, 'token')).toBeUndefined();
127
+ });
128
+
129
+ it('clear swallows errors', () => {
130
+ const execFileSync = jest.fn().mockImplementationOnce(() => {
131
+ throw new Error('not found');
132
+ });
133
+ const source = new MacosKeychainSource(makeDeps({ execFileSync: execFileSync as any }));
134
+ expect(() => source.clear(JIRA, 'token')).not.toThrow();
135
+ });
136
+ });
@@ -0,0 +1,59 @@
1
+ import { ProcessEnvSource } from '../../sources/process-env.js';
2
+ import type { ProductDefinition } from '../../source.js';
3
+
4
+ const JIRA: ProductDefinition = {
5
+ id: 'jira',
6
+ envVars: {
7
+ host: 'JIRA_HOST',
8
+ apiBasePath: 'JIRA_API_BASE_PATH',
9
+ token: 'JIRA_API_TOKEN',
10
+ defaultPageSize: 'JIRA_DEFAULT_PAGE_SIZE',
11
+ },
12
+ };
13
+
14
+ const CONFLUENCE: ProductDefinition = {
15
+ id: 'confluence',
16
+ envVars: {
17
+ host: 'CONFLUENCE_HOST',
18
+ apiBasePath: 'CONFLUENCE_API_BASE_PATH',
19
+ token: 'CONFLUENCE_API_TOKEN',
20
+ defaultPageSize: 'CONFLUENCE_DEFAULT_PAGE_SIZE',
21
+ },
22
+ };
23
+
24
+ describe('ProcessEnvSource', () => {
25
+ const originalEnv = process.env;
26
+
27
+ beforeEach(() => {
28
+ process.env = { ...originalEnv };
29
+ delete process.env.JIRA_HOST;
30
+ delete process.env.JIRA_API_TOKEN;
31
+ delete process.env.CONFLUENCE_HOST;
32
+ });
33
+
34
+ afterAll(() => {
35
+ process.env = originalEnv;
36
+ });
37
+
38
+ it('maps product+key to the correct env var', () => {
39
+ process.env.JIRA_HOST = 'j-host';
40
+ process.env.JIRA_API_TOKEN = 'j-token';
41
+ process.env.CONFLUENCE_HOST = 'c-host';
42
+ const source = new ProcessEnvSource();
43
+ expect(source.read(JIRA, 'host')).toBe('j-host');
44
+ expect(source.read(JIRA, 'token')).toBe('j-token');
45
+ expect(source.read(CONFLUENCE, 'host')).toBe('c-host');
46
+ });
47
+
48
+ it('returns undefined for absent or whitespace-only values', () => {
49
+ const source = new ProcessEnvSource();
50
+ expect(source.read(JIRA, 'host')).toBeUndefined();
51
+ process.env.JIRA_HOST = ' ';
52
+ expect(source.read(JIRA, 'host')).toBeUndefined();
53
+ });
54
+
55
+ it('trims surrounding whitespace', () => {
56
+ process.env.JIRA_HOST = ' j-host ';
57
+ expect(new ProcessEnvSource().read(JIRA, 'host')).toBe('j-host');
58
+ });
59
+ });
@@ -0,0 +1,8 @@
1
+ export * from './source.js';
2
+ export * from './registry.js';
3
+ export * from './runtime-config.js';
4
+ export * from './resolve-base.js';
5
+ export { ProcessEnvSource } from './sources/process-env.js';
6
+ export { EnvFileSource, ATLASSIAN_DC_MCP_CONFIG_FILE_ENV_VAR } from './sources/env-file.js';
7
+ export { HomeFileSource, getHomeFilePath, HOME_DIR_NAME } from './sources/home-file.js';
8
+ export { MacosKeychainSource, KEYCHAIN_SERVICE } from './sources/macos-keychain.js';