@lkangd/cc-env 1.0.0

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 (111) hide show
  1. package/.claude/settings.json +6 -0
  2. package/.claude/settings.local.json +3 -0
  3. package/.nvmrc +1 -0
  4. package/dist/cli.js +266 -0
  5. package/dist/commands/debug.js +17 -0
  6. package/dist/commands/init.js +64 -0
  7. package/dist/commands/preset/create.js +61 -0
  8. package/dist/commands/preset/delete.js +25 -0
  9. package/dist/commands/preset/edit.js +15 -0
  10. package/dist/commands/preset/list.js +16 -0
  11. package/dist/commands/preset/show.js +16 -0
  12. package/dist/commands/restore.js +65 -0
  13. package/dist/commands/run.js +80 -0
  14. package/dist/core/errors.js +11 -0
  15. package/dist/core/find-claude.js +64 -0
  16. package/dist/core/format.js +23 -0
  17. package/dist/core/fs.js +12 -0
  18. package/dist/core/gitignore.js +23 -0
  19. package/dist/core/lock.js +25 -0
  20. package/dist/core/logger.js +8 -0
  21. package/dist/core/mask.js +13 -0
  22. package/dist/core/paths.js +32 -0
  23. package/dist/core/process-env.js +4 -0
  24. package/dist/core/schema.js +38 -0
  25. package/dist/core/spawn.js +26 -0
  26. package/dist/flows/init-flow.js +35 -0
  27. package/dist/flows/preset-create-flow.js +80 -0
  28. package/dist/flows/restore-flow.js +75 -0
  29. package/dist/ink/init-app.js +54 -0
  30. package/dist/ink/preset-create-app.js +271 -0
  31. package/dist/ink/preset-delete-app.js +47 -0
  32. package/dist/ink/preset-list-app.js +27 -0
  33. package/dist/ink/preset-show-app.js +27 -0
  34. package/dist/ink/restore-app.js +102 -0
  35. package/dist/ink/run-preset-select-app.js +31 -0
  36. package/dist/ink/summary.js +28 -0
  37. package/dist/services/claude-settings-env-service.js +55 -0
  38. package/dist/services/config-service.js +26 -0
  39. package/dist/services/history-service.js +39 -0
  40. package/dist/services/preset-service.js +61 -0
  41. package/dist/services/project-env-service.js +90 -0
  42. package/dist/services/project-state-service.js +26 -0
  43. package/dist/services/runtime-env-service.js +13 -0
  44. package/dist/services/settings-env-service.js +36 -0
  45. package/dist/services/shell-env-service.js +77 -0
  46. package/docs/product-specs/index.draft.md +106 -0
  47. package/docs/product-specs/index.md +911 -0
  48. package/docs/product-specs/optional.md +42 -0
  49. package/docs/references/claude-code-env.md +224 -0
  50. package/docs/superpowers/plans/2026-04-24-cc-env-init-shell-migration.md +1331 -0
  51. package/docs/superpowers/plans/2026-04-24-cc-env.md +1666 -0
  52. package/docs/superpowers/plans/2026-04-26-preset-create-interactive-refactor.md +1432 -0
  53. package/docs/superpowers/specs/2026-04-24-cc-env-design.md +438 -0
  54. package/docs/superpowers/specs/2026-04-24-cc-env-init-shell-migration-design.md +181 -0
  55. package/docs/superpowers/specs/2026-04-26-preset-create-interactive-refactor-design.md +78 -0
  56. package/package.json +55 -0
  57. package/src/cli.ts +337 -0
  58. package/src/commands/init.ts +139 -0
  59. package/src/commands/preset/create.ts +96 -0
  60. package/src/commands/preset/delete.ts +62 -0
  61. package/src/commands/preset/show.ts +51 -0
  62. package/src/commands/restore.ts +150 -0
  63. package/src/commands/run.ts +158 -0
  64. package/src/core/errors.ts +13 -0
  65. package/src/core/find-claude.ts +70 -0
  66. package/src/core/format.ts +29 -0
  67. package/src/core/fs.ts +18 -0
  68. package/src/core/gitignore.ts +26 -0
  69. package/src/core/logger.ts +11 -0
  70. package/src/core/mask.ts +17 -0
  71. package/src/core/paths.ts +41 -0
  72. package/src/core/process-env.ts +11 -0
  73. package/src/core/schema.ts +55 -0
  74. package/src/core/spawn.ts +36 -0
  75. package/src/flows/init-flow.ts +61 -0
  76. package/src/flows/preset-create-flow.ts +129 -0
  77. package/src/flows/restore-flow.ts +144 -0
  78. package/src/ink/init-app.tsx +110 -0
  79. package/src/ink/preset-create-app.tsx +451 -0
  80. package/src/ink/preset-delete-app.tsx +114 -0
  81. package/src/ink/preset-show-app.tsx +76 -0
  82. package/src/ink/restore-app.tsx +230 -0
  83. package/src/ink/run-preset-select-app.tsx +83 -0
  84. package/src/ink/summary.tsx +91 -0
  85. package/src/services/claude-settings-env-service.ts +72 -0
  86. package/src/services/history-service.ts +48 -0
  87. package/src/services/preset-service.ts +72 -0
  88. package/src/services/project-env-service.ts +128 -0
  89. package/src/services/project-state-service.ts +31 -0
  90. package/src/services/settings-env-service.ts +40 -0
  91. package/src/services/shell-env-service.ts +112 -0
  92. package/src/types.d.ts +19 -0
  93. package/tests/cli/help.test.ts +133 -0
  94. package/tests/cli/init.test.ts +76 -0
  95. package/tests/cli/restore.test.ts +172 -0
  96. package/tests/commands/create.test.ts +263 -0
  97. package/tests/commands/output.test.ts +119 -0
  98. package/tests/commands/run.test.ts +218 -0
  99. package/tests/core/gitignore.test.ts +98 -0
  100. package/tests/core/paths.test.ts +24 -0
  101. package/tests/core/schema-mask.test.ts +182 -0
  102. package/tests/core/spawn.test.ts +47 -0
  103. package/tests/flows/init-flow.test.ts +40 -0
  104. package/tests/flows/preset-create-flow.test.ts +225 -0
  105. package/tests/flows/restore-flow.test.ts +157 -0
  106. package/tests/integration/init-restore.test.ts +406 -0
  107. package/tests/services/claude-shell.test.ts +183 -0
  108. package/tests/services/storage.test.ts +143 -0
  109. package/tsconfig.build.json +9 -0
  110. package/tsconfig.json +22 -0
  111. package/vitest.config.ts +8 -0
