@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,128 @@
|
|
|
1
|
+
import { access, mkdir, readFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { parse, stringify } from 'yaml'
|
|
5
|
+
|
|
6
|
+
import { atomicWriteFile } from '../core/fs.js'
|
|
7
|
+
import { CliError } from '../core/errors.js'
|
|
8
|
+
import { envMapSchema, type EnvMap } from '../core/schema.js'
|
|
9
|
+
|
|
10
|
+
export type ProjectEnvMeta = {
|
|
11
|
+
name?: string
|
|
12
|
+
createdAt?: string
|
|
13
|
+
updatedAt?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseEnvelope(value: unknown): {
|
|
17
|
+
env: EnvMap
|
|
18
|
+
name?: string | undefined
|
|
19
|
+
createdAt?: string | undefined
|
|
20
|
+
updatedAt?: string | undefined
|
|
21
|
+
} {
|
|
22
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
23
|
+
const obj = value as Record<string, unknown>
|
|
24
|
+
if (obj.env && typeof obj.env === 'object' && !Array.isArray(obj.env)) {
|
|
25
|
+
return {
|
|
26
|
+
env: envMapSchema.parse(obj.env),
|
|
27
|
+
name: typeof obj.name === 'string' ? obj.name : undefined,
|
|
28
|
+
createdAt: typeof obj.createdAt === 'string' ? obj.createdAt : undefined,
|
|
29
|
+
updatedAt: typeof obj.updatedAt === 'string' ? obj.updatedAt : undefined,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { env: envMapSchema.parse(value) }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createProjectEnvService({ cwd }: { cwd: string }) {
|
|
37
|
+
const envDir = join(cwd, '.cc-env')
|
|
38
|
+
const jsonPath = join(envDir, 'env.json')
|
|
39
|
+
const yamlPath = join(envDir, 'env.yaml')
|
|
40
|
+
|
|
41
|
+
async function exists(filePath: string): Promise<boolean> {
|
|
42
|
+
try {
|
|
43
|
+
await access(filePath)
|
|
44
|
+
return true
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
throw error
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function resolveMode(): Promise<'json' | 'yaml' | 'missing'> {
|
|
55
|
+
const [jsonExists, yamlExists] = await Promise.all([exists(jsonPath), exists(yamlPath)])
|
|
56
|
+
|
|
57
|
+
if (jsonExists && yamlExists) {
|
|
58
|
+
throw new CliError('Project env conflict: env.json and env.yaml both exist')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (yamlExists) {
|
|
62
|
+
return 'yaml'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (jsonExists) {
|
|
66
|
+
return 'json'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return 'missing'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function readRaw(): Promise<{
|
|
73
|
+
env: EnvMap
|
|
74
|
+
name?: string | undefined
|
|
75
|
+
createdAt?: string | undefined
|
|
76
|
+
updatedAt?: string | undefined
|
|
77
|
+
}> {
|
|
78
|
+
const mode = await resolveMode()
|
|
79
|
+
|
|
80
|
+
if (mode === 'missing') {
|
|
81
|
+
return { env: envMapSchema.parse({}) }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const filePath = mode === 'yaml' ? yamlPath : jsonPath
|
|
85
|
+
const content = await readFile(filePath, 'utf8')
|
|
86
|
+
const value = mode === 'yaml' ? parse(content) : JSON.parse(content)
|
|
87
|
+
return parseEnvelope(value ?? {})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
async read(): Promise<EnvMap> {
|
|
92
|
+
const { env } = await readRaw()
|
|
93
|
+
return env
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async readWithMeta(): Promise<{
|
|
97
|
+
env: EnvMap
|
|
98
|
+
name?: string | undefined
|
|
99
|
+
createdAt?: string | undefined
|
|
100
|
+
updatedAt?: string | undefined
|
|
101
|
+
}> {
|
|
102
|
+
return readRaw()
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async write(env: EnvMap, meta?: ProjectEnvMeta): Promise<EnvMap> {
|
|
106
|
+
const parsedEnv = envMapSchema.parse(env)
|
|
107
|
+
await mkdir(envDir, { recursive: true })
|
|
108
|
+
const mode = await resolveMode()
|
|
109
|
+
|
|
110
|
+
if (meta?.name !== undefined) {
|
|
111
|
+
const envelope: Record<string, unknown> = { name: meta.name, env: parsedEnv }
|
|
112
|
+
if (meta.createdAt !== undefined) envelope.createdAt = meta.createdAt
|
|
113
|
+
if (meta.updatedAt !== undefined) envelope.updatedAt = meta.updatedAt
|
|
114
|
+
const content = `${JSON.stringify(envelope, null, 2)}\n`
|
|
115
|
+
await atomicWriteFile(jsonPath, content)
|
|
116
|
+
return parsedEnv
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const filePath = mode === 'yaml' ? yamlPath : jsonPath
|
|
120
|
+
const content = mode === 'yaml'
|
|
121
|
+
? stringify(parsedEnv)
|
|
122
|
+
: `${JSON.stringify(parsedEnv, null, 2)}\n`
|
|
123
|
+
|
|
124
|
+
await atomicWriteFile(filePath, content)
|
|
125
|
+
return parsedEnv
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { atomicWriteFile } from '../core/fs.js'
|
|
5
|
+
|
|
6
|
+
type PresetRef = { presetName: string; source: 'global' | 'project' }
|
|
7
|
+
|
|
8
|
+
export function createProjectStateService(globalRoot: string) {
|
|
9
|
+
const filePath = join(globalRoot, 'project-state.json')
|
|
10
|
+
|
|
11
|
+
async function readAll(): Promise<Record<string, PresetRef>> {
|
|
12
|
+
try {
|
|
13
|
+
const content = await readFile(filePath, 'utf8')
|
|
14
|
+
return JSON.parse(content) as Record<string, PresetRef>
|
|
15
|
+
} catch {
|
|
16
|
+
return {}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
async getLastPreset(cwd: string): Promise<PresetRef | undefined> {
|
|
22
|
+
const state = await readAll()
|
|
23
|
+
return state[cwd]
|
|
24
|
+
},
|
|
25
|
+
async saveLastPreset(cwd: string, ref: PresetRef): Promise<void> {
|
|
26
|
+
const state = await readAll()
|
|
27
|
+
state[cwd] = ref
|
|
28
|
+
await atomicWriteFile(filePath, `${JSON.stringify(state, null, 2)}\n`)
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
import { atomicWriteFile } from '../core/fs.js'
|
|
4
|
+
import { envMapSchema, type EnvMap } from '../core/schema.js'
|
|
5
|
+
|
|
6
|
+
export function createSettingsEnvService({ settingsPath }: { settingsPath: string }) {
|
|
7
|
+
return {
|
|
8
|
+
async read(): Promise<EnvMap> {
|
|
9
|
+
try {
|
|
10
|
+
const content = await readFile(settingsPath, 'utf8')
|
|
11
|
+
const json = JSON.parse(content) as { env?: unknown }
|
|
12
|
+
return envMapSchema.parse(json.env ?? {})
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
15
|
+
return envMapSchema.parse({})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
throw error
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
async write(env: EnvMap): Promise<EnvMap> {
|
|
23
|
+
const parsedEnv = envMapSchema.parse(env)
|
|
24
|
+
let json: Record<string, unknown> = {}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const content = await readFile(settingsPath, 'utf8')
|
|
28
|
+
json = JSON.parse(content) as Record<string, unknown>
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
31
|
+
throw error
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
json.env = parsedEnv
|
|
36
|
+
await atomicWriteFile(settingsPath, `${JSON.stringify(json, null, 2)}\n`)
|
|
37
|
+
return parsedEnv
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
import { atomicWriteFile } from '../core/fs.js'
|
|
4
|
+
import { resolveShellConfigPaths } from '../core/paths.js'
|
|
5
|
+
import { envMapSchema, type EnvMap } from '../core/schema.js'
|
|
6
|
+
|
|
7
|
+
const startMarker = '# >>> cc-env >>>'
|
|
8
|
+
const endMarker = '# <<< cc-env <<<'
|
|
9
|
+
|
|
10
|
+
type ShellName = 'zsh' | 'bash' | 'fish'
|
|
11
|
+
|
|
12
|
+
export type ShellWriteRecord = {
|
|
13
|
+
shell: ShellName
|
|
14
|
+
filePath: string
|
|
15
|
+
env: EnvMap
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseManagedEnv(content: string): EnvMap {
|
|
19
|
+
const match = content.match(/# >>> cc-env >>>[\s\S]*?# <<< cc-env <<</)
|
|
20
|
+
if (!match) {
|
|
21
|
+
return envMapSchema.parse({})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const lines = match[0]
|
|
25
|
+
.split('\n')
|
|
26
|
+
.slice(1, -1)
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
|
|
29
|
+
return envMapSchema.parse(
|
|
30
|
+
Object.fromEntries(
|
|
31
|
+
lines.map((line) => {
|
|
32
|
+
if (line.startsWith('set -gx ')) {
|
|
33
|
+
const [, key, value] = line.match(/^set -gx ([A-Z0-9_]+) "(.*)"$/) ?? []
|
|
34
|
+
return [key, value]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const [, key, value] = line.match(/^export ([A-Z0-9_]+)="(.*)"$/) ?? []
|
|
38
|
+
return [key, value]
|
|
39
|
+
}),
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function renderBlock(shell: ShellName, env: EnvMap): string {
|
|
45
|
+
const lines = Object.entries(env)
|
|
46
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
47
|
+
.map(([key, value]) =>
|
|
48
|
+
shell === 'fish' ? `set -gx ${key} "${value}"` : `export ${key}="${value}"`,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return [startMarker, ...lines, endMarker, ''].join('\n')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function replaceManagedBlock(content: string, block: string): string {
|
|
55
|
+
const pattern = /# >>> cc-env >>>[\s\S]*?# <<< cc-env <<<\n?/
|
|
56
|
+
if (pattern.test(content)) {
|
|
57
|
+
const result = content.replace(pattern, block)
|
|
58
|
+
if (block === '') {
|
|
59
|
+
return result.replace(/\n{3,}/g, '\n\n').replace(/\n{2,}$/, '\n')
|
|
60
|
+
}
|
|
61
|
+
return result
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return content.length === 0 ? block : `${content.replace(/\n?$/, '\n')}\n${block}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createShellEnvService({ homeDir }: { homeDir?: string } = {}) {
|
|
68
|
+
const paths = resolveShellConfigPaths(homeDir)
|
|
69
|
+
|
|
70
|
+
async function readContent(path: string): Promise<string> {
|
|
71
|
+
try {
|
|
72
|
+
return await readFile(path, 'utf8')
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
75
|
+
return ''
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
throw error
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
async write(env: EnvMap): Promise<ShellWriteRecord[]> {
|
|
84
|
+
return Promise.all(
|
|
85
|
+
(Object.entries(paths) as Array<[ShellName, string]>).map(async ([shell, filePath]) => {
|
|
86
|
+
const content = await readContent(filePath)
|
|
87
|
+
const mergedEnv = envMapSchema.parse({
|
|
88
|
+
...parseManagedEnv(content),
|
|
89
|
+
...env,
|
|
90
|
+
})
|
|
91
|
+
await atomicWriteFile(filePath, replaceManagedBlock(content, renderBlock(shell, mergedEnv)))
|
|
92
|
+
return { shell, filePath, env: mergedEnv }
|
|
93
|
+
}),
|
|
94
|
+
)
|
|
95
|
+
},
|
|
96
|
+
async removeKeys(shellWrites: ShellWriteRecord[], keys: string[]): Promise<void> {
|
|
97
|
+
await Promise.all(
|
|
98
|
+
shellWrites.map(async ({ shell, filePath }) => {
|
|
99
|
+
const content = await readContent(filePath)
|
|
100
|
+
const current = parseManagedEnv(content)
|
|
101
|
+
const next = envMapSchema.parse(
|
|
102
|
+
Object.fromEntries(
|
|
103
|
+
Object.entries(current).filter(([key]) => !keys.includes(key)),
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
const block = Object.keys(next).length === 0 ? '' : renderBlock(shell, next)
|
|
107
|
+
await atomicWriteFile(filePath, replaceManagedBlock(content, block))
|
|
108
|
+
}),
|
|
109
|
+
)
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/types.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
declare module 'cross-spawn' {
|
|
2
|
+
import type { SpawnOptions } from 'node:child_process'
|
|
3
|
+
import type { EventEmitter } from 'node:events'
|
|
4
|
+
import type { Readable, Writable } from 'node:stream'
|
|
5
|
+
|
|
6
|
+
type ChildProcessLike = EventEmitter & {
|
|
7
|
+
stdout?: Readable | null
|
|
8
|
+
stderr?: Readable | null
|
|
9
|
+
stdin?: Writable | null
|
|
10
|
+
once(event: 'error', listener: (error: Error) => void): ChildProcessLike
|
|
11
|
+
once(event: 'close', listener: (code: number | null) => void): ChildProcessLike
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function spawn(
|
|
15
|
+
command: string,
|
|
16
|
+
args?: readonly string[],
|
|
17
|
+
options?: SpawnOptions,
|
|
18
|
+
): ChildProcessLike
|
|
19
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { mkdtemp, mkdir, 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 createCliFixture() {
|
|
16
|
+
const root = await mkdtemp(join(tmpdir(), 'cc-env-cli-'))
|
|
17
|
+
tempRoots.push(root)
|
|
18
|
+
|
|
19
|
+
const homeDir = join(root, 'home')
|
|
20
|
+
const projectDir = join(root, 'project')
|
|
21
|
+
|
|
22
|
+
await mkdir(join(homeDir, '.cc-env', 'presets'), { recursive: true })
|
|
23
|
+
await mkdir(join(projectDir, '.cc-env'), { recursive: true })
|
|
24
|
+
|
|
25
|
+
await writeFile(
|
|
26
|
+
join(homeDir, '.cc-env', 'config.json'),
|
|
27
|
+
`${JSON.stringify({ defaultPreset: 'openai' }, null, 2)}\n`,
|
|
28
|
+
'utf8',
|
|
29
|
+
)
|
|
30
|
+
await writeFile(
|
|
31
|
+
join(homeDir, '.cc-env', 'project-state.json'),
|
|
32
|
+
`${JSON.stringify({}, null, 2)}\n`,
|
|
33
|
+
'utf8',
|
|
34
|
+
)
|
|
35
|
+
await writeFile(
|
|
36
|
+
join(homeDir, '.cc-env', 'presets', 'openai.json'),
|
|
37
|
+
`${JSON.stringify({
|
|
38
|
+
name: 'openai',
|
|
39
|
+
createdAt: '2026-04-24T00:00:00.000Z',
|
|
40
|
+
updatedAt: '2026-04-24T00:00:00.000Z',
|
|
41
|
+
env: {
|
|
42
|
+
PRESET_KEY: 'preset',
|
|
43
|
+
},
|
|
44
|
+
}, null, 2)}\n`,
|
|
45
|
+
'utf8',
|
|
46
|
+
)
|
|
47
|
+
await writeFile(
|
|
48
|
+
join(projectDir, 'settings.json'),
|
|
49
|
+
`${JSON.stringify({
|
|
50
|
+
env: {
|
|
51
|
+
SETTINGS_KEY: 'settings',
|
|
52
|
+
},
|
|
53
|
+
}, null, 2)}\n`,
|
|
54
|
+
'utf8',
|
|
55
|
+
)
|
|
56
|
+
await writeFile(
|
|
57
|
+
join(projectDir, '.cc-env', 'env.json'),
|
|
58
|
+
`${JSON.stringify({ PROJECT_KEY: 'project' }, null, 2)}\n`,
|
|
59
|
+
'utf8',
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return { homeDir, projectDir }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
afterEach(async () => {
|
|
66
|
+
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('cc-env CLI help', () => {
|
|
70
|
+
it('shows the top-level commands in --help output', async () => {
|
|
71
|
+
const { stdout } = await execa('node', ['--import', tsxLoader, cliEntry, '--help'], {
|
|
72
|
+
cwd: repoRoot,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
expect(stdout).toContain('run')
|
|
76
|
+
expect(stdout).toContain('init')
|
|
77
|
+
expect(stdout).toContain('restore')
|
|
78
|
+
expect(stdout).toContain('preset')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('shows the preset subcommands in help output', async () => {
|
|
82
|
+
const { stdout } = await execa('node', ['--import', tsxLoader, cliEntry, 'preset', '--help'], {
|
|
83
|
+
cwd: repoRoot,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
expect(stdout).toContain('show')
|
|
87
|
+
expect(stdout).toContain('delete')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('uses real HOME and cwd wiring for dry-run env resolution', async () => {
|
|
91
|
+
const { homeDir, projectDir } = await createCliFixture()
|
|
92
|
+
const result = await execa(
|
|
93
|
+
'node',
|
|
94
|
+
['--import', tsxLoader, cliEntry, 'run', '--dry-run', '--yes', 'claude'],
|
|
95
|
+
{
|
|
96
|
+
cwd: projectDir,
|
|
97
|
+
env: {
|
|
98
|
+
HOME: homeDir,
|
|
99
|
+
},
|
|
100
|
+
reject: false,
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
expect(result.exitCode).toBe(0)
|
|
105
|
+
expect(result.stdout).toContain('PROJECT_KEY=')
|
|
106
|
+
expect(result.stdout).toContain('Would run: claude')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('prints CliError messages without stack traces at the top level', async () => {
|
|
110
|
+
const { homeDir, projectDir } = await createCliFixture()
|
|
111
|
+
|
|
112
|
+
await mkdir(join(projectDir, '.claude'), { recursive: true })
|
|
113
|
+
await writeFile(
|
|
114
|
+
join(projectDir, '.claude', 'settings.json'),
|
|
115
|
+
`${JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'stale' } }, null, 2)}\n`,
|
|
116
|
+
'utf8',
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const result = await execa(
|
|
120
|
+
'node',
|
|
121
|
+
['--import', tsxLoader, cliEntry, 'run', '--dry-run', '--yes'],
|
|
122
|
+
{
|
|
123
|
+
cwd: projectDir,
|
|
124
|
+
env: { HOME: homeDir },
|
|
125
|
+
reject: false,
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
expect(result.exitCode).toBe(1)
|
|
130
|
+
expect(result.stderr).not.toContain('CliError:')
|
|
131
|
+
expect(result.stderr).toContain('Error:')
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
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 createInitFixture() {
|
|
16
|
+
const root = await mkdtemp(join(tmpdir(), 'cc-env-init-cli-'))
|
|
17
|
+
tempRoots.push(root)
|
|
18
|
+
|
|
19
|
+
const homeDir = join(root, 'home')
|
|
20
|
+
await mkdir(join(homeDir, '.claude'), { recursive: true })
|
|
21
|
+
|
|
22
|
+
await writeFile(
|
|
23
|
+
join(homeDir, '.claude', 'settings.json'),
|
|
24
|
+
`${JSON.stringify({
|
|
25
|
+
env: {
|
|
26
|
+
ANTHROPIC_BASE_URL: 'https://settings.example.com',
|
|
27
|
+
},
|
|
28
|
+
}, null, 2)}\n`,
|
|
29
|
+
'utf8',
|
|
30
|
+
)
|
|
31
|
+
await writeFile(
|
|
32
|
+
join(homeDir, '.claude', 'settings.local.json'),
|
|
33
|
+
`${JSON.stringify({
|
|
34
|
+
env: {
|
|
35
|
+
ANTHROPIC_AUTH_TOKEN: 'local-token',
|
|
36
|
+
},
|
|
37
|
+
}, null, 2)}\n`,
|
|
38
|
+
'utf8',
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return { homeDir }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('cc-env CLI init', () => {
|
|
49
|
+
it('migrates env into shell config with --yes', async () => {
|
|
50
|
+
const { homeDir } = await createInitFixture()
|
|
51
|
+
|
|
52
|
+
await execa(
|
|
53
|
+
'node',
|
|
54
|
+
['--import', tsxLoader, cliEntry, 'init', '--yes'],
|
|
55
|
+
{
|
|
56
|
+
cwd: repoRoot,
|
|
57
|
+
env: {
|
|
58
|
+
HOME: homeDir,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const settings = JSON.parse(
|
|
64
|
+
await readFile(join(homeDir, '.claude', 'settings.json'), 'utf8'),
|
|
65
|
+
) as { env?: Record<string, string> }
|
|
66
|
+
const settingsLocal = JSON.parse(
|
|
67
|
+
await readFile(join(homeDir, '.claude', 'settings.local.json'), 'utf8'),
|
|
68
|
+
) as { env?: Record<string, string> }
|
|
69
|
+
const fishConfig = await readFile(join(homeDir, '.config', 'fish', 'config.fish'), 'utf8')
|
|
70
|
+
|
|
71
|
+
expect(settings.env).toEqual({})
|
|
72
|
+
expect(settingsLocal.env).toEqual({})
|
|
73
|
+
expect(fishConfig).toContain('set -gx ANTHROPIC_AUTH_TOKEN "local-token"')
|
|
74
|
+
expect(fishConfig).toContain('set -gx ANTHROPIC_BASE_URL "https://settings.example.com"')
|
|
75
|
+
})
|
|
76
|
+
})
|