infra-kit 0.1.124 → 0.1.126
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-2ccbd177-0d5d-4d4a-ab94-a22464786221.jsonl +1 -0
- package/.omc/state/agent-replay-eb44055b-c2fa-4e66-b491-a1d9b44f51c7.jsonl +1 -0
- package/.omc/state/idle-notif-cooldown.json +1 -1
- package/.omc/state/sessions/2ccbd177-0d5d-4d4a-ab94-a22464786221/pre-tool-advisory-throttle.json +18 -0
- package/.omc/state/sessions/2ccbd177-0d5d-4d4a-ab94-a22464786221/subagent-tracking-state.json +17 -0
- package/.omc/state/sessions/eb44055b-c2fa-4e66-b491-a1d9b44f51c7/last-tool-error-state.json +7 -0
- package/.omc/state/sessions/eb44055b-c2fa-4e66-b491-a1d9b44f51c7/pre-tool-advisory-throttle.json +18 -0
- package/.omc/state/sessions/eb44055b-c2fa-4e66-b491-a1d9b44f51c7/subagent-tracking-state.json +17 -0
- package/.turbo/turbo-build.log +6 -6
- package/.turbo/turbo-eslint-check.log +1 -1
- package/.turbo/turbo-prettier-check.log +1 -1
- package/.turbo/turbo-test.log +128 -127
- package/.turbo/turbo-ts-check.log +1 -1
- package/dist/chunk-EY5LDHXC.js +164 -0
- package/dist/chunk-EY5LDHXC.js.map +7 -0
- package/dist/cli.js +6 -6
- package/dist/cli.js.map +3 -3
- package/dist/mcp.js +1 -1
- package/package.json +1 -1
- package/src/.omc/state/agent-replay-2ccbd177-0d5d-4d4a-ab94-a22464786221.jsonl +2 -0
- package/src/.omc/state/idle-notif-cooldown.json +1 -1
- package/src/.omc/state/sessions/2ccbd177-0d5d-4d4a-ab94-a22464786221/last-tool-error-state.json +7 -0
- package/src/.omc/state/sessions/2ccbd177-0d5d-4d4a-ab94-a22464786221/mission-state.json +53 -0
- package/src/.omc/state/sessions/2ccbd177-0d5d-4d4a-ab94-a22464786221/pre-tool-advisory-throttle.json +22 -0
- package/src/.omc/state/sessions/2ccbd177-0d5d-4d4a-ab94-a22464786221/subagent-tracking-state.json +17 -0
- package/src/commands/env-clear/env-clear.ts +4 -1
- package/src/commands/env-load/__tests__/env-load.test.ts +99 -66
- package/src/commands/env-load/env-load.ts +87 -35
- package/src/commands/init/__tests__/shell-body.test.ts +4 -4
- package/src/commands/init/init.ts +23 -24
- package/src/commands/worktrees-add/worktrees-add.ts +6 -0
- package/src/commands/worktrees-remove/worktrees-remove.ts +6 -0
- package/src/commands/worktrees-sync/worktrees-sync.ts +6 -0
- package/src/entry/cli.ts +60 -45
- package/src/lib/constants/__tests__/constants.test.ts +22 -0
- package/src/lib/constants/constants.ts +40 -4
- package/src/lib/constants/index.ts +1 -0
- package/src/lib/env-autoload/__tests__/env-autoload.test.ts +15 -1
- package/src/lib/env-autoload/env-autoload.ts +54 -6
- package/src/lib/errors/__tests__/is-prompt-cancellation.test.ts +48 -0
- package/src/lib/errors/is-prompt-cancellation.ts +38 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/chunk-W4QG2W7G.js +0 -166
- package/dist/chunk-W4QG2W7G.js.map +0 -7
package/src/.omc/state/sessions/2ccbd177-0d5d-4d4a-ab94-a22464786221/pre-tool-advisory-throttle.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"entries": {
|
|
4
|
+
"466399dafa2d20f60587180bad0a07358eb7f2bce724df0ff682f038ad33f5ad": {
|
|
5
|
+
"last_emitted_at_ms": 1782817961585,
|
|
6
|
+
"message": "Read multiple files in parallel when possible for faster analysis."
|
|
7
|
+
},
|
|
8
|
+
"445ed27a3872b681d98190bae61ccb84954e1bc4e140df5370be958dee776b3a": {
|
|
9
|
+
"last_emitted_at_ms": 1782817799580,
|
|
10
|
+
"message": "Verify changes work after editing. Test functionality before marking complete."
|
|
11
|
+
},
|
|
12
|
+
"79a93d4a2f8f50b95f852280616242fee1855dc99a3c75211917f55e72e95fae": {
|
|
13
|
+
"last_emitted_at_ms": 1782817772318,
|
|
14
|
+
"message": "Use parallel execution for independent tasks. Use run_in_background for long operations (npm install, builds, tests)."
|
|
15
|
+
},
|
|
16
|
+
"a353cb6bbc54e52818d87f20d3e405a3b424eea973fb85201083ef0dac4defab": {
|
|
17
|
+
"last_emitted_at_ms": 1782817459860,
|
|
18
|
+
"message": "Spawning agent: oh-my-claudecode:executor (opus) | Task: Implement env-autoload bug fixes"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"updated_at": "2026-06-30T11:12:41.585Z"
|
|
22
|
+
}
|
package/src/.omc/state/sessions/2ccbd177-0d5d-4d4a-ab94-a22464786221/subagent-tracking-state.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"agents": [
|
|
3
|
+
{
|
|
4
|
+
"agent_id": "aa0bc2493749f11ee",
|
|
5
|
+
"agent_type": "oh-my-claudecode:executor",
|
|
6
|
+
"started_at": "2026-06-30T11:04:20.002Z",
|
|
7
|
+
"parent_mode": "none",
|
|
8
|
+
"status": "completed",
|
|
9
|
+
"completed_at": "2026-06-30T11:13:43.458Z",
|
|
10
|
+
"duration_ms": 563456
|
|
11
|
+
}
|
|
12
|
+
],
|
|
13
|
+
"total_spawned": 1,
|
|
14
|
+
"total_completed": 1,
|
|
15
|
+
"total_failed": 0,
|
|
16
|
+
"last_updated": "2026-06-30T11:13:43.560Z"
|
|
17
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
INFRA_KIT_ENV_CLEARED_VAR,
|
|
11
11
|
INFRA_KIT_ENV_CONFIG_VAR,
|
|
12
12
|
INFRA_KIT_ENV_LOADED_AT_VAR,
|
|
13
|
+
INFRA_KIT_ENV_PROJECT_ROOT_VAR,
|
|
13
14
|
INFRA_KIT_ENV_PROJECT_VAR,
|
|
14
15
|
atomicWriteFileSync,
|
|
15
16
|
getSessionCacheDir,
|
|
@@ -30,6 +31,7 @@ export const buildEnvClearLines = (varNames: string[]): string[] => {
|
|
|
30
31
|
}),
|
|
31
32
|
`unset ${INFRA_KIT_ENV_CONFIG_VAR}`,
|
|
32
33
|
`unset ${INFRA_KIT_ENV_PROJECT_VAR}`,
|
|
34
|
+
`unset ${INFRA_KIT_ENV_PROJECT_ROOT_VAR}`,
|
|
33
35
|
`unset ${INFRA_KIT_ENV_LOADED_AT_VAR}`,
|
|
34
36
|
`unset ${INFRA_KIT_ENV_AUTOLOADED_VAR}`,
|
|
35
37
|
`export ${INFRA_KIT_ENV_CLEARED_VAR}='1'`,
|
|
@@ -63,7 +65,8 @@ export const envClear = async () => {
|
|
|
63
65
|
process.stdout.write(`${clearFilePath}\n`)
|
|
64
66
|
|
|
65
67
|
// Remove env load file so the next env-clear call correctly reports "no env loaded".
|
|
66
|
-
|
|
68
|
+
// `force` so concurrent clears don't throw ENOENT when another already removed it.
|
|
69
|
+
fs.rmSync(envLoadPath, { force: true })
|
|
67
70
|
|
|
68
71
|
const structuredContent = {
|
|
69
72
|
filePath: clearFilePath,
|
|
@@ -3,8 +3,8 @@ import { describe, expect, it } from 'vitest'
|
|
|
3
3
|
import {
|
|
4
4
|
DOPPLER_MAX_OUTPUT_BYTES,
|
|
5
5
|
assertDopplerOutputSize,
|
|
6
|
-
assertValidEnvContent,
|
|
7
6
|
buildEnvLoadFileLines,
|
|
7
|
+
parseDopplerSecretsJson,
|
|
8
8
|
shellSingleQuote,
|
|
9
9
|
} from '../env-load'
|
|
10
10
|
|
|
@@ -34,68 +34,6 @@ describe('shellSingleQuote', () => {
|
|
|
34
34
|
})
|
|
35
35
|
})
|
|
36
36
|
|
|
37
|
-
describe('assertValidEnvContent', () => {
|
|
38
|
-
it('throws on empty content', () => {
|
|
39
|
-
expect(() => {
|
|
40
|
-
return assertValidEnvContent('')
|
|
41
|
-
}).toThrow(/empty output/)
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
it('throws on whitespace-only content', () => {
|
|
45
|
-
expect(() => {
|
|
46
|
-
return assertValidEnvContent(' \n\n ')
|
|
47
|
-
}).toThrow(/empty output/)
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
it('accepts a single KEY=value line', () => {
|
|
51
|
-
expect(() => {
|
|
52
|
-
return assertValidEnvContent('FOO=bar')
|
|
53
|
-
}).not.toThrow()
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('accepts multiple KEY=value lines', () => {
|
|
57
|
-
expect(() => {
|
|
58
|
-
return assertValidEnvContent('FOO=bar\nBAZ=qux\nQUUX=123')
|
|
59
|
-
}).not.toThrow()
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
it('ignores `set -a` / `set +a` directives', () => {
|
|
63
|
-
expect(() => {
|
|
64
|
-
return assertValidEnvContent('set -a\nFOO=bar\nset +a')
|
|
65
|
-
}).not.toThrow()
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
it('ignores blank lines between KEY=value lines', () => {
|
|
69
|
-
expect(() => {
|
|
70
|
-
return assertValidEnvContent('FOO=bar\n\nBAZ=qux')
|
|
71
|
-
}).not.toThrow()
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
it('throws when first non-blank line is not KEY=value', () => {
|
|
75
|
-
expect(() => {
|
|
76
|
-
return assertValidEnvContent('<html>error</html>\nFOO=bar')
|
|
77
|
-
}).toThrow(/unexpected output/)
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('throws when a later line is garbage (not only first-line validation)', () => {
|
|
81
|
-
expect(() => {
|
|
82
|
-
return assertValidEnvContent('FOO=bar\n<html>error</html>')
|
|
83
|
-
}).toThrow(/unexpected output/)
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
it('throws when a line looks like a shell command rather than an assignment', () => {
|
|
87
|
-
expect(() => {
|
|
88
|
-
return assertValidEnvContent('FOO=bar\nunset BAR')
|
|
89
|
-
}).toThrow(/unexpected output/)
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
it('accepts values containing = characters (connection strings)', () => {
|
|
93
|
-
expect(() => {
|
|
94
|
-
return assertValidEnvContent('DB_URL=host=db;user=admin\nFOO=bar')
|
|
95
|
-
}).not.toThrow()
|
|
96
|
-
})
|
|
97
|
-
})
|
|
98
|
-
|
|
99
37
|
describe('assertDopplerOutputSize', () => {
|
|
100
38
|
it('accepts empty output (shape is validated separately)', () => {
|
|
101
39
|
expect(() => {
|
|
@@ -138,15 +76,27 @@ describe('assertDopplerOutputSize', () => {
|
|
|
138
76
|
})
|
|
139
77
|
|
|
140
78
|
describe('buildEnvLoadFileLines', () => {
|
|
141
|
-
const args = {
|
|
142
|
-
|
|
143
|
-
|
|
79
|
+
const args = {
|
|
80
|
+
pairs: [
|
|
81
|
+
['A', '1'],
|
|
82
|
+
['B', '2'],
|
|
83
|
+
] as Array<[string, string]>,
|
|
84
|
+
config: 'dev',
|
|
85
|
+
project: 'proj',
|
|
86
|
+
projectRoot: '/repo',
|
|
87
|
+
loadedAt: '2026-01-01T00:00:00.000Z',
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
it('wraps the body in set -a / set +a and records config/project/projectRoot/loadedAt', () => {
|
|
144
91
|
const lines = buildEnvLoadFileLines({ ...args, autoLoaded: false })
|
|
145
92
|
|
|
146
93
|
expect(lines[0]).toBe('set -a')
|
|
147
94
|
expect(lines.at(-1)).toBe('set +a')
|
|
95
|
+
expect(lines).toContain("A='1'")
|
|
96
|
+
expect(lines).toContain("B='2'")
|
|
148
97
|
expect(lines).toContain("INFRA_KIT_ENV_CONFIG='dev'")
|
|
149
98
|
expect(lines).toContain("INFRA_KIT_ENV_PROJECT='proj'")
|
|
99
|
+
expect(lines).toContain("INFRA_KIT_ENV_PROJECT_ROOT='/repo'")
|
|
150
100
|
expect(lines).toContain("INFRA_KIT_ENV_LOADED_AT='2026-01-01T00:00:00.000Z'")
|
|
151
101
|
})
|
|
152
102
|
|
|
@@ -165,3 +115,86 @@ describe('buildEnvLoadFileLines', () => {
|
|
|
165
115
|
expect(lines).not.toContain("INFRA_KIT_ENV_AUTOLOADED='1'")
|
|
166
116
|
})
|
|
167
117
|
})
|
|
118
|
+
|
|
119
|
+
describe('buildEnvLoadFileLines — injection neutralization (single-quoting)', () => {
|
|
120
|
+
const baseArgs = {
|
|
121
|
+
config: 'dev',
|
|
122
|
+
project: 'proj',
|
|
123
|
+
projectRoot: '/repo',
|
|
124
|
+
loadedAt: '2026-01-01T00:00:00.000Z',
|
|
125
|
+
autoLoaded: false as const,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
it('single-quotes shell-active values so nothing expands or executes on source', () => {
|
|
129
|
+
const pairs: Array<[string, string]> = [
|
|
130
|
+
['CMD', 'a$(id)b'],
|
|
131
|
+
['VAR', 'x$HOME'],
|
|
132
|
+
['TICK', '`whoami`'],
|
|
133
|
+
['Q', "it's"],
|
|
134
|
+
['NL', 'line1\nline2'],
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
const lines = buildEnvLoadFileLines({ ...baseArgs, pairs })
|
|
138
|
+
|
|
139
|
+
// Each value line is exactly KEY=<posix-single-quoted value>.
|
|
140
|
+
expect(lines).toContain("CMD='a$(id)b'")
|
|
141
|
+
expect(lines).toContain("VAR='x$HOME'")
|
|
142
|
+
expect(lines).toContain("TICK='`whoami`'")
|
|
143
|
+
expect(lines).toContain(String.raw`Q='it'\''s'`)
|
|
144
|
+
// The newline stays literal inside the single quotes.
|
|
145
|
+
expect(lines).toContain("NL='line1\nline2'")
|
|
146
|
+
|
|
147
|
+
// The load-bearing guarantee: no emitted value line carries an unescaped
|
|
148
|
+
// command substitution, backtick, or a double-quote-wrapped value.
|
|
149
|
+
const valueLines = lines.filter((line) => {
|
|
150
|
+
return /^(?:CMD|VAR|TICK|Q|NL)=/.test(line)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
for (const line of valueLines) {
|
|
154
|
+
const value = line.slice(line.indexOf('=') + 1)
|
|
155
|
+
|
|
156
|
+
expect(value.startsWith("'")).toBe(true)
|
|
157
|
+
expect(value.endsWith("'")).toBe(true)
|
|
158
|
+
expect(value).not.toMatch(/^"/)
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('parseDopplerSecretsJson', () => {
|
|
164
|
+
it('parses a flat KEY:value object into ordered pairs (values returned raw)', () => {
|
|
165
|
+
expect(parseDopplerSecretsJson('{"FOO":"bar","BAZ":"a$(x)"}')).toEqual([
|
|
166
|
+
['FOO', 'bar'],
|
|
167
|
+
['BAZ', 'a$(x)'],
|
|
168
|
+
])
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('throws on non-JSON output', () => {
|
|
172
|
+
expect(() => {
|
|
173
|
+
return parseDopplerSecretsJson('<html>nope</html>')
|
|
174
|
+
}).toThrow(/non-JSON output/)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('throws on a JSON array (wrong shape)', () => {
|
|
178
|
+
expect(() => {
|
|
179
|
+
return parseDopplerSecretsJson('["FOO","bar"]')
|
|
180
|
+
}).toThrow(/unexpected JSON shape/)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('throws on an empty object', () => {
|
|
184
|
+
expect(() => {
|
|
185
|
+
return parseDopplerSecretsJson('{}')
|
|
186
|
+
}).toThrow(/empty output/)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('throws on an invalid env var name', () => {
|
|
190
|
+
expect(() => {
|
|
191
|
+
return parseDopplerSecretsJson('{"1BAD":"x"}')
|
|
192
|
+
}).toThrow(/invalid env var name/)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('throws on a non-string value', () => {
|
|
196
|
+
expect(() => {
|
|
197
|
+
return parseDopplerSecretsJson('{"FOO":123}')
|
|
198
|
+
}).toThrow(/non-string value/)
|
|
199
|
+
})
|
|
200
|
+
})
|
|
@@ -17,16 +17,17 @@ import {
|
|
|
17
17
|
import { commandEcho } from 'src/lib/command-echo'
|
|
18
18
|
import {
|
|
19
19
|
ENV_LOAD_FILE,
|
|
20
|
-
ENV_VAR_LINE_PATTERN,
|
|
21
20
|
INFRA_KIT_ENV_AUTOLOADED_VAR,
|
|
22
21
|
INFRA_KIT_ENV_CLEARED_VAR,
|
|
23
22
|
INFRA_KIT_ENV_CONFIG_VAR,
|
|
24
23
|
INFRA_KIT_ENV_LOADED_AT_VAR,
|
|
24
|
+
INFRA_KIT_ENV_PROJECT_ROOT_VAR,
|
|
25
25
|
INFRA_KIT_ENV_PROJECT_VAR,
|
|
26
26
|
atomicWriteFileSync,
|
|
27
27
|
getSessionCacheDir,
|
|
28
28
|
} from 'src/lib/constants'
|
|
29
29
|
import { extractStderr } from 'src/lib/errors/operation-error'
|
|
30
|
+
import { getProjectRoot } from 'src/lib/git-utils'
|
|
30
31
|
import { getInfraKitConfig } from 'src/lib/infra-kit-config'
|
|
31
32
|
import { defineMcpTool, textContent } from 'src/types'
|
|
32
33
|
|
|
@@ -43,6 +44,12 @@ interface WriteEnvLoadFileArgs {
|
|
|
43
44
|
* marker and lifts any clear suppression so a deliberate load wins.
|
|
44
45
|
*/
|
|
45
46
|
autoLoaded?: boolean
|
|
47
|
+
/**
|
|
48
|
+
* Auto-load only: re-checked AFTER the Doppler download, immediately before the
|
|
49
|
+
* atomic write. Return false to abort (a clear or a manual load landed during the
|
|
50
|
+
* slow download). Manual loads omit it and always write.
|
|
51
|
+
*/
|
|
52
|
+
beforeWrite?: () => boolean
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
export interface EnvLoadFileResult {
|
|
@@ -53,9 +60,10 @@ export interface EnvLoadFileResult {
|
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
interface EnvLoadFileLinesArgs {
|
|
56
|
-
|
|
63
|
+
pairs: Array<[string, string]>
|
|
57
64
|
config: string
|
|
58
65
|
project: string
|
|
66
|
+
projectRoot: string
|
|
59
67
|
loadedAt: string
|
|
60
68
|
autoLoaded: boolean
|
|
61
69
|
}
|
|
@@ -84,23 +92,41 @@ const buildAutoLoadMarkerLines = (autoLoaded: boolean): string[] => {
|
|
|
84
92
|
* assignment when the file is sourced.
|
|
85
93
|
*/
|
|
86
94
|
export const buildEnvLoadFileLines = ({
|
|
87
|
-
|
|
95
|
+
pairs,
|
|
88
96
|
config,
|
|
89
97
|
project,
|
|
98
|
+
projectRoot,
|
|
90
99
|
loadedAt,
|
|
91
100
|
autoLoaded,
|
|
92
101
|
}: EnvLoadFileLinesArgs): string[] => {
|
|
93
102
|
return [
|
|
94
103
|
'set -a',
|
|
95
|
-
|
|
104
|
+
...pairs.map(([key, value]) => {
|
|
105
|
+
return `${key}=${shellSingleQuote(value)}`
|
|
106
|
+
}),
|
|
96
107
|
`${INFRA_KIT_ENV_CONFIG_VAR}=${shellSingleQuote(config)}`,
|
|
97
108
|
`${INFRA_KIT_ENV_PROJECT_VAR}=${shellSingleQuote(project)}`,
|
|
109
|
+
`${INFRA_KIT_ENV_PROJECT_ROOT_VAR}=${shellSingleQuote(projectRoot)}`,
|
|
98
110
|
`${INFRA_KIT_ENV_LOADED_AT_VAR}=${shellSingleQuote(loadedAt)}`,
|
|
99
111
|
...buildAutoLoadMarkerLines(autoLoaded),
|
|
100
112
|
'set +a',
|
|
101
113
|
]
|
|
102
114
|
}
|
|
103
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Project root (git top-level) the loaded env belongs to, for the cross-project
|
|
118
|
+
* shell gate. Returns '' when this isn't a git checkout so a non-git infra-kit
|
|
119
|
+
* project still loads — the shell gate then fails open (spawns rather than skips)
|
|
120
|
+
* instead of breaking the whole load on `git rev-parse` throwing.
|
|
121
|
+
*/
|
|
122
|
+
const resolveProjectRootSafe = async (): Promise<string> => {
|
|
123
|
+
try {
|
|
124
|
+
return await getProjectRoot()
|
|
125
|
+
} catch {
|
|
126
|
+
return ''
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
104
130
|
/**
|
|
105
131
|
* Download Doppler secrets for a resolved config and atomically write env-load.sh
|
|
106
132
|
* to the session cache dir. Does NOT print to stdout — shared by the CLI/MCP
|
|
@@ -110,27 +136,31 @@ export const buildEnvLoadFileLines = ({
|
|
|
110
136
|
export const writeEnvLoadFile = async ({
|
|
111
137
|
config,
|
|
112
138
|
autoLoaded = false,
|
|
113
|
-
|
|
139
|
+
beforeWrite,
|
|
140
|
+
}: WriteEnvLoadFileArgs): Promise<EnvLoadFileResult | null> => {
|
|
114
141
|
await validateDopplerCliAndAuth()
|
|
115
142
|
|
|
116
143
|
const project = await getDopplerProject()
|
|
144
|
+
const projectRoot = await resolveProjectRootSafe()
|
|
117
145
|
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
assertValidEnvContent(envContent)
|
|
146
|
+
const pairs = await downloadDopplerSecrets(project, config)
|
|
121
147
|
|
|
122
148
|
const loadedAt = new Date().toISOString()
|
|
123
|
-
const envFileLines = buildEnvLoadFileLines({
|
|
149
|
+
const envFileLines = buildEnvLoadFileLines({ pairs, config, project, projectRoot, loadedAt, autoLoaded })
|
|
124
150
|
|
|
125
151
|
const cacheDir = getSessionCacheDir()
|
|
126
152
|
const envFilePath = path.resolve(cacheDir, ENV_LOAD_FILE)
|
|
127
153
|
|
|
154
|
+
// Re-check suppression immediately before the atomic write: a clear or a manual
|
|
155
|
+
// load may have landed during the slow Doppler download (auto-load path only).
|
|
156
|
+
if (beforeWrite && !beforeWrite()) return null
|
|
157
|
+
|
|
128
158
|
fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 })
|
|
129
159
|
atomicWriteFileSync(envFilePath, `${envFileLines.join('\n')}\n`, 0o600)
|
|
130
160
|
|
|
131
161
|
return {
|
|
132
162
|
filePath: envFilePath,
|
|
133
|
-
variableCount:
|
|
163
|
+
variableCount: pairs.length,
|
|
134
164
|
project,
|
|
135
165
|
config,
|
|
136
166
|
}
|
|
@@ -175,6 +205,10 @@ export const envLoad = async (args: EnvLoadArgs) => {
|
|
|
175
205
|
// A manual load is authoritative: autoLoaded=false drops the auto marker.
|
|
176
206
|
const result = await writeEnvLoadFile({ config: selectedConfig, autoLoaded: false })
|
|
177
207
|
|
|
208
|
+
// A manual load passes no beforeWrite, so it always writes — this guards the
|
|
209
|
+
// invariant (and narrows the nullable result) rather than handling a real path.
|
|
210
|
+
if (!result) throw new Error('env-load: write was unexpectedly aborted')
|
|
211
|
+
|
|
178
212
|
// REQUIRED
|
|
179
213
|
process.stdout.write(`${result.filePath}\n`)
|
|
180
214
|
|
|
@@ -209,7 +243,7 @@ export const DOPPLER_MAX_OUTPUT_BYTES = 1024 * 1024
|
|
|
209
243
|
*/
|
|
210
244
|
const DOPPLER_DOWNLOAD_TIMEOUT_MS = 30_000
|
|
211
245
|
|
|
212
|
-
const downloadDopplerSecrets = async (project: string, config: string): Promise<string
|
|
246
|
+
const downloadDopplerSecrets = async (project: string, config: string): Promise<Array<[string, string]>> => {
|
|
213
247
|
const prevQuiet = $.quiet
|
|
214
248
|
|
|
215
249
|
$.quiet = true
|
|
@@ -217,16 +251,17 @@ const downloadDopplerSecrets = async (project: string, config: string): Promise<
|
|
|
217
251
|
let result
|
|
218
252
|
|
|
219
253
|
try {
|
|
220
|
-
result =
|
|
221
|
-
|
|
222
|
-
|
|
254
|
+
result =
|
|
255
|
+
await $`doppler secrets download --no-file --format json --project ${project} --config ${config}`.timeout(
|
|
256
|
+
DOPPLER_DOWNLOAD_TIMEOUT_MS,
|
|
257
|
+
)
|
|
223
258
|
} catch (error: unknown) {
|
|
224
259
|
throw await translateDopplerDownloadError(error, project, config)
|
|
225
260
|
}
|
|
226
261
|
|
|
227
262
|
assertDopplerOutputSize(result.stdout)
|
|
228
263
|
|
|
229
|
-
return result.stdout
|
|
264
|
+
return parseDopplerSecretsJson(result.stdout)
|
|
230
265
|
} finally {
|
|
231
266
|
$.quiet = prevQuiet
|
|
232
267
|
}
|
|
@@ -262,42 +297,59 @@ export const assertDopplerOutputSize = (stdout: string): void => {
|
|
|
262
297
|
}
|
|
263
298
|
}
|
|
264
299
|
|
|
265
|
-
const countEnvVarLines = (content: string): number => {
|
|
266
|
-
return content.split('\n').filter((line) => {
|
|
267
|
-
return ENV_VAR_LINE_PATTERN.test(line)
|
|
268
|
-
}).length
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
const SHELL_DIRECTIVE_LINES = new Set(['set -a', 'set +a'])
|
|
272
|
-
|
|
273
300
|
export const shellSingleQuote = (value: string): string => {
|
|
274
301
|
const escaped = value.replaceAll("'", "'\\''")
|
|
275
302
|
|
|
276
303
|
return `'${escaped}'`
|
|
277
304
|
}
|
|
278
305
|
|
|
306
|
+
/** A valid POSIX env-var name: a letter or underscore, then word characters. */
|
|
307
|
+
const ENV_VAR_NAME_PATTERN = /^[A-Z_]\w*$/i
|
|
308
|
+
|
|
279
309
|
/**
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
310
|
+
* Parse the `doppler secrets download --format json` output (a flat
|
|
311
|
+
* `{"KEY":"value"}` map) into ordered `[key, value]` pairs, validating the shape
|
|
312
|
+
* and every name/value. Values are returned RAW — callers must single-quote them
|
|
313
|
+
* before emitting shell (an unquoted `a$(cmd)b` would execute on `source`).
|
|
314
|
+
*
|
|
315
|
+
* @example
|
|
316
|
+
* parseDopplerSecretsJson('{"FOO":"bar","BAZ":"a$(x)"}')
|
|
317
|
+
* // => [['FOO', 'bar'], ['BAZ', 'a$(x)']]
|
|
284
318
|
*/
|
|
285
|
-
export const
|
|
286
|
-
|
|
287
|
-
|
|
319
|
+
export const parseDopplerSecretsJson = (stdout: string): Array<[string, string]> => {
|
|
320
|
+
let parsed: unknown
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
parsed = JSON.parse(stdout)
|
|
324
|
+
} catch {
|
|
325
|
+
throw new Error(`doppler returned non-JSON output for env-load (got: ${JSON.stringify(stdout.slice(0, 80))})`)
|
|
288
326
|
}
|
|
289
327
|
|
|
290
|
-
|
|
291
|
-
|
|
328
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
329
|
+
throw new Error('doppler returned unexpected JSON shape for env-load (expected an object of KEY: value pairs)')
|
|
330
|
+
}
|
|
292
331
|
|
|
293
|
-
|
|
332
|
+
const pairs: Array<[string, string]> = []
|
|
294
333
|
|
|
295
|
-
|
|
334
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
335
|
+
if (!ENV_VAR_NAME_PATTERN.test(key)) {
|
|
296
336
|
throw new Error(
|
|
297
|
-
`doppler returned
|
|
337
|
+
`doppler returned an invalid env var name for env-load (got: ${JSON.stringify(key.slice(0, 80))})`,
|
|
298
338
|
)
|
|
299
339
|
}
|
|
340
|
+
|
|
341
|
+
if (typeof value !== 'string') {
|
|
342
|
+
throw new TypeError(`doppler returned a non-string value for "${key}" (got ${typeof value})`)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
pairs.push([key, value])
|
|
300
346
|
}
|
|
347
|
+
|
|
348
|
+
if (pairs.length === 0) {
|
|
349
|
+
throw new Error('doppler returned empty output for env-load')
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return pairs
|
|
301
353
|
}
|
|
302
354
|
|
|
303
355
|
// MCP Tool Registration
|
|
@@ -10,14 +10,14 @@ describe('buildShellBody — env auto-load shell-startup block', () => {
|
|
|
10
10
|
expect(body).toContain('_infra_kit_startup_autoload\n')
|
|
11
11
|
})
|
|
12
12
|
|
|
13
|
-
it('gates on a session and skips
|
|
13
|
+
it('gates on a session and skips the spawn only when the env is for THIS project root', () => {
|
|
14
14
|
expect(body).toContain('[[ -z "$INFRA_KIT_SESSION" ]] && return')
|
|
15
|
-
expect(body).toContain('[[ -n "$INFRA_KIT_ENV_CONFIG" ]] && return')
|
|
15
|
+
expect(body).toContain('[[ -n "$INFRA_KIT_ENV_CONFIG" && "$dir" == "$INFRA_KIT_ENV_PROJECT_ROOT" ]] && return')
|
|
16
16
|
})
|
|
17
17
|
|
|
18
|
-
it('walks up for infra-kit.json (
|
|
18
|
+
it('walks up for infra-kit.json with a fixed-point break (no infinite loop on empty $PWD)', () => {
|
|
19
19
|
expect(body).toContain('[[ -f "$dir/infra-kit.json" ]]')
|
|
20
|
-
expect(body).toContain('while [[ "$dir" !=
|
|
20
|
+
expect(body).toContain('while [[ -n "$dir" && "$dir" != "$prev" ]]')
|
|
21
21
|
})
|
|
22
22
|
|
|
23
23
|
it('spawns env-autoload backgrounded and silenced (precmd does the sourcing)', () => {
|
|
@@ -299,23 +299,19 @@ export const buildShellBody = (): string => {
|
|
|
299
299
|
' local dir="$cache_root/$INFRA_KIT_SESSION"',
|
|
300
300
|
' local load_file="$dir/env-load.sh"',
|
|
301
301
|
' local clear_file="$dir/env-clear.sh"',
|
|
302
|
-
' local
|
|
303
|
-
'
|
|
304
|
-
'
|
|
305
|
-
'
|
|
306
|
-
'
|
|
307
|
-
'
|
|
302
|
+
' local load_mtime=0 clear_mtime=0',
|
|
303
|
+
' [[ -f "$load_file" ]] && load_mtime=$(zstat +mtime -- "$load_file" 2>/dev/null || echo 0)',
|
|
304
|
+
' [[ -f "$clear_file" ]] && clear_mtime=$(zstat +mtime -- "$clear_file" 2>/dev/null || echo 0)',
|
|
305
|
+
' if (( load_mtime > _INFRA_KIT_LAST_LOAD_MTIME && load_mtime >= _INFRA_KIT_SHELL_STARTED && load_mtime >= clear_mtime )); then',
|
|
306
|
+
' source "$load_file"',
|
|
307
|
+
' _INFRA_KIT_LAST_LOAD_MTIME=$load_mtime',
|
|
308
308
|
// eslint-disable-next-line no-template-curly-in-string
|
|
309
|
-
'
|
|
310
|
-
' fi',
|
|
309
|
+
' print -u2 "infra-kit: auto-loaded vars for ${INFRA_KIT_ENV_CONFIG:-?}"',
|
|
311
310
|
' fi',
|
|
312
|
-
' if
|
|
313
|
-
'
|
|
314
|
-
'
|
|
315
|
-
'
|
|
316
|
-
' _INFRA_KIT_LAST_CLEAR_MTIME=$mtime',
|
|
317
|
-
' print -u2 "infra-kit: auto-cleared env"',
|
|
318
|
-
' fi',
|
|
311
|
+
' if (( clear_mtime > _INFRA_KIT_LAST_CLEAR_MTIME && clear_mtime >= _INFRA_KIT_SHELL_STARTED && clear_mtime > load_mtime )); then',
|
|
312
|
+
' source "$clear_file"',
|
|
313
|
+
' _INFRA_KIT_LAST_CLEAR_MTIME=$clear_mtime',
|
|
314
|
+
' print -u2 "infra-kit: auto-cleared env"',
|
|
319
315
|
' fi',
|
|
320
316
|
'}',
|
|
321
317
|
'autoload -Uz add-zsh-hook',
|
|
@@ -325,21 +321,24 @@ export const buildShellBody = (): string => {
|
|
|
325
321
|
// One-shot env auto-load when a NEW shell opens inside an infra-kit project
|
|
326
322
|
// or worktree (config: envAutoLoad.trigger "shell-startup"). Cheap pure-zsh
|
|
327
323
|
// project gate (walk up for infra-kit.json) avoids spawning node in unrelated
|
|
328
|
-
// shells; the
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
324
|
+
// shells; the spawn is skipped only when the already-loaded env belongs to THIS
|
|
325
|
+
// project root (INFRA_KIT_ENV_PROJECT_ROOT), so cd'ing into a DIFFERENT project
|
|
326
|
+
// re-loads instead of silently keeping the previous project's secrets. The spawn
|
|
327
|
+
// is DETACHED+backgrounded ( ... & ) so it never blocks the prompt; it only
|
|
328
|
+
// WRITES env-load.sh and the precmd hook above is the sole sourcer (picked up on
|
|
329
|
+
// a subsequent prompt). Trade-off: the gate can't read the merged JSON config, so
|
|
330
|
+
// a fresh shell in a project WITHOUT envAutoLoad still spawns one background
|
|
331
|
+
// process that resolves config, finds nothing, and exits.
|
|
334
332
|
'_infra_kit_startup_autoload() {',
|
|
335
333
|
' [[ -z "$INFRA_KIT_SESSION" ]] && return',
|
|
336
|
-
'
|
|
337
|
-
'
|
|
338
|
-
' while [[ "$dir" != / ]]; do',
|
|
334
|
+
' local dir="$PWD" prev=""',
|
|
335
|
+
' while [[ -n "$dir" && "$dir" != "$prev" ]]; do',
|
|
339
336
|
' if [[ -f "$dir/infra-kit.json" ]]; then',
|
|
337
|
+
' [[ -n "$INFRA_KIT_ENV_CONFIG" && "$dir" == "$INFRA_KIT_ENV_PROJECT_ROOT" ]] && return',
|
|
340
338
|
` ( ${runCmd} env-autoload & ) >/dev/null 2>&1`,
|
|
341
339
|
' return',
|
|
342
340
|
' fi',
|
|
341
|
+
' prev="$dir"',
|
|
343
342
|
// eslint-disable-next-line no-template-curly-in-string
|
|
344
343
|
' dir="${dir:h}"',
|
|
345
344
|
' done',
|
|
@@ -16,6 +16,7 @@ import { IDE_MODES, addIdeWorktreeFolders } from 'src/integrations/ide'
|
|
|
16
16
|
import type { IdeMode } from 'src/integrations/ide'
|
|
17
17
|
import { commandEcho } from 'src/lib/command-echo'
|
|
18
18
|
import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
|
|
19
|
+
import { isPromptCancellation } from 'src/lib/errors/is-prompt-cancellation'
|
|
19
20
|
import { OperationError } from 'src/lib/errors/operation-error'
|
|
20
21
|
import { assertManagementContext } from 'src/lib/git-guard'
|
|
21
22
|
import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
|
|
@@ -225,6 +226,11 @@ export const worktreesAdd = async (options: WorktreeManagementArgs) => {
|
|
|
225
226
|
structuredContent,
|
|
226
227
|
}
|
|
227
228
|
} catch (error) {
|
|
229
|
+
// A cancelled prompt (Ctrl-C / Esc) is a user back-out, not a failure: let it
|
|
230
|
+
// reach the top-level boundary untouched so it exits cleanly, instead of being
|
|
231
|
+
// logged as an error with a misleading "branches already exist" remediation.
|
|
232
|
+
if (isPromptCancellation(error)) throw error
|
|
233
|
+
|
|
228
234
|
logger.error({ error }, '❌ Error managing worktrees')
|
|
229
235
|
throw new OperationError(error, {
|
|
230
236
|
operation: 'create worktrees',
|
|
@@ -7,6 +7,7 @@ import { getReleasePRsWithInfo } from 'src/integrations/gh'
|
|
|
7
7
|
import { removeIdeWorktreeFolders } from 'src/integrations/ide'
|
|
8
8
|
import { commandEcho } from 'src/lib/command-echo'
|
|
9
9
|
import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
|
|
10
|
+
import { isPromptCancellation } from 'src/lib/errors/is-prompt-cancellation'
|
|
10
11
|
import { OperationError } from 'src/lib/errors/operation-error'
|
|
11
12
|
import { assertManagementContext } from 'src/lib/git-guard'
|
|
12
13
|
import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
|
|
@@ -143,6 +144,11 @@ export const worktreesRemove = async (options: WorktreeManagementArgs) => {
|
|
|
143
144
|
structuredContent,
|
|
144
145
|
}
|
|
145
146
|
} catch (error) {
|
|
147
|
+
// A cancelled prompt (Ctrl-C / Esc) is a user back-out, not a failure: let it
|
|
148
|
+
// reach the top-level boundary untouched so it exits cleanly, instead of being
|
|
149
|
+
// logged as an error with a misleading remediation.
|
|
150
|
+
if (isPromptCancellation(error)) throw error
|
|
151
|
+
|
|
146
152
|
logger.error({ error }, '❌ Error managing worktrees')
|
|
147
153
|
throw new OperationError(error, {
|
|
148
154
|
operation: 'remove worktrees',
|
|
@@ -8,6 +8,7 @@ import { getReleasePRs } from 'src/integrations/gh'
|
|
|
8
8
|
import { removeIdeWorktreeFolders } from 'src/integrations/ide'
|
|
9
9
|
import { commandEcho } from 'src/lib/command-echo'
|
|
10
10
|
import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
|
|
11
|
+
import { isPromptCancellation } from 'src/lib/errors/is-prompt-cancellation'
|
|
11
12
|
import { OperationError } from 'src/lib/errors/operation-error'
|
|
12
13
|
import { assertManagementContext } from 'src/lib/git-guard'
|
|
13
14
|
import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
|
|
@@ -96,6 +97,11 @@ export const worktreesSync = async (options: WorktreeSyncArgs) => {
|
|
|
96
97
|
structuredContent,
|
|
97
98
|
}
|
|
98
99
|
} catch (error) {
|
|
100
|
+
// A cancelled prompt (Ctrl-C / Esc) is a user back-out, not a failure: let it
|
|
101
|
+
// reach the top-level boundary untouched so it exits cleanly, instead of being
|
|
102
|
+
// logged as an error with a misleading remediation.
|
|
103
|
+
if (isPromptCancellation(error)) throw error
|
|
104
|
+
|
|
99
105
|
logger.error({ error }, '❌ Error managing worktrees')
|
|
100
106
|
throw new OperationError(error, {
|
|
101
107
|
operation: 'sync worktrees with remote',
|