@@ -0,0 +1,218 @@
1
+ import { rm } from 'node:fs/promises'
2
+
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import { createRunCommand } from '../../src/commands/run.js'
6
+ import { CliError } from '../../src/core/errors.js'
7
+ import type { EnvMap } from '../../src/core/schema.js'
8
+
9
+ const tempDirs: string[] = []
10
+
11
+ afterEach(async () => {
12
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
13
+ })
14
+
15
+ const emptySettingsSources = [
16
+ { path: '/home/.claude/settings.json', exists: false, env: {} as EnvMap },
17
+ { path: '/home/.claude/settings.local.json', exists: false, env: {} as EnvMap },
18
+ { path: '/project/.claude/settings.json', exists: false, env: {} as EnvMap },
19
+ { path: '/project/.claude/settings.local.json', exists: false, env: {} as EnvMap },
20
+ ]
21
+
22
+ type PresetRef = { presetName: string; source: 'global' | 'project' }
23
+ type PresetSelectItem = { name: string; env: EnvMap; source: 'global' | 'project' }
24
+
25
+ function createMocks(overrides: Partial<{
26
+ claudeSettingsEnvService: { read: () => Promise<typeof emptySettingsSources> }
27
+ presetService: { listNames: () => Promise<string[]>; read: (name: string) => Promise<{ env: EnvMap }> }
28
+ projectEnvService: { readWithMeta: () => Promise<{ env: EnvMap; name?: string | undefined }> }
29
+ projectStateService: { getLastPreset: (cwd: string) => Promise<PresetRef | undefined>; saveLastPreset: (cwd: string, ref: PresetRef) => Promise<void> }
30
+ findClaude: () => string
31
+ renderPresetSelect: (input: { presets: Array<PresetSelectItem>; defaultIndex: number }) => Promise<PresetSelectItem | undefined>
32
+ spawnCommand: (command: string, args: string[], env: NodeJS.ProcessEnv) => Promise<void>
33
+ stdout: Pick<NodeJS.WriteStream, 'write'>
34
+ }> = {}) {
35
+ const defaultPreset = { env: { OPENAI_API_KEY: 'sk-1234567890' } as EnvMap }
36
+ const presetItem = { name: 'openai', env: defaultPreset.env, source: 'global' as const }
37
+
38
+ return {
39
+ claudeSettingsEnvService: overrides.claudeSettingsEnvService ?? {
40
+ read: vi.fn().mockResolvedValue(emptySettingsSources),
41
+ },
42
+ presetService: overrides.presetService ?? {
43
+ listNames: vi.fn().mockResolvedValue(['openai']),
44
+ read: vi.fn().mockResolvedValue(defaultPreset),
45
+ },
46
+ projectEnvService: overrides.projectEnvService ?? {
47
+ readWithMeta: vi.fn().mockResolvedValue({ env: {} as EnvMap }),
48
+ },
49
+ projectStateService: overrides.projectStateService ?? {
50
+ getLastPreset: vi.fn().mockResolvedValue(undefined),
51
+ saveLastPreset: vi.fn().mockResolvedValue(undefined),
52
+ },
53
+ findClaude: overrides.findClaude ?? vi.fn().mockReturnValue('/usr/local/bin/claude'),
54
+ renderPresetSelect: overrides.renderPresetSelect ?? vi.fn().mockResolvedValue(presetItem),
55
+ spawnCommand: overrides.spawnCommand ?? vi.fn().mockResolvedValue(undefined),
56
+ stdout: overrides.stdout ?? { write: vi.fn() },
57
+ }
58
+ }
59
+
60
+ function buildRun(mocks: ReturnType<typeof createMocks>) {
61
+ return createRunCommand({
62
+ claudeSettingsEnvService: mocks.claudeSettingsEnvService,
63
+ presetService: mocks.presetService,
64
+ projectEnvService: mocks.projectEnvService,
65
+ projectStateService: mocks.projectStateService,
66
+ findClaude: mocks.findClaude,
67
+ renderPresetSelect: mocks.renderPresetSelect,
68
+ spawnCommand: mocks.spawnCommand,
69
+ stdout: mocks.stdout,
70
+ })
71
+ }
72
+
73
+ describe('createRunCommand', () => {
74
+ it('throws when init-managed keys found in Claude settings', async () => {
75
+ const staleSources = emptySettingsSources.map((s, i) =>
76
+ i === 0 ? { ...s, exists: true, env: { ANTHROPIC_AUTH_TOKEN: 'sk-old' } as EnvMap } : s,
77
+ )
78
+ const mocks = createMocks({
79
+ claudeSettingsEnvService: { read: vi.fn().mockResolvedValue(staleSources) },
80
+ })
81
+
82
+ await expect(buildRun(mocks)({ cwd: '/project' })).rejects.toEqual(
83
+ new CliError('Found init-managed keys in Claude settings:\n\n ANTHROPIC_AUTH_TOKEN. \n\n Run "cc-env init" first.'),
84
+ )
85
+ })
86
+
87
+ it('throws when no presets exist', async () => {
88
+ const mocks = createMocks({
89
+ presetService: {
90
+ listNames: vi.fn().mockResolvedValue([]),
91
+ read: vi.fn(),
92
+ },
93
+ projectEnvService: {
94
+ readWithMeta: vi.fn().mockResolvedValue({ env: {} as EnvMap }),
95
+ },
96
+ })
97
+
98
+ await expect(buildRun(mocks)({ cwd: '/project' })).rejects.toEqual(
99
+ new CliError('No presets found. Create one with "cc-env preset create".'),
100
+ )
101
+ })
102
+
103
+ it('returns cleanly when user cancels preset selection', async () => {
104
+ const mocks = createMocks({
105
+ renderPresetSelect: vi.fn().mockResolvedValue(undefined),
106
+ })
107
+
108
+ await buildRun(mocks)({ cwd: '/project' })
109
+
110
+ expect(mocks.spawnCommand).not.toHaveBeenCalled()
111
+ expect(mocks.projectStateService.saveLastPreset).not.toHaveBeenCalled()
112
+ })
113
+
114
+ it('auto-finds claude when no args provided', async () => {
115
+ const mocks = createMocks({
116
+ findClaude: vi.fn().mockReturnValue('/usr/local/bin/claude'),
117
+ })
118
+
119
+ await buildRun(mocks)({ args: [], cwd: '/project' })
120
+
121
+ expect(mocks.findClaude).toHaveBeenCalled()
122
+ expect(mocks.spawnCommand).toHaveBeenCalledWith(
123
+ '/usr/local/bin/claude',
124
+ [],
125
+ expect.objectContaining({ OPENAI_API_KEY: 'sk-1234567890' }),
126
+ )
127
+ })
128
+
129
+ it('uses claude directly when args[0] is "claude"', async () => {
130
+ const mocks = createMocks()
131
+
132
+ await buildRun(mocks)({ args: ['claude', '--model', 'opus'], cwd: '/project' })
133
+
134
+ expect(mocks.findClaude).not.toHaveBeenCalled()
135
+ expect(mocks.spawnCommand).toHaveBeenCalledWith(
136
+ 'claude',
137
+ ['--model', 'opus'],
138
+ expect.objectContaining({ OPENAI_API_KEY: 'sk-1234567890' }),
139
+ )
140
+ })
141
+
142
+ it('saves last-selected preset after selection', async () => {
143
+ const mocks = createMocks()
144
+
145
+ await buildRun(mocks)({ cwd: '/project' })
146
+
147
+ expect(mocks.projectStateService.saveLastPreset).toHaveBeenCalledWith('/project', {
148
+ presetName: 'openai',
149
+ source: 'global',
150
+ })
151
+ })
152
+
153
+ it('uses saved preset as default selection index', async () => {
154
+ const mocks = createMocks({
155
+ projectStateService: {
156
+ getLastPreset: vi.fn().mockResolvedValue({ presetName: 'openai', source: 'global' }),
157
+ saveLastPreset: vi.fn().mockResolvedValue(undefined),
158
+ },
159
+ })
160
+
161
+ await buildRun(mocks)({ cwd: '/project' })
162
+
163
+ expect(mocks.renderPresetSelect).toHaveBeenCalledWith(
164
+ expect.objectContaining({ defaultIndex: 0 }),
165
+ )
166
+ })
167
+
168
+ it('falls back to index 0 when saved preset no longer exists', async () => {
169
+ const mocks = createMocks({
170
+ projectStateService: {
171
+ getLastPreset: vi.fn().mockResolvedValue({ presetName: 'deleted', source: 'global' }),
172
+ saveLastPreset: vi.fn().mockResolvedValue(undefined),
173
+ },
174
+ })
175
+
176
+ await buildRun(mocks)({ cwd: '/project' })
177
+
178
+ expect(mocks.renderPresetSelect).toHaveBeenCalledWith(
179
+ expect.objectContaining({ defaultIndex: 0 }),
180
+ )
181
+ })
182
+
183
+ it('prints env vars and preset info before spawning', async () => {
184
+ const mocks = createMocks()
185
+
186
+ await buildRun(mocks)({ args: ['claude'], cwd: '/project' })
187
+
188
+ expect(mocks.stdout.write).toHaveBeenCalledWith(
189
+ expect.stringContaining('Using preset: openai (global)'),
190
+ )
191
+ expect(mocks.stdout.write).toHaveBeenCalledWith(
192
+ expect.stringContaining('OPENAI_API_KEY=sk-123456********'),
193
+ )
194
+ })
195
+
196
+ it('prints would-run in dry-run mode without spawning', async () => {
197
+ const mocks = createMocks()
198
+
199
+ await buildRun(mocks)({ args: ['claude', '--model', 'opus'], dryRun: true, cwd: '/project' })
200
+
201
+ expect(mocks.stdout.write).toHaveBeenCalledWith(
202
+ expect.stringContaining('Would run: claude --model opus'),
203
+ )
204
+ expect(mocks.spawnCommand).not.toHaveBeenCalled()
205
+ })
206
+
207
+ it('auto-selects default preset in yes mode without rendering UI', async () => {
208
+ const mocks = createMocks()
209
+
210
+ await buildRun(mocks)({ yes: true, cwd: '/project' })
211
+
212
+ expect(mocks.renderPresetSelect).not.toHaveBeenCalled()
213
+ expect(mocks.projectStateService.saveLastPreset).toHaveBeenCalledWith('/project', {
214
+ presetName: 'openai',
215
+ source: 'global',
216
+ })
217
+ })
218
+ })
@@ -0,0 +1,98 @@
1
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ import { afterEach, describe, expect, it } from 'vitest'
6
+
7
+ import { ensureGitignoreEntry, isGitRepo } from '../../src/core/gitignore.js'
8
+
9
+ const tempRoots: string[] = []
10
+
11
+ async function createTempDir() {
12
+ const dir = await mkdtemp(join(tmpdir(), 'cc-env-git-'))
13
+ tempRoots.push(dir)
14
+ return dir
15
+ }
16
+
17
+ afterEach(async () => {
18
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
19
+ })
20
+
21
+ describe('isGitRepo', () => {
22
+ it('returns true when .git exists', async () => {
23
+ const dir = await createTempDir()
24
+ await mkdir(join(dir, '.git'))
25
+
26
+ expect(isGitRepo(dir)).toBe(true)
27
+ })
28
+
29
+ it('returns false when .git is missing', async () => {
30
+ const dir = await createTempDir()
31
+
32
+ expect(isGitRepo(dir)).toBe(false)
33
+ })
34
+ })
35
+
36
+ describe('ensureGitignoreEntry', () => {
37
+ it('creates .gitignore if missing', async () => {
38
+ const dir = await createTempDir()
39
+ await mkdir(join(dir, '.git'))
40
+
41
+ await ensureGitignoreEntry(dir, '.cc-env')
42
+
43
+ const content = await readFile(join(dir, '.gitignore'), 'utf8')
44
+ expect(content).toBe('.cc-env\n')
45
+ })
46
+
47
+ it('appends entry to existing .gitignore', async () => {
48
+ const dir = await createTempDir()
49
+ await mkdir(join(dir, '.git'))
50
+ await writeFile(join(dir, '.gitignore'), 'node_modules\n', 'utf8')
51
+
52
+ await ensureGitignoreEntry(dir, '.cc-env')
53
+
54
+ const content = await readFile(join(dir, '.gitignore'), 'utf8')
55
+ expect(content).toBe('node_modules\n.cc-env\n')
56
+ })
57
+
58
+ it('skips if entry already present', async () => {
59
+ const dir = await createTempDir()
60
+ await mkdir(join(dir, '.git'))
61
+ await writeFile(join(dir, '.gitignore'), 'node_modules\n.cc-env\n', 'utf8')
62
+
63
+ await ensureGitignoreEntry(dir, '.cc-env')
64
+
65
+ const content = await readFile(join(dir, '.gitignore'), 'utf8')
66
+ expect(content).toBe('node_modules\n.cc-env\n')
67
+ })
68
+
69
+ it('skips if entry with trailing slash present', async () => {
70
+ const dir = await createTempDir()
71
+ await mkdir(join(dir, '.git'))
72
+ await writeFile(join(dir, '.gitignore'), '.cc-env/\n', 'utf8')
73
+
74
+ await ensureGitignoreEntry(dir, '.cc-env')
75
+
76
+ const content = await readFile(join(dir, '.gitignore'), 'utf8')
77
+ expect(content).toBe('.cc-env/\n')
78
+ })
79
+
80
+ it('does nothing outside git repo', async () => {
81
+ const dir = await createTempDir()
82
+
83
+ await ensureGitignoreEntry(dir, '.cc-env')
84
+
85
+ await expect(readFile(join(dir, '.gitignore'), 'utf8')).rejects.toThrow()
86
+ })
87
+
88
+ it('handles .gitignore without trailing newline', async () => {
89
+ const dir = await createTempDir()
90
+ await mkdir(join(dir, '.git'))
91
+ await writeFile(join(dir, '.gitignore'), 'node_modules', 'utf8')
92
+
93
+ await ensureGitignoreEntry(dir, '.cc-env')
94
+
95
+ const content = await readFile(join(dir, '.gitignore'), 'utf8')
96
+ expect(content).toBe('node_modules\n.cc-env\n')
97
+ })
98
+ })
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ resolveClaudeSettingsLocalPath,
5
+ resolveClaudeSettingsPath,
6
+ resolveShellConfigPaths,
7
+ } from '../../src/core/paths.js'
8
+
9
+ describe('Claude home path helpers', () => {
10
+ it('resolves both Claude settings files under the given home directory', () => {
11
+ expect(resolveClaudeSettingsPath('/Users/test')).toBe('/Users/test/.claude/settings.json')
12
+ expect(resolveClaudeSettingsLocalPath('/Users/test')).toBe(
13
+ '/Users/test/.claude/settings.local.json',
14
+ )
15
+ })
16
+
17
+ it('resolves zsh, bash, and fish config targets', () => {
18
+ expect(resolveShellConfigPaths('/Users/test')).toEqual({
19
+ zsh: '/Users/test/.zshrc',
20
+ bash: '/Users/test/.bashrc',
21
+ fish: '/Users/test/.config/fish/config.fish',
22
+ })
23
+ })
24
+ })
@@ -0,0 +1,182 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { maskValue, isSensitiveKey } from '../../src/core/mask.js'
4
+ import { envMapSchema, historySchema, presetSchema } from '../../src/core/schema.js'
5
+
6
+ describe('envMapSchema', () => {
7
+ it('accepts uppercase flat string maps', () => {
8
+ const result = envMapSchema.parse({
9
+ API_URL: 'https://example.com',
10
+ PORT: '3000',
11
+ FEATURE_FLAG_1: 'enabled',
12
+ })
13
+
14
+ expect(result).toEqual({
15
+ API_URL: 'https://example.com',
16
+ PORT: '3000',
17
+ FEATURE_FLAG_1: 'enabled',
18
+ })
19
+ })
20
+
21
+ it('rejects nested values', () => {
22
+ expect(() => envMapSchema.parse({ NESTED: { NOPE: 'x' } })).toThrow()
23
+ })
24
+
25
+ it('accepts non-object values by stringifying them', () => {
26
+ const result = envMapSchema.parse({
27
+ PORT: 3000,
28
+ ENABLED: true,
29
+ EMPTY: null,
30
+ })
31
+
32
+ expect(result).toEqual({
33
+ PORT: '3000',
34
+ ENABLED: 'true',
35
+ EMPTY: 'null',
36
+ })
37
+ })
38
+
39
+ it('rejects lowercase keys', () => {
40
+ expect(() => envMapSchema.parse({ api_url: 'https://example.com' })).toThrow()
41
+ })
42
+ })
43
+
44
+ describe('sensitive masking', () => {
45
+ it('returns true for ANTHROPIC_AUTH_TOKEN', () => {
46
+ expect(isSensitiveKey('ANTHROPIC_AUTH_TOKEN')).toBe(true)
47
+ })
48
+
49
+ it('returns true for mixed-case sensitive suffixes', () => {
50
+ expect(isSensitiveKey('Anthropic_Auth_Token')).toBe(true)
51
+ })
52
+
53
+ it('masks sensitive values', () => {
54
+ expect(maskValue('ANTHROPIC_AUTH_TOKEN', 'sk-1234567890')).toBe('sk-123456********')
55
+ })
56
+
57
+ it('masks short sensitive values without exposing the original secret', () => {
58
+ expect(maskValue('ANTHROPIC_AUTH_TOKEN', 'short')).toBe('*****')
59
+ expect(maskValue('ANTHROPIC_AUTH_TOKEN', '12345678')).toBe('********')
60
+ })
61
+
62
+ it('leaves non-sensitive values unchanged', () => {
63
+ expect(maskValue('API_URL', 'https://example.com')).toBe('https://example.com')
64
+ })
65
+ })
66
+
67
+ describe('historySchema', () => {
68
+ it('accepts init history with sources and shell writes', () => {
69
+ const result = historySchema.parse({
70
+ timestamp: '2026-04-24T12:00:00.000Z',
71
+ action: 'init',
72
+ migratedKeys: ['ANTHROPIC_AUTH_TOKEN'],
73
+ sources: [
74
+ {
75
+ file: '/Users/test/.claude/settings.json',
76
+ backup: {
77
+ ANTHROPIC_BASE_URL: 'https://settings.example.com',
78
+ },
79
+ },
80
+ {
81
+ file: '/Users/test/.claude/settings.local.json',
82
+ backup: {
83
+ ANTHROPIC_AUTH_TOKEN: 'local-token',
84
+ },
85
+ },
86
+ ],
87
+ shellWrites: [
88
+ {
89
+ shell: 'zsh',
90
+ filePath: '/Users/test/.zshrc',
91
+ env: {
92
+ ANTHROPIC_AUTH_TOKEN: 'local-token',
93
+ },
94
+ },
95
+ ],
96
+ })
97
+
98
+ expect(result).toEqual({
99
+ timestamp: '2026-04-24T12:00:00.000Z',
100
+ action: 'init',
101
+ migratedKeys: ['ANTHROPIC_AUTH_TOKEN'],
102
+ sources: [
103
+ {
104
+ file: '/Users/test/.claude/settings.json',
105
+ backup: {
106
+ ANTHROPIC_BASE_URL: 'https://settings.example.com',
107
+ },
108
+ },
109
+ {
110
+ file: '/Users/test/.claude/settings.local.json',
111
+ backup: {
112
+ ANTHROPIC_AUTH_TOKEN: 'local-token',
113
+ },
114
+ },
115
+ ],
116
+ shellWrites: [
117
+ {
118
+ shell: 'zsh',
119
+ filePath: '/Users/test/.zshrc',
120
+ env: {
121
+ ANTHROPIC_AUTH_TOKEN: 'local-token',
122
+ },
123
+ },
124
+ ],
125
+ })
126
+ })
127
+
128
+ it('rejects init history without migratedKeys', () => {
129
+ expect(() =>
130
+ historySchema.parse({
131
+ timestamp: '2026-04-24T12:00:00.000Z',
132
+ action: 'init',
133
+ sources: [
134
+ {
135
+ file: '/Users/test/.claude/settings.local.json',
136
+ backup: {
137
+ ANTHROPIC_AUTH_TOKEN: 'local-token',
138
+ },
139
+ },
140
+ ],
141
+ shellWrites: [],
142
+ }),
143
+ ).toThrow()
144
+ })
145
+ })
146
+
147
+ describe('presetSchema', () => {
148
+ it('validates a preset object with name, createdAt, updatedAt, and env', () => {
149
+ const result = presetSchema.parse({
150
+ name: 'default',
151
+ createdAt: '2026-04-24T12:00:00.000Z',
152
+ updatedAt: '2026-04-24T12:30:00.000Z',
153
+ env: {
154
+ API_URL: 'https://example.com',
155
+ ANTHROPIC_AUTH_TOKEN: 'sk-1234567890',
156
+ },
157
+ })
158
+
159
+ expect(result).toEqual({
160
+ name: 'default',
161
+ createdAt: '2026-04-24T12:00:00.000Z',
162
+ updatedAt: '2026-04-24T12:30:00.000Z',
163
+ env: {
164
+ API_URL: 'https://example.com',
165
+ ANTHROPIC_AUTH_TOKEN: 'sk-1234567890',
166
+ },
167
+ })
168
+ })
169
+
170
+ it('rejects invalid ISO datetimes for createdAt and updatedAt', () => {
171
+ expect(() =>
172
+ presetSchema.parse({
173
+ name: 'default',
174
+ createdAt: 'not-a-date',
175
+ updatedAt: '2026-04-24',
176
+ env: {
177
+ API_URL: 'https://example.com',
178
+ },
179
+ }),
180
+ ).toThrow()
181
+ })
182
+ })
@@ -0,0 +1,47 @@
1
+ import { EventEmitter } from 'node:events'
2
+
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import { CliError } from '../../src/core/errors.js'
6
+
7
+ const spawnMock = vi.fn()
8
+
9
+ vi.mock('cross-spawn', () => ({
10
+ default: spawnMock,
11
+ }))
12
+
13
+ describe('spawnCommand', () => {
14
+ afterEach(() => {
15
+ vi.resetModules()
16
+ vi.clearAllMocks()
17
+ })
18
+
19
+ it('rejects with a CliError when the process closes due to a signal', async () => {
20
+ const child = new EventEmitter()
21
+ spawnMock.mockReturnValue(child)
22
+
23
+ const { spawnCommand } = await import('../../src/core/spawn.js')
24
+ const promise = spawnCommand('node', ['script.js'], process.env)
25
+
26
+ child.emit('close', null, 'SIGTERM')
27
+
28
+ await expect(promise).rejects.toEqual(
29
+ new CliError('Command terminated by signal SIGTERM'),
30
+ )
31
+ })
32
+
33
+ it('rejects with a human-readable CliError when the process closes without an exit code or signal', async () => {
34
+ const child = new EventEmitter()
35
+ spawnMock.mockReturnValue(child)
36
+
37
+ const { spawnCommand } = await import('../../src/core/spawn.js')
38
+ const promise = spawnCommand('node', ['script.js'], process.env)
39
+
40
+ child.emit('close', null, null)
41
+
42
+ await expect(promise).rejects.toMatchObject({
43
+ message: 'Command terminated without an exit code',
44
+ exitCode: 1,
45
+ })
46
+ })
47
+ })
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ advanceInitFlow,
5
+ createInitFlowState,
6
+ } from '../../src/flows/init-flow.js'
7
+
8
+ describe('init flow', () => {
9
+ it('preselects required keys and does not let them be toggled off', () => {
10
+ const state = createInitFlowState(
11
+ ['ANTHROPIC_AUTH_TOKEN', 'EXTRA_KEY'],
12
+ ['ANTHROPIC_AUTH_TOKEN'],
13
+ )
14
+
15
+ expect(state).toEqual({
16
+ step: 'keys',
17
+ availableKeys: ['ANTHROPIC_AUTH_TOKEN', 'EXTRA_KEY'],
18
+ requiredKeys: ['ANTHROPIC_AUTH_TOKEN'],
19
+ selectedKeys: ['ANTHROPIC_AUTH_TOKEN'],
20
+ })
21
+
22
+ expect(
23
+ advanceInitFlow(state, {
24
+ type: 'toggle-key',
25
+ key: 'ANTHROPIC_AUTH_TOKEN',
26
+ }).selectedKeys,
27
+ ).toEqual(['ANTHROPIC_AUTH_TOKEN'])
28
+ })
29
+
30
+ it('moves directly from key selection to confirm', () => {
31
+ const state = createInitFlowState(['ANTHROPIC_AUTH_TOKEN'], ['ANTHROPIC_AUTH_TOKEN'])
32
+
33
+ expect(advanceInitFlow(state, { type: 'continue' })).toEqual({
34
+ step: 'confirm',
35
+ availableKeys: ['ANTHROPIC_AUTH_TOKEN'],
36
+ requiredKeys: ['ANTHROPIC_AUTH_TOKEN'],
37
+ selectedKeys: ['ANTHROPIC_AUTH_TOKEN'],
38
+ })
39
+ })
40
+ })