infra-kit 0.1.89 → 0.1.90
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 +4 -5
- package/.turbo/turbo-test.log +14 -17
- package/.turbo/turbo-ts-check.log +4 -5
- package/dist/cli.js +31 -30
- package/dist/cli.js.map +4 -4
- package/dist/mcp.js +24 -23
- package/dist/mcp.js.map +4 -4
- package/package.json +4 -4
- package/src/commands/env-clear/env-clear.ts +9 -17
- package/src/commands/env-list/env-list.ts +6 -5
- package/src/commands/env-load/__tests__/env-load.test.ts +132 -0
- package/src/commands/env-load/env-load.ts +95 -14
- package/src/commands/env-status/env-status.ts +8 -0
- package/src/commands/init/init.ts +8 -5
- package/src/commands/worktrees-add/worktrees-add.ts +36 -3
- package/src/entry/cli.ts +21 -2
- package/src/integrations/cmux/index.ts +1 -0
- package/src/integrations/cmux/open-workspace-with-layout.ts +41 -0
- package/src/integrations/doppler/doppler-cli-auth.ts +9 -11
- package/src/lib/__tests__/constants.test.ts +160 -0
- package/src/lib/__tests__/infra-kit-config.test.ts +129 -0
- package/src/lib/constants.ts +50 -11
- package/src/lib/git-utils/git-utils.ts +36 -0
- package/src/lib/git-utils/index.ts +1 -1
- package/src/lib/infra-kit-config.ts +26 -12
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +14 -11
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infra-kit",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.90",
|
|
5
5
|
"description": "infra-kit",
|
|
6
6
|
"main": "dist/cli.js",
|
|
7
7
|
"module": "dist/cli.js",
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"fix": "pnpm run prettier-fix && pnpm run eslint-fix && pnpm run qa"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@inquirer/checkbox": "^5.1.
|
|
34
|
-
"@inquirer/confirm": "^6.0.
|
|
35
|
-
"@inquirer/select": "^5.1.
|
|
33
|
+
"@inquirer/checkbox": "^5.1.4",
|
|
34
|
+
"@inquirer/confirm": "^6.0.12",
|
|
35
|
+
"@inquirer/select": "^5.1.4",
|
|
36
36
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
37
37
|
"commander": "^14.0.3",
|
|
38
38
|
"pino": "^10.3.1",
|
|
@@ -9,35 +9,27 @@ import {
|
|
|
9
9
|
INFRA_KIT_ENV_CONFIG_VAR,
|
|
10
10
|
INFRA_KIT_ENV_LOADED_AT_VAR,
|
|
11
11
|
INFRA_KIT_ENV_PROJECT_VAR,
|
|
12
|
+
atomicWriteFileSync,
|
|
12
13
|
getSessionCacheDir,
|
|
13
14
|
parseVarNamesFromEnvFile,
|
|
14
15
|
} from 'src/lib/constants'
|
|
15
|
-
import { logger } from 'src/lib/logger'
|
|
16
16
|
import type { ToolsExecutionResult } from 'src/types'
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Clear loaded env vars. Prints a file path to stdout that must be sourced to apply.
|
|
19
|
+
* Clear loaded env vars. Prints a file path to stdout that must be sourced to apply.
|
|
20
|
+
* The env-clear shell alias does this automatically. Throws when no env is loaded
|
|
21
|
+
* so CLI callers exit non-zero and MCP callers receive a structured tool error.
|
|
20
22
|
*/
|
|
21
23
|
export const envClear = async (): Promise<ToolsExecutionResult> => {
|
|
22
24
|
const cacheDir = getSessionCacheDir()
|
|
23
25
|
const envLoadPath = path.join(cacheDir, ENV_LOAD_FILE)
|
|
24
26
|
|
|
25
27
|
if (!fs.existsSync(envLoadPath)) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return {
|
|
29
|
-
content: [
|
|
30
|
-
{
|
|
31
|
-
type: 'text',
|
|
32
|
-
text: 'No loaded environment found. Run `env-load` first.',
|
|
33
|
-
},
|
|
34
|
-
],
|
|
35
|
-
}
|
|
28
|
+
throw new Error('No loaded environment found. Run `env-load` first.')
|
|
36
29
|
}
|
|
37
30
|
|
|
38
31
|
const varNames = parseVarNamesFromEnvFile(envLoadPath)
|
|
39
32
|
|
|
40
|
-
// Build unset script
|
|
41
33
|
const unsetLines = [
|
|
42
34
|
...varNames.map((v) => {
|
|
43
35
|
return `unset ${v}`
|
|
@@ -47,16 +39,16 @@ export const envClear = async (): Promise<ToolsExecutionResult> => {
|
|
|
47
39
|
`unset ${INFRA_KIT_ENV_LOADED_AT_VAR}`,
|
|
48
40
|
]
|
|
49
41
|
|
|
50
|
-
// Write unset script to cache
|
|
51
42
|
const clearFilePath = path.resolve(cacheDir, ENV_CLEAR_FILE)
|
|
52
43
|
|
|
53
|
-
fs.mkdirSync(cacheDir, { recursive: true })
|
|
54
|
-
|
|
44
|
+
fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 })
|
|
45
|
+
|
|
46
|
+
atomicWriteFileSync(clearFilePath, `${unsetLines.join('\n')}\n`, 0o600)
|
|
55
47
|
|
|
56
48
|
// REQUIRED
|
|
57
49
|
process.stdout.write(`${clearFilePath}\n`)
|
|
58
50
|
|
|
59
|
-
// Remove env load file so env-clear
|
|
51
|
+
// Remove env load file so the next env-clear call correctly reports "no env loaded".
|
|
60
52
|
fs.unlinkSync(envLoadPath)
|
|
61
53
|
|
|
62
54
|
const structuredContent = {
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
|
-
import { validateDopplerCliAndAuth } from 'src/integrations/doppler'
|
|
4
3
|
import { getDopplerProject } from 'src/integrations/doppler/doppler-project'
|
|
5
4
|
import { getInfraKitConfig } from 'src/lib/infra-kit-config'
|
|
6
5
|
import { logger } from 'src/lib/logger'
|
|
7
6
|
import type { ToolsExecutionResult } from 'src/types'
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
|
-
* List available Doppler configs for the detected project
|
|
9
|
+
* List available Doppler configs for the detected project.
|
|
10
|
+
*
|
|
11
|
+
* Purely local: reads infra-kit.yml and does not call Doppler. We intentionally
|
|
12
|
+
* do not run validateDopplerCliAndAuth here — users listing envs often do so
|
|
13
|
+
* before `doppler login`, and a spurious auth error would be misleading.
|
|
11
14
|
*/
|
|
12
15
|
export const envList = async (): Promise<ToolsExecutionResult> => {
|
|
13
|
-
await validateDopplerCliAndAuth()
|
|
14
|
-
|
|
15
16
|
const project = await getDopplerProject()
|
|
16
17
|
const { environments } = await getInfraKitConfig()
|
|
17
18
|
|
|
@@ -42,7 +43,7 @@ export const envList = async (): Promise<ToolsExecutionResult> => {
|
|
|
42
43
|
export const envListMcpTool = {
|
|
43
44
|
name: 'env-list',
|
|
44
45
|
description:
|
|
45
|
-
'List the environments the project is configured to support. Returns
|
|
46
|
+
'List the environments the project is configured to support. Returns the `environments` list declared in infra-kit.yml at the project root (not a live fetch from Doppler) plus the Doppler project name resolved from the same file. Read-only.',
|
|
46
47
|
inputSchema: {},
|
|
47
48
|
outputSchema: {
|
|
48
49
|
project: z.string().describe('Detected Doppler project name'),
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { DOPPLER_MAX_OUTPUT_BYTES, assertDopplerOutputSize, assertValidEnvContent, shellSingleQuote } from '../env-load'
|
|
4
|
+
|
|
5
|
+
describe('shellSingleQuote', () => {
|
|
6
|
+
it('wraps plain values in single quotes', () => {
|
|
7
|
+
expect(shellSingleQuote('dev')).toBe("'dev'")
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('escapes embedded single quotes using the posix idiom', () => {
|
|
11
|
+
expect(shellSingleQuote("it's")).toBe(String.raw`'it'\''s'`)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('handles multiple single quotes', () => {
|
|
15
|
+
expect(shellSingleQuote("a'b'c")).toBe(String.raw`'a'\''b'\''c'`)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('wraps empty strings as empty single quotes', () => {
|
|
19
|
+
expect(shellSingleQuote('')).toBe("''")
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('passes unicode through unchanged', () => {
|
|
23
|
+
expect(shellSingleQuote('café')).toBe("'café'")
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('does not alter backslashes (single-quoted shell strings treat them literally)', () => {
|
|
27
|
+
expect(shellSingleQuote(String.raw`a\b`)).toBe(String.raw`'a\b'`)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('assertValidEnvContent', () => {
|
|
32
|
+
it('throws on empty content', () => {
|
|
33
|
+
expect(() => {
|
|
34
|
+
return assertValidEnvContent('')
|
|
35
|
+
}).toThrow(/empty output/)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('throws on whitespace-only content', () => {
|
|
39
|
+
expect(() => {
|
|
40
|
+
return assertValidEnvContent(' \n\n ')
|
|
41
|
+
}).toThrow(/empty output/)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('accepts a single KEY=value line', () => {
|
|
45
|
+
expect(() => {
|
|
46
|
+
return assertValidEnvContent('FOO=bar')
|
|
47
|
+
}).not.toThrow()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('accepts multiple KEY=value lines', () => {
|
|
51
|
+
expect(() => {
|
|
52
|
+
return assertValidEnvContent('FOO=bar\nBAZ=qux\nQUUX=123')
|
|
53
|
+
}).not.toThrow()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('ignores `set -a` / `set +a` directives', () => {
|
|
57
|
+
expect(() => {
|
|
58
|
+
return assertValidEnvContent('set -a\nFOO=bar\nset +a')
|
|
59
|
+
}).not.toThrow()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('ignores blank lines between KEY=value lines', () => {
|
|
63
|
+
expect(() => {
|
|
64
|
+
return assertValidEnvContent('FOO=bar\n\nBAZ=qux')
|
|
65
|
+
}).not.toThrow()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('throws when first non-blank line is not KEY=value', () => {
|
|
69
|
+
expect(() => {
|
|
70
|
+
return assertValidEnvContent('<html>error</html>\nFOO=bar')
|
|
71
|
+
}).toThrow(/unexpected output/)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('throws when a later line is garbage (not only first-line validation)', () => {
|
|
75
|
+
expect(() => {
|
|
76
|
+
return assertValidEnvContent('FOO=bar\n<html>error</html>')
|
|
77
|
+
}).toThrow(/unexpected output/)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('throws when a line looks like a shell command rather than an assignment', () => {
|
|
81
|
+
expect(() => {
|
|
82
|
+
return assertValidEnvContent('FOO=bar\nunset BAR')
|
|
83
|
+
}).toThrow(/unexpected output/)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('accepts values containing = characters (connection strings)', () => {
|
|
87
|
+
expect(() => {
|
|
88
|
+
return assertValidEnvContent('DB_URL=host=db;user=admin\nFOO=bar')
|
|
89
|
+
}).not.toThrow()
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('assertDopplerOutputSize', () => {
|
|
94
|
+
it('accepts empty output (shape is validated separately)', () => {
|
|
95
|
+
expect(() => {
|
|
96
|
+
return assertDopplerOutputSize('')
|
|
97
|
+
}).not.toThrow()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('accepts typical-sized output', () => {
|
|
101
|
+
expect(() => {
|
|
102
|
+
return assertDopplerOutputSize('FOO=bar\n'.repeat(100))
|
|
103
|
+
}).not.toThrow()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('accepts output exactly at the cap', () => {
|
|
107
|
+
const atCap = 'x'.repeat(DOPPLER_MAX_OUTPUT_BYTES)
|
|
108
|
+
|
|
109
|
+
expect(() => {
|
|
110
|
+
return assertDopplerOutputSize(atCap)
|
|
111
|
+
}).not.toThrow()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('rejects output one byte over the cap', () => {
|
|
115
|
+
const overCap = 'x'.repeat(DOPPLER_MAX_OUTPUT_BYTES + 1)
|
|
116
|
+
|
|
117
|
+
expect(() => {
|
|
118
|
+
return assertDopplerOutputSize(overCap)
|
|
119
|
+
}).toThrow(/unexpectedly large output/)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('counts bytes not characters (multi-byte unicode)', () => {
|
|
123
|
+
// "💥" is 4 bytes in UTF-8; N copies = 4N bytes. Pick N so bytes > cap but chars < cap.
|
|
124
|
+
const chars = Math.ceil(DOPPLER_MAX_OUTPUT_BYTES / 4) + 1
|
|
125
|
+
const multiByte = '💥'.repeat(chars)
|
|
126
|
+
|
|
127
|
+
expect(multiByte.length).toBeLessThan(DOPPLER_MAX_OUTPUT_BYTES)
|
|
128
|
+
expect(() => {
|
|
129
|
+
return assertDopplerOutputSize(multiByte)
|
|
130
|
+
}).toThrow(/unexpectedly large output/)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import select from '@inquirer/select'
|
|
2
|
+
import { Buffer } from 'node:buffer'
|
|
2
3
|
import fs from 'node:fs'
|
|
3
4
|
import path from 'node:path'
|
|
4
5
|
import process from 'node:process'
|
|
@@ -10,9 +11,11 @@ import { getDopplerProject } from 'src/integrations/doppler/doppler-project'
|
|
|
10
11
|
import { commandEcho } from 'src/lib/command-echo'
|
|
11
12
|
import {
|
|
12
13
|
ENV_LOAD_FILE,
|
|
14
|
+
ENV_VAR_LINE_PATTERN,
|
|
13
15
|
INFRA_KIT_ENV_CONFIG_VAR,
|
|
14
16
|
INFRA_KIT_ENV_LOADED_AT_VAR,
|
|
15
17
|
INFRA_KIT_ENV_PROJECT_VAR,
|
|
18
|
+
atomicWriteFileSync,
|
|
16
19
|
getSessionCacheDir,
|
|
17
20
|
} from 'src/lib/constants'
|
|
18
21
|
import { getInfraKitConfig } from 'src/lib/infra-kit-config'
|
|
@@ -57,37 +60,35 @@ export const envLoad = async (args: EnvLoadArgs): Promise<ToolsExecutionResult>
|
|
|
57
60
|
|
|
58
61
|
const project = await getDopplerProject()
|
|
59
62
|
|
|
60
|
-
|
|
61
|
-
const result =
|
|
62
|
-
await $`doppler secrets download --no-file --format env --project ${project} --config ${selectedConfig}`
|
|
63
|
+
const envContent = await downloadDopplerSecrets(project, selectedConfig)
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
const envContent = result.stdout.trim()
|
|
65
|
+
assertValidEnvContent(envContent)
|
|
66
66
|
|
|
67
67
|
// Build env file content in dotenv format
|
|
68
68
|
const loadedAt = new Date().toISOString()
|
|
69
69
|
const envFileLines = [
|
|
70
70
|
'set -a',
|
|
71
71
|
envContent,
|
|
72
|
-
`${INFRA_KIT_ENV_CONFIG_VAR}=${selectedConfig}`,
|
|
73
|
-
`${INFRA_KIT_ENV_PROJECT_VAR}=${project}`,
|
|
74
|
-
`${INFRA_KIT_ENV_LOADED_AT_VAR}=${loadedAt}`,
|
|
72
|
+
`${INFRA_KIT_ENV_CONFIG_VAR}=${shellSingleQuote(selectedConfig)}`,
|
|
73
|
+
`${INFRA_KIT_ENV_PROJECT_VAR}=${shellSingleQuote(project)}`,
|
|
74
|
+
`${INFRA_KIT_ENV_LOADED_AT_VAR}=${shellSingleQuote(loadedAt)}`,
|
|
75
75
|
'set +a',
|
|
76
76
|
]
|
|
77
77
|
|
|
78
|
-
// Write env file to cache
|
|
79
78
|
const cacheDir = getSessionCacheDir()
|
|
80
79
|
const envFilePath = path.resolve(cacheDir, ENV_LOAD_FILE)
|
|
81
80
|
|
|
82
|
-
fs.mkdirSync(cacheDir, { recursive: true })
|
|
83
|
-
|
|
81
|
+
fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 })
|
|
82
|
+
atomicWriteFileSync(envFilePath, `${envFileLines.join('\n')}\n`, 0o600)
|
|
84
83
|
|
|
85
84
|
// REQUIRED
|
|
86
85
|
process.stdout.write(`${envFilePath}\n`)
|
|
87
86
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
// Logs to stderr (pino → pretty-print), so it doesn't pollute the captured
|
|
88
|
+
// file path that the shell wrapper reads from stdout.
|
|
89
|
+
commandEcho.print()
|
|
90
|
+
|
|
91
|
+
const varCount = countEnvVarLines(envContent)
|
|
91
92
|
|
|
92
93
|
const structuredContent = {
|
|
93
94
|
filePath: envFilePath,
|
|
@@ -107,6 +108,86 @@ export const envLoad = async (args: EnvLoadArgs): Promise<ToolsExecutionResult>
|
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Cap the Doppler stdout we're willing to accept. A well-formed env bundle is
|
|
113
|
+
* O(10 KB); megabytes would indicate a service regression or the wrong stream
|
|
114
|
+
* being captured, and we don't want to write that to disk or source it.
|
|
115
|
+
*/
|
|
116
|
+
export const DOPPLER_MAX_OUTPUT_BYTES = 1024 * 1024
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Hard upper bound for the Doppler subprocess. Well under zx's default so a
|
|
120
|
+
* hung call surfaces quickly instead of blocking an interactive shell or an
|
|
121
|
+
* MCP tool handler.
|
|
122
|
+
*/
|
|
123
|
+
const DOPPLER_DOWNLOAD_TIMEOUT_MS = 30_000
|
|
124
|
+
|
|
125
|
+
const downloadDopplerSecrets = async (project: string, config: string): Promise<string> => {
|
|
126
|
+
const prevQuiet = $.quiet
|
|
127
|
+
|
|
128
|
+
$.quiet = true
|
|
129
|
+
try {
|
|
130
|
+
const result =
|
|
131
|
+
await $`doppler secrets download --no-file --format env --project ${project} --config ${config}`.timeout(
|
|
132
|
+
DOPPLER_DOWNLOAD_TIMEOUT_MS,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
assertDopplerOutputSize(result.stdout)
|
|
136
|
+
|
|
137
|
+
return result.stdout.trim()
|
|
138
|
+
} finally {
|
|
139
|
+
$.quiet = prevQuiet
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const assertDopplerOutputSize = (stdout: string): void => {
|
|
144
|
+
const bytes = Buffer.byteLength(stdout, 'utf-8')
|
|
145
|
+
|
|
146
|
+
if (bytes > DOPPLER_MAX_OUTPUT_BYTES) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`doppler returned unexpectedly large output (${bytes} bytes > ${DOPPLER_MAX_OUTPUT_BYTES}) — refusing to write to disk`,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const countEnvVarLines = (content: string): number => {
|
|
154
|
+
return content.split('\n').filter((line) => {
|
|
155
|
+
return ENV_VAR_LINE_PATTERN.test(line)
|
|
156
|
+
}).length
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const SHELL_DIRECTIVE_LINES = new Set(['set -a', 'set +a'])
|
|
160
|
+
|
|
161
|
+
export const shellSingleQuote = (value: string): string => {
|
|
162
|
+
const escaped = value.replaceAll("'", "'\\''")
|
|
163
|
+
|
|
164
|
+
return `'${escaped}'`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Guard against Doppler returning non-env output (auth warnings on stdout,
|
|
169
|
+
* partial downloads, HTML error pages, etc.). Every non-blank, non-directive
|
|
170
|
+
* line must match KEY=VALUE — skipping directives keeps future format tweaks
|
|
171
|
+
* cheap without loosening the check.
|
|
172
|
+
*/
|
|
173
|
+
export const assertValidEnvContent = (content: string): void => {
|
|
174
|
+
if (content.trim().length === 0) {
|
|
175
|
+
throw new Error('doppler returned empty output for env-load')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const line of content.split('\n')) {
|
|
179
|
+
const trimmed = line.trim()
|
|
180
|
+
|
|
181
|
+
if (trimmed.length === 0 || SHELL_DIRECTIVE_LINES.has(trimmed)) continue
|
|
182
|
+
|
|
183
|
+
if (!ENV_VAR_LINE_PATTERN.test(trimmed)) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`doppler returned unexpected output for env-load (expected KEY=value lines, got: ${JSON.stringify(trimmed.slice(0, 80))})`,
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
110
191
|
// MCP Tool Registration
|
|
111
192
|
export const envLoadMcpTool = {
|
|
112
193
|
name: 'env-load',
|
|
@@ -51,6 +51,14 @@ export const envStatus = async (): Promise<ToolsExecutionResult> => {
|
|
|
51
51
|
logger.info(
|
|
52
52
|
` ${sessionConfig}: ${sessionLoadedCount} of ${sessionTotalCount} vars loaded (project: ${sessionProject}, loadedAt: ${loadedAtDisplay}, session: ${sessionId})\n`,
|
|
53
53
|
)
|
|
54
|
+
|
|
55
|
+
if (sessionTotalCount > 0 && sessionLoadedCount < sessionTotalCount) {
|
|
56
|
+
const missing = sessionTotalCount - sessionLoadedCount
|
|
57
|
+
|
|
58
|
+
logger.warn(
|
|
59
|
+
` ${missing} cached var(s) are not present in the current process — env-load needs to be re-sourced, or vars were unset manually.`,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
54
62
|
} else {
|
|
55
63
|
logger.info(` Session ${sessionId}: no env loaded\n`)
|
|
56
64
|
}
|
|
@@ -38,7 +38,6 @@ const isBlockLine = (line: string): boolean => {
|
|
|
38
38
|
line.startsWith('env-status') ||
|
|
39
39
|
line.startsWith('if ') ||
|
|
40
40
|
line.startsWith(' export INFRA_KIT_SESSION') ||
|
|
41
|
-
line.startsWith('export _INFRA_KIT_LAST_') ||
|
|
42
41
|
line.startsWith('export _INFRA_KIT_') ||
|
|
43
42
|
line.startsWith(': ${_INFRA_KIT_') ||
|
|
44
43
|
line.startsWith('fi') ||
|
|
@@ -113,13 +112,15 @@ const buildShellBlock = (): string => {
|
|
|
113
112
|
// eslint-disable-next-line no-template-curly-in-string
|
|
114
113
|
': ${_INFRA_KIT_SHELL_STARTED:=${EPOCHSECONDS:-0}}',
|
|
115
114
|
'export _INFRA_KIT_LAST_LOAD_MTIME _INFRA_KIT_LAST_CLEAR_MTIME _INFRA_KIT_SHELL_STARTED',
|
|
116
|
-
`env-load() { local f; f=$(${runCmd} env-load "$@")
|
|
117
|
-
`env-clear() { local f; f=$(${runCmd} env-clear)
|
|
115
|
+
`env-load() { local f m; f=$(${runCmd} env-load "$@") || return; m=$(zstat +mtime -- "$f" 2>/dev/null || echo 0); _INFRA_KIT_LAST_LOAD_MTIME=$m; source "$f"; ${runCmd} env-status; }`,
|
|
116
|
+
`env-clear() { local f m; f=$(${runCmd} env-clear) || return; m=$(zstat +mtime -- "$f" 2>/dev/null || echo 0); _INFRA_KIT_LAST_CLEAR_MTIME=$m; source "$f"; ${runCmd} env-status; }`,
|
|
118
117
|
`env-status() { ${runCmd} env-status; }`,
|
|
119
118
|
`alias ik='${runCmd}'`,
|
|
120
119
|
'_infra_kit_autoload() {',
|
|
121
120
|
' [[ -z "$INFRA_KIT_SESSION" ]] && return',
|
|
122
|
-
|
|
121
|
+
// eslint-disable-next-line no-template-curly-in-string
|
|
122
|
+
' local cache_root="${XDG_CACHE_HOME:-$HOME/.cache}/infra-kit"',
|
|
123
|
+
' local dir="$cache_root/$INFRA_KIT_SESSION"',
|
|
123
124
|
' local load_file="$dir/env-load.sh"',
|
|
124
125
|
' local clear_file="$dir/env-clear.sh"',
|
|
125
126
|
' local mtime',
|
|
@@ -142,7 +143,9 @@ const buildShellBlock = (): string => {
|
|
|
142
143
|
' fi',
|
|
143
144
|
'}',
|
|
144
145
|
'autoload -Uz add-zsh-hook',
|
|
145
|
-
'
|
|
146
|
+
'if (( _INFRA_KIT_SHELL_STARTED > 0 )); then',
|
|
147
|
+
' add-zsh-hook precmd _infra_kit_autoload',
|
|
148
|
+
'fi',
|
|
146
149
|
MARKER_END,
|
|
147
150
|
].join('\n')
|
|
148
151
|
}
|
|
@@ -5,10 +5,11 @@ import process from 'node:process'
|
|
|
5
5
|
import { z } from 'zod'
|
|
6
6
|
import { $ } from 'zx'
|
|
7
7
|
|
|
8
|
+
import { openCmuxWorkspaceWithLayout } from 'src/integrations/cmux'
|
|
8
9
|
import { getReleasePRsWithInfo } from 'src/integrations/gh'
|
|
9
10
|
import { commandEcho } from 'src/lib/command-echo'
|
|
10
11
|
import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
|
|
11
|
-
import { getCurrentWorktrees, getProjectRoot } from 'src/lib/git-utils'
|
|
12
|
+
import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
|
|
12
13
|
import { logger } from 'src/lib/logger'
|
|
13
14
|
import { detectReleaseType, formatBranchChoices, getJiraDescriptions } from 'src/lib/release-utils'
|
|
14
15
|
import type { ReleaseType } from 'src/lib/release-utils'
|
|
@@ -24,6 +25,7 @@ interface WorktreeManagementArgs extends RequiredConfirmedOptionArg {
|
|
|
24
25
|
versions?: string
|
|
25
26
|
cursor?: boolean
|
|
26
27
|
githubDesktop?: boolean
|
|
28
|
+
cmux?: boolean
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -31,7 +33,7 @@ interface WorktreeManagementArgs extends RequiredConfirmedOptionArg {
|
|
|
31
33
|
* Creates worktrees for active release branches and removes unused ones
|
|
32
34
|
*/
|
|
33
35
|
export const worktreesAdd = async (options: WorktreeManagementArgs): Promise<ToolsExecutionResult> => {
|
|
34
|
-
const { confirmedCommand, all, versions, cursor, githubDesktop } = options
|
|
36
|
+
const { confirmedCommand, all, versions, cursor, githubDesktop, cmux } = options
|
|
35
37
|
|
|
36
38
|
commandEcho.start('worktrees-add')
|
|
37
39
|
|
|
@@ -147,6 +149,18 @@ export const worktreesAdd = async (options: WorktreeManagementArgs): Promise<Too
|
|
|
147
149
|
commandEcho.addOption('--no-github-desktop', true)
|
|
148
150
|
}
|
|
149
151
|
|
|
152
|
+
const openInCmux = cmux ?? (await confirm({ message: 'Open created worktrees in cmux?' }))
|
|
153
|
+
|
|
154
|
+
if (typeof cmux === 'undefined') {
|
|
155
|
+
commandEcho.setInteractive()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (openInCmux) {
|
|
159
|
+
commandEcho.addOption('--cmux', true)
|
|
160
|
+
} else {
|
|
161
|
+
commandEcho.addOption('--no-cmux', true)
|
|
162
|
+
}
|
|
163
|
+
|
|
150
164
|
const { branchesToCreate } = categorizeWorktrees({
|
|
151
165
|
selectedReleaseBranches,
|
|
152
166
|
currentWorktrees,
|
|
@@ -169,6 +183,19 @@ export const worktreesAdd = async (options: WorktreeManagementArgs): Promise<Too
|
|
|
169
183
|
}
|
|
170
184
|
}
|
|
171
185
|
|
|
186
|
+
if (openInCmux) {
|
|
187
|
+
const repoName = await getRepoName()
|
|
188
|
+
|
|
189
|
+
for (const branch of createdWorktrees) {
|
|
190
|
+
const version = branch.replace('release/', '')
|
|
191
|
+
|
|
192
|
+
await openCmuxWorkspaceWithLayout({
|
|
193
|
+
cwd: `${worktreeDir}/${branch}`,
|
|
194
|
+
title: `${repoName} ${version}`,
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
172
199
|
commandEcho.print()
|
|
173
200
|
|
|
174
201
|
const structuredContent = {
|
|
@@ -269,7 +296,7 @@ const logResults = (created: string[]): void => {
|
|
|
269
296
|
export const worktreesAddMcpTool = {
|
|
270
297
|
name: 'worktrees-add',
|
|
271
298
|
description:
|
|
272
|
-
'Create local git worktrees for release branches under the worktrees directory and run "pnpm install" in each. Mutates the local filesystem. When invoked via MCP, pass either "versions" (comma-separated) or all=true — the branch picker and "open in Cursor / GitHub Desktop" follow-up prompts are unreachable without a TTY, and the CLI confirmation is auto-skipped for MCP calls.',
|
|
299
|
+
'Create local git worktrees for release branches under the worktrees directory and run "pnpm install" in each. Mutates the local filesystem. When invoked via MCP, pass either "versions" (comma-separated) or all=true — the branch picker and "open in Cursor / GitHub Desktop / cmux" follow-up prompts are unreachable without a TTY, and the CLI confirmation is auto-skipped for MCP calls.',
|
|
273
300
|
inputSchema: {
|
|
274
301
|
all: z
|
|
275
302
|
.boolean()
|
|
@@ -295,6 +322,12 @@ export const worktreesAddMcpTool = {
|
|
|
295
322
|
.describe(
|
|
296
323
|
'Open each created worktree in GitHub Desktop. Defaults to false in MCP mode (the follow-up prompt is not shown).',
|
|
297
324
|
),
|
|
325
|
+
cmux: z
|
|
326
|
+
.boolean()
|
|
327
|
+
.optional()
|
|
328
|
+
.describe(
|
|
329
|
+
'Open each created worktree in a new cmux workspace with a 3-pane layout (left-top, left-bottom, full-height right), all rooted at the worktree directory. Defaults to false in MCP mode (the follow-up prompt is not shown).',
|
|
330
|
+
),
|
|
298
331
|
},
|
|
299
332
|
outputSchema: {
|
|
300
333
|
createdWorktrees: z.array(z.string()).describe('List of created git worktree branches'),
|
package/src/entry/cli.ts
CHANGED
|
@@ -19,9 +19,25 @@ import { worktreesAdd } from 'src/commands/worktrees-add'
|
|
|
19
19
|
import { worktreesList } from 'src/commands/worktrees-list'
|
|
20
20
|
import { worktreesRemove } from 'src/commands/worktrees-remove'
|
|
21
21
|
import { worktreesSync } from 'src/commands/worktrees-sync'
|
|
22
|
+
import { logger } from 'src/lib/logger'
|
|
22
23
|
|
|
23
24
|
const program = new Command()
|
|
24
25
|
|
|
26
|
+
const runProgram = async (argv?: string[]): Promise<void> => {
|
|
27
|
+
try {
|
|
28
|
+
if (argv) {
|
|
29
|
+
await program.parseAsync(argv)
|
|
30
|
+
} else {
|
|
31
|
+
await program.parseAsync()
|
|
32
|
+
}
|
|
33
|
+
} catch (error) {
|
|
34
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
35
|
+
|
|
36
|
+
logger.error(message)
|
|
37
|
+
process.exit(1)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
program
|
|
26
42
|
.command('merge-dev')
|
|
27
43
|
.description('Merge dev branch into every release branch')
|
|
@@ -123,6 +139,8 @@ program
|
|
|
123
139
|
.option('--no-cursor', 'Skip Cursor prompt')
|
|
124
140
|
.option('-g, --github-desktop', 'Open created worktrees in GitHub Desktop')
|
|
125
141
|
.option('--no-github-desktop', 'Skip GitHub Desktop prompt')
|
|
142
|
+
.option('-m, --cmux', 'Open created worktrees in cmux (3-pane layout)')
|
|
143
|
+
.option('--no-cmux', 'Skip cmux prompt')
|
|
126
144
|
.action(async (options) => {
|
|
127
145
|
await worktreesAdd({
|
|
128
146
|
confirmedCommand: options.yes,
|
|
@@ -130,6 +148,7 @@ program
|
|
|
130
148
|
versions: options.versions,
|
|
131
149
|
cursor: options.cursor,
|
|
132
150
|
githubDesktop: options.githubDesktop,
|
|
151
|
+
cmux: options.cmux,
|
|
133
152
|
})
|
|
134
153
|
})
|
|
135
154
|
|
|
@@ -250,7 +269,7 @@ if (process.argv.length <= 2) {
|
|
|
250
269
|
{ output: process.stderr },
|
|
251
270
|
)
|
|
252
271
|
|
|
253
|
-
|
|
272
|
+
await runProgram(['node', 'infra-kit', selected])
|
|
254
273
|
} else {
|
|
255
|
-
|
|
274
|
+
await runProgram()
|
|
256
275
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { openCmuxWorkspaceWithLayout } from './open-workspace-with-layout'
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { $ } from 'zx'
|
|
2
|
+
|
|
3
|
+
interface OpenCmuxWorkspaceArgs {
|
|
4
|
+
cwd: string
|
|
5
|
+
title?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Opens a new cmux workspace rooted at `cwd` with three panes:
|
|
10
|
+
* left-top (primary) | right (full-height)
|
|
11
|
+
* left-bottom |
|
|
12
|
+
* All panes inherit `cwd` from the workspace.
|
|
13
|
+
*/
|
|
14
|
+
export const openCmuxWorkspaceWithLayout = async (args: OpenCmuxWorkspaceArgs): Promise<void> => {
|
|
15
|
+
const { cwd, title } = args
|
|
16
|
+
|
|
17
|
+
await $`cmux new-workspace --cwd ${cwd}`
|
|
18
|
+
|
|
19
|
+
const workspaceRef = (await $`cmux current-workspace`).stdout.trim()
|
|
20
|
+
|
|
21
|
+
const surfacesOutput = (await $`cmux list-pane-surfaces --workspace ${workspaceRef}`).stdout
|
|
22
|
+
|
|
23
|
+
const leftTopRef = parseFirstSurfaceRef(surfacesOutput)
|
|
24
|
+
|
|
25
|
+
await $`cmux new-split right --workspace ${workspaceRef} --surface ${leftTopRef}`
|
|
26
|
+
await $`cmux new-split down --workspace ${workspaceRef} --surface ${leftTopRef}`
|
|
27
|
+
|
|
28
|
+
if (title) {
|
|
29
|
+
await $`cmux rename-workspace --workspace ${workspaceRef} ${title}`
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const parseFirstSurfaceRef = (output: string): string => {
|
|
34
|
+
const match = output.match(/surface:\d+/)
|
|
35
|
+
|
|
36
|
+
if (!match) {
|
|
37
|
+
throw new Error('cmux: could not locate initial surface in list-pane-surfaces output')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return match[0]
|
|
41
|
+
}
|