infra-kit 0.1.101 → 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.
Files changed (129) hide show
  1. package/.eslintcache +1 -1
  2. package/.omc/state/agent-replay-0a58307d-2a37-4c69-851c-83a646502d62.jsonl +1 -0
  3. package/.omc/state/agent-replay-11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc.jsonl +16 -0
  4. package/.omc/state/agent-replay-4cf1c186-81b2-497c-b002-d7f84e7839f3.jsonl +9 -0
  5. package/.omc/state/agent-replay-5c4ab554-64f1-42ae-83e3-21e0237e955c.jsonl +11 -0
  6. package/.omc/state/agent-replay-a60ac2ec-afbd-449f-a540-6df287392fc2.jsonl +1 -0
  7. package/.omc/state/agent-replay-be37e426-6fc8-47f4-8178-221c8494551c.jsonl +3 -0
  8. package/.omc/state/agent-replay-c967c819-3d1c-447b-ab48-56a8448ef9f8.jsonl +2 -0
  9. package/.omc/state/idle-notif-cooldown.json +3 -0
  10. package/.omc/state/last-tool-error.json +4 -4
  11. package/.omc/state/mission-state.json +53 -0
  12. package/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/pre-tool-advisory-throttle.json +18 -0
  13. package/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/subagent-tracking-state.json +7 -0
  14. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/last-tool-error-state.json +7 -0
  15. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/mission-state.json +117 -0
  16. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/pre-tool-advisory-throttle.json +42 -0
  17. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/subagent-tracking-state.json +53 -0
  18. package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/last-tool-error-state.json +7 -0
  19. package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/pre-tool-advisory-throttle.json +18 -0
  20. package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/subagent-tracking-state.json +7 -0
  21. package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/mission-state.json +117 -0
  22. package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/pre-tool-advisory-throttle.json +18 -0
  23. package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/subagent-tracking-state.json +17 -0
  24. package/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/pre-tool-advisory-throttle.json +18 -0
  25. package/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/subagent-tracking-state.json +7 -0
  26. package/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/pre-tool-advisory-throttle.json +10 -0
  27. package/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/subagent-tracking-state.json +7 -0
  28. package/.omc/state/subagent-tracking.json +14 -4
  29. package/.turbo/turbo-build.log +7 -0
  30. package/.turbo/turbo-check.log +14 -0
  31. package/.turbo/turbo-prettier-fix.log +2 -1
  32. package/.turbo/turbo-test.log +28 -5
  33. package/.turbo/turbo-validate.log +14 -0
  34. package/dist/cli.js +81 -74
  35. package/dist/cli.js.map +4 -4
  36. package/dist/entry/index.d.ts +2 -0
  37. package/dist/index.js +2 -0
  38. package/dist/index.js.map +7 -0
  39. package/dist/lib/package-config/package-config.d.ts +71 -0
  40. package/dist/mcp.js +41 -39
  41. package/dist/mcp.js.map +4 -4
  42. package/eslint.config.js +1 -1
  43. package/infra-kit.config.ts +5 -0
  44. package/package.json +20 -13
  45. package/scripts/build.js +32 -3
  46. package/src/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/pre-tool-advisory-throttle.json +18 -0
  47. package/src/commands/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/pre-tool-advisory-throttle.json +18 -0
  48. package/src/commands/audit/__tests__/audit.test.ts +59 -0
  49. package/src/commands/audit/audit.ts +177 -0
  50. package/src/commands/audit/index.ts +1 -0
  51. package/src/commands/config/config.ts +49 -7
  52. package/src/commands/doctor/doctor.ts +3 -3
  53. package/src/commands/env-clear/env-clear.ts +1 -1
  54. package/src/commands/env-list/env-list.ts +3 -3
  55. package/src/commands/env-load/env-load.ts +1 -1
  56. package/src/commands/env-status/env-status.ts +1 -1
  57. package/src/commands/gh-merge-dev/gh-merge-dev.ts +3 -8
  58. package/src/commands/gh-release-deliver/gh-release-deliver.ts +77 -21
  59. package/src/commands/gh-release-deploy-all/gh-release-deploy-all.ts +13 -7
  60. package/src/commands/gh-release-deploy-selected/gh-release-deploy-selected.ts +12 -6
  61. package/src/commands/gh-release-list/gh-release-list.ts +19 -8
  62. package/src/commands/init/__tests__/migrate-config.test.ts +160 -0
  63. package/src/commands/init/init.ts +48 -35
  64. package/src/commands/init/migrate-config.ts +146 -0
  65. package/src/commands/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  66. package/src/commands/release-create/__tests__/release-create.test.ts +55 -0
  67. package/src/commands/release-create/release-create.ts +142 -38
  68. package/src/commands/release-desc-edit/release-desc-edit.ts +28 -8
  69. package/src/commands/version/version.ts +1 -1
  70. package/src/commands/worktrees-add/worktrees-add.ts +7 -12
  71. package/src/commands/worktrees-list/worktrees-list.ts +13 -5
  72. package/src/commands/worktrees-open/worktrees-open.ts +1 -1
  73. package/src/commands/worktrees-remove/worktrees-remove.ts +8 -67
  74. package/src/commands/worktrees-sync/worktrees-sync.ts +3 -5
  75. package/src/entry/cli.ts +49 -6
  76. package/src/entry/index.ts +5 -0
  77. package/src/integrations/cmux/open-workspace-with-layout.ts +4 -4
  78. package/src/integrations/cmux/workspace-title.ts +10 -4
  79. package/src/integrations/doppler/doppler-project.ts +1 -1
  80. package/src/integrations/gh/gh-release-prs/__tests__/gh-release-prs.test.ts +115 -0
  81. package/src/integrations/gh/gh-release-prs/gh-release-prs.ts +49 -32
  82. package/src/lib/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/pre-tool-advisory-throttle.json +14 -0
  83. package/src/lib/constants/index.ts +15 -0
  84. package/src/lib/git-utils/__tests__/git-utils.test.ts +49 -0
  85. package/src/lib/git-utils/git-utils.ts +3 -1
  86. package/src/lib/infra-kit-config/__tests__/infra-kit-config.test.ts +270 -0
  87. package/src/lib/infra-kit-config/index.ts +7 -1
  88. package/src/lib/infra-kit-config/infra-kit-config.ts +46 -28
  89. package/src/lib/package-config/__tests__/package-config.test.ts +95 -0
  90. package/src/lib/package-config/index.ts +3 -0
  91. package/src/lib/package-config/package-config-schema.ts +19 -0
  92. package/src/lib/package-config/package-config.ts +99 -0
  93. package/src/lib/package-validator/__tests__/package-validator.test.ts +263 -0
  94. package/src/lib/package-validator/checks/__tests__/checks.test.ts +130 -0
  95. package/src/lib/package-validator/checks/config-check.ts +30 -0
  96. package/src/lib/package-validator/checks/files-check.ts +29 -0
  97. package/src/lib/package-validator/checks/index.ts +4 -0
  98. package/src/lib/package-validator/checks/scripts-check.ts +23 -0
  99. package/src/lib/package-validator/checks/turbo-check.ts +47 -0
  100. package/src/lib/package-validator/fs-utils.ts +18 -0
  101. package/src/lib/package-validator/index.ts +3 -0
  102. package/src/lib/package-validator/loader/config-loader.ts +77 -0
  103. package/src/lib/package-validator/loader/index.ts +2 -0
  104. package/src/lib/package-validator/loader/package-discovery.ts +98 -0
  105. package/src/lib/package-validator/package-validator.ts +48 -0
  106. package/src/lib/package-validator/types.ts +15 -0
  107. package/src/lib/release-id/__tests__/release-id.test.ts +351 -0
  108. package/src/lib/release-id/__tests__/versioned-regression.test.ts +69 -0
  109. package/src/lib/release-id/index.ts +15 -0
  110. package/src/lib/release-id/release-id.ts +257 -0
  111. package/src/lib/release-utils/__tests__/release-utils.test.ts +122 -0
  112. package/src/lib/release-utils/index.ts +4 -0
  113. package/src/lib/release-utils/release-utils.ts +85 -17
  114. package/src/lib/version-utils/__tests__/load-existing-versions.test.ts +37 -0
  115. package/src/lib/version-utils/__tests__/next-version.test.ts +119 -13
  116. package/src/lib/version-utils/index.ts +3 -0
  117. package/src/lib/version-utils/load-existing-versions.ts +29 -10
  118. package/src/lib/version-utils/next-version.ts +67 -12
  119. package/src/lib/version-utils/version-utils.ts +13 -4
  120. package/src/lib/worktrees/index.ts +1 -0
  121. package/src/lib/worktrees/remove-worktrees.ts +65 -0
  122. package/src/mcp/tools/index.ts +2 -0
  123. package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  124. package/src/types.ts +1 -1
  125. package/tsconfig.tsbuildinfo +1 -1
  126. package/src/lib/__tests__/infra-kit-config.test.ts +0 -231
  127. /package/src/integrations/{clickup → linear}/.gitkeep +0 -0
  128. /package/src/lib/{__tests__ → constants/__tests__}/constants.test.ts +0 -0
  129. /package/src/lib/{constants.ts → constants/constants.ts} +0 -0
