@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.
- package/.claude/settings.json +6 -0
- package/.claude/settings.local.json +3 -0
- package/.nvmrc +1 -0
- package/dist/cli.js +266 -0
- package/dist/commands/debug.js +17 -0
- package/dist/commands/init.js +64 -0
- package/dist/commands/preset/create.js +61 -0
- package/dist/commands/preset/delete.js +25 -0
- package/dist/commands/preset/edit.js +15 -0
- package/dist/commands/preset/list.js +16 -0
- package/dist/commands/preset/show.js +16 -0
- package/dist/commands/restore.js +65 -0
- package/dist/commands/run.js +80 -0
- package/dist/core/errors.js +11 -0
- package/dist/core/find-claude.js +64 -0
- package/dist/core/format.js +23 -0
- package/dist/core/fs.js +12 -0
- package/dist/core/gitignore.js +23 -0
- package/dist/core/lock.js +25 -0
- package/dist/core/logger.js +8 -0
- package/dist/core/mask.js +13 -0
- package/dist/core/paths.js +32 -0
- package/dist/core/process-env.js +4 -0
- package/dist/core/schema.js +38 -0
- package/dist/core/spawn.js +26 -0
- package/dist/flows/init-flow.js +35 -0
- package/dist/flows/preset-create-flow.js +80 -0
- package/dist/flows/restore-flow.js +75 -0
- package/dist/ink/init-app.js +54 -0
- package/dist/ink/preset-create-app.js +271 -0
- package/dist/ink/preset-delete-app.js +47 -0
- package/dist/ink/preset-list-app.js +27 -0
- package/dist/ink/preset-show-app.js +27 -0
- package/dist/ink/restore-app.js +102 -0
- package/dist/ink/run-preset-select-app.js +31 -0
- package/dist/ink/summary.js +28 -0
- package/dist/services/claude-settings-env-service.js +55 -0
- package/dist/services/config-service.js +26 -0
- package/dist/services/history-service.js +39 -0
- package/dist/services/preset-service.js +61 -0
- package/dist/services/project-env-service.js +90 -0
- package/dist/services/project-state-service.js +26 -0
- package/dist/services/runtime-env-service.js +13 -0
- package/dist/services/settings-env-service.js +36 -0
- package/dist/services/shell-env-service.js +77 -0
- package/docs/product-specs/index.draft.md +106 -0
- package/docs/product-specs/index.md +911 -0
- package/docs/product-specs/optional.md +42 -0
- package/docs/references/claude-code-env.md +224 -0
- package/docs/superpowers/plans/2026-04-24-cc-env-init-shell-migration.md +1331 -0
- package/docs/superpowers/plans/2026-04-24-cc-env.md +1666 -0
- package/docs/superpowers/plans/2026-04-26-preset-create-interactive-refactor.md +1432 -0
- package/docs/superpowers/specs/2026-04-24-cc-env-design.md +438 -0
- package/docs/superpowers/specs/2026-04-24-cc-env-init-shell-migration-design.md +181 -0
- package/docs/superpowers/specs/2026-04-26-preset-create-interactive-refactor-design.md +78 -0
- package/package.json +55 -0
- package/src/cli.ts +337 -0
- package/src/commands/init.ts +139 -0
- package/src/commands/preset/create.ts +96 -0
- package/src/commands/preset/delete.ts +62 -0
- package/src/commands/preset/show.ts +51 -0
- package/src/commands/restore.ts +150 -0
- package/src/commands/run.ts +158 -0
- package/src/core/errors.ts +13 -0
- package/src/core/find-claude.ts +70 -0
- package/src/core/format.ts +29 -0
- package/src/core/fs.ts +18 -0
- package/src/core/gitignore.ts +26 -0
- package/src/core/logger.ts +11 -0
- package/src/core/mask.ts +17 -0
- package/src/core/paths.ts +41 -0
- package/src/core/process-env.ts +11 -0
- package/src/core/schema.ts +55 -0
- package/src/core/spawn.ts +36 -0
- package/src/flows/init-flow.ts +61 -0
- package/src/flows/preset-create-flow.ts +129 -0
- package/src/flows/restore-flow.ts +144 -0
- package/src/ink/init-app.tsx +110 -0
- package/src/ink/preset-create-app.tsx +451 -0
- package/src/ink/preset-delete-app.tsx +114 -0
- package/src/ink/preset-show-app.tsx +76 -0
- package/src/ink/restore-app.tsx +230 -0
- package/src/ink/run-preset-select-app.tsx +83 -0
- package/src/ink/summary.tsx +91 -0
- package/src/services/claude-settings-env-service.ts +72 -0
- package/src/services/history-service.ts +48 -0
- package/src/services/preset-service.ts +72 -0
- package/src/services/project-env-service.ts +128 -0
- package/src/services/project-state-service.ts +31 -0
- package/src/services/settings-env-service.ts +40 -0
- package/src/services/shell-env-service.ts +112 -0
- package/src/types.d.ts +19 -0
- package/tests/cli/help.test.ts +133 -0
- package/tests/cli/init.test.ts +76 -0
- package/tests/cli/restore.test.ts +172 -0
- package/tests/commands/create.test.ts +263 -0
- package/tests/commands/output.test.ts +119 -0
- package/tests/commands/run.test.ts +218 -0
- package/tests/core/gitignore.test.ts +98 -0
- package/tests/core/paths.test.ts +24 -0
- package/tests/core/schema-mask.test.ts +182 -0
- package/tests/core/spawn.test.ts +47 -0
- package/tests/flows/init-flow.test.ts +40 -0
- package/tests/flows/preset-create-flow.test.ts +225 -0
- package/tests/flows/restore-flow.test.ts +157 -0
- package/tests/integration/init-restore.test.ts +406 -0
- package/tests/services/claude-shell.test.ts +183 -0
- package/tests/services/storage.test.ts +143 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.json +22 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
import { execa } from 'execa'
|
|
7
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
8
|
+
|
|
9
|
+
const repoRoot = fileURLToPath(new URL('../../', import.meta.url))
|
|
10
|
+
const tsxLoader = join(repoRoot, 'node_modules/tsx/dist/loader.mjs')
|
|
11
|
+
const cliEntry = join(repoRoot, 'src/cli.ts')
|
|
12
|
+
|
|
13
|
+
const tempRoots: string[] = []
|
|
14
|
+
|
|
15
|
+
async function createRestoreFixture({ latestIncludesExtraKey = false }: { latestIncludesExtraKey?: boolean } = {}) {
|
|
16
|
+
const root = await mkdtemp(join(tmpdir(), 'cc-env-restore-cli-'))
|
|
17
|
+
tempRoots.push(root)
|
|
18
|
+
|
|
19
|
+
const homeDir = join(root, 'home')
|
|
20
|
+
await mkdir(join(homeDir, '.claude'), { recursive: true })
|
|
21
|
+
await mkdir(join(homeDir, '.cc-env', 'history'), { recursive: true })
|
|
22
|
+
await mkdir(join(homeDir, '.config', 'fish'), { recursive: true })
|
|
23
|
+
|
|
24
|
+
await writeFile(
|
|
25
|
+
join(homeDir, '.claude', 'settings.json'),
|
|
26
|
+
`${JSON.stringify({ env: {} }, null, 2)}\n`,
|
|
27
|
+
'utf8',
|
|
28
|
+
)
|
|
29
|
+
await writeFile(
|
|
30
|
+
join(homeDir, '.claude', 'settings.local.json'),
|
|
31
|
+
`${JSON.stringify({ env: {} }, null, 2)}\n`,
|
|
32
|
+
'utf8',
|
|
33
|
+
)
|
|
34
|
+
await writeFile(
|
|
35
|
+
join(homeDir, '.config', 'fish', 'config.fish'),
|
|
36
|
+
[
|
|
37
|
+
'# >>> cc-env >>>',
|
|
38
|
+
'set -gx ANTHROPIC_AUTH_TOKEN "local-token"',
|
|
39
|
+
...(latestIncludesExtraKey ? ['set -gx API_TIMEOUT_MS "3000000"'] : []),
|
|
40
|
+
'# <<< cc-env <<<',
|
|
41
|
+
'',
|
|
42
|
+
].join('\n'),
|
|
43
|
+
'utf8',
|
|
44
|
+
)
|
|
45
|
+
await writeFile(
|
|
46
|
+
join(homeDir, '.cc-env', 'history', '2026-04-24T12-00-00.000Z.json'),
|
|
47
|
+
`${JSON.stringify({
|
|
48
|
+
timestamp: '2026-04-24T12:00:00.000Z',
|
|
49
|
+
action: 'init',
|
|
50
|
+
migratedKeys: ['ANTHROPIC_AUTH_TOKEN'],
|
|
51
|
+
sources: [
|
|
52
|
+
{
|
|
53
|
+
file: join(homeDir, '.claude', 'settings.json'),
|
|
54
|
+
backup: {},
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
file: join(homeDir, '.claude', 'settings.local.json'),
|
|
58
|
+
backup: {
|
|
59
|
+
ANTHROPIC_AUTH_TOKEN: 'local-token',
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
shellWrites: [
|
|
64
|
+
{
|
|
65
|
+
shell: 'fish',
|
|
66
|
+
filePath: join(homeDir, '.config', 'fish', 'config.fish'),
|
|
67
|
+
env: {
|
|
68
|
+
ANTHROPIC_AUTH_TOKEN: 'local-token',
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
}, null, 2)}\n`,
|
|
73
|
+
'utf8',
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if (latestIncludesExtraKey) {
|
|
77
|
+
await writeFile(
|
|
78
|
+
join(homeDir, '.cc-env', 'history', '2026-04-25T12-00-00.000Z.json'),
|
|
79
|
+
`${JSON.stringify({
|
|
80
|
+
timestamp: '2026-04-25T12:00:00.000Z',
|
|
81
|
+
action: 'init',
|
|
82
|
+
migratedKeys: ['ANTHROPIC_AUTH_TOKEN', 'API_TIMEOUT_MS'],
|
|
83
|
+
sources: [
|
|
84
|
+
{
|
|
85
|
+
file: join(homeDir, '.claude', 'settings.json'),
|
|
86
|
+
backup: {},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
file: join(homeDir, '.claude', 'settings.local.json'),
|
|
90
|
+
backup: {
|
|
91
|
+
ANTHROPIC_AUTH_TOKEN: 'local-token',
|
|
92
|
+
API_TIMEOUT_MS: '3000000',
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
shellWrites: [
|
|
97
|
+
{
|
|
98
|
+
shell: 'fish',
|
|
99
|
+
filePath: join(homeDir, '.config', 'fish', 'config.fish'),
|
|
100
|
+
env: {
|
|
101
|
+
ANTHROPIC_AUTH_TOKEN: 'local-token',
|
|
102
|
+
API_TIMEOUT_MS: '3000000',
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
}, null, 2)}\n`,
|
|
107
|
+
'utf8',
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { homeDir }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
afterEach(async () => {
|
|
115
|
+
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('cc-env CLI restore', () => {
|
|
119
|
+
it('restores Claude settings from init history with --yes', async () => {
|
|
120
|
+
const { homeDir } = await createRestoreFixture()
|
|
121
|
+
|
|
122
|
+
const { stdout } = await execa(
|
|
123
|
+
'node',
|
|
124
|
+
['--import', tsxLoader, cliEntry, 'restore', '--yes'],
|
|
125
|
+
{
|
|
126
|
+
cwd: repoRoot,
|
|
127
|
+
env: {
|
|
128
|
+
HOME: homeDir,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
const settingsLocal = JSON.parse(
|
|
134
|
+
await readFile(join(homeDir, '.claude', 'settings.local.json'), 'utf8'),
|
|
135
|
+
) as { env?: Record<string, string> }
|
|
136
|
+
const fishConfig = await readFile(join(homeDir, '.config', 'fish', 'config.fish'), 'utf8')
|
|
137
|
+
|
|
138
|
+
expect(stdout).toContain('Restore complete')
|
|
139
|
+
expect(settingsLocal.env).toEqual({
|
|
140
|
+
ANTHROPIC_AUTH_TOKEN: 'local-token',
|
|
141
|
+
})
|
|
142
|
+
expect(fishConfig).not.toContain('ANTHROPIC_AUTH_TOKEN')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('restores the latest init record with extra migrated keys when using --yes', async () => {
|
|
146
|
+
const { homeDir } = await createRestoreFixture({ latestIncludesExtraKey: true })
|
|
147
|
+
|
|
148
|
+
await execa(
|
|
149
|
+
'node',
|
|
150
|
+
['--import', tsxLoader, cliEntry, 'restore', '--yes'],
|
|
151
|
+
{
|
|
152
|
+
cwd: repoRoot,
|
|
153
|
+
env: {
|
|
154
|
+
HOME: homeDir,
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
const settingsLocal = JSON.parse(
|
|
160
|
+
await readFile(join(homeDir, '.claude', 'settings.local.json'), 'utf8'),
|
|
161
|
+
) as { env?: Record<string, string> }
|
|
162
|
+
const fishConfig = await readFile(join(homeDir, '.config', 'fish', 'config.fish'), 'utf8')
|
|
163
|
+
|
|
164
|
+
expect(settingsLocal.env).toEqual({
|
|
165
|
+
ANTHROPIC_AUTH_TOKEN: 'local-token',
|
|
166
|
+
API_TIMEOUT_MS: '3000000',
|
|
167
|
+
})
|
|
168
|
+
expect(fishConfig).not.toContain('ANTHROPIC_AUTH_TOKEN')
|
|
169
|
+
expect(fishConfig).not.toContain('API_TIMEOUT_MS')
|
|
170
|
+
expect(fishConfig).not.toContain('# >>> cc-env >>>')
|
|
171
|
+
})
|
|
172
|
+
})
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it, vi, afterEach } from 'vitest'
|
|
6
|
+
|
|
7
|
+
import { createPresetCreateCommand, readEnvFile } from '../../src/commands/preset/create.js'
|
|
8
|
+
import { CliError } from '../../src/core/errors.js'
|
|
9
|
+
|
|
10
|
+
const tempRoots: string[] = []
|
|
11
|
+
|
|
12
|
+
async function createTempRoot() {
|
|
13
|
+
const root = await mkdtemp(join(tmpdir(), 'cc-env-create-'))
|
|
14
|
+
tempRoots.push(root)
|
|
15
|
+
return root
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('readEnvFile', () => {
|
|
23
|
+
it('reads a flat JSON file', async () => {
|
|
24
|
+
const root = await createTempRoot()
|
|
25
|
+
const file = join(root, 'env.json')
|
|
26
|
+
await writeFile(file, JSON.stringify({ API_KEY: 'secret', PORT: '3000' }))
|
|
27
|
+
|
|
28
|
+
const result = await readEnvFile(file)
|
|
29
|
+
expect(result.allKeys).toEqual(['API_KEY', 'PORT'])
|
|
30
|
+
expect(result.env).toEqual({ API_KEY: 'secret', PORT: '3000' })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('extracts from nested env field in JSON', async () => {
|
|
34
|
+
const root = await createTempRoot()
|
|
35
|
+
const file = join(root, 'env.json')
|
|
36
|
+
await writeFile(file, JSON.stringify({ env: { API_KEY: 'secret' }, other: true }))
|
|
37
|
+
|
|
38
|
+
const result = await readEnvFile(file)
|
|
39
|
+
expect(result.allKeys).toEqual(['API_KEY'])
|
|
40
|
+
expect(result.env).toEqual({ API_KEY: 'secret' })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('falls back to top-level when env is not an object', async () => {
|
|
44
|
+
const root = await createTempRoot()
|
|
45
|
+
const file = join(root, 'env.json')
|
|
46
|
+
await writeFile(file, JSON.stringify({ env: 'not-an-object', API_KEY: 'secret' }))
|
|
47
|
+
|
|
48
|
+
const result = await readEnvFile(file)
|
|
49
|
+
expect(result.env).toEqual({ API_KEY: 'secret' })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('reads a YAML file', async () => {
|
|
53
|
+
const root = await createTempRoot()
|
|
54
|
+
const file = join(root, 'env.yaml')
|
|
55
|
+
await writeFile(file, 'API_KEY: secret\nPORT: "3000"\n')
|
|
56
|
+
|
|
57
|
+
const result = await readEnvFile(file)
|
|
58
|
+
expect(result.allKeys).toEqual(['API_KEY', 'PORT'])
|
|
59
|
+
expect(result.env).toEqual({ API_KEY: 'secret', PORT: '3000' })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('throws for unsupported file extensions', async () => {
|
|
63
|
+
const root = await createTempRoot()
|
|
64
|
+
const file = join(root, 'env.toml')
|
|
65
|
+
await writeFile(file, 'content')
|
|
66
|
+
|
|
67
|
+
await expect(readEnvFile(file)).rejects.toThrowError(
|
|
68
|
+
new CliError('Unsupported file format: .toml', 2),
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('throws CliError for unreadable files', async () => {
|
|
73
|
+
await expect(readEnvFile('/nonexistent/file.json')).rejects.toThrowError(
|
|
74
|
+
expect.any(CliError),
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('throws CliError for invalid JSON content', async () => {
|
|
79
|
+
const root = await createTempRoot()
|
|
80
|
+
const file = join(root, 'env.json')
|
|
81
|
+
await writeFile(file, '{invalid')
|
|
82
|
+
|
|
83
|
+
await expect(readEnvFile(file)).rejects.toThrowError(
|
|
84
|
+
expect.any(CliError),
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('createPresetCreateCommand', () => {
|
|
90
|
+
it('writes to presetService when destination is global', async () => {
|
|
91
|
+
const presetService = {
|
|
92
|
+
write: vi.fn().mockResolvedValue(undefined),
|
|
93
|
+
}
|
|
94
|
+
const projectEnvService = {
|
|
95
|
+
write: vi.fn().mockResolvedValue(undefined),
|
|
96
|
+
}
|
|
97
|
+
const ensureGitignore = vi.fn().mockResolvedValue(undefined)
|
|
98
|
+
const renderFlow = vi.fn().mockResolvedValue({
|
|
99
|
+
source: 'manual',
|
|
100
|
+
env: { API_KEY: 'secret' },
|
|
101
|
+
selectedKeys: ['API_KEY'],
|
|
102
|
+
presetName: 'my-preset',
|
|
103
|
+
destination: 'global',
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const createPreset = createPresetCreateCommand({
|
|
107
|
+
presetService,
|
|
108
|
+
projectEnvService,
|
|
109
|
+
renderFlow,
|
|
110
|
+
ensureGitignore,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
await createPreset({ cwd: '/project' })
|
|
114
|
+
|
|
115
|
+
expect(presetService.write).toHaveBeenCalledWith({
|
|
116
|
+
name: 'my-preset',
|
|
117
|
+
createdAt: expect.any(String),
|
|
118
|
+
updatedAt: expect.any(String),
|
|
119
|
+
env: { API_KEY: 'secret' },
|
|
120
|
+
})
|
|
121
|
+
expect(projectEnvService.write).not.toHaveBeenCalled()
|
|
122
|
+
expect(ensureGitignore).not.toHaveBeenCalled()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('writes to projectEnvService when destination is project', async () => {
|
|
126
|
+
const presetService = {
|
|
127
|
+
write: vi.fn().mockResolvedValue(undefined),
|
|
128
|
+
}
|
|
129
|
+
const projectEnvService = {
|
|
130
|
+
write: vi.fn().mockResolvedValue(undefined),
|
|
131
|
+
}
|
|
132
|
+
const ensureGitignore = vi.fn().mockResolvedValue(undefined)
|
|
133
|
+
const renderFlow = vi.fn().mockResolvedValue({
|
|
134
|
+
source: 'file',
|
|
135
|
+
filePath: '/path/to/env.json',
|
|
136
|
+
env: { API_KEY: 'secret', OTHER: 'value' },
|
|
137
|
+
selectedKeys: ['API_KEY'],
|
|
138
|
+
presetName: 'proj',
|
|
139
|
+
destination: 'project',
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const createPreset = createPresetCreateCommand({
|
|
143
|
+
presetService,
|
|
144
|
+
projectEnvService,
|
|
145
|
+
renderFlow,
|
|
146
|
+
ensureGitignore,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
await createPreset({ cwd: '/project' })
|
|
150
|
+
|
|
151
|
+
expect(projectEnvService.write).toHaveBeenCalledWith({ API_KEY: 'secret' }, { name: 'proj', createdAt: expect.any(String), updatedAt: expect.any(String) })
|
|
152
|
+
expect(presetService.write).not.toHaveBeenCalled()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('calls ensureGitignore when destination is project', async () => {
|
|
156
|
+
const presetService = {
|
|
157
|
+
write: vi.fn().mockResolvedValue(undefined),
|
|
158
|
+
}
|
|
159
|
+
const projectEnvService = {
|
|
160
|
+
write: vi.fn().mockResolvedValue(undefined),
|
|
161
|
+
}
|
|
162
|
+
const ensureGitignore = vi.fn().mockResolvedValue(undefined)
|
|
163
|
+
const renderFlow = vi.fn().mockResolvedValue({
|
|
164
|
+
source: 'manual',
|
|
165
|
+
env: { KEY: 'val' },
|
|
166
|
+
selectedKeys: ['KEY'],
|
|
167
|
+
presetName: 'test',
|
|
168
|
+
destination: 'project',
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const createPreset = createPresetCreateCommand({
|
|
172
|
+
presetService,
|
|
173
|
+
projectEnvService,
|
|
174
|
+
renderFlow,
|
|
175
|
+
ensureGitignore,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
await createPreset({ cwd: '/my-project' })
|
|
179
|
+
|
|
180
|
+
expect(ensureGitignore).toHaveBeenCalledWith('/my-project', '.cc-env')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('does not call ensureGitignore when destination is global', async () => {
|
|
184
|
+
const presetService = {
|
|
185
|
+
write: vi.fn().mockResolvedValue(undefined),
|
|
186
|
+
}
|
|
187
|
+
const projectEnvService = {
|
|
188
|
+
write: vi.fn().mockResolvedValue(undefined),
|
|
189
|
+
}
|
|
190
|
+
const ensureGitignore = vi.fn().mockResolvedValue(undefined)
|
|
191
|
+
const renderFlow = vi.fn().mockResolvedValue({
|
|
192
|
+
source: 'manual',
|
|
193
|
+
env: { KEY: 'val' },
|
|
194
|
+
selectedKeys: ['KEY'],
|
|
195
|
+
presetName: 'test',
|
|
196
|
+
destination: 'global',
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const createPreset = createPresetCreateCommand({
|
|
200
|
+
presetService,
|
|
201
|
+
projectEnvService,
|
|
202
|
+
renderFlow,
|
|
203
|
+
ensureGitignore,
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
await createPreset({ cwd: '/my-project' })
|
|
207
|
+
|
|
208
|
+
expect(ensureGitignore).not.toHaveBeenCalled()
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('does nothing when renderFlow returns undefined', async () => {
|
|
212
|
+
const presetService = {
|
|
213
|
+
write: vi.fn().mockResolvedValue(undefined),
|
|
214
|
+
}
|
|
215
|
+
const projectEnvService = {
|
|
216
|
+
write: vi.fn().mockResolvedValue(undefined),
|
|
217
|
+
}
|
|
218
|
+
const renderFlow = vi.fn().mockResolvedValue(undefined)
|
|
219
|
+
|
|
220
|
+
const createPreset = createPresetCreateCommand({
|
|
221
|
+
presetService,
|
|
222
|
+
projectEnvService,
|
|
223
|
+
renderFlow,
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
await createPreset({ cwd: '/project' })
|
|
227
|
+
|
|
228
|
+
expect(presetService.write).not.toHaveBeenCalled()
|
|
229
|
+
expect(projectEnvService.write).not.toHaveBeenCalled()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('only includes selected keys in the written env', async () => {
|
|
233
|
+
const presetService = {
|
|
234
|
+
write: vi.fn().mockResolvedValue(undefined),
|
|
235
|
+
}
|
|
236
|
+
const projectEnvService = {
|
|
237
|
+
write: vi.fn().mockResolvedValue(undefined),
|
|
238
|
+
}
|
|
239
|
+
const renderFlow = vi.fn().mockResolvedValue({
|
|
240
|
+
source: 'file',
|
|
241
|
+
filePath: '/env.json',
|
|
242
|
+
env: { A: '1', B: '2', C: '3' },
|
|
243
|
+
selectedKeys: ['A', 'C'],
|
|
244
|
+
presetName: 'partial',
|
|
245
|
+
destination: 'global',
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
const createPreset = createPresetCreateCommand({
|
|
249
|
+
presetService,
|
|
250
|
+
projectEnvService,
|
|
251
|
+
renderFlow,
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
await createPreset({ cwd: '/project' })
|
|
255
|
+
|
|
256
|
+
expect(presetService.write).toHaveBeenCalledWith({
|
|
257
|
+
name: 'partial',
|
|
258
|
+
createdAt: expect.any(String),
|
|
259
|
+
updatedAt: expect.any(String),
|
|
260
|
+
env: { A: '1', C: '3' },
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { createDeletePresetCommand } from '../../src/commands/preset/delete.js'
|
|
4
|
+
import { formatEnvBlock, formatRestorePreview } from '../../src/core/format.js'
|
|
5
|
+
|
|
6
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
logSpy.mockClear()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
describe('formatEnvBlock', () => {
|
|
13
|
+
it('masks sensitive values', () => {
|
|
14
|
+
expect(
|
|
15
|
+
formatEnvBlock({
|
|
16
|
+
OPENAI_API_KEY: 'sk-1234567890',
|
|
17
|
+
BASE_URL: 'https://api.openai.com',
|
|
18
|
+
}),
|
|
19
|
+
).toBe('BASE_URL=https://api.openai.com\nOPENAI_API_KEY=sk-123456********')
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('formatRestorePreview', () => {
|
|
24
|
+
it('shows restore values as newline-delimited key=value entries without masking', () => {
|
|
25
|
+
expect(
|
|
26
|
+
formatRestorePreview({
|
|
27
|
+
OPENAI_API_KEY: 'sk-123',
|
|
28
|
+
BASE_URL: 'https://api.openai.com',
|
|
29
|
+
}),
|
|
30
|
+
).toBe('BASE_URL=https://api.openai.com\nOPENAI_API_KEY=sk-123')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('createDeletePresetCommand', () => {
|
|
35
|
+
it('deletes selected global preset and prints success message', async () => {
|
|
36
|
+
const remove = vi.fn().mockResolvedValue(undefined)
|
|
37
|
+
const deletePreset = createDeletePresetCommand({
|
|
38
|
+
presetService: {
|
|
39
|
+
listNames: vi.fn().mockResolvedValue(['openai', 'anthropic']),
|
|
40
|
+
read: vi.fn().mockResolvedValue({ env: { API_KEY: 'test' } }),
|
|
41
|
+
remove,
|
|
42
|
+
},
|
|
43
|
+
projectEnvService: {
|
|
44
|
+
readWithMeta: vi.fn().mockResolvedValue({ env: {} }),
|
|
45
|
+
write: vi.fn(),
|
|
46
|
+
},
|
|
47
|
+
renderDelete: vi.fn().mockResolvedValue({ name: 'anthropic', env: { API_KEY: 'test' }, source: 'global' }),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
await deletePreset()
|
|
51
|
+
|
|
52
|
+
expect(remove).toHaveBeenCalledWith('anthropic')
|
|
53
|
+
expect(logSpy).toHaveBeenCalledWith('Deleted preset: anthropic')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('deletes project preset by writing empty env', async () => {
|
|
57
|
+
const remove = vi.fn().mockResolvedValue(undefined)
|
|
58
|
+
const write = vi.fn().mockResolvedValue({})
|
|
59
|
+
const deletePreset = createDeletePresetCommand({
|
|
60
|
+
presetService: {
|
|
61
|
+
listNames: vi.fn().mockResolvedValue([]),
|
|
62
|
+
read: vi.fn(),
|
|
63
|
+
remove,
|
|
64
|
+
},
|
|
65
|
+
projectEnvService: {
|
|
66
|
+
readWithMeta: vi.fn().mockResolvedValue({ env: { KEY: 'val' }, name: 'my-proj' }),
|
|
67
|
+
write,
|
|
68
|
+
},
|
|
69
|
+
renderDelete: vi.fn().mockResolvedValue({ name: 'my-proj', env: { KEY: 'val' }, source: 'project' }),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
await deletePreset()
|
|
73
|
+
|
|
74
|
+
expect(write).toHaveBeenCalledWith({})
|
|
75
|
+
expect(remove).not.toHaveBeenCalled()
|
|
76
|
+
expect(logSpy).toHaveBeenCalledWith('Deleted preset: my-proj')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('prints message when no presets exist', async () => {
|
|
80
|
+
const remove = vi.fn().mockResolvedValue(undefined)
|
|
81
|
+
const deletePreset = createDeletePresetCommand({
|
|
82
|
+
presetService: {
|
|
83
|
+
listNames: vi.fn().mockResolvedValue([]),
|
|
84
|
+
read: vi.fn(),
|
|
85
|
+
remove,
|
|
86
|
+
},
|
|
87
|
+
projectEnvService: {
|
|
88
|
+
readWithMeta: vi.fn().mockResolvedValue({ env: {} }),
|
|
89
|
+
write: vi.fn(),
|
|
90
|
+
},
|
|
91
|
+
renderDelete: vi.fn(),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
await deletePreset()
|
|
95
|
+
|
|
96
|
+
expect(logSpy).toHaveBeenCalledWith('No presets found.')
|
|
97
|
+
expect(remove).not.toHaveBeenCalled()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('does nothing when user cancels', async () => {
|
|
101
|
+
const remove = vi.fn().mockResolvedValue(undefined)
|
|
102
|
+
const deletePreset = createDeletePresetCommand({
|
|
103
|
+
presetService: {
|
|
104
|
+
listNames: vi.fn().mockResolvedValue(['openai']),
|
|
105
|
+
read: vi.fn().mockResolvedValue({ env: { API_KEY: 'test' } }),
|
|
106
|
+
remove,
|
|
107
|
+
},
|
|
108
|
+
projectEnvService: {
|
|
109
|
+
readWithMeta: vi.fn().mockResolvedValue({ env: {} }),
|
|
110
|
+
write: vi.fn(),
|
|
111
|
+
},
|
|
112
|
+
renderDelete: vi.fn().mockResolvedValue(undefined),
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
await deletePreset()
|
|
116
|
+
|
|
117
|
+
expect(remove).not.toHaveBeenCalled()
|
|
118
|
+
})
|
|
119
|
+
})
|