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.
Files changed (45) hide show
  1. package/.eslintcache +1 -1
  2. package/.omc/state/agent-replay-2ccbd177-0d5d-4d4a-ab94-a22464786221.jsonl +1 -0
  3. package/.omc/state/agent-replay-eb44055b-c2fa-4e66-b491-a1d9b44f51c7.jsonl +1 -0
  4. package/.omc/state/idle-notif-cooldown.json +1 -1
  5. package/.omc/state/sessions/2ccbd177-0d5d-4d4a-ab94-a22464786221/pre-tool-advisory-throttle.json +18 -0
  6. package/.omc/state/sessions/2ccbd177-0d5d-4d4a-ab94-a22464786221/subagent-tracking-state.json +17 -0
  7. package/.omc/state/sessions/eb44055b-c2fa-4e66-b491-a1d9b44f51c7/last-tool-error-state.json +7 -0
  8. package/.omc/state/sessions/eb44055b-c2fa-4e66-b491-a1d9b44f51c7/pre-tool-advisory-throttle.json +18 -0
  9. package/.omc/state/sessions/eb44055b-c2fa-4e66-b491-a1d9b44f51c7/subagent-tracking-state.json +17 -0
  10. package/.turbo/turbo-build.log +6 -6
  11. package/.turbo/turbo-eslint-check.log +1 -1
  12. package/.turbo/turbo-prettier-check.log +1 -1
  13. package/.turbo/turbo-test.log +128 -127
  14. package/.turbo/turbo-ts-check.log +1 -1
  15. package/dist/chunk-VI7TEWVJ.js +164 -0
  16. package/dist/chunk-VI7TEWVJ.js.map +7 -0
  17. package/dist/cli.js +6 -6
  18. package/dist/cli.js.map +3 -3
  19. package/dist/mcp.js +1 -1
  20. package/package.json +1 -1
  21. package/src/.omc/state/agent-replay-2ccbd177-0d5d-4d4a-ab94-a22464786221.jsonl +2 -0
  22. package/src/.omc/state/idle-notif-cooldown.json +1 -1
  23. package/src/.omc/state/sessions/2ccbd177-0d5d-4d4a-ab94-a22464786221/last-tool-error-state.json +7 -0
  24. package/src/.omc/state/sessions/2ccbd177-0d5d-4d4a-ab94-a22464786221/mission-state.json +53 -0
  25. package/src/.omc/state/sessions/2ccbd177-0d5d-4d4a-ab94-a22464786221/pre-tool-advisory-throttle.json +22 -0
  26. package/src/.omc/state/sessions/2ccbd177-0d5d-4d4a-ab94-a22464786221/subagent-tracking-state.json +17 -0
  27. package/src/commands/env-clear/env-clear.ts +4 -1
  28. package/src/commands/env-load/__tests__/env-load.test.ts +99 -66
  29. package/src/commands/env-load/env-load.ts +87 -35
  30. package/src/commands/init/__tests__/shell-body.test.ts +4 -4
  31. package/src/commands/init/init.ts +23 -24
  32. package/src/commands/worktrees-add/worktrees-add.ts +6 -0
  33. package/src/commands/worktrees-remove/worktrees-remove.ts +6 -0
  34. package/src/commands/worktrees-sync/worktrees-sync.ts +6 -0
  35. package/src/entry/cli.ts +60 -45
  36. package/src/lib/constants/__tests__/constants.test.ts +22 -0
  37. package/src/lib/constants/constants.ts +40 -4
  38. package/src/lib/constants/index.ts +1 -0
  39. package/src/lib/env-autoload/__tests__/env-autoload.test.ts +15 -1
  40. package/src/lib/env-autoload/env-autoload.ts +54 -6
  41. package/src/lib/errors/__tests__/is-prompt-cancellation.test.ts +48 -0
  42. package/src/lib/errors/is-prompt-cancellation.ts +38 -0
  43. package/tsconfig.tsbuildinfo +1 -1
  44. package/dist/chunk-W4QG2W7G.js +0 -166
  45. 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
- if (process.stdout.isTTY && process.stdin.isTTY) {
541
- const { runCommandPalette } = await import('src/tui/boot')
542
-
543
- selected = await runCommandPalette(paletteItems)
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
- paletteItems.forEach((item, index) => {
553
- labelByName.set(item.name, alignedLabels[index] ?? item.name)
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
- const toChoices = (names: string[]) => {
557
- return names
558
- .filter((name) => {
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
- selected = await select(
570
- {
571
- message: 'Select a command to run',
572
- choices: [
573
- new Separator(' '),
574
- new Separator('— Release Management —'),
575
- ...toChoices(releaseCommands),
576
- new Separator(' '),
577
- new Separator('— Worktrees —'),
578
- ...toChoices(worktreeCommands),
579
- new Separator(' '),
580
- new Separator('— Environment —'),
581
- ...toChoices(envCommands),
582
- ],
583
- },
584
- { output: process.stderr },
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
- const match = ENV_VAR_LINE_PATTERN.exec(line)
42
-
43
- if (match) {
44
- names.push(match[1]!)
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
@@ -9,6 +9,7 @@ export {
9
9
  INFRA_KIT_ENV_CLEARED_VAR,
10
10
  INFRA_KIT_ENV_CONFIG_VAR,
11
11
  INFRA_KIT_ENV_LOADED_AT_VAR,
12
+ INFRA_KIT_ENV_PROJECT_ROOT_VAR,
12
13
  INFRA_KIT_ENV_PROJECT_VAR,
13
14
  INFRA_KIT_SESSION_VAR,
14
15
  LOG_FILE_PATH,
@@ -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 file that de-dups the auto-load warning (misconfig or failure). */
24
- const WARN_SENTINEL_FILE = 'autoload-warn.flag'
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 result = await writeEnvLoadFile({ config: resolved.config, autoLoaded: true })
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, WARN_SENTINEL_FILE)
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
+ }