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.
- package/.eslintcache +1 -1
- package/.turbo/turbo-eslint-check.log +4 -5
- package/.turbo/turbo-prettier-check.log +7 -5
- package/.turbo/turbo-test.log +82 -18
- package/.turbo/turbo-ts-check.log +10 -5
- package/dist/cli.js +37 -34
- package/dist/cli.js.map +4 -4
- package/dist/mcp.js +30 -27
- package/dist/mcp.js.map +4 -4
- package/package.json +2 -2
- package/src/commands/doctor/doctor.ts +119 -1
- package/src/commands/env-clear/env-clear.ts +1 -1
- package/src/commands/env-list/env-list.ts +1 -1
- package/src/commands/env-load/env-load.ts +1 -1
- package/src/commands/env-status/env-status.ts +1 -1
- package/src/commands/gh-merge-dev/gh-merge-dev.ts +1 -1
- package/src/commands/gh-release-deliver/gh-release-deliver.ts +1 -1
- package/src/commands/gh-release-deploy-all/gh-release-deploy-all.ts +1 -1
- package/src/commands/gh-release-deploy-selected/gh-release-deploy-selected.ts +1 -1
- package/src/commands/gh-release-list/gh-release-list.ts +1 -1
- package/src/commands/init/init.ts +3 -3
- package/src/commands/release-create/release-create.ts +1 -1
- package/src/commands/release-create-batch/release-create-batch.ts +1 -1
- package/src/commands/version/version.ts +1 -1
- package/src/commands/worktrees-add/index.ts +2 -1
- package/src/commands/worktrees-add/worktrees-add.ts +55 -14
- package/src/commands/worktrees-list/worktrees-list.ts +1 -1
- package/src/commands/worktrees-remove/worktrees-remove.ts +68 -4
- package/src/commands/worktrees-sync/worktrees-sync.ts +69 -5
- package/src/entry/cli.ts +25 -4
- package/src/integrations/clickup/.gitkeep +0 -0
- package/src/integrations/cmux/close-workspace-by-title.ts +51 -0
- package/src/integrations/cmux/index.ts +2 -0
- package/src/integrations/cmux/workspace-title.ts +17 -0
- package/src/integrations/cursor/add-folders-to-workspace.ts +84 -0
- package/src/integrations/cursor/index.ts +3 -0
- package/src/integrations/cursor/remove-folders-from-workspace.ts +93 -0
- package/src/integrations/cursor/resolve-workspace-path.ts +13 -0
- package/src/integrations/doppler/doppler-project.ts +2 -2
- package/src/lib/__tests__/infra-kit-config.test.ts +64 -14
- package/src/lib/infra-kit-config/index.ts +2 -0
- package/src/lib/infra-kit-config/infra-kit-config.ts +143 -0
- package/tsconfig.json +3 -2
- package/tsconfig.tsbuildinfo +1 -1
- 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', '
|
|
140
|
-
.option('--no-cursor', 'Skip Cursor
|
|
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
|
+
}
|
|
@@ -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,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 {
|
|
7
|
+
const { envManagement } = await getInfraKitConfig()
|
|
8
8
|
|
|
9
|
-
return
|
|
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 = `
|
|
18
|
-
environments:
|
|
17
|
+
const VALID_YML = `environments:
|
|
19
18
|
- dev
|
|
20
19
|
- staging
|
|
21
|
-
|
|
20
|
+
envManagement:
|
|
21
|
+
provider: doppler
|
|
22
|
+
config:
|
|
23
|
+
name: my-project
|
|
22
24
|
`
|
|
23
25
|
|
|
24
|
-
const ALTERNATE_YML = `
|
|
25
|
-
environments:
|
|
26
|
+
const ALTERNATE_YML = `environments:
|
|
26
27
|
- dev
|
|
27
|
-
|
|
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.
|
|
64
|
+
expect(cfg.envManagement.config.name).toBe('my-project')
|
|
61
65
|
expect(cfg.environments).toEqual(['dev', 'staging'])
|
|
62
|
-
expect(cfg.
|
|
66
|
+
expect(cfg.taskManager).toBeUndefined()
|
|
67
|
+
expect(cfg.ide).toBeUndefined()
|
|
63
68
|
})
|
|
64
69
|
})
|
|
65
70
|
|
|
66
|
-
it('accepts
|
|
71
|
+
it('accepts ide and taskManager when provided', async () => {
|
|
67
72
|
await withTmpRepo(async (tmp) => {
|
|
68
|
-
fs.writeFileSync(
|
|
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.
|
|
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
|
-
'
|
|
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.
|
|
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.
|
|
161
|
+
expect(second.envManagement.config.name).toBe('other-project')
|
|
112
162
|
expect(second.environments).toEqual(['dev'])
|
|
113
163
|
})
|
|
114
164
|
})
|
|
@@ -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,
|