infra-kit 0.1.97 → 0.1.99

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "infra-kit",
3
3
  "type": "module",
4
- "version": "0.1.97",
4
+ "version": "0.1.99",
5
5
  "description": "infra-kit",
6
6
  "main": "dist/cli.js",
7
7
  "module": "dist/cli.js",
@@ -0,0 +1,125 @@
1
+ import fs from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import process from 'node:process'
5
+ import { $ } from 'zx'
6
+
7
+ import { getInfraKitConfigPaths, resetInfraKitConfigCache } from 'src/lib/infra-kit-config'
8
+ import { logger } from 'src/lib/logger'
9
+ import type { ToolsExecutionResult } from 'src/types'
10
+
11
+ /**
12
+ * Resolve whether a file is reachable, suppressing ENOENT into a boolean.
13
+ *
14
+ * @example
15
+ * await fileExists('/etc/hosts') // => true
16
+ * await fileExists('/nope.txt') // => false
17
+ */
18
+ const fileExists = async (filePath: string): Promise<boolean> => {
19
+ try {
20
+ await fs.access(filePath)
21
+
22
+ return true
23
+ } catch {
24
+ return false
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Replace the user's home prefix with `~` so logged paths stay short and
30
+ * portable across machines. Leaves non-home paths untouched.
31
+ *
32
+ * @example
33
+ * // os.homedir() === '/Users/arthur'
34
+ * tildify('/Users/arthur/.infra-kit/config.yml') // => '~/.infra-kit/config.yml'
35
+ * tildify('/etc/hosts') // => '/etc/hosts'
36
+ */
37
+ const tildify = (filePath: string): string => {
38
+ const home = os.homedir()
39
+
40
+ return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath
41
+ }
42
+
43
+ /**
44
+ * Print the file paths that participate in the config merge chain along with
45
+ * existence markers, so the user can see at a glance which override layers
46
+ * are active.
47
+ *
48
+ * @example
49
+ * // CLI: `infra-kit config path`
50
+ * // INFO: Project name: api
51
+ * // INFO: Config merge chain (later overrides earlier):
52
+ * // INFO: [✓] project (committed) ~/projects/api/infra-kit.yml
53
+ * // INFO: [ ] user global ~/.infra-kit/config.yml
54
+ * // INFO: [✓] user project ~/.infra-kit/projects/api/infra-kit.yml
55
+ */
56
+ export const configPath = async (): Promise<ToolsExecutionResult> => {
57
+ const paths = await getInfraKitConfigPaths()
58
+
59
+ const rows: { label: string; path: string; exists: boolean }[] = await Promise.all(
60
+ [
61
+ { label: 'project (committed)', path: paths.main },
62
+ { label: 'user global', path: paths.userGlobal },
63
+ { label: 'user project', path: paths.userProject },
64
+ ].map(async (row) => {
65
+ return { ...row, exists: await fileExists(row.path) }
66
+ }),
67
+ )
68
+
69
+ logger.info(`Project name: ${paths.projectName}\n`)
70
+ logger.info('Config merge chain (later overrides earlier):\n')
71
+
72
+ for (const row of rows) {
73
+ const marker = row.exists ? ' [✓]' : ' [ ]'
74
+
75
+ logger.info(`${marker} ${row.label.padEnd(22)} ${tildify(row.path)}`)
76
+ }
77
+
78
+ const structuredContent = {
79
+ projectName: paths.projectName,
80
+ layers: rows.map((r) => {
81
+ return { label: r.label, path: r.path, exists: r.exists }
82
+ }),
83
+ }
84
+
85
+ return {
86
+ content: [{ type: 'text', text: JSON.stringify(structuredContent, null, 2) }],
87
+ structuredContent,
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Open the user-scope per-project override file in $EDITOR, creating the
93
+ * parent directory and a stub file on first use. Resets the config cache
94
+ * after the editor exits so subsequent reads pick up edits without a restart.
95
+ *
96
+ * @example
97
+ * // CLI: `infra-kit config edit`
98
+ * // first run — creates ~/.infra-kit/projects/api/infra-kit.yml from a stub, then $EDITOR opens it
99
+ * // subsequent runs — opens the existing file as-is
100
+ */
101
+ export const configEdit = async (): Promise<ToolsExecutionResult> => {
102
+ const paths = await getInfraKitConfigPaths()
103
+ const editor = process.env.EDITOR || process.env.VISUAL || 'vi'
104
+
105
+ await fs.mkdir(path.dirname(paths.userProject), { recursive: true })
106
+
107
+ if (!(await fileExists(paths.userProject))) {
108
+ const stub = `# infra-kit user override for ${paths.projectName} — ~/.infra-kit/projects/${paths.projectName}/infra-kit.yml\n#\n# Layer 3 (highest precedence) of the config merge chain. Shallow-merged on\n# top of <repo>/infra-kit.yml and ~/.infra-kit/config.yml — top-level keys\n# (environments, envManagement, ide, taskManager, worktrees) replace wholesale.\n`
109
+
110
+ await fs.writeFile(paths.userProject, stub, 'utf-8')
111
+ }
112
+
113
+ logger.info(`Opening ${tildify(paths.userProject)} in ${editor}`)
114
+
115
+ await $({ stdio: 'inherit' })`${editor} ${paths.userProject}`
116
+
117
+ resetInfraKitConfigCache()
118
+
119
+ const structuredContent = { path: paths.userProject, editor }
120
+
121
+ return {
122
+ content: [{ type: 'text', text: JSON.stringify(structuredContent, null, 2) }],
123
+ structuredContent,
124
+ }
125
+ }
@@ -0,0 +1 @@
1
+ export { configEdit, configPath } from './config'
@@ -6,12 +6,10 @@ import { $ } from 'zx'
6
6
 
7
7
  import { MARKER_END, MARKER_START, buildShellBlock } from 'src/commands/init/init'
8
8
  import { getProjectRoot } from 'src/lib/git-utils/git-utils'
9
- import { getInfraKitConfig, resetInfraKitConfigCache } from 'src/lib/infra-kit-config'
9
+ import { getInfraKitConfig, getInfraKitConfigPaths, resetInfraKitConfigCache } from 'src/lib/infra-kit-config'
10
10
  import { logger } from 'src/lib/logger'
11
11
  import type { ToolsExecutionResult } from 'src/types'
12
12
 
13
- const LOCAL_CONFIG_FILE = 'infra-kit.local.yml'
14
-
15
13
  interface CheckResult {
16
14
  name: string
17
15
  status: 'pass' | 'fail'
@@ -110,32 +108,43 @@ const checkInfraKitConfigValid = async (): Promise<CheckResult> => {
110
108
  return {
111
109
  name,
112
110
  status: 'pass',
113
- message: 'infra-kit.yml is valid (infra-kit.local.yml overrides applied if present)',
111
+ message: 'infra-kit.yml is valid (user overrides applied if present)',
114
112
  }
115
113
  } catch (err) {
116
114
  return { name, status: 'fail', message: (err as Error).message }
117
115
  }
118
116
  }
119
117
 
120
- const checkLocalConfigGitignored = async (): Promise<CheckResult> => {
121
- const name = 'infra-kit.local.yml gitignored'
118
+ /**
119
+ * Surface where this developer's user-scope override file would live and
120
+ * whether it has been created. Always passes — informational only — so the
121
+ * user knows the resolved project name and target path at a glance.
122
+ *
123
+ * @example
124
+ * await checkUserOverridePath()
125
+ * // {
126
+ * // name: 'user override path',
127
+ * // status: 'pass',
128
+ * // message: '~/.infra-kit/projects/api/infra-kit.yml (not yet created) — project: api',
129
+ * // }
130
+ */
131
+ const checkUserOverridePath = async (): Promise<CheckResult> => {
132
+ const name = 'user override path'
122
133
 
123
134
  try {
124
- const root = await getProjectRoot()
125
-
126
- await $({ cwd: root, nothrow: true })`git check-ignore -q ${LOCAL_CONFIG_FILE}`.then((result) => {
127
- if (result.exitCode !== 0) {
128
- throw new Error('not ignored')
129
- }
130
- })
135
+ const paths = await getInfraKitConfigPaths()
136
+ const home = os.homedir()
137
+ const display = paths.userProject.startsWith(home) ? `~${paths.userProject.slice(home.length)}` : paths.userProject
138
+ const exists = fs.existsSync(paths.userProject)
139
+ const suffix = exists ? '(exists)' : '(not yet created)'
131
140
 
132
- return { name, status: 'pass', message: `${LOCAL_CONFIG_FILE} is covered by .gitignore` }
133
- } catch {
134
141
  return {
135
142
  name,
136
- status: 'fail',
137
- message: `${LOCAL_CONFIG_FILE} is not gitignored. Add "${LOCAL_CONFIG_FILE}" to .gitignore.`,
143
+ status: 'pass',
144
+ message: `${display} ${suffix} project: ${paths.projectName}`,
138
145
  }
146
+ } catch (err) {
147
+ return { name, status: 'fail', message: (err as Error).message }
139
148
  }
140
149
  }
141
150
 
@@ -184,7 +193,7 @@ export const doctor = async (): Promise<ToolsExecutionResult> => {
184
193
  Promise.resolve(checkZshrcInitialized()),
185
194
  checkPnpmWorkspaceVirtualStore(),
186
195
  checkInfraKitConfigValid(),
187
- checkLocalConfigGitignored(),
196
+ checkUserOverridePath(),
188
197
  ])
189
198
 
190
199
  logger.info('Doctor check results:\n')
@@ -10,8 +10,44 @@ export const MARKER_END = '# -- infra-kit:end --'
10
10
  const LEGACY_PAIRED: [start: string, end: string][] = [['# region infra-kit', '# endregion infra-kit']]
11
11
  const LEGACY_SINGLE = '# infra-kit shell functions'
12
12
 
13
+ const USER_GLOBAL_CONFIG_STUB = `# infra-kit user-global config — ~/.infra-kit/config.yml
14
+ #
15
+ # Merge chain (later layers override earlier ones at top-level keys):
16
+ # 1. <repo>/infra-kit.yml — committed project config (required)
17
+ # 2. ~/.infra-kit/config.yml — this file (user-global)
18
+ # 3. ~/.infra-kit/projects/<repo-name>/infra-kit.yml — user-scope per-project override
19
+ #
20
+ # Merge is shallow: setting a top-level key here replaces that whole section
21
+ # from layer 1. Arrays do not concatenate. Top-level keys recognized:
22
+ # environments, envManagement, ide, taskManager, worktrees.
23
+ #
24
+ # Uncomment the blocks you want to apply globally across every project on this
25
+ # machine. Per-project tweaks belong in layer 3 — run \`infra-kit config edit\`.
26
+
27
+ # Per-developer IDE config
28
+ # ide:
29
+ # provider: cursor
30
+ # config:
31
+ # mode: workspace
32
+ # workspaceConfigPath: /path/to/your.code-workspace
33
+
34
+ # Worktree prompt defaults — silences the follow-up prompts in \`worktrees-add\`
35
+ # worktrees:
36
+ # openInGithubDesktop: false
37
+ # openInCmux: true
38
+ `
39
+
13
40
  /**
14
- * Append infra-kit shell functions directly to .zshrc.
41
+ * Append infra-kit shell functions to .zshrc and seed the user-global
42
+ * config stub at ~/.infra-kit/config.yml on first run. Idempotent: a
43
+ * subsequent run replaces the existing zshrc block in place and leaves
44
+ * the user-global config untouched.
45
+ *
46
+ * @example
47
+ * // CLI: `infra-kit init` (or via the `pnpm dx-init` alias)
48
+ * // INFO: Added infra-kit shell functions to /Users/me/.zshrc
49
+ * // INFO: Wrote user-global config stub to /Users/me/.infra-kit/config.yml
50
+ * // INFO: Run `source ~/.zshrc` or open a new terminal to activate.
15
51
  */
16
52
  export const init = async (): Promise<void> => {
17
53
  const zshrcPath = path.join(os.homedir(), '.zshrc')
@@ -26,9 +62,37 @@ export const init = async (): Promise<void> => {
26
62
 
27
63
  fs.appendFileSync(zshrcPath, `\n${shellBlock}\n`)
28
64
  logger.info(`Added infra-kit shell functions to ${zshrcPath}`)
65
+
66
+ seedUserGlobalConfig()
67
+
29
68
  logger.info('Run `source ~/.zshrc` or open a new terminal to activate.')
30
69
  }
31
70
 
71
+ /**
72
+ * Create `~/.infra-kit/config.yml` with the documented stub when absent.
73
+ * Skips silently if the file already exists so user edits are preserved.
74
+ *
75
+ * @example
76
+ * seedUserGlobalConfig()
77
+ * // first call: writes ~/.infra-kit/config.yml from USER_GLOBAL_CONFIG_STUB
78
+ * // later calls: leaves the file alone, logs that it is already present
79
+ */
80
+ const seedUserGlobalConfig = (): void => {
81
+ const userConfigDir = path.join(os.homedir(), '.infra-kit')
82
+ const userConfigPath = path.join(userConfigDir, 'config.yml')
83
+
84
+ if (fs.existsSync(userConfigPath)) {
85
+ logger.info(`User-global config already present at ${userConfigPath}`)
86
+
87
+ return
88
+ }
89
+
90
+ fs.mkdirSync(userConfigDir, { recursive: true })
91
+ fs.writeFileSync(userConfigPath, USER_GLOBAL_CONFIG_STUB, 'utf-8')
92
+
93
+ logger.info(`Wrote user-global config stub to ${userConfigPath}`)
94
+ }
95
+
32
96
  const isBlockLine = (line: string): boolean => {
33
97
  return (
34
98
  line.startsWith('#') ||