infra-kit 0.1.93 → 0.1.95

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 (45) hide show
  1. package/.eslintcache +1 -1
  2. package/.turbo/turbo-eslint-check.log +4 -5
  3. package/.turbo/turbo-prettier-check.log +7 -5
  4. package/.turbo/turbo-test.log +82 -18
  5. package/.turbo/turbo-ts-check.log +10 -5
  6. package/dist/cli.js +37 -34
  7. package/dist/cli.js.map +4 -4
  8. package/dist/mcp.js +30 -27
  9. package/dist/mcp.js.map +4 -4
  10. package/package.json +2 -2
  11. package/src/commands/doctor/doctor.ts +119 -1
  12. package/src/commands/env-clear/env-clear.ts +1 -1
  13. package/src/commands/env-list/env-list.ts +1 -1
  14. package/src/commands/env-load/env-load.ts +1 -1
  15. package/src/commands/env-status/env-status.ts +1 -1
  16. package/src/commands/gh-merge-dev/gh-merge-dev.ts +1 -1
  17. package/src/commands/gh-release-deliver/gh-release-deliver.ts +1 -1
  18. package/src/commands/gh-release-deploy-all/gh-release-deploy-all.ts +1 -1
  19. package/src/commands/gh-release-deploy-selected/gh-release-deploy-selected.ts +1 -1
  20. package/src/commands/gh-release-list/gh-release-list.ts +1 -1
  21. package/src/commands/init/init.ts +3 -3
  22. package/src/commands/release-create/release-create.ts +1 -1
  23. package/src/commands/release-create-batch/release-create-batch.ts +1 -1
  24. package/src/commands/version/version.ts +1 -1
  25. package/src/commands/worktrees-add/index.ts +2 -1
  26. package/src/commands/worktrees-add/worktrees-add.ts +55 -14
  27. package/src/commands/worktrees-list/worktrees-list.ts +1 -1
  28. package/src/commands/worktrees-remove/worktrees-remove.ts +68 -4
  29. package/src/commands/worktrees-sync/worktrees-sync.ts +69 -5
  30. package/src/entry/cli.ts +25 -4
  31. package/src/integrations/clickup/.gitkeep +0 -0
  32. package/src/integrations/cmux/close-workspace-by-title.ts +51 -0
  33. package/src/integrations/cmux/index.ts +2 -0
  34. package/src/integrations/cmux/workspace-title.ts +17 -0
  35. package/src/integrations/cursor/add-folders-to-workspace.ts +84 -0
  36. package/src/integrations/cursor/index.ts +3 -0
  37. package/src/integrations/cursor/remove-folders-from-workspace.ts +93 -0
  38. package/src/integrations/cursor/resolve-workspace-path.ts +13 -0
  39. package/src/integrations/doppler/doppler-project.ts +2 -2
  40. package/src/lib/__tests__/infra-kit-config.test.ts +64 -14
  41. package/src/lib/infra-kit-config/index.ts +2 -0
  42. package/src/lib/infra-kit-config/infra-kit-config.ts +143 -0
  43. package/tsconfig.json +3 -2
  44. package/tsconfig.tsbuildinfo +1 -1
  45. package/src/lib/infra-kit-config.ts +0 -69
package/src/entry/cli.ts CHANGED
@@ -16,7 +16,8 @@ import { init } from 'src/commands/init'
16
16
  import { releaseCreate } from 'src/commands/release-create'
17
17
  import { releaseCreateBatch } from 'src/commands/release-create-batch'
18
18
  import { version } from 'src/commands/version'
19
- import { worktreesAdd } from 'src/commands/worktrees-add'
19
+ import { CURSOR_MODES, worktreesAdd } from 'src/commands/worktrees-add'
20
+ import type { CursorMode } from 'src/commands/worktrees-add'
20
21
  import { worktreesList } from 'src/commands/worktrees-list'
21
22
  import { worktreesRemove } from 'src/commands/worktrees-remove'
22
23
  import { worktreesSync } from 'src/commands/worktrees-sync'
@@ -24,6 +25,26 @@ import { logger } from 'src/lib/logger'
24
25
 
