infra-kit 0.1.102 → 0.1.105
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/.omc/state/agent-replay-0a58307d-2a37-4c69-851c-83a646502d62.jsonl +1 -0
- package/.omc/state/agent-replay-11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc.jsonl +16 -0
- package/.omc/state/agent-replay-4cf1c186-81b2-497c-b002-d7f84e7839f3.jsonl +9 -0
- package/.omc/state/agent-replay-5c4ab554-64f1-42ae-83e3-21e0237e955c.jsonl +11 -0
- package/.omc/state/agent-replay-a60ac2ec-afbd-449f-a540-6df287392fc2.jsonl +1 -0
- package/.omc/state/agent-replay-be37e426-6fc8-47f4-8178-221c8494551c.jsonl +3 -0
- package/.omc/state/agent-replay-c967c819-3d1c-447b-ab48-56a8448ef9f8.jsonl +2 -0
- package/.omc/state/idle-notif-cooldown.json +3 -0
- package/.omc/state/last-tool-error.json +4 -4
- package/.omc/state/mission-state.json +53 -0
- package/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/pre-tool-advisory-throttle.json +18 -0
- package/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/subagent-tracking-state.json +7 -0
- package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/last-tool-error-state.json +7 -0
- package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/mission-state.json +117 -0
- package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/pre-tool-advisory-throttle.json +42 -0
- package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/subagent-tracking-state.json +53 -0
- package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/last-tool-error-state.json +7 -0
- package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/pre-tool-advisory-throttle.json +18 -0
- package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/subagent-tracking-state.json +7 -0
- package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/mission-state.json +117 -0
- package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/pre-tool-advisory-throttle.json +18 -0
- package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/subagent-tracking-state.json +17 -0
- package/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/pre-tool-advisory-throttle.json +18 -0
- package/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/subagent-tracking-state.json +7 -0
- package/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/pre-tool-advisory-throttle.json +10 -0
- package/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/subagent-tracking-state.json +7 -0
- package/.omc/state/subagent-tracking.json +14 -4
- package/.turbo/turbo-build.log +7 -0
- package/.turbo/turbo-check.log +14 -0
- package/.turbo/turbo-prettier-fix.log +2 -1
- package/.turbo/turbo-test.log +28 -5
- package/.turbo/turbo-validate.log +14 -0
- package/dist/cli.js +81 -74
- package/dist/cli.js.map +4 -4
- package/dist/entry/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +7 -0
- package/dist/lib/package-config/package-config.d.ts +71 -0
- package/dist/mcp.js +43 -41
- package/dist/mcp.js.map +4 -4
- package/eslint.config.js +1 -1
- package/infra-kit.config.ts +5 -0
- package/package.json +20 -13
- package/scripts/build.js +32 -3
- package/src/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/pre-tool-advisory-throttle.json +18 -0
- package/src/commands/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/pre-tool-advisory-throttle.json +18 -0
- package/src/commands/audit/__tests__/audit.test.ts +59 -0
- package/src/commands/audit/audit.ts +177 -0
- package/src/commands/audit/index.ts +1 -0
- package/src/commands/config/config.ts +49 -7
- package/src/commands/doctor/doctor.ts +3 -3
- package/src/commands/env-clear/env-clear.ts +1 -1
- package/src/commands/env-list/env-list.ts +3 -3
- 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 +3 -8
- package/src/commands/gh-release-deliver/gh-release-deliver.ts +47 -21
- package/src/commands/gh-release-deploy-all/gh-release-deploy-all.ts +13 -7
- package/src/commands/gh-release-deploy-selected/gh-release-deploy-selected.ts +12 -6
- package/src/commands/gh-release-list/gh-release-list.ts +19 -8
- package/src/commands/init/__tests__/migrate-config.test.ts +160 -0
- package/src/commands/init/init.ts +48 -35
- package/src/commands/init/migrate-config.ts +146 -0
- package/src/commands/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/src/commands/release-create/__tests__/release-create.test.ts +55 -0
- package/src/commands/release-create/release-create.ts +142 -38
- package/src/commands/release-desc-edit/release-desc-edit.ts +28 -8
- package/src/commands/version/version.ts +1 -1
- package/src/commands/worktrees-add/worktrees-add.ts +7 -12
- package/src/commands/worktrees-list/worktrees-list.ts +13 -5
- package/src/commands/worktrees-open/worktrees-open.ts +1 -1
- package/src/commands/worktrees-remove/worktrees-remove.ts +6 -10
- package/src/commands/worktrees-sync/worktrees-sync.ts +3 -5
- package/src/entry/cli.ts +49 -6
- package/src/entry/index.ts +5 -0
- package/src/integrations/cmux/open-workspace-with-layout.ts +4 -4
- package/src/integrations/cmux/workspace-title.ts +10 -4
- package/src/integrations/doppler/doppler-project.ts +1 -1
- package/src/integrations/gh/gh-release-prs/__tests__/gh-release-prs.test.ts +115 -0
- package/src/integrations/gh/gh-release-prs/gh-release-prs.ts +49 -32
- package/src/lib/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/pre-tool-advisory-throttle.json +14 -0
- package/src/lib/constants/index.ts +15 -0
- package/src/lib/git-utils/__tests__/git-utils.test.ts +49 -0
- package/src/lib/git-utils/git-utils.ts +3 -1
- package/src/lib/infra-kit-config/__tests__/infra-kit-config.test.ts +270 -0
- package/src/lib/infra-kit-config/index.ts +7 -1
- package/src/lib/infra-kit-config/infra-kit-config.ts +46 -28
- package/src/lib/package-config/__tests__/package-config.test.ts +95 -0
- package/src/lib/package-config/index.ts +3 -0
- package/src/lib/package-config/package-config-schema.ts +19 -0
- package/src/lib/package-config/package-config.ts +99 -0
- package/src/lib/package-validator/__tests__/package-validator.test.ts +263 -0
- package/src/lib/package-validator/checks/__tests__/checks.test.ts +130 -0
- package/src/lib/package-validator/checks/config-check.ts +30 -0
- package/src/lib/package-validator/checks/files-check.ts +29 -0
- package/src/lib/package-validator/checks/index.ts +4 -0
- package/src/lib/package-validator/checks/scripts-check.ts +23 -0
- package/src/lib/package-validator/checks/turbo-check.ts +47 -0
- package/src/lib/package-validator/fs-utils.ts +18 -0
- package/src/lib/package-validator/index.ts +3 -0
- package/src/lib/package-validator/loader/config-loader.ts +77 -0
- package/src/lib/package-validator/loader/index.ts +2 -0
- package/src/lib/package-validator/loader/package-discovery.ts +98 -0
- package/src/lib/package-validator/package-validator.ts +48 -0
- package/src/lib/package-validator/types.ts +15 -0
- package/src/lib/release-id/__tests__/release-id.test.ts +351 -0
- package/src/lib/release-id/__tests__/versioned-regression.test.ts +69 -0
- package/src/lib/release-id/index.ts +15 -0
- package/src/lib/release-id/release-id.ts +257 -0
- package/src/lib/release-utils/__tests__/release-utils.test.ts +122 -0
- package/src/lib/release-utils/index.ts +4 -0
- package/src/lib/release-utils/release-utils.ts +85 -17
- package/src/lib/version-utils/__tests__/load-existing-versions.test.ts +37 -0
- package/src/lib/version-utils/__tests__/next-version.test.ts +119 -13
- package/src/lib/version-utils/index.ts +3 -0
- package/src/lib/version-utils/load-existing-versions.ts +29 -10
- package/src/lib/version-utils/next-version.ts +67 -12
- package/src/lib/version-utils/version-utils.ts +13 -4
- package/src/mcp/tools/index.ts +2 -0
- package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/src/types.ts +1 -1
- package/tsconfig.tsbuildinfo +1 -1
- package/src/lib/__tests__/infra-kit-config.test.ts +0 -231
- /package/src/integrations/{clickup → linear}/.gitkeep +0 -0
- /package/src/lib/{__tests__ → constants/__tests__}/constants.test.ts +0 -0
- /package/src/lib/{constants.ts → constants/constants.ts} +0 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { getCurrentWorktrees } from 'src/lib/git-utils'
|
|
4
|
+
|
|
5
|
+
const worktreeList = vi.hoisted(() => {
|
|
6
|
+
return { stdout: '' }
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
vi.mock('zx', () => {
|
|
10
|
+
return {
|
|
11
|
+
$: vi.fn(() => {
|
|
12
|
+
return Promise.resolve(worktreeList)
|
|
13
|
+
}),
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const asWorktreeLine = (branch: string): string => {
|
|
18
|
+
return `/repos/project-worktrees/${branch} abc1234 [${branch}]`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('getCurrentWorktrees', () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
worktreeList.stdout = [
|
|
24
|
+
asWorktreeLine('main'),
|
|
25
|
+
asWorktreeLine('release/v1.18.22'),
|
|
26
|
+
asWorktreeLine('release/n/checkout-redesign'),
|
|
27
|
+
asWorktreeLine('release/garbage'),
|
|
28
|
+
asWorktreeLine('feature/login-page'),
|
|
29
|
+
'/repos/project abc1234 (bare)',
|
|
30
|
+
'',
|
|
31
|
+
].join('\n')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('returns versioned AND named release worktrees for type release', async () => {
|
|
35
|
+
await expect(getCurrentWorktrees('release')).resolves.toEqual(['release/v1.18.22', 'release/n/checkout-redesign'])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('excludes junk release branches and non-release branches', async () => {
|
|
39
|
+
const branches = await getCurrentWorktrees('release')
|
|
40
|
+
|
|
41
|
+
expect(branches).not.toContain('release/garbage')
|
|
42
|
+
expect(branches).not.toContain('feature/login-page')
|
|
43
|
+
expect(branches).not.toContain('main')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns feature worktrees for type feature', async () => {
|
|
47
|
+
await expect(getCurrentWorktrees('feature')).resolves.toEqual(['feature/login-page'])
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import { $ } from 'zx'
|
|
3
3
|
|
|
4
|
+
import { isReleaseBranch } from 'src/lib/release-id'
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
7
|
* Get current git worktrees
|
|
6
8
|
*
|
|
@@ -60,7 +62,7 @@ const parseWorktreeBranch = (line: string): string | null => {
|
|
|
60
62
|
const releaseWorktreePredicate = (line: string): string | null => {
|
|
61
63
|
const branch = parseWorktreeBranch(line)
|
|
62
64
|
|
|
63
|
-
return branch
|
|
65
|
+
return isReleaseBranch(branch) ? branch : null
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
/**
|
|
@@ -0,0 +1,270 @@
|
|
|
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, getRepoName } 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
|
+
getRepoName: vi.fn(),
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const VALID_JSON = JSON.stringify({
|
|
19
|
+
environments: ['dev', 'staging'],
|
|
20
|
+
envManagement: { provider: 'doppler', config: { name: 'my-project' } },
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const ALTERNATE_JSON = JSON.stringify({
|
|
24
|
+
environments: ['dev'],
|
|
25
|
+
envManagement: { provider: 'doppler', config: { name: 'other-project' } },
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const withTmpRepo = async (fn: (tmp: string) => Promise<void>): Promise<void> => {
|
|
29
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'infra-kit-config-test-'))
|
|
30
|
+
|
|
31
|
+
vi.mocked(getProjectRoot).mockResolvedValue(tmp)
|
|
32
|
+
vi.mocked(getRepoName).mockResolvedValue(path.basename(tmp))
|
|
33
|
+
// Point os.homedir() at the tmp dir so user-scope override layers
|
|
34
|
+
// (~/.infra-kit/config.json, ~/.infra-kit/projects/<repo>/infra-kit.json)
|
|
35
|
+
// can't leak the developer's real config into the test.
|
|
36
|
+
const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp)
|
|
37
|
+
|
|
38
|
+
resetInfraKitConfigCache()
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
await fn(tmp)
|
|
42
|
+
} finally {
|
|
43
|
+
homedirSpy.mockRestore()
|
|
44
|
+
resetInfraKitConfigCache()
|
|
45
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('getInfraKitConfig', () => {
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
resetInfraKitConfigCache()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
resetInfraKitConfigCache()
|
|
56
|
+
vi.clearAllMocks()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('reads and validates a well-formed infra-kit.json', async () => {
|
|
60
|
+
await withTmpRepo(async (tmp) => {
|
|
61
|
+
fs.writeFileSync(path.join(tmp, 'infra-kit.json'), VALID_JSON)
|
|
62
|
+
|
|
63
|
+
const cfg = await getInfraKitConfig()
|
|
64
|
+
|
|
65
|
+
expect(cfg.envManagement.config.name).toBe('my-project')
|
|
66
|
+
expect(cfg.environments).toEqual(['dev', 'staging'])
|
|
67
|
+
expect(cfg.taskManager).toBeUndefined()
|
|
68
|
+
expect(cfg.ide).toBeUndefined()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('accepts ide and taskManager when provided', async () => {
|
|
73
|
+
await withTmpRepo(async (tmp) => {
|
|
74
|
+
fs.writeFileSync(
|
|
75
|
+
path.join(tmp, 'infra-kit.json'),
|
|
76
|
+
JSON.stringify({
|
|
77
|
+
environments: ['dev'],
|
|
78
|
+
envManagement: { provider: 'doppler', config: { name: 'p' } },
|
|
79
|
+
ide: { provider: 'cursor', config: { mode: 'workspace', workspaceConfigPath: './ws.code-workspace' } },
|
|
80
|
+
taskManager: { provider: 'jira', config: { baseUrl: 'https://example.atlassian.net', projectId: 123 } },
|
|
81
|
+
}),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
const cfg = await getInfraKitConfig()
|
|
85
|
+
|
|
86
|
+
expect(cfg.ide?.provider).toBe('cursor')
|
|
87
|
+
|
|
88
|
+
if (cfg.ide?.provider === 'cursor') {
|
|
89
|
+
expect(cfg.ide.config.mode).toBe('workspace')
|
|
90
|
+
expect(cfg.ide.config.workspaceConfigPath).toBe('./ws.code-workspace')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
expect(cfg.taskManager?.provider).toBe('jira')
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('accepts a worktrees prompt-defaults block', async () => {
|
|
98
|
+
await withTmpRepo(async (tmp) => {
|
|
99
|
+
fs.writeFileSync(
|
|
100
|
+
path.join(tmp, 'infra-kit.json'),
|
|
101
|
+
JSON.stringify({
|
|
102
|
+
environments: ['dev'],
|
|
103
|
+
envManagement: { provider: 'doppler', config: { name: 'p' } },
|
|
104
|
+
worktrees: { openInGithubDesktop: false, openInCmux: true },
|
|
105
|
+
}),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
const cfg = await getInfraKitConfig()
|
|
109
|
+
|
|
110
|
+
expect(cfg.worktrees?.openInGithubDesktop).toBe(false)
|
|
111
|
+
expect(cfg.worktrees?.openInCmux).toBe(true)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('lets the user-global config layer supply a worktrees block when the project omits it', async () => {
|
|
116
|
+
await withTmpRepo(async (tmp) => {
|
|
117
|
+
fs.writeFileSync(path.join(tmp, 'infra-kit.json'), VALID_JSON)
|
|
118
|
+
|
|
119
|
+
const userGlobalDir = path.join(tmp, '.infra-kit')
|
|
120
|
+
|
|
121
|
+
fs.mkdirSync(userGlobalDir, { recursive: true })
|
|
122
|
+
fs.writeFileSync(
|
|
123
|
+
path.join(userGlobalDir, 'config.json'),
|
|
124
|
+
JSON.stringify({ worktrees: { openInGithubDesktop: false, openInCmux: true } }),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
const cfg = await getInfraKitConfig()
|
|
128
|
+
|
|
129
|
+
expect(cfg.worktrees?.openInGithubDesktop).toBe(false)
|
|
130
|
+
expect(cfg.worktrees?.openInCmux).toBe(true)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('treats an empty optional layer file as {}', async () => {
|
|
135
|
+
await withTmpRepo(async (tmp) => {
|
|
136
|
+
fs.writeFileSync(path.join(tmp, 'infra-kit.json'), VALID_JSON)
|
|
137
|
+
|
|
138
|
+
const userGlobalDir = path.join(tmp, '.infra-kit')
|
|
139
|
+
|
|
140
|
+
fs.mkdirSync(userGlobalDir, { recursive: true })
|
|
141
|
+
fs.writeFileSync(path.join(userGlobalDir, 'config.json'), ' \n')
|
|
142
|
+
|
|
143
|
+
const cfg = await getInfraKitConfig()
|
|
144
|
+
|
|
145
|
+
expect(cfg.envManagement.config.name).toBe('my-project')
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('throws a descriptive error on malformed JSON', async () => {
|
|
150
|
+
await withTmpRepo(async (tmp) => {
|
|
151
|
+
fs.writeFileSync(path.join(tmp, 'infra-kit.json'), '{ not valid json ')
|
|
152
|
+
|
|
153
|
+
await expect(getInfraKitConfig()).rejects.toThrow(/Invalid JSON in infra-kit\.json/)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('rejects ide.cursor mode=workspace without workspaceConfigPath', async () => {
|
|
158
|
+
await withTmpRepo(async (tmp) => {
|
|
159
|
+
fs.writeFileSync(
|
|
160
|
+
path.join(tmp, 'infra-kit.json'),
|
|
161
|
+
JSON.stringify({
|
|
162
|
+
environments: ['dev'],
|
|
163
|
+
envManagement: { provider: 'doppler', config: { name: 'p' } },
|
|
164
|
+
ide: { provider: 'cursor', config: { mode: 'workspace' } },
|
|
165
|
+
}),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
await expect(getInfraKitConfig()).rejects.toThrow(/workspaceConfigPath/)
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('throws a plain not-found error when neither infra-kit.json nor a legacy .yml exists', async () => {
|
|
173
|
+
await withTmpRepo(async () => {
|
|
174
|
+
await expect(getInfraKitConfig()).rejects.toThrow(/not found/)
|
|
175
|
+
await expect(getInfraKitConfig()).rejects.not.toThrow(/infra-kit init/)
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('points at `infra-kit init` when a legacy infra-kit.yml exists but infra-kit.json does not', async () => {
|
|
180
|
+
await withTmpRepo(async (tmp) => {
|
|
181
|
+
fs.writeFileSync(path.join(tmp, 'infra-kit.yml'), 'environments:\n - dev\n')
|
|
182
|
+
|
|
183
|
+
await expect(getInfraKitConfig()).rejects.toThrow(/infra-kit init/)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('ignores a non-loaded infra-kit.example.jsonc sibling', async () => {
|
|
188
|
+
await withTmpRepo(async (tmp) => {
|
|
189
|
+
fs.writeFileSync(path.join(tmp, 'infra-kit.json'), VALID_JSON)
|
|
190
|
+
// Content that would FAIL schema if it were ever merged into the config.
|
|
191
|
+
fs.writeFileSync(path.join(tmp, 'infra-kit.example.jsonc'), '{\n // comment\n "environments": []\n}\n')
|
|
192
|
+
|
|
193
|
+
const cfg = await getInfraKitConfig()
|
|
194
|
+
|
|
195
|
+
expect(cfg.environments).toEqual(['dev', 'staging'])
|
|
196
|
+
expect(cfg.envManagement.config.name).toBe('my-project')
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('ignores a non-loaded config.example.jsonc in the user-global layer', async () => {
|
|
201
|
+
await withTmpRepo(async (tmp) => {
|
|
202
|
+
fs.writeFileSync(path.join(tmp, 'infra-kit.json'), VALID_JSON)
|
|
203
|
+
|
|
204
|
+
const userGlobalDir = path.join(tmp, '.infra-kit')
|
|
205
|
+
|
|
206
|
+
fs.mkdirSync(userGlobalDir, { recursive: true })
|
|
207
|
+
// Schema-failing content that must never be merged.
|
|
208
|
+
fs.writeFileSync(path.join(userGlobalDir, 'config.example.jsonc'), '{\n // comment\n "environments": []\n}\n')
|
|
209
|
+
|
|
210
|
+
const cfg = await getInfraKitConfig()
|
|
211
|
+
|
|
212
|
+
expect(cfg.environments).toEqual(['dev', 'staging'])
|
|
213
|
+
expect(cfg.envManagement.config.name).toBe('my-project')
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('throws when infra-kit.json is missing', async () => {
|
|
218
|
+
await withTmpRepo(async () => {
|
|
219
|
+
await expect(getInfraKitConfig()).rejects.toThrow(/not found/)
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('throws a descriptive error on schema violations', async () => {
|
|
224
|
+
await withTmpRepo(async (tmp) => {
|
|
225
|
+
fs.writeFileSync(
|
|
226
|
+
path.join(tmp, 'infra-kit.json'),
|
|
227
|
+
JSON.stringify({ environments: [], envManagement: { provider: 'doppler', config: { name: '' } } }),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
await expect(getInfraKitConfig()).rejects.toThrow(/Invalid infra-kit\.json/)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('re-reads the file when mtime changes (long-running MCP scenario)', async () => {
|
|
235
|
+
await withTmpRepo(async (tmp) => {
|
|
236
|
+
const jsonPath = path.join(tmp, 'infra-kit.json')
|
|
237
|
+
|
|
238
|
+
fs.writeFileSync(jsonPath, VALID_JSON)
|
|
239
|
+
|
|
240
|
+
const first = await getInfraKitConfig()
|
|
241
|
+
|
|
242
|
+
expect(first.envManagement.config.name).toBe('my-project')
|
|
243
|
+
|
|
244
|
+
// Advance mtime past the previous stat to simulate an edit; write new content.
|
|
245
|
+
const future = new Date(Date.now() + 2_000)
|
|
246
|
+
|
|
247
|
+
fs.writeFileSync(jsonPath, ALTERNATE_JSON)
|
|
248
|
+
fs.utimesSync(jsonPath, future, future)
|
|
249
|
+
|
|
250
|
+
const second = await getInfraKitConfig()
|
|
251
|
+
|
|
252
|
+
expect(second.envManagement.config.name).toBe('other-project')
|
|
253
|
+
expect(second.environments).toEqual(['dev'])
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('returns the cached value on repeated calls when mtime is unchanged', async () => {
|
|
258
|
+
await withTmpRepo(async (tmp) => {
|
|
259
|
+
const jsonPath = path.join(tmp, 'infra-kit.json')
|
|
260
|
+
|
|
261
|
+
fs.writeFileSync(jsonPath, VALID_JSON)
|
|
262
|
+
|
|
263
|
+
const a = await getInfraKitConfig()
|
|
264
|
+
const b = await getInfraKitConfig()
|
|
265
|
+
|
|
266
|
+
// Same object reference — no re-parse.
|
|
267
|
+
expect(a).toBe(b)
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
})
|
|
@@ -1,2 +1,8 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
getInfraKitConfig,
|
|
3
|
+
getInfraKitConfigPaths,
|
|
4
|
+
infraKitConfigSchema,
|
|
5
|
+
infraKitOverrideConfigSchema,
|
|
6
|
+
resetInfraKitConfigCache,
|
|
7
|
+
} from './infra-kit-config'
|
|
2
8
|
export type { InfraKitConfig, InfraKitConfigPaths } from './infra-kit-config'
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import fs from 'node:fs/promises'
|
|
2
2
|
import os from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
-
import
|
|
5
|
-
import { z } from 'zod/v4'
|
|
4
|
+
import { z } from 'zod'
|
|
6
5
|
|
|
7
6
|
import { getProjectRoot, getRepoName } from 'src/lib/git-utils'
|
|
8
7
|
|
|
9
|
-
const INFRA_KIT_CONFIG_FILE = 'infra-kit.
|
|
8
|
+
const INFRA_KIT_CONFIG_FILE = 'infra-kit.json'
|
|
10
9
|
|
|
11
10
|
const USER_CONFIG_DIR_NAME = '.infra-kit'
|
|
12
|
-
const USER_GLOBAL_CONFIG_FILE = 'config.
|
|
11
|
+
const USER_GLOBAL_CONFIG_FILE = 'config.json'
|
|
13
12
|
const USER_PROJECTS_DIR = 'projects'
|
|
14
13
|
|
|
15
14
|
// envManagement
|
|
@@ -62,7 +61,7 @@ const worktreesConfigSchema = z.object({
|
|
|
62
61
|
openInCmux: z.boolean().optional(),
|
|
63
62
|
})
|
|
64
63
|
|
|
65
|
-
const infraKitConfigSchema = z.object({
|
|
64
|
+
export const infraKitConfigSchema = z.object({
|
|
66
65
|
environments: z.array(z.string().min(1)).min(1),
|
|
67
66
|
envManagement: envManagementSchema,
|
|
68
67
|
ide: ideSchema.optional(),
|
|
@@ -70,7 +69,7 @@ const infraKitConfigSchema = z.object({
|
|
|
70
69
|
worktrees: worktreesConfigSchema.optional(),
|
|
71
70
|
})
|
|
72
71
|
|
|
73
|
-
const infraKitOverrideConfigSchema = infraKitConfigSchema.partial()
|
|
72
|
+
export const infraKitOverrideConfigSchema = infraKitConfigSchema.partial()
|
|
74
73
|
|
|
75
74
|
export type InfraKitConfig = z.infer<typeof infraKitConfigSchema>
|
|
76
75
|
|
|
@@ -79,7 +78,7 @@ export interface InfraKitConfigPaths {
|
|
|
79
78
|
main: string
|
|
80
79
|
/** User-scope global overrides applied to every project. */
|
|
81
80
|
userGlobal: string
|
|
82
|
-
/** User-scope per-project overrides — `<userProjectsDir>/<projectName>/infra-kit.
|
|
81
|
+
/** User-scope per-project overrides — `<userProjectsDir>/<projectName>/infra-kit.json`. */
|
|
83
82
|
userProject: string
|
|
84
83
|
/** Repo basename (`path.basename(projectRoot)`) used to namespace the user-project file. */
|
|
85
84
|
projectName: string
|
|
@@ -100,9 +99,9 @@ let cached: CacheEntry | null = null
|
|
|
100
99
|
* @example
|
|
101
100
|
* const paths = await getInfraKitConfigPaths()
|
|
102
101
|
* // {
|
|
103
|
-
* // main: '/Users/arthur/projects/api/infra-kit.
|
|
104
|
-
* // userGlobal: '/Users/arthur/.infra-kit/config.
|
|
105
|
-
* // userProject: '/Users/arthur/.infra-kit/projects/api/infra-kit.
|
|
102
|
+
* // main: '/Users/arthur/projects/api/infra-kit.json',
|
|
103
|
+
* // userGlobal: '/Users/arthur/.infra-kit/config.json',
|
|
104
|
+
* // userProject: '/Users/arthur/.infra-kit/projects/api/infra-kit.json',
|
|
106
105
|
* // projectName: 'api',
|
|
107
106
|
* // }
|
|
108
107
|
*/
|
|
@@ -120,19 +119,19 @@ export const getInfraKitConfigPaths = async (): Promise<InfraKitConfigPaths> =>
|
|
|
120
119
|
}
|
|
121
120
|
|
|
122
121
|
/**
|
|
123
|
-
* Read and validate `infra-kit.
|
|
122
|
+
* Read and validate `infra-kit.json`, with optional override layers shallow-merged
|
|
124
123
|
* on top in this order (later wins):
|
|
125
|
-
* 1. project `infra-kit.
|
|
126
|
-
* 2. `~/.infra-kit/config.
|
|
127
|
-
* 3. `~/.infra-kit/projects/<repo-name>/infra-kit.
|
|
124
|
+
* 1. project `infra-kit.json` — committed source of truth
|
|
125
|
+
* 2. `~/.infra-kit/config.json` — user-global defaults
|
|
126
|
+
* 3. `~/.infra-kit/projects/<repo-name>/infra-kit.json` — user-scope per-project overrides
|
|
128
127
|
*
|
|
129
128
|
* Top-level keys (entire capability sections like `ide`, `envManagement`)
|
|
130
129
|
* replace wholesale. Results are cached per file mtimes so the long-running
|
|
131
130
|
* MCP server picks up edits without a restart.
|
|
132
131
|
*
|
|
133
132
|
* @example
|
|
134
|
-
* // infra-kit.
|
|
135
|
-
* // ~/.infra-kit/config.
|
|
133
|
+
* // infra-kit.json: { "environments": ["dev"], "envManagement": { "provider": "doppler", "config": { "name": "p" } } }
|
|
134
|
+
* // ~/.infra-kit/config.json: { "ide": { "provider": "cursor", "config": { "mode": "windows" } } }
|
|
136
135
|
* const cfg = await getInfraKitConfig()
|
|
137
136
|
* // => { environments: ['dev'], envManagement: {...}, ide: { provider: 'cursor', config: { mode: 'windows' } } }
|
|
138
137
|
*/
|
|
@@ -145,7 +144,18 @@ export const getInfraKitConfig = async (): Promise<InfraKitConfig> => {
|
|
|
145
144
|
mainStat = await fs.stat(paths.main)
|
|
146
145
|
} catch {
|
|
147
146
|
cached = null
|
|
148
|
-
|
|
147
|
+
|
|
148
|
+
// Bridge the YAML→JSON cutover: if a legacy infra-kit.yml is sitting where
|
|
149
|
+
// the JSON config should be, point the user at the one-shot migration.
|
|
150
|
+
const legacyYmlPath = paths.main.replace(/\.json$/, '.yml')
|
|
151
|
+
|
|
152
|
+
if (await statIfExists(legacyYmlPath)) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`infra-kit.json not found at ${paths.main}. A legacy infra-kit.yml exists — run \`infra-kit init\` to convert it.`,
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
throw new Error(`infra-kit.json not found at ${paths.main}`)
|
|
149
159
|
}
|
|
150
160
|
|
|
151
161
|
const [userGlobalStat, userProjectStat] = await Promise.all([
|
|
@@ -164,10 +174,10 @@ export const getInfraKitConfig = async (): Promise<InfraKitConfig> => {
|
|
|
164
174
|
}
|
|
165
175
|
|
|
166
176
|
const layers: ConfigLayer[] = [
|
|
167
|
-
{ label: 'infra-kit.
|
|
168
|
-
{ label: '~/.infra-kit/config.
|
|
177
|
+
{ label: 'infra-kit.json', path: paths.main, required: true },
|
|
178
|
+
{ label: '~/.infra-kit/config.json', path: paths.userGlobal, required: false },
|
|
169
179
|
{
|
|
170
|
-
label: `~/.infra-kit/projects/${paths.projectName}/infra-kit.
|
|
180
|
+
label: `~/.infra-kit/projects/${paths.projectName}/infra-kit.json`,
|
|
171
181
|
path: paths.userProject,
|
|
172
182
|
required: false,
|
|
173
183
|
},
|
|
@@ -224,8 +234,8 @@ const statIfExists = async (filePath: string): Promise<Awaited<ReturnType<typeof
|
|
|
224
234
|
* `fs.readFile` that returns `null` instead of throwing on ENOENT.
|
|
225
235
|
*
|
|
226
236
|
* @example
|
|
227
|
-
* const raw = await readIfExists('/missing.
|
|
228
|
-
* const raw = await readIfExists('/exists.
|
|
237
|
+
* const raw = await readIfExists('/missing.json') // => null
|
|
238
|
+
* const raw = await readIfExists('/exists.json') // => '{ "environments": ["dev"] }\n'
|
|
229
239
|
*/
|
|
230
240
|
const readIfExists = async (filePath: string): Promise<string | null> => {
|
|
231
241
|
try {
|
|
@@ -261,17 +271,18 @@ interface ConfigLayer {
|
|
|
261
271
|
}
|
|
262
272
|
|
|
263
273
|
/**
|
|
264
|
-
* Read a single layer of the merge chain: parse the
|
|
274
|
+
* Read a single layer of the merge chain: parse the JSON if the file exists
|
|
265
275
|
* and validate it against the override schema. Returns `null` if an optional
|
|
266
|
-
* layer is missing; throws if the layer is required or invalid.
|
|
276
|
+
* layer is missing; throws if the layer is required, malformed, or invalid.
|
|
277
|
+
* An empty/whitespace-only file is treated as `{}` (JSON.parse would throw).
|
|
267
278
|
*
|
|
268
279
|
* @example
|
|
269
|
-
* await loadLayer({ label: '~/.infra-kit/config.
|
|
280
|
+
* await loadLayer({ label: '~/.infra-kit/config.json', path: '/missing.json', required: false })
|
|
270
281
|
* // => null
|
|
271
282
|
*
|
|
272
283
|
* @example
|
|
273
|
-
* // /home/me/.infra-kit/config.
|
|
274
|
-
* await loadLayer({ label: '~/.infra-kit/config.
|
|
284
|
+
* // /home/me/.infra-kit/config.json: '{ "ide": { "provider": "cursor", "config": { "mode": "windows" } } }'
|
|
285
|
+
* await loadLayer({ label: '~/.infra-kit/config.json', path: '/home/me/.infra-kit/config.json', required: false })
|
|
275
286
|
* // => { ide: { provider: 'cursor', config: { mode: 'windows' } } }
|
|
276
287
|
*/
|
|
277
288
|
const loadLayer = async (layer: ConfigLayer): Promise<Record<string, unknown> | null> => {
|
|
@@ -285,7 +296,14 @@ const loadLayer = async (layer: ConfigLayer): Promise<Record<string, unknown> |
|
|
|
285
296
|
return null
|
|
286
297
|
}
|
|
287
298
|
|
|
288
|
-
|
|
299
|
+
let parsedRaw: unknown
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
parsedRaw = raw.trim() === '' ? {} : JSON.parse(raw)
|
|
303
|
+
} catch (err) {
|
|
304
|
+
throw new Error(`Invalid JSON in ${layer.label} at ${layer.path}: ${(err as Error).message}`)
|
|
305
|
+
}
|
|
306
|
+
|
|
289
307
|
const result = infraKitOverrideConfigSchema.safeParse(parsedRaw)
|
|
290
308
|
|
|
291
309
|
if (!result.success) {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_RULES, ROOT_DEFAULT_RULES, defineConfig, resolvePackageConfig } from '../package-config'
|
|
4
|
+
import { packageConfigSchema } from '../package-config-schema'
|
|
5
|
+
|
|
6
|
+
describe('defineConfig', () => {
|
|
7
|
+
it('returns an object input unchanged (identity)', () => {
|
|
8
|
+
const input = { requiredScripts: ['build'] }
|
|
9
|
+
|
|
10
|
+
expect(defineConfig(input)).toBe(input)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('returns a factory input unchanged so the loader can resolve it', () => {
|
|
14
|
+
const factory = () => {
|
|
15
|
+
return { requiredFiles: ['a.txt'] }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
expect(defineConfig(factory)).toBe(factory)
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('resolvePackageConfig', () => {
|
|
23
|
+
it('falls back to defaults for every unset key', () => {
|
|
24
|
+
const rules = resolvePackageConfig({})
|
|
25
|
+
|
|
26
|
+
expect(rules.requiredScripts).toEqual(DEFAULT_RULES.requiredScripts)
|
|
27
|
+
expect(rules.requiredFiles).toEqual(DEFAULT_RULES.requiredFiles)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('replaces a key wholesale when provided, including an empty array opt-out', () => {
|
|
31
|
+
const rules = resolvePackageConfig({ requiredScripts: [], requiredFiles: ['serverless.common.yml'] })
|
|
32
|
+
|
|
33
|
+
expect(rules.requiredScripts).toEqual([])
|
|
34
|
+
expect(rules.requiredFiles).toEqual(['serverless.common.yml'])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('does not share the default array reference with the resolved result', () => {
|
|
38
|
+
const rules = resolvePackageConfig({})
|
|
39
|
+
|
|
40
|
+
rules.requiredScripts.push('mutated')
|
|
41
|
+
|
|
42
|
+
expect(DEFAULT_RULES.requiredScripts).not.toContain('mutated')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('falls back to the supplied baseline (root) for unset keys, including turbo tasks', () => {
|
|
46
|
+
const rules = resolvePackageConfig({}, ROOT_DEFAULT_RULES)
|
|
47
|
+
|
|
48
|
+
expect(rules.requiredScripts).toEqual(ROOT_DEFAULT_RULES.requiredScripts)
|
|
49
|
+
expect(rules.turboTasks).toEqual(ROOT_DEFAULT_RULES.turboTasks)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('lets a config override turbo.requiredTasks', () => {
|
|
53
|
+
const rules = resolvePackageConfig({ turbo: { requiredTasks: ['build'] } }, ROOT_DEFAULT_RULES)
|
|
54
|
+
|
|
55
|
+
expect(rules.turboTasks).toEqual(['build'])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('defaults turboTasks to an empty array for packages', () => {
|
|
59
|
+
const rules = resolvePackageConfig({})
|
|
60
|
+
|
|
61
|
+
expect(rules.turboTasks).toEqual([])
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('packageConfigSchema', () => {
|
|
66
|
+
it('rejects unknown keys so config typos surface as errors', () => {
|
|
67
|
+
const result = packageConfigSchema.safeParse({ requiredScript: ['build'] })
|
|
68
|
+
|
|
69
|
+
expect(result.success).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('rejects a non-array requiredScripts', () => {
|
|
73
|
+
const result = packageConfigSchema.safeParse({ requiredScripts: 'build' })
|
|
74
|
+
|
|
75
|
+
expect(result.success).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('accepts a well-formed config', () => {
|
|
79
|
+
const result = packageConfigSchema.safeParse({ requiredScripts: ['build'], requiredFiles: ['tsconfig.json'] })
|
|
80
|
+
|
|
81
|
+
expect(result.success).toBe(true)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('accepts a turbo.requiredTasks block', () => {
|
|
85
|
+
const result = packageConfigSchema.safeParse({ turbo: { requiredTasks: ['build', 'validate'] } })
|
|
86
|
+
|
|
87
|
+
expect(result.success).toBe(true)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('rejects an unknown key inside turbo', () => {
|
|
91
|
+
const result = packageConfigSchema.safeParse({ turbo: { tasks: ['build'] } })
|
|
92
|
+
|
|
93
|
+
expect(result.success).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { DEFAULT_RULES, defineConfig, resolvePackageConfig, ROOT_DEFAULT_RULES } from './package-config'
|
|
2
|
+
export type { InfraKitPackageConfig, InfraKitPackageConfigInput, ResolvedPackageRules } from './package-config'
|
|
3
|
+
export { packageConfigSchema } from './package-config-schema'
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Schema for the resolved (post-factory) package config object. `strictObject`
|
|
5
|
+
* rejects unknown keys so typos in `infra-kit.config.ts` surface as validation
|
|
6
|
+
* errors instead of being silently ignored.
|
|
7
|
+
*
|
|
8
|
+
* Kept in its own module — separate from the public `defineConfig`/types entry —
|
|
9
|
+
* so the published `infra-kit` type surface stays free of a `zod` import.
|
|
10
|
+
*/
|
|
11
|
+
export const packageConfigSchema = z.strictObject({
|
|
12
|
+
requiredScripts: z.array(z.string().min(1)).optional(),
|
|
13
|
+
requiredFiles: z.array(z.string().min(1)).optional(),
|
|
14
|
+
turbo: z
|
|
15
|
+
.strictObject({
|
|
16
|
+
requiredTasks: z.array(z.string().min(1)).optional(),
|
|
17
|
+
})
|
|
18
|
+
.optional(),
|
|
19
|
+
})
|