infra-kit 0.1.124 → 0.1.125
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-VI7TEWVJ.js +164 -0
- package/dist/chunk-VI7TEWVJ.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/entry/cli.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { IDE_MODES } from 'src/integrations/ide'
|
|
|
33
33
|
import type { IdeMode } from 'src/integrations/ide'
|
|
34
34
|
import { getMenuGroupCommands } from 'src/lib/command-catalog'
|
|
35
35
|
import { runEnvAutoLoad } from 'src/lib/env-autoload'
|
|
36
|
+
import { isPromptCancellation } from 'src/lib/errors/is-prompt-cancellation'
|
|
36
37
|
import { addJsonOption, emit, jsonOutput } from 'src/lib/json-output'
|
|
37
38
|
import { logger } from 'src/lib/logger'
|
|
38
39
|
import { formatAlignedRows } from 'src/lib/render'
|
|
@@ -78,6 +79,14 @@ const runProgram = async (argv?: string[]): Promise<void> => {
|
|
|
78
79
|
await program.parseAsync()
|
|
79
80
|
}
|
|
80
81
|
} catch (error) {
|
|
82
|
+
// Ctrl-C / Esc out of any prompt is a deliberate back-out, not a failure:
|
|
83
|
+
// exit quietly with success so it matches the explicit "Operation cancelled."
|
|
84
|
+
// decline path and never trips scripts/CI into treating a cancel as an error.
|
|
85
|
+
if (isPromptCancellation(error)) {
|
|
86
|
+
logger.info('Operation cancelled.')
|
|
87
|
+
process.exit(0)
|
|
88
|
+
}
|
|
89
|
+
|
|
81
90
|
const message = error instanceof Error ? error.message : String(error)
|
|
82
91
|
|
|
83
92
|
logger.error(message)
|
|
@@ -532,57 +541,63 @@ if (process.argv.length <= 2) {
|
|
|
532
541
|
})
|
|
533
542
|
})
|
|
534
543
|
|
|
535
|
-
let selected: string | null
|
|
544
|
+
let selected: string | null = null
|
|
536
545
|
|
|
537
546
|
// Interactive TTY → Ink command palette, loaded lazily via dynamic import so
|
|
538
547
|
// React/Ink never touch the MCP / `--json` / non-TTY code paths. Otherwise fall
|
|
539
|
-
// back to the Inquirer menu (scripts, pipes, CI).
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
} else {
|
|
545
|
-
const alignedLabels = formatAlignedRows(
|
|
546
|
-
paletteItems.map((item) => {
|
|
547
|
-
return [item.name, item.description] as const
|
|
548
|
-
}),
|
|
549
|
-
)
|
|
550
|
-
const labelByName = new Map<string, string>()
|
|
548
|
+
// back to the Inquirer menu (scripts, pipes, CI). Ctrl-C / Esc at the menu
|
|
549
|
+
// throws from the Inquirer fallback; treat it as a clean exit (nothing picked).
|
|
550
|
+
try {
|
|
551
|
+
if (process.stdout.isTTY && process.stdin.isTTY) {
|
|
552
|
+
const { runCommandPalette } = await import('src/tui/boot')
|
|
551
553
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
554
|
+
selected = await runCommandPalette(paletteItems)
|
|
555
|
+
} else {
|
|
556
|
+
const alignedLabels = formatAlignedRows(
|
|
557
|
+
paletteItems.map((item) => {
|
|
558
|
+
return [item.name, item.description] as const
|
|
559
|
+
}),
|
|
560
|
+
)
|
|
561
|
+
const labelByName = new Map<string, string>()
|
|
555
562
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
return commandMap.has(name)
|
|
560
|
-
})
|
|
561
|
-
.map((name) => {
|
|
562
|
-
return {
|
|
563
|
-
name: labelByName.get(name) ?? name,
|
|
564
|
-
value: name,
|
|
565
|
-
}
|
|
566
|
-
})
|
|
567
|
-
}
|
|
563
|
+
paletteItems.forEach((item, index) => {
|
|
564
|
+
labelByName.set(item.name, alignedLabels[index] ?? item.name)
|
|
565
|
+
})
|
|
568
566
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
567
|
+
const toChoices = (names: string[]) => {
|
|
568
|
+
return names
|
|
569
|
+
.filter((name) => {
|
|
570
|
+
return commandMap.has(name)
|
|
571
|
+
})
|
|
572
|
+
.map((name) => {
|
|
573
|
+
return {
|
|
574
|
+
name: labelByName.get(name) ?? name,
|
|
575
|
+
value: name,
|
|
576
|
+
}
|
|
577
|
+
})
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
selected = await select(
|
|
581
|
+
{
|
|
582
|
+
message: 'Select a command to run',
|
|
583
|
+
choices: [
|
|
584
|
+
new Separator(' '),
|
|
585
|
+
new Separator('— Release Management —'),
|
|
586
|
+
...toChoices(releaseCommands),
|
|
587
|
+
new Separator(' '),
|
|
588
|
+
new Separator('— Worktrees —'),
|
|
589
|
+
...toChoices(worktreeCommands),
|
|
590
|
+
new Separator(' '),
|
|
591
|
+
new Separator('— Environment —'),
|
|
592
|
+
...toChoices(envCommands),
|
|
593
|
+
],
|
|
594
|
+
},
|
|
595
|
+
{ output: process.stderr },
|
|
596
|
+
)
|
|
597
|
+
}
|
|
598
|
+
} catch (error) {
|
|
599
|
+
// Ctrl-C / Esc at the menu is a clean back-out; leave `selected` as null.
|
|
600
|
+
if (!isPromptCancellation(error)) throw error
|
|
586
601
|
}
|
|
587
602
|
|
|
588
603
|
// The menu is a guided surface; don't nag about deprecated flat names here.
|
|
@@ -70,6 +70,28 @@ describe('parseVarNamesFromEnvFile', () => {
|
|
|
70
70
|
expect(parseVarNamesFromEnvFile(file)).toEqual(['API_KEY_2', '_INTERNAL'])
|
|
71
71
|
})
|
|
72
72
|
})
|
|
73
|
+
|
|
74
|
+
it('does not surface KEY=-looking lines embedded inside a multiline single-quoted value', () => {
|
|
75
|
+
withTmpDir((dir) => {
|
|
76
|
+
const file = path.join(dir, 'env.sh')
|
|
77
|
+
|
|
78
|
+
// A multiline secret value (e.g. a PEM key) whose body contains a line that
|
|
79
|
+
// looks like an assignment must NOT yield a spurious var name to unset.
|
|
80
|
+
fs.writeFileSync(file, "set -a\nPRIVATE_KEY='line1\nNOT_A_VAR=evil\nline3'\nFOO=bar\nset +a\n")
|
|
81
|
+
expect(parseVarNamesFromEnvFile(file)).toEqual(['PRIVATE_KEY', 'FOO'])
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('handles a single-quoted value containing the escaped-quote idiom', () => {
|
|
86
|
+
withTmpDir((dir) => {
|
|
87
|
+
const file = path.join(dir, 'env.sh')
|
|
88
|
+
|
|
89
|
+
// shellSingleQuote("it's") => 'it'\''s' — quote state must end balanced so
|
|
90
|
+
// the following assignment is still detected.
|
|
91
|
+
fs.writeFileSync(file, "GREETING='it'\\''s'\nNEXT=ok\n")
|
|
92
|
+
expect(parseVarNamesFromEnvFile(file)).toEqual(['GREETING', 'NEXT'])
|
|
93
|
+
})
|
|
94
|
+
})
|
|
73
95
|
})
|
|
74
96
|
|
|
75
97
|
describe('getSessionCacheDir', () => {
|
|
@@ -9,6 +9,12 @@ export const ENV_CLEAR_FILE = 'env-clear.sh'
|
|
|
9
9
|
export const INFRA_KIT_SESSION_VAR = 'INFRA_KIT_SESSION'
|
|
10
10
|
export const INFRA_KIT_ENV_CONFIG_VAR = 'INFRA_KIT_ENV_CONFIG'
|
|
11
11
|
export const INFRA_KIT_ENV_PROJECT_VAR = 'INFRA_KIT_ENV_PROJECT'
|
|
12
|
+
/**
|
|
13
|
+
* Absolute project root (git top-level) the loaded env belongs to. Lets the shell
|
|
14
|
+
* startup gate tell a same-project subshell (skip) from a NEW project (load), so a
|
|
15
|
+
* different project's secrets are never silently kept after `cd`/new shell.
|
|
16
|
+
*/
|
|
17
|
+
export const INFRA_KIT_ENV_PROJECT_ROOT_VAR = 'INFRA_KIT_ENV_PROJECT_ROOT'
|
|
12
18
|
export const INFRA_KIT_ENV_LOADED_AT_VAR = 'INFRA_KIT_ENV_LOADED_AT'
|
|
13
19
|
/**
|
|
14
20
|
* Marker exported into env-load.sh ONLY when the load was triggered automatically
|
|
@@ -31,18 +37,48 @@ export const INFRA_KIT_ENV_CLEARED_VAR = 'INFRA_KIT_ENV_CLEARED'
|
|
|
31
37
|
*/
|
|
32
38
|
export const ENV_VAR_LINE_PATTERN = /^([A-Z_]\w*)=/i
|
|
33
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Track whether a physical line leaves us inside an open single-quoted value,
|
|
42
|
+
* mirroring how `shellSingleQuote` emits values (`'…'`, with literal quotes as
|
|
43
|
+
* `'\''`). Outside a quote a backslash escapes the next char; inside a quote a
|
|
44
|
+
* `'` closes it. Lets the parser skip the continuation lines of a multiline value.
|
|
45
|
+
*/
|
|
46
|
+
const advanceSingleQuoteState = (line: string, startInQuote: boolean): boolean => {
|
|
47
|
+
let inQuote = startInQuote
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < line.length; i++) {
|
|
50
|
+
if (!inQuote && line[i] === '\\') {
|
|
51
|
+
i++
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (line[i] === "'") {
|
|
56
|
+
inQuote = !inQuote
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return inQuote
|
|
61
|
+
}
|
|
62
|
+
|
|
34
63
|
export const parseVarNamesFromEnvFile = (filePath: string): string[] => {
|
|
35
64
|
if (!fs.existsSync(filePath)) return []
|
|
36
65
|
|
|
37
66
|
const content = fs.readFileSync(filePath, 'utf-8')
|
|
38
67
|
const names: string[] = []
|
|
68
|
+
let inQuote = false
|
|
39
69
|
|
|
40
70
|
for (const line of content.split('\n')) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
|
|
71
|
+
// Only a line that starts OUTSIDE a quoted value can be a real assignment;
|
|
72
|
+
// continuation lines of a multiline secret value are skipped.
|
|
73
|
+
if (!inQuote) {
|
|
74
|
+
const match = ENV_VAR_LINE_PATTERN.exec(line)
|
|
75
|
+
|
|
76
|
+
if (match) {
|
|
77
|
+
names.push(match[1]!)
|
|
78
|
+
}
|
|
45
79
|
}
|
|
80
|
+
|
|
81
|
+
inQuote = advanceSingleQuoteState(line, inQuote)
|
|
46
82
|
}
|
|
47
83
|
|
|
48
84
|
return names
|
|
@@ -215,7 +215,21 @@ describe('runEnvAutoLoad', () => {
|
|
|
215
215
|
const result = await runEnvAutoLoad({ expectedTrigger: 'shell-startup' })
|
|
216
216
|
|
|
217
217
|
expect(result).toBe('/cache/env-load.sh')
|
|
218
|
-
expect(writeEnvLoadFile).toHaveBeenCalledWith({ config: 'dev', autoLoaded: true })
|
|
218
|
+
expect(writeEnvLoadFile).toHaveBeenCalledWith(expect.objectContaining({ config: 'dev', autoLoaded: true }))
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('returns null without throwing or warning when the write is aborted (beforeWrite returned false)', async () => {
|
|
222
|
+
vi.mocked(getInfraKitConfig).mockResolvedValue({
|
|
223
|
+
...baseConfig,
|
|
224
|
+
envAutoLoad: { trigger: 'cli-invocation', config: 'dev' },
|
|
225
|
+
} as never)
|
|
226
|
+
vi.mocked(writeEnvLoadFile).mockResolvedValue(null)
|
|
227
|
+
|
|
228
|
+
const result = await runEnvAutoLoad({ expectedTrigger: 'cli-invocation' })
|
|
229
|
+
|
|
230
|
+
expect(result).toBeNull()
|
|
231
|
+
expect(logger.warn).not.toHaveBeenCalled()
|
|
232
|
+
expect(logger.debug).not.toHaveBeenCalled()
|
|
219
233
|
})
|
|
220
234
|
|
|
221
235
|
it('returns null without fetching when the configured trigger is for the other callsite', async () => {
|
|
@@ -20,8 +20,10 @@ import { logger } from 'src/lib/logger'
|
|
|
20
20
|
/** Which moment a concrete callsite represents. Matches the config `trigger`. */
|
|
21
21
|
export type AutoLoadTrigger = EnvAutoLoadConfig['trigger']
|
|
22
22
|
|
|
23
|
-
/** Per-session flag
|
|
24
|
-
const
|
|
23
|
+
/** Per-session flag de-duping the MISCONFIG warning (bad envAutoLoad.config). */
|
|
24
|
+
const WARN_MISCONFIG_SENTINEL_FILE = 'autoload-warn-misconfig.flag'
|
|
25
|
+
/** Per-session flag de-duping the transient-FAILURE warning (Doppler down/unauth). */
|
|
26
|
+
const WARN_FAIL_SENTINEL_FILE = 'autoload-warn-fail.flag'
|
|
25
27
|
|
|
26
28
|
/** Per-session marker recording the last auto-load failure (mtime = when). */
|
|
27
29
|
const FAIL_SENTINEL_FILE = 'autoload-fail.flag'
|
|
@@ -76,6 +78,7 @@ export const resolveEnvAutoLoad = async (canWarn = true): Promise<ResolvedEnvAut
|
|
|
76
78
|
`infra-kit: envAutoLoad.config "${autoLoad.config}" is not one of environments [${config.environments.join(
|
|
77
79
|
', ',
|
|
78
80
|
)}] — env auto-load disabled.`,
|
|
81
|
+
WARN_MISCONFIG_SENTINEL_FILE,
|
|
79
82
|
)
|
|
80
83
|
}
|
|
81
84
|
|
|
@@ -182,7 +185,18 @@ export const runEnvAutoLoad = async ({ expectedTrigger }: RunEnvAutoLoadArgs): P
|
|
|
182
185
|
// re-probed on every command in the same session.
|
|
183
186
|
if (recentlyFailed()) return null
|
|
184
187
|
|
|
185
|
-
const
|
|
188
|
+
const preWriteMtime = readLoadFileMtime()
|
|
189
|
+
const result = await writeEnvLoadFile({
|
|
190
|
+
config: resolved.config,
|
|
191
|
+
autoLoaded: true,
|
|
192
|
+
// Re-check after the slow Doppler download: abort if a clear or a manual load
|
|
193
|
+
// landed meanwhile, so a backgrounded auto-load never clobbers a deliberate action.
|
|
194
|
+
beforeWrite: () => {
|
|
195
|
+
return !isClearedOnDisk() && !manualLoadLandedSince(preWriteMtime)
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
if (!result) return null
|
|
186
200
|
|
|
187
201
|
clearFailure()
|
|
188
202
|
|
|
@@ -195,7 +209,7 @@ export const runEnvAutoLoad = async ({ expectedTrigger }: RunEnvAutoLoadArgs): P
|
|
|
195
209
|
// Surface the failure once per session on the interactive channel; stay silent
|
|
196
210
|
// (debug only) on the backgrounded shell-startup path.
|
|
197
211
|
if (canWarn) {
|
|
198
|
-
warnOnce(`infra-kit: env auto-load failed — ${reason} (will retry later)
|
|
212
|
+
warnOnce(`infra-kit: env auto-load failed — ${reason} (will retry later)`, WARN_FAIL_SENTINEL_FILE)
|
|
199
213
|
} else {
|
|
200
214
|
logger.debug(`env auto-load skipped: ${reason}`)
|
|
201
215
|
}
|
|
@@ -215,6 +229,40 @@ const readAutoLoadEnvSnapshot = (): AutoLoadEnvSnapshot => {
|
|
|
215
229
|
}
|
|
216
230
|
}
|
|
217
231
|
|
|
232
|
+
/** mtime of the on-disk env-load.sh, or null if absent/unreadable. */
|
|
233
|
+
const readLoadFileMtime = (): number | null => {
|
|
234
|
+
try {
|
|
235
|
+
return fs.statSync(path.join(getSessionCacheDir(), ENV_LOAD_FILE)).mtimeMs
|
|
236
|
+
} catch {
|
|
237
|
+
return null
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* True when a MANUAL env-load landed on disk after `sinceMtime` (the file now
|
|
243
|
+
* exists, is newer, and carries the manual-load `unset INFRA_KIT_ENV_AUTOLOADED`
|
|
244
|
+
* line). Used to abort an in-flight auto-load so it never clobbers a deliberate load.
|
|
245
|
+
*/
|
|
246
|
+
const manualLoadLandedSince = (sinceMtime: number | null): boolean => {
|
|
247
|
+
try {
|
|
248
|
+
const p = path.join(getSessionCacheDir(), ENV_LOAD_FILE)
|
|
249
|
+
|
|
250
|
+
if (!fs.existsSync(p)) return false
|
|
251
|
+
|
|
252
|
+
const mtime = fs.statSync(p).mtimeMs
|
|
253
|
+
|
|
254
|
+
if (sinceMtime !== null && mtime <= sinceMtime) return false
|
|
255
|
+
|
|
256
|
+
// Anchor to a whole line so the marker can't match inside a single-quoted
|
|
257
|
+
// secret value that happens to contain this literal text.
|
|
258
|
+
const manualMarker = new RegExp(`^unset ${INFRA_KIT_ENV_AUTOLOADED_VAR}$`, 'm')
|
|
259
|
+
|
|
260
|
+
return manualMarker.test(fs.readFileSync(p, 'utf-8'))
|
|
261
|
+
} catch {
|
|
262
|
+
return false
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
218
266
|
/**
|
|
219
267
|
* True when a clear is pending on disk: an env-clear.sh exists and is at least as
|
|
220
268
|
* new as env-load.sh (or no load file remains). Belt-and-suspenders next to the
|
|
@@ -277,10 +325,10 @@ const clearFailure = (): void => {
|
|
|
277
325
|
* does not spam a warning on every cli-invocation. Falls back to a plain warn when
|
|
278
326
|
* no session cache dir is available.
|
|
279
327
|
*/
|
|
280
|
-
const warnOnce = (message: string): void => {
|
|
328
|
+
const warnOnce = (message: string, sentinelFile: string): void => {
|
|
281
329
|
try {
|
|
282
330
|
const dir = getSessionCacheDir()
|
|
283
|
-
const flagPath = path.join(dir,
|
|
331
|
+
const flagPath = path.join(dir, sentinelFile)
|
|
284
332
|
|
|
285
333
|
if (fs.existsSync(flagPath)) return
|
|
286
334
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { isPromptCancellation } from '../is-prompt-cancellation'
|
|
4
|
+
import { OperationError } from '../operation-error'
|
|
5
|
+
|
|
6
|
+
/** Mirrors the @inquirer/core error shape (matched by `name`, not class). */
|
|
7
|
+
const makeNamedError = (name: string): Error => {
|
|
8
|
+
const err = new Error('prompt ended')
|
|
9
|
+
|
|
10
|
+
err.name = name
|
|
11
|
+
|
|
12
|
+
return err
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('isPromptCancellation', () => {
|
|
16
|
+
it('detects a Ctrl-C / Esc ExitPromptError', () => {
|
|
17
|
+
expect(isPromptCancellation(makeNamedError('ExitPromptError'))).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('detects a signal-driven AbortPromptError', () => {
|
|
21
|
+
expect(isPromptCancellation(makeNamedError('AbortPromptError'))).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('unwraps a cancellation re-wrapped in an OperationError', () => {
|
|
25
|
+
const wrapped = new OperationError(makeNamedError('ExitPromptError'), {
|
|
26
|
+
operation: 'create worktrees',
|
|
27
|
+
remediation: 'irrelevant',
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
expect(isPromptCancellation(wrapped)).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('returns false for a genuine operational error', () => {
|
|
34
|
+
expect(isPromptCancellation(new Error('fatal: not a git repository'))).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('returns false for an OperationError wrapping a real failure', () => {
|
|
38
|
+
const wrapped = new OperationError(new Error('boom'), { operation: 'create worktrees' })
|
|
39
|
+
|
|
40
|
+
expect(isPromptCancellation(wrapped)).toBe(false)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('returns false for non-error values', () => {
|
|
44
|
+
expect(isPromptCancellation(undefined)).toBe(false)
|
|
45
|
+
expect(isPromptCancellation(null)).toBe(false)
|
|
46
|
+
expect(isPromptCancellation('ExitPromptError')).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Names of the `@inquirer/core` error classes thrown when an interactive prompt
|
|
3
|
+
* ends without a value: `ExitPromptError` (user pressed Ctrl-C / Esc) and
|
|
4
|
+
* `AbortPromptError` (the prompt was aborted via an `AbortSignal`). Both are
|
|
5
|
+
* intentional cancellations, not failures.
|
|
6
|
+
*/
|
|
7
|
+
const CANCELLATION_ERROR_NAMES = new Set(['ExitPromptError', 'AbortPromptError'])
|
|
8
|
+
|
|
9
|
+
const hasCancellationName = (value: unknown): boolean => {
|
|
10
|
+
return value instanceof Error && CANCELLATION_ERROR_NAMES.has(value.name)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* True when `error` represents a user (or signal) cancellation of an
|
|
15
|
+
* `@inquirer/*` prompt — i.e. pressing Ctrl-C / Esc in the branch picker or a
|
|
16
|
+
* confirm step. Matched by `name` rather than `instanceof` so it stays correct
|
|
17
|
+
* even when pnpm dedupes more than one copy of `@inquirer/core` into the tree
|
|
18
|
+
* (an `instanceof` check fails across realms/duplicate classes).
|
|
19
|
+
*
|
|
20
|
+
* Also unwraps one level of `cause`, so a cancellation re-wrapped in an
|
|
21
|
+
* {@link ./operation-error.OperationError} is still recognised at the top-level
|
|
22
|
+
* error boundary.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* try {
|
|
26
|
+
* await checkbox({ message: 'Select release branches', choices })
|
|
27
|
+
* } catch (err) {
|
|
28
|
+
* if (isPromptCancellation(err)) process.exit(0) // clean back-out, not an error
|
|
29
|
+
* throw err
|
|
30
|
+
* }
|
|
31
|
+
*/
|
|
32
|
+
export const isPromptCancellation = (error: unknown): boolean => {
|
|
33
|
+
if (hasCancellationName(error)) return true
|
|
34
|
+
|
|
35
|
+
const cause = (error as { cause?: unknown } | null | undefined)?.cause
|
|
36
|
+
|
|
37
|
+
return hasCancellationName(cause)
|
|
38
|
+
}
|