@@ -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 yaml from 'yaml'
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.yml'
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.yml'
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.yml`. */
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.yml',
104
- * // userGlobal: '/Users/arthur/.infra-kit/config.yml',
105
- * // userProject: '/Users/arthur/.infra-kit/projects/api/infra-kit.yml',
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.yml`, with optional override layers shallow-merged
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.yml` — committed source of truth
126
- * 2. `~/.infra-kit/config.yml` — user-global defaults
127
- * 3. `~/.infra-kit/projects/<repo-name>/infra-kit.yml` — user-scope per-project overrides
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.yml: { environments: ['dev'], envManagement: { provider: 'doppler', config: { name: 'p' } } }
135
- * // ~/.infra-kit/config.yml: { ide: { provider: 'cursor', config: { mode: 'windows' } } }
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
- throw new Error(`infra-kit.yml not found at ${paths.main}`)
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.yml', path: paths.main, required: true },
168
- { label: '~/.infra-kit/config.yml', path: paths.userGlobal, required: false },
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.yml`,
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.yml') // => null
228
- * const raw = await readIfExists('/exists.yml') // => 'environments: [dev]\n'
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 YAML if the file exists
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.yml', path: '/missing.yml', required: false })
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.yml: 'ide:\n provider: cursor\n config: { mode: windows }'
274
- * await loadLayer({ label: '~/.infra-kit/config.yml', path: '/home/me/.infra-kit/config.yml', required: false })
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
- const parsedRaw = yaml.parse(raw) ?? {}
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
+ })
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Validation rules for a single workspace package, declared in its
3
+ * `infra-kit.config.js`. Every field is optional: a key left unset falls back to
4
+ * the active baseline ({@link DEFAULT_RULES} for packages, {@link ROOT_DEFAULT_RULES}
5
+ * for the monorepo root), and a key set replaces that default wholesale (per-key,
6
+ * no array concatenation) so a package can opt out with an explicit empty array.
7
+ *
8
+ * Most packages need none of these — the standard rules live in the baseline, so
9
+ * a typical config is just `defineConfig(() => ({}))`.
10
+ *
11
+ * @example
12
+ * // infra-kit.config.js
13
+ * import { defineConfig } from 'infra-kit'
14
+ *
15
+ * export default defineConfig(() => ({}))
16
+ */
17
+ export interface InfraKitPackageConfig {
18
+ /** Scripts that must be present in the package's package.json `scripts` map. */
19
+ requiredScripts?: string[]
20
+ /** Files (relative to the package root) that must exist on disk. */
21
+ requiredFiles?: string[]
22
+ /** Turborepo expectations — only meaningful where a turbo.json lives (the root). */
23
+ turbo?: {
24
+ /** Tasks that must be defined in turbo.json `tasks`. */
25
+ requiredTasks?: string[]
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Accepted shapes for a package config's default export — mirrors Vite's
31
+ * `defineConfig` input: a plain object, a sync factory, or an async factory.
32
+ */
33
+ export type InfraKitPackageConfigInput =
34
+ | InfraKitPackageConfig
35
+ | (() => InfraKitPackageConfig)
36
+ | (() => Promise<InfraKitPackageConfig>)
37
+
38
+ /**
39
+ * Identity helper that gives `infra-kit.config.js` authors full type inference
40
+ * and editor autocomplete without changing the value — exactly like Vite's
41
+ * `defineConfig`. Resolution of the factory form happens in the loader, not here.
42
+ *
43
+ * @example
44
+ * export default defineConfig(() => ({}))
45
+ *
46
+ * @example
47
+ * export default defineConfig(() => ({ requiredScripts: [] }))
48
+ */
49
+ export const defineConfig = (config: InfraKitPackageConfigInput): InfraKitPackageConfigInput => {
50
+ return config
51
+ }
52
+
53
+ /** Fully-resolved rules with every defaultable field present. */
54
+ export interface ResolvedPackageRules {
55
+ requiredScripts: string[]
56
+ requiredFiles: string[]
57
+ turboTasks: string[]
58
+ }
59
+
60
+ /**
61
+ * Baseline rules for a standard TypeScript workspace package, applied to any key
62
+ * a package leaves unset. These are the "under the hood" defaults so a conforming
63
+ * package's config can stay empty; non-standard packages override the relevant key.
64
+ */
65
+ export const DEFAULT_RULES: Readonly<ResolvedPackageRules> = {
66
+ requiredScripts: ['build', 'ts-check', 'eslint-check', 'prettier-check', 'test'],
67
+ requiredFiles: ['tsconfig.json', 'eslint.config.js', 'readme.md'],
68
+ turboTasks: [],
69
+ }
70
+
71
+ /**
72
+ * Baseline rules for the monorepo root (`infra-kit audit --root`). Checks the
73
+ * root commands, the workspace/turbo files, and that the turbo pipeline defines
74
+ * the expected tasks — so the root's own config can also stay empty.
75
+ */
76
+ export const ROOT_DEFAULT_RULES: Readonly<ResolvedPackageRules> = {
77
+ requiredScripts: ['build', 'dev', 'test', 'qa', 'check', 'fix'],
78
+ requiredFiles: ['turbo.json', 'pnpm-workspace.yaml'],
79
+ turboTasks: ['build', 'test', 'ts-check', 'eslint-check', 'prettier-check', 'check'],
80
+ }
81
+
82
+ /**
83
+ * Merge a parsed package config over a baseline. Each key is replaced wholesale
84
+ * when the package provides it, otherwise the baseline value is used.
85
+ *
86
+ * @example
87
+ * resolvePackageConfig({ requiredScripts: [] })
88
+ * // => { requiredScripts: [], requiredFiles: [...DEFAULT_RULES.requiredFiles], turboTasks: [] }
89
+ */
90
+ export const resolvePackageConfig = (
91
+ config: InfraKitPackageConfig,
92
+ baseline: Readonly<ResolvedPackageRules> = DEFAULT_RULES,
93
+ ): ResolvedPackageRules => {
94
+ return {
95
+ requiredScripts: config.requiredScripts ?? [...baseline.requiredScripts],
96
+ requiredFiles: config.requiredFiles ?? [...baseline.requiredFiles],
97
+ turboTasks: config.turbo?.requiredTasks ?? [...baseline.turboTasks],
98
+ }
99
+ }
@@ -0,0 +1,263 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+
6
+ import { DEFAULT_RULES, ROOT_DEFAULT_RULES } from 'src/lib/package-config'
7
+
8
+ import { discoverPackages, loadPackageConfig, validatePackage } from '../package-validator'
9
+
10
+ const tmpDirs: string[] = []
11
+
12
+ const makeTmpDir = (): string => {
13
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pkg-validator-'))
14
+
15
+ tmpDirs.push(dir)
16
+
17
+ return dir
18
+ }
19
+
20
+ interface PackageFixture {
21
+ packageJson?: Record<string, unknown>
22
+ config?: string
23
+ files?: Record<string, string>
24
+ }
25
+
26
+ const writePackage = (dir: string, fixture: PackageFixture): void => {
27
+ const packageJson = fixture.packageJson ?? { name: '@x/pkg', type: 'module' }
28
+
29
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(packageJson, null, 2))
30
+
31
+ if (fixture.config !== undefined) {
32
+ fs.writeFileSync(path.join(dir, 'infra-kit.config.ts'), fixture.config)
33
+ }
34
+
35
+ for (const [name, content] of Object.entries(fixture.files ?? {})) {
36
+ fs.writeFileSync(path.join(dir, name), content)
37
+ }
38
+ }
39
+
40
+ afterEach(() => {
41
+ while (tmpDirs.length > 0) {
42
+ const dir = tmpDirs.pop()
43
+
44
+ if (dir) {
45
+ fs.rmSync(dir, { recursive: true, force: true })
46
+ }
47
+ }
48
+ })
49
+
50
+ describe('loadPackageConfig', () => {
51
+ it('throws when infra-kit.config.js is missing', async () => {
52
+ const dir = makeTmpDir()
53
+
54
+ writePackage(dir, {})
55
+
56
+ await expect(loadPackageConfig(dir)).rejects.toThrow(/not found/)
57
+ })
58
+
59
+ it('loads an object default export and merges defaults', async () => {
60
+ const dir = makeTmpDir()
61
+
62
+ writePackage(dir, { config: 'export default { requiredScripts: [] }' })
63
+
64
+ const rules = await loadPackageConfig(dir)
65
+
66
+ expect(rules.requiredScripts).toEqual([])
67
+ expect(rules.requiredFiles).toEqual(DEFAULT_RULES.requiredFiles)
68
+ })
69
+
70
+ it('resolves a factory (function) default export', async () => {
71
+ const dir = makeTmpDir()
72
+
73
+ writePackage(dir, { config: "export default () => ({ requiredFiles: ['a.txt'] })" })
74
+
75
+ const rules = await loadPackageConfig(dir)
76
+
77
+ expect(rules.requiredFiles).toEqual(['a.txt'])
78
+ })
79
+
80
+ it('rejects an invalid config shape with a descriptive error', async () => {
81
+ const dir = makeTmpDir()
82
+
83
+ writePackage(dir, { config: "export default { requiredScripts: 'build' }" })
84
+
85
+ await expect(loadPackageConfig(dir)).rejects.toThrow(/Invalid/)
86
+ })
87
+
88
+ it('rejects an unknown key (typo protection)', async () => {
89
+ const dir = makeTmpDir()
90
+
91
+ writePackage(dir, { config: 'export default { requiredScript: [] }' })
92
+
93
+ await expect(loadPackageConfig(dir)).rejects.toThrow(/Invalid/)
94
+ })
95
+ })
96
+
97
+ describe('validatePackage', () => {
98
+ it('passes when config, required scripts, and required files are all satisfied', async () => {
99
+ const dir = makeTmpDir()
100
+
101
+ writePackage(dir, {
102
+ packageJson: { name: '@x/ok', type: 'module', scripts: { build: 'x' } },
103
+ config: "export default { requiredScripts: ['build'], requiredFiles: ['tsconfig.json'] }",
104
+ files: { 'tsconfig.json': '{}' },
105
+ })
106
+
107
+ const result = await validatePackage(dir)
108
+
109
+ expect(result.passed).toBe(true)
110
+ expect(result.packageName).toBe('@x/ok')
111
+ })
112
+
113
+ it('applies the under-the-hood defaults when the config is empty', async () => {
114
+ const dir = makeTmpDir()
115
+
116
+ writePackage(dir, {
117
+ packageJson: {
118
+ name: '@x/std',
119
+ type: 'module',
120
+ scripts: { build: 'x', 'ts-check': 'x', 'eslint-check': 'x', 'prettier-check': 'x', test: 'x' },
121
+ },
122
+ config: 'export default {}',
123
+ files: { 'tsconfig.json': '{}', 'eslint.config.js': '', 'readme.md': '' },
124
+ })
125
+
126
+ const result = await validatePackage(dir)
127
+
128
+ expect(result.passed).toBe(true)
129
+ })
130
+
131
+ it('fails when a required script is missing', async () => {
132
+ const dir = makeTmpDir()
133
+
134
+ writePackage(dir, {
135
+ packageJson: { name: '@x/no-script', type: 'module', scripts: { build: 'x' } },
136
+ config: "export default { requiredScripts: ['build', 'ts-check'], requiredFiles: [] }",
137
+ })
138
+
139
+ const result = await validatePackage(dir)
140
+
141
+ expect(result.passed).toBe(false)
142
+ expect(result.checks).toContainEqual(expect.objectContaining({ name: 'script:ts-check', status: 'fail' }))
143
+ })
144
+
145
+ it('fails when a required file is missing', async () => {
146
+ const dir = makeTmpDir()
147
+
148
+ writePackage(dir, {
149
+ packageJson: { name: '@x/no-file', type: 'module', scripts: {} },
150
+ config: "export default { requiredScripts: [], requiredFiles: ['tsconfig.json'] }",
151
+ })
152
+
153
+ const result = await validatePackage(dir)
154
+
155
+ expect(result.passed).toBe(false)
156
+ expect(result.checks).toContainEqual(expect.objectContaining({ name: 'file:tsconfig.json', status: 'fail' }))
157
+ })
158
+
159
+ it('fails with only the config check when infra-kit.config.ts is missing', async () => {
160
+ const dir = makeTmpDir()
161
+
162
+ writePackage(dir, { packageJson: { name: '@x/no-config', type: 'module' } })
163
+
164
+ const result = await validatePackage(dir)
165
+
166
+ expect(result.passed).toBe(false)
167
+ expect(result.checks).toHaveLength(1)
168
+ expect(result.checks[0]).toMatchObject({ name: 'infra-kit.config.ts', status: 'fail' })
169
+ })
170
+ })
171
+
172
+ describe('validatePackage — root / turbo', () => {
173
+ it('passes the root baseline when scripts, files, and turbo tasks are present', async () => {
174
+ const dir = makeTmpDir()
175
+
176
+ writePackage(dir, {
177
+ packageJson: {
178
+ name: 'monorepo',
179
+ type: 'module',
180
+ scripts: { build: 'x', dev: 'x', test: 'x', qa: 'x', check: 'x', fix: 'x' },
181
+ },
182
+ config: 'export default {}',
183
+ files: {
184
+ 'pnpm-workspace.yaml': 'packages: []\n',
185
+ 'turbo.json': JSON.stringify({
186
+ tasks: { build: {}, test: {}, 'ts-check': {}, 'eslint-check': {}, 'prettier-check': {}, check: {} },
187
+ }),
188
+ },
189
+ })
190
+
191
+ const result = await validatePackage(dir, ROOT_DEFAULT_RULES)
192
+
193
+ expect(result.passed).toBe(true)
194
+ })
195
+
196
+ it('fails when a required turbo task is missing from turbo.json', async () => {
197
+ const dir = makeTmpDir()
198
+
199
+ writePackage(dir, {
200
+ packageJson: { name: 'monorepo', type: 'module' },
201
+ config: "export default { requiredScripts: [], requiredFiles: [], turbo: { requiredTasks: ['build', 'lint'] } }",
202
+ files: { 'turbo.json': JSON.stringify({ tasks: { build: {} } }) },
203
+ })
204
+
205
+ const result = await validatePackage(dir)
206
+
207
+ expect(result.passed).toBe(false)
208
+ expect(result.checks).toContainEqual(expect.objectContaining({ name: 'turbo:lint', status: 'fail' }))
209
+ expect(result.checks).toContainEqual(expect.objectContaining({ name: 'turbo:build', status: 'pass' }))
210
+ })
211
+
212
+ it('accepts a root task keyed as //#name in turbo.json', async () => {
213
+ const dir = makeTmpDir()
214
+
215
+ writePackage(dir, {
216
+ packageJson: { name: 'monorepo', type: 'module' },
217
+ config: "export default { requiredScripts: [], requiredFiles: [], turbo: { requiredTasks: ['check-root'] } }",
218
+ files: { 'turbo.json': JSON.stringify({ tasks: { '//#check-root': {} } }) },
219
+ })
220
+
221
+ const result = await validatePackage(dir)
222
+
223
+ expect(result.checks).toContainEqual(expect.objectContaining({ name: 'turbo:check-root', status: 'pass' }))
224
+ })
225
+ })
226
+
227
+ describe('discoverPackages', () => {
228
+ it('expands non-vendor globs and excludes vendor and negations', async () => {
229
+ const root = makeTmpDir()
230
+
231
+ fs.writeFileSync(
232
+ path.join(root, 'pnpm-workspace.yaml'),
233
+ 'packages:\n - apps/*/*\n - packages/*\n - vendor/packages/*\n - "!**/test/**"\n',
234
+ )
235
+
236
+ const dirs = ['apps/infra-kit/cli', 'packages/p1', 'vendor/packages/v1']
237
+
238
+ for (const dir of dirs) {
239
+ const full = path.join(root, dir)
240
+
241
+ fs.mkdirSync(full, { recursive: true })
242
+ fs.writeFileSync(path.join(full, 'package.json'), '{}')
243
+ }
244
+
245
+ const found = await discoverPackages(root)
246
+
247
+ expect(found).toContain(path.join(root, 'apps/infra-kit/cli'))
248
+ expect(found).toContain(path.join(root, 'packages/p1'))
249
+ expect(found).not.toContain(path.join(root, 'vendor/packages/v1'))
250
+ })
251
+
252
+ it('omits directories that lack a package.json', async () => {
253
+ const root = makeTmpDir()
254
+
255
+ fs.writeFileSync(path.join(root, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n')
256
+
257
+ fs.mkdirSync(path.join(root, 'packages/empty'), { recursive: true })
258
+
259
+ const found = await discoverPackages(root)
260
+
261
+ expect(found).not.toContain(path.join(root, 'packages/empty'))
262
+ })
263
+ })