infra-kit 0.1.89 → 0.1.90
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/.eslintcache +1 -1
- package/.turbo/turbo-eslint-check.log +4 -5
- package/.turbo/turbo-prettier-check.log +4 -5
- package/.turbo/turbo-test.log +14 -17
- package/.turbo/turbo-ts-check.log +4 -5
- package/dist/cli.js +31 -30
- package/dist/cli.js.map +4 -4
- package/dist/mcp.js +24 -23
- package/dist/mcp.js.map +4 -4
- package/package.json +4 -4
- package/src/commands/env-clear/env-clear.ts +9 -17
- package/src/commands/env-list/env-list.ts +6 -5
- package/src/commands/env-load/__tests__/env-load.test.ts +132 -0
- package/src/commands/env-load/env-load.ts +95 -14
- package/src/commands/env-status/env-status.ts +8 -0
- package/src/commands/init/init.ts +8 -5
- package/src/commands/worktrees-add/worktrees-add.ts +36 -3
- package/src/entry/cli.ts +21 -2
- package/src/integrations/cmux/index.ts +1 -0
- package/src/integrations/cmux/open-workspace-with-layout.ts +41 -0
- package/src/integrations/doppler/doppler-cli-auth.ts +9 -11
- package/src/lib/__tests__/constants.test.ts +160 -0
- package/src/lib/__tests__/infra-kit-config.test.ts +129 -0
- package/src/lib/constants.ts +50 -11
- package/src/lib/git-utils/git-utils.ts +36 -0
- package/src/lib/git-utils/index.ts +1 -1
- package/src/lib/infra-kit-config.ts +26 -12
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +14 -11
|
@@ -1,25 +1,23 @@
|
|
|
1
|
-
import process from 'node:process'
|
|
2
1
|
import { $ } from 'zx'
|
|
3
2
|
|
|
4
|
-
import { logger } from 'src/lib/logger'
|
|
5
|
-
|
|
6
3
|
/**
|
|
7
|
-
* Validate Doppler CLI installation and authentication status
|
|
4
|
+
* Validate Doppler CLI installation and authentication status. Throws on failure
|
|
5
|
+
* so callers (CLI entry, MCP tool handler) can translate to the right surface —
|
|
6
|
+
* CLI exits non-zero; MCP returns a structured tool error instead of tearing
|
|
7
|
+
* down the server.
|
|
8
8
|
*/
|
|
9
|
-
export const validateDopplerCliAndAuth = async () => {
|
|
9
|
+
export const validateDopplerCliAndAuth = async (): Promise<void> => {
|
|
10
10
|
try {
|
|
11
11
|
await $`doppler --version`
|
|
12
12
|
} catch (error: unknown) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
throw new Error('Doppler CLI is not installed. Install it from: https://docs.doppler.com/docs/install-cli', {
|
|
14
|
+
cause: error,
|
|
15
|
+
})
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
try {
|
|
19
19
|
await $`doppler me`
|
|
20
20
|
} catch (error: unknown) {
|
|
21
|
-
|
|
22
|
-
logger.error('Please authenticate by running: doppler login')
|
|
23
|
-
process.exit(1)
|
|
21
|
+
throw new Error('Doppler CLI is not authenticated. Run: doppler login', { cause: error })
|
|
24
22
|
}
|
|
25
23
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
INFRA_KIT_SESSION_VAR,
|
|
8
|
+
atomicWriteFileSync,
|
|
9
|
+
getCacheRoot,
|
|
10
|
+
getSessionCacheDir,
|
|
11
|
+
parseVarNamesFromEnvFile,
|
|
12
|
+
} from '../constants'
|
|
13
|
+
|
|
14
|
+
const withTmpDir = (fn: (dir: string) => void): void => {
|
|
15
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'infra-kit-test-'))
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
fn(dir)
|
|
19
|
+
} finally {
|
|
20
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('parseVarNamesFromEnvFile', () => {
|
|
25
|
+
it('returns empty array when file does not exist', () => {
|
|
26
|
+
expect(parseVarNamesFromEnvFile('/nonexistent/path/env.sh')).toEqual([])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('extracts var names from a standard env file', () => {
|
|
30
|
+
withTmpDir((dir) => {
|
|
31
|
+
const file = path.join(dir, 'env.sh')
|
|
32
|
+
|
|
33
|
+
fs.writeFileSync(file, 'FOO=bar\nBAZ=qux\n')
|
|
34
|
+
expect(parseVarNamesFromEnvFile(file)).toEqual(['FOO', 'BAZ'])
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('skips `set -a` / `set +a` shell directives', () => {
|
|
39
|
+
withTmpDir((dir) => {
|
|
40
|
+
const file = path.join(dir, 'env.sh')
|
|
41
|
+
|
|
42
|
+
fs.writeFileSync(file, 'set -a\nFOO=bar\nset +a\n')
|
|
43
|
+
expect(parseVarNamesFromEnvFile(file)).toEqual(['FOO'])
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('handles values that contain = characters', () => {
|
|
48
|
+
withTmpDir((dir) => {
|
|
49
|
+
const file = path.join(dir, 'env.sh')
|
|
50
|
+
|
|
51
|
+
fs.writeFileSync(file, 'CONNECTION_STRING=host=db;user=admin\nFOO=bar\n')
|
|
52
|
+
expect(parseVarNamesFromEnvFile(file)).toEqual(['CONNECTION_STRING', 'FOO'])
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('skips blank lines and lines that do not match KEY= prefix', () => {
|
|
57
|
+
withTmpDir((dir) => {
|
|
58
|
+
const file = path.join(dir, 'env.sh')
|
|
59
|
+
|
|
60
|
+
fs.writeFileSync(file, '\n# comment\nFOO=1\n\nunset BAR\nBAZ=2\n')
|
|
61
|
+
expect(parseVarNamesFromEnvFile(file)).toEqual(['FOO', 'BAZ'])
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('accepts numerics and underscores in the name but rejects names starting with digits', () => {
|
|
66
|
+
withTmpDir((dir) => {
|
|
67
|
+
const file = path.join(dir, 'env.sh')
|
|
68
|
+
|
|
69
|
+
fs.writeFileSync(file, 'API_KEY_2=a\n2FOO=b\n_INTERNAL=c\n')
|
|
70
|
+
expect(parseVarNamesFromEnvFile(file)).toEqual(['API_KEY_2', '_INTERNAL'])
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('getSessionCacheDir', () => {
|
|
76
|
+
let originalSession: string | undefined
|
|
77
|
+
let originalXdg: string | undefined
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
originalSession = process.env[INFRA_KIT_SESSION_VAR]
|
|
81
|
+
originalXdg = process.env.XDG_CACHE_HOME
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
if (originalSession === undefined) {
|
|
86
|
+
delete process.env[INFRA_KIT_SESSION_VAR]
|
|
87
|
+
} else {
|
|
88
|
+
process.env[INFRA_KIT_SESSION_VAR] = originalSession
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (originalXdg === undefined) {
|
|
92
|
+
delete process.env.XDG_CACHE_HOME
|
|
93
|
+
} else {
|
|
94
|
+
process.env.XDG_CACHE_HOME = originalXdg
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('throws with actionable message when INFRA_KIT_SESSION is unset', () => {
|
|
99
|
+
delete process.env[INFRA_KIT_SESSION_VAR]
|
|
100
|
+
expect(() => {
|
|
101
|
+
return getSessionCacheDir()
|
|
102
|
+
}).toThrow(/INFRA_KIT_SESSION/)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('uses XDG_CACHE_HOME when set', () => {
|
|
106
|
+
process.env[INFRA_KIT_SESSION_VAR] = 'abc123'
|
|
107
|
+
process.env.XDG_CACHE_HOME = '/custom/cache'
|
|
108
|
+
expect(getSessionCacheDir()).toBe('/custom/cache/infra-kit/abc123')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('falls back to ~/.cache/infra-kit/<session> when XDG_CACHE_HOME is unset', () => {
|
|
112
|
+
process.env[INFRA_KIT_SESSION_VAR] = 'xyz'
|
|
113
|
+
delete process.env.XDG_CACHE_HOME
|
|
114
|
+
expect(getSessionCacheDir()).toBe(path.join(os.homedir(), '.cache', 'infra-kit', 'xyz'))
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('getCacheRoot treats empty XDG_CACHE_HOME as unset', () => {
|
|
118
|
+
process.env.XDG_CACHE_HOME = ''
|
|
119
|
+
expect(getCacheRoot()).toBe(path.join(os.homedir(), '.cache', 'infra-kit'))
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('atomicWriteFileSync', () => {
|
|
124
|
+
it('writes content with the requested mode', () => {
|
|
125
|
+
withTmpDir((dir) => {
|
|
126
|
+
const file = path.join(dir, 'secret.sh')
|
|
127
|
+
|
|
128
|
+
atomicWriteFileSync(file, 'HELLO=world\n', 0o600)
|
|
129
|
+
expect(fs.readFileSync(file, 'utf-8')).toBe('HELLO=world\n')
|
|
130
|
+
|
|
131
|
+
const mode = fs.statSync(file).mode & 0o777
|
|
132
|
+
|
|
133
|
+
expect(mode).toBe(0o600)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('leaves no temp file behind after a successful write', () => {
|
|
138
|
+
withTmpDir((dir) => {
|
|
139
|
+
const file = path.join(dir, 'secret.sh')
|
|
140
|
+
|
|
141
|
+
atomicWriteFileSync(file, 'X=1\n', 0o600)
|
|
142
|
+
|
|
143
|
+
const leftovers = fs.readdirSync(dir).filter((name) => {
|
|
144
|
+
return name.includes('.tmp.')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
expect(leftovers).toEqual([])
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('overwrites an existing file', () => {
|
|
152
|
+
withTmpDir((dir) => {
|
|
153
|
+
const file = path.join(dir, 'secret.sh')
|
|
154
|
+
|
|
155
|
+
fs.writeFileSync(file, 'OLD=1\n', { mode: 0o600 })
|
|
156
|
+
atomicWriteFileSync(file, 'NEW=2\n', 0o600)
|
|
157
|
+
expect(fs.readFileSync(file, 'utf-8')).toBe('NEW=2\n')
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
// Import AFTER the mock is declared so the module picks up the mocked dep.
|
|
7
|
+
import { getProjectRoot } from 'src/lib/git-utils'
|
|
8
|
+
|
|
9
|
+
import { getInfraKitConfig, resetInfraKitConfigCache } from '../infra-kit-config'
|
|
10
|
+
|
|
11
|
+
vi.mock('src/lib/git-utils', () => {
|
|
12
|
+
return {
|
|
13
|
+
getProjectRoot: vi.fn(),
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const VALID_YML = `dopplerProjectName: my-project
|
|
18
|
+
environments:
|
|
19
|
+
- dev
|
|
20
|
+
- staging
|
|
21
|
+
taskManagerProvider: linear
|
|
22
|
+
`
|
|
23
|
+
|
|
24
|
+
const ALTERNATE_YML = `dopplerProjectName: other-project
|
|
25
|
+
environments:
|
|
26
|
+
- dev
|
|
27
|
+
taskManagerProvider: false
|
|
28
|
+
`
|
|
29
|
+
|
|
30
|
+
const withTmpRepo = async (fn: (tmp: string) => Promise<void>): Promise<void> => {
|
|
31
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'infra-kit-config-test-'))
|
|
32
|
+
|
|
33
|
+
vi.mocked(getProjectRoot).mockResolvedValue(tmp)
|
|
34
|
+
resetInfraKitConfigCache()
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
await fn(tmp)
|
|
38
|
+
} finally {
|
|
39
|
+
resetInfraKitConfigCache()
|
|
40
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('getInfraKitConfig', () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
resetInfraKitConfigCache()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
resetInfraKitConfigCache()
|
|
51
|
+
vi.clearAllMocks()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('reads and validates a well-formed infra-kit.yml', async () => {
|
|
55
|
+
await withTmpRepo(async (tmp) => {
|
|
56
|
+
fs.writeFileSync(path.join(tmp, 'infra-kit.yml'), VALID_YML)
|
|
57
|
+
|
|
58
|
+
const cfg = await getInfraKitConfig()
|
|
59
|
+
|
|
60
|
+
expect(cfg.dopplerProjectName).toBe('my-project')
|
|
61
|
+
expect(cfg.environments).toEqual(['dev', 'staging'])
|
|
62
|
+
expect(cfg.taskManagerProvider).toBe('linear')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('accepts taskManagerProvider: false as a literal boolean', async () => {
|
|
67
|
+
await withTmpRepo(async (tmp) => {
|
|
68
|
+
fs.writeFileSync(path.join(tmp, 'infra-kit.yml'), ALTERNATE_YML)
|
|
69
|
+
|
|
70
|
+
const cfg = await getInfraKitConfig()
|
|
71
|
+
|
|
72
|
+
expect(cfg.taskManagerProvider).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('throws when infra-kit.yml is missing', async () => {
|
|
77
|
+
await withTmpRepo(async () => {
|
|
78
|
+
await expect(getInfraKitConfig()).rejects.toThrow(/not found/)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('throws a descriptive error on schema violations', async () => {
|
|
83
|
+
await withTmpRepo(async (tmp) => {
|
|
84
|
+
fs.writeFileSync(
|
|
85
|
+
path.join(tmp, 'infra-kit.yml'),
|
|
86
|
+
'dopplerProjectName: ""\nenvironments: []\ntaskManagerProvider: false\n',
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
await expect(getInfraKitConfig()).rejects.toThrow(/Invalid infra-kit.yml/)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('re-reads the file when mtime changes (long-running MCP scenario)', async () => {
|
|
94
|
+
await withTmpRepo(async (tmp) => {
|
|
95
|
+
const ymlPath = path.join(tmp, 'infra-kit.yml')
|
|
96
|
+
|
|
97
|
+
fs.writeFileSync(ymlPath, VALID_YML)
|
|
98
|
+
|
|
99
|
+
const first = await getInfraKitConfig()
|
|
100
|
+
|
|
101
|
+
expect(first.dopplerProjectName).toBe('my-project')
|
|
102
|
+
|
|
103
|
+
// Advance mtime past the previous stat to simulate an edit; write new content.
|
|
104
|
+
const future = new Date(Date.now() + 2_000)
|
|
105
|
+
|
|
106
|
+
fs.writeFileSync(ymlPath, ALTERNATE_YML)
|
|
107
|
+
fs.utimesSync(ymlPath, future, future)
|
|
108
|
+
|
|
109
|
+
const second = await getInfraKitConfig()
|
|
110
|
+
|
|
111
|
+
expect(second.dopplerProjectName).toBe('other-project')
|
|
112
|
+
expect(second.environments).toEqual(['dev'])
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('returns the cached value on repeated calls when mtime is unchanged', async () => {
|
|
117
|
+
await withTmpRepo(async (tmp) => {
|
|
118
|
+
const ymlPath = path.join(tmp, 'infra-kit.yml')
|
|
119
|
+
|
|
120
|
+
fs.writeFileSync(ymlPath, VALID_YML)
|
|
121
|
+
|
|
122
|
+
const a = await getInfraKitConfig()
|
|
123
|
+
const b = await getInfraKitConfig()
|
|
124
|
+
|
|
125
|
+
// Same object reference — no re-parse.
|
|
126
|
+
expect(a).toBe(b)
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
})
|
package/src/lib/constants.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
2
3
|
import path from 'node:path'
|
|
3
4
|
import process from 'node:process'
|
|
4
5
|
|
|
5
|
-
export const ENV_CACHE_DIR = './node_modules/.cache/infra-kit'
|
|
6
6
|
export const ENV_LOAD_FILE = 'env-load.sh'
|
|
7
7
|
export const ENV_CLEAR_FILE = 'env-clear.sh'
|
|
8
8
|
|
|
@@ -11,29 +11,68 @@ export const INFRA_KIT_ENV_CONFIG_VAR = 'INFRA_KIT_ENV_CONFIG'
|
|
|
11
11
|
export const INFRA_KIT_ENV_PROJECT_VAR = 'INFRA_KIT_ENV_PROJECT'
|
|
12
12
|
export const INFRA_KIT_ENV_LOADED_AT_VAR = 'INFRA_KIT_ENV_LOADED_AT'
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Matches a line of the form `KEY=...` where KEY is an env-var identifier
|
|
16
|
+
* (letter or underscore, then word chars). Capture group 1 is the name. Shared
|
|
17
|
+
* between env-load (validation, var counting) and parseVarNamesFromEnvFile.
|
|
18
|
+
*/
|
|
19
|
+
export const ENV_VAR_LINE_PATTERN = /^([A-Z_]\w*)=/i
|
|
20
|
+
|
|
14
21
|
export const parseVarNamesFromEnvFile = (filePath: string): string[] => {
|
|
15
22
|
if (!fs.existsSync(filePath)) return []
|
|
16
23
|
|
|
17
24
|
const content = fs.readFileSync(filePath, 'utf-8')
|
|
25
|
+
const names: string[] = []
|
|
26
|
+
|
|
27
|
+
for (const line of content.split('\n')) {
|
|
28
|
+
const match = ENV_VAR_LINE_PATTERN.exec(line)
|
|
29
|
+
|
|
30
|
+
if (match) {
|
|
31
|
+
names.push(match[1]!)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return names
|
|
36
|
+
}
|
|
18
37
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Root cache dir for infra-kit across all sessions. Resolved from
|
|
40
|
+
* $XDG_CACHE_HOME when set, falling back to ~/.cache/infra-kit. Keep in sync
|
|
41
|
+
* with the shell block emitted by `infra-kit init` (src/commands/init/init.ts).
|
|
42
|
+
*/
|
|
43
|
+
export const getCacheRoot = (): string => {
|
|
44
|
+
const xdg = process.env.XDG_CACHE_HOME
|
|
45
|
+
const base = xdg && xdg.length > 0 ? xdg : path.join(os.homedir(), '.cache')
|
|
46
|
+
|
|
47
|
+
return path.join(base, 'infra-kit')
|
|
27
48
|
}
|
|
28
49
|
|
|
29
50
|
export const getSessionCacheDir = (): string => {
|
|
30
51
|
const session = process.env[INFRA_KIT_SESSION_VAR]
|
|
31
52
|
|
|
32
53
|
if (!session) {
|
|
33
|
-
throw new Error(
|
|
54
|
+
throw new Error(`${INFRA_KIT_SESSION_VAR} is not set. Run \`infra-kit init\` then \`source ~/.zshrc\`.`)
|
|
34
55
|
}
|
|
35
56
|
|
|
36
|
-
return path.join(
|
|
57
|
+
return path.join(getCacheRoot(), session)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Write content atomically: write to a pid-suffixed temp file in the same
|
|
62
|
+
* directory, then rename. fs.renameSync is atomic on a single filesystem, so
|
|
63
|
+
* concurrent writers can't produce a half-written secret file.
|
|
64
|
+
*/
|
|
65
|
+
export const atomicWriteFileSync = (filePath: string, content: string, mode: number): void => {
|
|
66
|
+
const tmpPath = `${filePath}.tmp.${process.pid}`
|
|
67
|
+
|
|
68
|
+
fs.writeFileSync(tmpPath, content, { mode })
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
fs.renameSync(tmpPath, filePath)
|
|
72
|
+
} catch (error) {
|
|
73
|
+
fs.rmSync(tmpPath, { force: true })
|
|
74
|
+
throw error
|
|
75
|
+
}
|
|
37
76
|
}
|
|
38
77
|
|
|
39
78
|
export const WORKTREES_DIR_SUFFIX = '-worktrees'
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
1
2
|
import { $ } from 'zx'
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -20,6 +21,19 @@ export const getCurrentWorktrees = async (type: 'release' | 'feature'): Promise<
|
|
|
20
21
|
})
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Extract a release branch name from a `git worktree list` output line.
|
|
26
|
+
*
|
|
27
|
+
* Returns `null` for lines that are not release worktrees.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* releaseWorktreePredicate('/path/to/release/v1.18.22 abc1234 [release/v1.18.22]')
|
|
31
|
+
* // => 'release/v1.18.22'
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* releaseWorktreePredicate('/path/to/feature/login abc1234 [feature/login]')
|
|
35
|
+
* // => null
|
|
36
|
+
*/
|
|
23
37
|
const releaseWorktreePredicate = (line: string): string | null => {
|
|
24
38
|
const parts = line.split(' ').filter(Boolean)
|
|
25
39
|
|
|
@@ -28,6 +42,19 @@ const releaseWorktreePredicate = (line: string): string | null => {
|
|
|
28
42
|
return `release/${parts[0]?.split('/').pop() || ''}`
|
|
29
43
|
}
|
|
30
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Extract a feature branch name from a `git worktree list` output line.
|
|
47
|
+
*
|
|
48
|
+
* Returns `null` for lines that are not feature worktrees.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* featureWorktreePredicate('/path/to/feature/login-page abc1234 [feature/login-page]')
|
|
52
|
+
* // => 'feature/login-page'
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* featureWorktreePredicate('/path/to/release/v1.18.22 abc1234 [release/v1.18.22]')
|
|
56
|
+
* // => null
|
|
57
|
+
*/
|
|
31
58
|
const featureWorktreePredicate = (line: string): string | null => {
|
|
32
59
|
const parts = line.split(' ').filter(Boolean)
|
|
33
60
|
|
|
@@ -44,3 +71,12 @@ export const getProjectRoot = async (): Promise<string> => {
|
|
|
44
71
|
|
|
45
72
|
return result.stdout.trim()
|
|
46
73
|
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get the current repository name (basename of the project root)
|
|
77
|
+
*/
|
|
78
|
+
export const getRepoName = async (): Promise<string> => {
|
|
79
|
+
const projectRoot = await getProjectRoot()
|
|
80
|
+
|
|
81
|
+
return path.basename(projectRoot)
|
|
82
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { getCurrentWorktrees, getProjectRoot } from './git-utils'
|
|
1
|
+
export { getCurrentWorktrees, getProjectRoot, getRepoName } from './git-utils'
|
|
@@ -15,29 +15,36 @@ const infraKitConfigSchema = z.object({
|
|
|
15
15
|
|
|
16
16
|
export type InfraKitConfig = z.infer<typeof infraKitConfigSchema>
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (!cached) {
|
|
22
|
-
cached = loadInfraKitConfig()
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
return cached
|
|
18
|
+
interface CacheEntry {
|
|
19
|
+
mtimeMs: number
|
|
20
|
+
value: InfraKitConfig
|
|
26
21
|
}
|
|
27
22
|
|
|
28
|
-
|
|
29
|
-
const projectRoot = await getProjectRoot()
|
|
23
|
+
let cached: CacheEntry | null = null
|
|
30
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Read and validate infra-kit.yml. Results are cached per file mtime so the
|
|
27
|
+
* long-running MCP server picks up edits without a restart — if the user edits
|
|
28
|
+
* infra-kit.yml mid-session, the next call re-reads it.
|
|
29
|
+
*/
|
|
30
|
+
export const getInfraKitConfig = async (): Promise<InfraKitConfig> => {
|
|
31
|
+
const projectRoot = await getProjectRoot()
|
|
31
32
|
const configPath = path.join(projectRoot, INFRA_KIT_CONFIG_FILE)
|
|
32
33
|
|
|
33
|
-
let
|
|
34
|
+
let stat: Awaited<ReturnType<typeof fs.stat>>
|
|
34
35
|
|
|
35
36
|
try {
|
|
36
|
-
|
|
37
|
+
stat = await fs.stat(configPath)
|
|
37
38
|
} catch {
|
|
39
|
+
cached = null
|
|
38
40
|
throw new Error(`infra-kit.yml not found at ${configPath}`)
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
if (cached && cached.mtimeMs === stat.mtimeMs) {
|
|
44
|
+
return cached.value
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const raw = await fs.readFile(configPath, 'utf-8')
|
|
41
48
|
const parsed = yaml.parse(raw)
|
|
42
49
|
const result = infraKitConfigSchema.safeParse(parsed)
|
|
43
50
|
|
|
@@ -45,5 +52,12 @@ const loadInfraKitConfig = async (): Promise<InfraKitConfig> => {
|
|
|
45
52
|
throw new Error(`Invalid infra-kit.yml at ${configPath}: ${result.error.message}`)
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
cached = { mtimeMs: stat.mtimeMs, value: result.data }
|
|
56
|
+
|
|
48
57
|
return result.data
|
|
49
58
|
}
|
|
59
|
+
|
|
60
|
+
/** For tests — drops the in-memory cache. */
|
|
61
|
+
export const resetInfraKitConfigCache = (): void => {
|
|
62
|
+
cached = null
|
|
63
|
+
}
|