25
26
  const program = new Command()
26
27
 
28
+ const normalizeCursorMode = (value: unknown): CursorMode | undefined => {
29
+ if (typeof value === 'undefined') {
30
+ return undefined
31
+ }
32
+
33
+ if (value === true) {
34
+ return 'workspace'
35
+ }
36
+
37
+ if (value === false) {
38
+ return 'none'
39
+ }
40
+
41
+ if (typeof value === 'string' && (CURSOR_MODES as readonly string[]).includes(value)) {
42
+ return value as CursorMode
43
+ }
44
+
45
+ throw new Error(`Invalid --cursor value "${String(value)}". Expected one of: ${CURSOR_MODES.join(', ')}.`)
46
+ }
47
+
27
48
  const runProgram = async (argv?: string[]): Promise<void> => {
28
49
  try {
29
50
  if (argv) {
@@ -136,8 +157,8 @@ program
136
157
  .option('-y, --yes', 'Skip confirmation prompt')
137
158
  .option('-a, --all', 'Select all active release branches')
138
159
  .option('-v, --versions <versions>', 'Specify versions by comma, e.g. 1.2.5, 1.2.6')
139
- .option('-c, --cursor', 'Open created worktrees in Cursor')
140
- .option('--no-cursor', 'Skip Cursor prompt')
160
+ .option('-c, --cursor [mode]', 'Cursor mode for created worktrees: workspace (default) | windows | none')
161
+ .option('--no-cursor', 'Skip Cursor (alias for --cursor none)')
141
162
  .option('-g, --github-desktop', 'Open created worktrees in GitHub Desktop')
142
163
  .option('--no-github-desktop', 'Skip GitHub Desktop prompt')
143
164
  .option('-m, --cmux', 'Open created worktrees in cmux (3-pane layout)')
@@ -147,7 +168,7 @@ program
147
168
  confirmedCommand: options.yes,
148
169
  all: options.all,
149
170
  versions: options.versions,
150
- cursor: options.cursor,
171
+ cursor: normalizeCursorMode(options.cursor),
151
172
  githubDesktop: options.githubDesktop,
152
173
  cmux: options.cmux,
153
174
  })
File without changes
@@ -0,0 +1,51 @@
1
+ import { $ } from 'zx'
2
+
3
+ import { logger } from 'src/lib/logger'
4
+
5
+ /**
6
+ * Best-effort close of the cmux workspace whose title exactly matches `title`.
7
+ * Silently no-ops if cmux isn't running, the workspace isn't found, or close fails.
8
+ */
9
+ export const closeCmuxWorkspaceByTitle = async (title: string): Promise<void> => {
10
+ try {
11
+ const listOutput = (await $`cmux list-workspaces`.quiet()).stdout
12
+
13
+ const ref = findWorkspaceRefByTitle(listOutput, title)
14
+
15
+ if (!ref) {
16
+ return
17
+ }
18
+
19
+ await $`cmux close-workspace --workspace ${ref}`.quiet()
20
+ } catch (error) {
21
+ logger.debug({ error, title }, 'cmux: skipped closing workspace')
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Parses `cmux list-workspaces` output and returns the workspace ref whose
27
+ * title exactly matches `title`, or undefined if no match.
28
+ *
29
+ * Each line looks like:
30
+ * " workspace:8 hulyo-monorepo v1.48.0"
31
+ * "* workspace:6 obsidian-workspace [selected]"
32
+ */
33
+ const findWorkspaceRefByTitle = (output: string, title: string): string | undefined => {
34
+ for (const rawLine of output.split('\n')) {
35
+ // eslint-disable-next-line sonarjs/slow-regex, regexp/no-super-linear-backtracking
36
+ const match = rawLine.match(/^[* ]\s*(workspace:\d+)\s+(.+?)(?:\s+\[selected\])?\s*$/)
37
+
38
+ if (!match) {
39
+ continue
40
+ }
41
+
42
+ const ref = match[1]
43
+ const lineTitle = match[2]?.trim() ?? ''
44
+
45
+ if (lineTitle === title) {
46
+ return ref
47
+ }
48
+ }
49
+
50
+ return undefined
51
+ }
@@ -1 +1,3 @@
1
+ export { closeCmuxWorkspaceByTitle } from './close-workspace-by-title'
1
2
  export { openCmuxWorkspaceWithLayout } from './open-workspace-with-layout'
3
+ export { buildCmuxWorkspaceTitle } from './workspace-title'
@@ -0,0 +1,17 @@
1
+ interface BuildCmuxWorkspaceTitleArgs {
2
+ repoName: string
3
+ branch: string
4
+ }
5
+
6
+ /**
7
+ * Builds the cmux workspace title used by `worktrees-add` and looked up by
8
+ * `worktrees-remove`. The `release/` prefix is stripped so the title reads
9
+ * e.g. `"hulyo-monorepo v1.48.0"` for branch `"release/v1.48.0"`.
10
+ */
11
+ export const buildCmuxWorkspaceTitle = (args: BuildCmuxWorkspaceTitleArgs): string => {
12
+ const { repoName, branch } = args
13
+
14
+ const version = branch.replace('release/', '')
15
+
16
+ return `${repoName} ${version}`
17
+ }
@@ -0,0 +1,84 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ interface AddFoldersToCursorWorkspaceArgs {
5
+ workspacePath: string
6
+ folderPaths: string[]
7
+ }
8
+
9
+ interface AddFoldersToCursorWorkspaceResult {
10
+ added: string[]
11
+ skipped: string[]
12
+ }
13
+
14
+ interface WorkspaceFolderEntry {
15
+ path: string
16
+ name?: string
17
+ }
18
+
19
+ interface WorkspaceFile {
20
+ folders?: WorkspaceFolderEntry[]
21
+ [key: string]: unknown
22
+ }
23
+
24
+ /**
25
+ * Adds folders to a Cursor (`.code-workspace`) file's `folders` array, skipping
26
+ * entries that already point to the same absolute path. Folder paths are written
27
+ * as relative to the workspace file's directory to match Cursor's default style.
28
+ */
29
+ export const addFoldersToCursorWorkspace = async (
30
+ args: AddFoldersToCursorWorkspaceArgs,
31
+ ): Promise<AddFoldersToCursorWorkspaceResult> => {
32
+ const { workspacePath, folderPaths } = args
33
+
34
+ const workspaceDir = path.dirname(workspacePath)
35
+
36
+ let raw: string
37
+
38
+ try {
39
+ raw = await fs.readFile(workspacePath, 'utf-8')
40
+ } catch (error) {
41
+ throw new Error(`Cursor workspace file not found at ${workspacePath}: ${(error as Error).message}`)
42
+ }
43
+
44
+ let parsed: WorkspaceFile
45
+
46
+ try {
47
+ parsed = JSON.parse(raw) as WorkspaceFile
48
+ } catch (error) {
49
+ throw new Error(
50
+ `Failed to parse ${workspacePath} as JSON. Comments (JSONC) are not supported. ${(error as Error).message}`,
51
+ )
52
+ }
53
+
54
+ const existingFolders = parsed.folders ?? []
55
+ const existingAbsolutePaths = new Set(
56
+ existingFolders.map((entry) => {
57
+ return path.resolve(workspaceDir, entry.path)
58
+ }),
59
+ )
60
+
61
+ const added: string[] = []
62
+ const skipped: string[] = []
63
+
64
+ for (const folderPath of folderPaths) {
65
+ const absolutePath = path.resolve(folderPath)
66
+
67
+ if (existingAbsolutePaths.has(absolutePath)) {
68
+ skipped.push(folderPath)
69
+ continue
70
+ }
71
+
72
+ const relativePath = path.relative(workspaceDir, absolutePath)
73
+
74
+ existingFolders.push({ path: relativePath })
75
+ existingAbsolutePaths.add(absolutePath)
76
+ added.push(folderPath)
77
+ }
78
+
79
+ parsed.folders = existingFolders
80
+
81
+ await fs.writeFile(workspacePath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf-8')
82
+
83
+ return { added, skipped }
84
+ }
@@ -0,0 +1,3 @@
1
+ export { addFoldersToCursorWorkspace } from './add-folders-to-workspace'
2
+ export { removeFoldersFromCursorWorkspace } from './remove-folders-from-workspace'
3
+ export { resolveCursorWorkspacePath } from './resolve-workspace-path'
@@ -0,0 +1,93 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ interface RemoveFoldersFromCursorWorkspaceArgs {
5
+ workspacePath: string
6
+ folderPaths: string[]
7
+ }
8
+
9
+ interface RemoveFoldersFromCursorWorkspaceResult {
10
+ removed: string[]
11
+ notFound: string[]
12
+ }
13
+
14
+ interface WorkspaceFolderEntry {
15
+ path: string
16
+ name?: string
17
+ }
18
+
19
+ interface WorkspaceFile {
20
+ folders?: WorkspaceFolderEntry[]
21
+ [key: string]: unknown
22
+ }
23
+
24
+ /**
25
+ * Removes folders from a Cursor (`.code-workspace`) file's `folders` array. Entries
26
+ * are matched by resolved absolute path, so relative and absolute entries pointing
27
+ * at the same folder are both removed.
28
+ */
29
+ export const removeFoldersFromCursorWorkspace = async (
30
+ args: RemoveFoldersFromCursorWorkspaceArgs,
31
+ ): Promise<RemoveFoldersFromCursorWorkspaceResult> => {
32
+ const { workspacePath, folderPaths } = args
33
+
34
+ const workspaceDir = path.dirname(workspacePath)
35
+
36
+ let raw: string
37
+
38
+ try {
39
+ raw = await fs.readFile(workspacePath, 'utf-8')
40
+ } catch (error) {
41
+ throw new Error(`Cursor workspace file not found at ${workspacePath}: ${(error as Error).message}`)
42
+ }
43
+
44
+ let parsed: WorkspaceFile
45
+
46
+ try {
47
+ parsed = JSON.parse(raw) as WorkspaceFile
48
+ } catch (error) {
49
+ throw new Error(
50
+ `Failed to parse ${workspacePath} as JSON. Comments (JSONC) are not supported. ${(error as Error).message}`,
51
+ )
52
+ }
53
+
54
+ const existingFolders = parsed.folders ?? []
55
+ const targetAbsolutePaths = new Set(
56
+ folderPaths.map((folderPath) => {
57
+ return path.resolve(folderPath)
58
+ }),
59
+ )
60
+
61
+ const removedAbsolutePaths = new Set<string>()
62
+
63
+ const filteredFolders = existingFolders.filter((entry) => {
64
+ const entryAbsolutePath = path.resolve(workspaceDir, entry.path)
65
+
66
+ if (targetAbsolutePaths.has(entryAbsolutePath)) {
67
+ removedAbsolutePaths.add(entryAbsolutePath)
68
+
69
+ return false
70
+ }
71
+
72
+ return true
73
+ })
74
+
75
+ parsed.folders = filteredFolders
76
+
77
+ await fs.writeFile(workspacePath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf-8')
78
+
79
+ const removed: string[] = []
80
+ const notFound: string[] = []
81
+
82
+ for (const folderPath of folderPaths) {
83
+ const absolutePath = path.resolve(folderPath)
84
+
85
+ if (removedAbsolutePaths.has(absolutePath)) {
86
+ removed.push(folderPath)
87
+ } else {
88
+ notFound.push(folderPath)
89
+ }
90
+ }
91
+
92
+ return { removed, notFound }
93
+ }
@@ -0,0 +1,13 @@
1
+ import path from 'node:path'
2
+
3
+ /**
4
+ * Resolves the configured Cursor workspace path against the project root.
5
+ * Absolute paths are returned unchanged.
6
+ */
7
+ export const resolveCursorWorkspacePath = (configValue: string, projectRoot: string): string => {
8
+ if (path.isAbsolute(configValue)) {
9
+ return configValue
10
+ }
11
+
12
+ return path.resolve(projectRoot, configValue)
13
+ }
@@ -4,7 +4,7 @@ import { getInfraKitConfig } from 'src/lib/infra-kit-config'
4
4
  * Resolve Doppler project name from infra-kit.yml at the project root
5
5
  */
6
6
  export const getDopplerProject = async (): Promise<string> => {
7
- const { dopplerProjectName } = await getInfraKitConfig()
7
+ const { envManagement } = await getInfraKitConfig()
8
8
 
9
- return dopplerProjectName
9
+ return envManagement.config.name
10
10
  }
@@ -14,17 +14,21 @@ vi.mock('src/lib/git-utils', () => {
14
14
  }
15
15
  })
16
16
 
17
- const VALID_YML = `dopplerProjectName: my-project
18
- environments:
17
+ const VALID_YML = `environments:
19
18
  - dev
20
19
  - staging
21
- taskManagerProvider: linear
20
+ envManagement:
21
+ provider: doppler
22
+ config:
23
+ name: my-project
22
24
  `
23
25
 
24
- const ALTERNATE_YML = `dopplerProjectName: other-project
25
- environments:
26
+ const ALTERNATE_YML = `environments:
26
27
  - dev
27
- taskManagerProvider: false
28
+ envManagement:
29
+ provider: doppler
30
+ config:
31
+ name: other-project
28
32
  `
29
33
 
30
34
  const withTmpRepo = async (fn: (tmp: string) => Promise<void>): Promise<void> => {
@@ -57,19 +61,65 @@ describe('getInfraKitConfig', () => {
57
61
 
58
62
  const cfg = await getInfraKitConfig()
59
63
 
60
- expect(cfg.dopplerProjectName).toBe('my-project')
64
+ expect(cfg.envManagement.config.name).toBe('my-project')
61
65
  expect(cfg.environments).toEqual(['dev', 'staging'])
62
- expect(cfg.taskManagerProvider).toBe('linear')
66
+ expect(cfg.taskManager).toBeUndefined()
67
+ expect(cfg.ide).toBeUndefined()
63
68
  })
64
69
  })
65
70
 
66
- it('accepts taskManagerProvider: false as a literal boolean', async () => {
71
+ it('accepts ide and taskManager when provided', async () => {
67
72
  await withTmpRepo(async (tmp) => {
68
- fs.writeFileSync(path.join(tmp, 'infra-kit.yml'), ALTERNATE_YML)
73
+ fs.writeFileSync(
74
+ path.join(tmp, 'infra-kit.yml'),
75
+ `environments: [dev]
76
+ envManagement:
77
+ provider: doppler
78
+ config:
79
+ name: p
80
+ ide:
81
+ provider: cursor
82
+ config:
83
+ mode: workspace
84
+ workspaceConfigPath: ./ws.code-workspace
85
+ taskManager:
86
+ provider: jira
87
+ config:
88
+ baseUrl: https://example.atlassian.net
89
+ projectId: 123
90
+ `,
91
+ )
69
92
 
70
93
  const cfg = await getInfraKitConfig()
71
94
 
72
- expect(cfg.taskManagerProvider).toBe(false)
95
+ expect(cfg.ide?.provider).toBe('cursor')
96
+
97
+ if (cfg.ide?.provider === 'cursor') {
98
+ expect(cfg.ide.config.mode).toBe('workspace')
99
+ expect(cfg.ide.config.workspaceConfigPath).toBe('./ws.code-workspace')
100
+ }
101
+
102
+ expect(cfg.taskManager?.provider).toBe('jira')
103
+ })
104
+ })
105
+
106
+ it('rejects ide.cursor mode=workspace without workspaceConfigPath', async () => {
107
+ await withTmpRepo(async (tmp) => {
108
+ fs.writeFileSync(
109
+ path.join(tmp, 'infra-kit.yml'),
110
+ `environments: [dev]
111
+ envManagement:
112
+ provider: doppler
113
+ config:
114
+ name: p
115
+ ide:
116
+ provider: cursor
117
+ config:
118
+ mode: workspace
119
+ `,
120
+ )
121
+
122
+ await expect(getInfraKitConfig()).rejects.toThrow(/workspaceConfigPath/)
73
123
  })
74
124
  })
75
125
 
@@ -83,7 +133,7 @@ describe('getInfraKitConfig', () => {
83
133
  await withTmpRepo(async (tmp) => {
84
134
  fs.writeFileSync(
85
135
  path.join(tmp, 'infra-kit.yml'),
86
- 'dopplerProjectName: ""\nenvironments: []\ntaskManagerProvider: false\n',
136
+ 'environments: []\nenvManagement:\n provider: doppler\n config:\n name: ""\n',
87
137
  )
88
138
 
89
139
  await expect(getInfraKitConfig()).rejects.toThrow(/Invalid infra-kit.yml/)
@@ -98,7 +148,7 @@ describe('getInfraKitConfig', () => {
98
148
 
99
149
  const first = await getInfraKitConfig()
100
150
 
101
- expect(first.dopplerProjectName).toBe('my-project')
151
+ expect(first.envManagement.config.name).toBe('my-project')
102
152
 
103
153
  // Advance mtime past the previous stat to simulate an edit; write new content.
104
154
  const future = new Date(Date.now() + 2_000)
@@ -108,7 +158,7 @@ describe('getInfraKitConfig', () => {
108
158
 
109
159
  const second = await getInfraKitConfig()
110
160
 
111
- expect(second.dopplerProjectName).toBe('other-project')
161
+ expect(second.envManagement.config.name).toBe('other-project')
112
162
  expect(second.environments).toEqual(['dev'])
113
163
  })
114
164
  })
@@ -0,0 +1,2 @@
1
+ export { getInfraKitConfig, resetInfraKitConfigCache } from './infra-kit-config'
2
+ export type { InfraKitConfig } from './infra-kit-config'
@@ -0,0 +1,143 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import yaml from 'yaml'
4
+ import { z } from 'zod/v4'
5
+
6
+ import { getProjectRoot } from 'src/lib/git-utils'
7
+
8
+ const INFRA_KIT_CONFIG_FILE = 'infra-kit.yml'
9
+ const INFRA_KIT_LOCAL_CONFIG_FILE = 'infra-kit.local.yml'
10
+
11
+ // envManagement
12
+ const dopplerEnvManagementSchema = z.object({
13
+ provider: z.literal('doppler'),
14
+ config: z.object({
15
+ name: z.string().min(1),
16
+ }),
17
+ })
18
+
19
+ const envManagementSchema = z.discriminatedUnion('provider', [dopplerEnvManagementSchema])
20
+
21
+ // ide
22
+ const cursorIdeConfigSchema = z
23
+ .object({
24
+ mode: z.enum(['workspace', 'windows']).default('workspace'),
25
+ workspaceConfigPath: z.string().min(1).optional(),
26
+ })
27
+ .refine(
28
+ (v) => {
29
+ return v.mode !== 'workspace' || !!v.workspaceConfigPath
30
+ },
31
+ {
32
+ message: 'workspaceConfigPath is required when mode is "workspace"',
33
+ path: ['workspaceConfigPath'],
34
+ },
35
+ )
36
+
37
+ const cursorIdeSchema = z.object({
38
+ provider: z.literal('cursor'),
39
+ config: cursorIdeConfigSchema,
40
+ })
41
+
42
+ const ideSchema = z.discriminatedUnion('provider', [cursorIdeSchema])
43
+
44
+ // taskManager
45
+ const jiraTaskManagerSchema = z.object({
46
+ provider: z.literal('jira'),
47
+ config: z.object({
48
+ baseUrl: z.string().url(),
49
+ projectId: z.number().int().positive(),
50
+ }),
51
+ })
52
+
53
+ const taskManagerSchema = z.discriminatedUnion('provider', [jiraTaskManagerSchema])
54
+
55
+ const infraKitConfigSchema = z.object({
56
+ environments: z.array(z.string().min(1)).min(1),
57
+ envManagement: envManagementSchema,
58
+ ide: ideSchema.optional(),
59
+ taskManager: taskManagerSchema.optional(),
60
+ })
61
+
62
+ const infraKitLocalConfigSchema = infraKitConfigSchema.partial()
63
+
64
+ export type InfraKitConfig = z.infer<typeof infraKitConfigSchema>
65
+
66
+ interface CacheEntry {
67
+ mainMtimeMs: number
68
+ localMtimeMs: number | null
69
+ value: InfraKitConfig
70
+ }
71
+
72
+ let cached: CacheEntry | null = null
73
+
74
+ /**
75
+ * Read and validate `infra-kit.yml`, with optional `infra-kit.local.yml` overrides
76
+ * shallow-merged on top (per-developer, gitignored). Top-level keys (entire
77
+ * capability sections like `ide`, `envManagement`) replace wholesale. Results are
78
+ * cached per file mtimes so the long-running MCP server picks up edits without a
79
+ * restart.
80
+ */
81
+ export const getInfraKitConfig = async (): Promise<InfraKitConfig> => {
82
+ const projectRoot = await getProjectRoot()
83
+ const mainPath = path.join(projectRoot, INFRA_KIT_CONFIG_FILE)
84
+ const localPath = path.join(projectRoot, INFRA_KIT_LOCAL_CONFIG_FILE)
85
+
86
+ let mainStat: Awaited<ReturnType<typeof fs.stat>>
87
+
88
+ try {
89
+ mainStat = await fs.stat(mainPath)
90
+ } catch {
91
+ cached = null
92
+ throw new Error(`infra-kit.yml not found at ${mainPath}`)
93
+ }
94
+
95
+ const localStat = await statIfExists(localPath)
96
+ const mainMtimeMs = Number(mainStat.mtimeMs)
97
+ const localMtimeMs = localStat ? Number(localStat.mtimeMs) : null
98
+
99
+ if (cached && cached.mainMtimeMs === mainMtimeMs && cached.localMtimeMs === localMtimeMs) {
100
+ return cached.value
101
+ }
102
+
103
+ const mainRaw = await fs.readFile(mainPath, 'utf-8')
104
+ const mainParsed = yaml.parse(mainRaw)
105
+
106
+ let merged: unknown = mainParsed
107
+
108
+ if (localStat) {
109
+ const localRaw = await fs.readFile(localPath, 'utf-8')
110
+ const localParsedRaw = yaml.parse(localRaw) ?? {}
111
+
112
+ const localResult = infraKitLocalConfigSchema.safeParse(localParsedRaw)
113
+
114
+ if (!localResult.success) {
115
+ throw new Error(`Invalid infra-kit.local.yml at ${localPath}: ${z.prettifyError(localResult.error)}`)
116
+ }
117
+
118
+ merged = { ...(mainParsed as object), ...localResult.data }
119
+ }
120
+
121
+ const result = infraKitConfigSchema.safeParse(merged)
122
+
123
+ if (!result.success) {
124
+ throw new Error(`Invalid infra-kit.yml at ${mainPath}: ${z.prettifyError(result.error)}`)
125
+ }
126
+
127
+ cached = { mainMtimeMs, localMtimeMs, value: result.data }
128
+
129
+ return result.data
130
+ }
131
+
132
+ /** For tests — drops the in-memory cache. */
133
+ export const resetInfraKitConfigCache = (): void => {
134
+ cached = null
135
+ }
136
+
137
+ const statIfExists = async (filePath: string): Promise<Awaited<ReturnType<typeof fs.stat>> | null> => {
138
+ try {
139
+ return await fs.stat(filePath)
140
+ } catch {
141
+ return null
142
+ }
143
+ }
package/tsconfig.json CHANGED
@@ -6,9 +6,11 @@
6
6
  "composite": true,
7
7
  "target": "ESNext",
8
8
  "lib": ["ESNext"],
9
- "baseUrl": ".",
10
9
  "module": "ESNext",
11
10
  "moduleResolution": "bundler",
11
+ "paths": {
12
+ "src/*": ["./src/*"]
13
+ },
12
14
  "resolveJsonModule": true,
13
15
  "types": ["node"],
14
16
  "allowJs": true,
@@ -31,7 +33,6 @@
31
33
  "noStrictGenericChecks": false,
32
34
  "suppressExcessPropertyErrors": false,
33
35
  "suppressImplicitAnyIndexErrors": false,
34
- "downlevelIteration": true,
35
36
  "noEmit": true,
36
37
  "allowSyntheticDefaultImports": true,
37
38
  "esModuleInterop": true,