@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,230 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { Box, Text, useApp, useInput } from 'ink'
|
|
3
|
+
|
|
4
|
+
import { advanceRestoreFlow, type RestoreFlowState } from '../flows/restore-flow.js'
|
|
5
|
+
import { EnvEntries, EnvSummary } from './summary.js'
|
|
6
|
+
|
|
7
|
+
export function RestoreApp({
|
|
8
|
+
state,
|
|
9
|
+
onSubmit,
|
|
10
|
+
}: {
|
|
11
|
+
state: RestoreFlowState
|
|
12
|
+
onSubmit?: (result: {
|
|
13
|
+
confirmed: boolean
|
|
14
|
+
timestamp?: string
|
|
15
|
+
targetType?: 'settings' | 'preset'
|
|
16
|
+
targetName?: string
|
|
17
|
+
}) => void
|
|
18
|
+
}) {
|
|
19
|
+
const { exit } = useApp()
|
|
20
|
+
const [currentState, setCurrentState] = useState(state)
|
|
21
|
+
const [cursor, setCursor] = useState(0)
|
|
22
|
+
const recordAtCursor = currentState.records[cursor]
|
|
23
|
+
const selectedRecord = currentState.records.find(
|
|
24
|
+
(record) => record.timestamp === currentState.selectedTimestamp,
|
|
25
|
+
)
|
|
26
|
+
const activeRecord = currentState.step === 'record'
|
|
27
|
+
? recordAtCursor
|
|
28
|
+
: selectedRecord ?? currentState.records[0]
|
|
29
|
+
const restoreEntries = useMemo(
|
|
30
|
+
() =>
|
|
31
|
+
activeRecord
|
|
32
|
+
? Object.entries(
|
|
33
|
+
activeRecord.action === 'init'
|
|
34
|
+
? Object.fromEntries(activeRecord.sources.flatMap((s) => Object.entries(s.backup)))
|
|
35
|
+
: activeRecord.backup,
|
|
36
|
+
).sort(([left], [right]) => left.localeCompare(right)) as [string, string][]
|
|
37
|
+
: [],
|
|
38
|
+
[activeRecord],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const fromFiles = useMemo(() => {
|
|
42
|
+
if (!activeRecord || activeRecord.action !== 'init') {
|
|
43
|
+
return []
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return activeRecord.shellWrites.map((sw) => sw.filePath)
|
|
47
|
+
}, [activeRecord])
|
|
48
|
+
|
|
49
|
+
const toFiles = useMemo(() => {
|
|
50
|
+
if (!activeRecord || activeRecord.action !== 'init') {
|
|
51
|
+
return []
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return activeRecord.sources.map((s) => s.file)
|
|
55
|
+
}, [activeRecord])
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
setCurrentState(state)
|
|
59
|
+
setCursor(0)
|
|
60
|
+
}, [state])
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!onSubmit || currentState.records.length !== 0) {
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
onSubmit({
|
|
68
|
+
confirmed: false,
|
|
69
|
+
})
|
|
70
|
+
exit()
|
|
71
|
+
}, [currentState.records.length, exit, onSubmit])
|
|
72
|
+
|
|
73
|
+
useInput((input, key) => {
|
|
74
|
+
if (!onSubmit) {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (key.escape || input.toLowerCase() === 'q') {
|
|
79
|
+
onSubmit({ confirmed: false })
|
|
80
|
+
exit()
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (currentState.step === 'record') {
|
|
85
|
+
if (key.upArrow || input === 'k') {
|
|
86
|
+
setCursor((value) => Math.max(0, value - 1))
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (key.downArrow || input === 'j') {
|
|
91
|
+
setCursor((value) => Math.min(currentState.records.length - 1, value + 1))
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!key.return || !activeRecord) {
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (currentState.step === 'record') {
|
|
101
|
+
setCurrentState((previousState) =>
|
|
102
|
+
advanceRestoreFlow(previousState, {
|
|
103
|
+
type: 'select-record',
|
|
104
|
+
timestamp: activeRecord.timestamp,
|
|
105
|
+
}),
|
|
106
|
+
)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (currentState.step === 'target') {
|
|
111
|
+
if (activeRecord.action !== 'restore') {
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
setCurrentState((previousState) =>
|
|
116
|
+
advanceRestoreFlow(previousState, {
|
|
117
|
+
type: 'select-target',
|
|
118
|
+
targetType: activeRecord.targetType,
|
|
119
|
+
...(activeRecord.targetType === 'preset'
|
|
120
|
+
? { targetName: activeRecord.targetName }
|
|
121
|
+
: {}),
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (currentState.step === 'confirm') {
|
|
128
|
+
onSubmit({
|
|
129
|
+
confirmed: true,
|
|
130
|
+
timestamp: activeRecord.timestamp,
|
|
131
|
+
...(currentState.targetType ? { targetType: currentState.targetType } : {}),
|
|
132
|
+
...(currentState.targetType === 'preset' && 'targetName' in currentState
|
|
133
|
+
? { targetName: currentState.targetName }
|
|
134
|
+
: {}),
|
|
135
|
+
})
|
|
136
|
+
exit()
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<Box flexDirection="column">
|
|
142
|
+
<Text>Restore record</Text>
|
|
143
|
+
{currentState.step === 'record' ? (
|
|
144
|
+
<>
|
|
145
|
+
<Text dimColor>↑/k ↓/j navigate · enter confirm · q cancel</Text>
|
|
146
|
+
<Box marginTop={1}>
|
|
147
|
+
<Box flexDirection="column" width={28} marginRight={2}>
|
|
148
|
+
<Text bold color="cyan">History</Text>
|
|
149
|
+
<Box flexDirection="column" marginTop={1}>
|
|
150
|
+
{currentState.records.map((record, index) => (
|
|
151
|
+
<Text key={record.timestamp}>
|
|
152
|
+
{index === cursor ? '❯ ' : ' '}
|
|
153
|
+
{record.timestamp}
|
|
154
|
+
</Text>
|
|
155
|
+
))}
|
|
156
|
+
</Box>
|
|
157
|
+
</Box>
|
|
158
|
+
<Box flexDirection="column" flexGrow={1} borderStyle="round" borderColor="green" paddingX={1}>
|
|
159
|
+
<Text bold color="green">Preview</Text>
|
|
160
|
+
{activeRecord?.action === 'init' ? (
|
|
161
|
+
<Box flexDirection="column">
|
|
162
|
+
{fromFiles.length > 0 ? (
|
|
163
|
+
<Box flexDirection="column">
|
|
164
|
+
<Text dimColor>From:</Text>
|
|
165
|
+
{fromFiles.map((file) => (
|
|
166
|
+
<Text key={file} color="cyan"> {file}</Text>
|
|
167
|
+
))}
|
|
168
|
+
</Box>
|
|
169
|
+
) : null}
|
|
170
|
+
{toFiles.length > 0 ? (
|
|
171
|
+
<Box flexDirection="column">
|
|
172
|
+
<Text dimColor>To:</Text>
|
|
173
|
+
{toFiles.map((file) => (
|
|
174
|
+
<Text key={file} color="cyan"> {file}</Text>
|
|
175
|
+
))}
|
|
176
|
+
</Box>
|
|
177
|
+
) : null}
|
|
178
|
+
</Box>
|
|
179
|
+
) : (
|
|
180
|
+
<Text dimColor>
|
|
181
|
+
Restore to {activeRecord?.targetType === 'preset' ? `preset ${activeRecord.targetName}` : activeRecord?.targetType ?? 'settings'}
|
|
182
|
+
</Text>
|
|
183
|
+
)}
|
|
184
|
+
<Box flexDirection="column" marginTop={1}>
|
|
185
|
+
<EnvEntries entries={restoreEntries} />
|
|
186
|
+
</Box>
|
|
187
|
+
</Box>
|
|
188
|
+
</Box>
|
|
189
|
+
</>
|
|
190
|
+
) : null}
|
|
191
|
+
{currentState.step === 'target' ? (
|
|
192
|
+
<Text>
|
|
193
|
+
Select target for {selectedRecord?.timestamp ?? 'record'}: settings or preset
|
|
194
|
+
</Text>
|
|
195
|
+
) : null}
|
|
196
|
+
{currentState.step === 'confirm' && selectedRecord?.action === 'init' ? (
|
|
197
|
+
<Box flexDirection="column" marginTop={1}>
|
|
198
|
+
<Text>
|
|
199
|
+
Confirm restore from <Text color="cyan">{selectedRecord.timestamp}</Text>
|
|
200
|
+
</Text>
|
|
201
|
+
<EnvSummary
|
|
202
|
+
title="Will restore"
|
|
203
|
+
entries={restoreEntries}
|
|
204
|
+
{...(fromFiles.length > 0 ? { fromFiles } : {})}
|
|
205
|
+
{...(toFiles.length > 0 ? { toFiles } : {})}
|
|
206
|
+
/>
|
|
207
|
+
</Box>
|
|
208
|
+
) : null}
|
|
209
|
+
{currentState.step === 'confirm' && selectedRecord?.action !== 'init' ? (
|
|
210
|
+
<Box flexDirection="column" marginTop={1}>
|
|
211
|
+
<Text>
|
|
212
|
+
Confirm restore from <Text color="cyan">{selectedRecord?.timestamp ?? 'record'}</Text> to{' '}
|
|
213
|
+
<Text color="green">
|
|
214
|
+
{currentState.targetType === 'preset'
|
|
215
|
+
? `preset ${currentState.targetName}`
|
|
216
|
+
: currentState.targetType ?? 'settings'}
|
|
217
|
+
</Text>
|
|
218
|
+
</Text>
|
|
219
|
+
<EnvSummary title="Will restore" entries={restoreEntries} />
|
|
220
|
+
</Box>
|
|
221
|
+
) : null}
|
|
222
|
+
{currentState.step === 'done' ? (
|
|
223
|
+
<Text color="green">{'\n'}Restore complete</Text>
|
|
224
|
+
) : null}
|
|
225
|
+
{currentState.step !== 'done' ? (
|
|
226
|
+
<Text>Press Enter to confirm or q to cancel</Text>
|
|
227
|
+
) : null}
|
|
228
|
+
</Box>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react'
|
|
2
|
+
import { Box, Text, useApp, useInput } from 'ink'
|
|
3
|
+
|
|
4
|
+
import type { EnvMap } from '../core/schema.js'
|
|
5
|
+
import { EnvEntries } from './summary.js'
|
|
6
|
+
|
|
7
|
+
export type PresetSource = 'global' | 'project'
|
|
8
|
+
|
|
9
|
+
export type PresetSelectItem = {
|
|
10
|
+
name: string
|
|
11
|
+
env: EnvMap
|
|
12
|
+
source: PresetSource
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Props = {
|
|
16
|
+
presets: Array<PresetSelectItem>
|
|
17
|
+
defaultIndex?: number
|
|
18
|
+
onSubmit: (preset: PresetSelectItem) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function RunPresetSelectApp({ presets, defaultIndex = 0, onSubmit }: Props) {
|
|
22
|
+
const { exit } = useApp()
|
|
23
|
+
const [cursor, setCursor] = useState(defaultIndex)
|
|
24
|
+
const active = presets[cursor]
|
|
25
|
+
|
|
26
|
+
const entries = useMemo(
|
|
27
|
+
() =>
|
|
28
|
+
active
|
|
29
|
+
? (Object.entries(active.env).sort(([a], [b]) => a.localeCompare(b)) as [string, string][])
|
|
30
|
+
: [],
|
|
31
|
+
[active],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
useInput((input, key) => {
|
|
35
|
+
if (key.escape || input.toLowerCase() === 'q') {
|
|
36
|
+
exit()
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (key.upArrow || input === 'k') {
|
|
41
|
+
setCursor((c) => Math.max(0, c - 1))
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (key.downArrow || input === 'j') {
|
|
46
|
+
setCursor((c) => Math.min(presets.length - 1, c + 1))
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (key.return && active) {
|
|
51
|
+
onSubmit(active)
|
|
52
|
+
exit()
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<Box flexDirection="column">
|
|
58
|
+
<Text bold>Select a preset</Text>
|
|
59
|
+
<Text dimColor>↑/k ↓/j navigate · enter select · q cancel</Text>
|
|
60
|
+
<Box marginTop={1}>
|
|
61
|
+
<Box flexDirection="column" width={28} marginRight={2}>
|
|
62
|
+
<Text bold color="cyan">Presets</Text>
|
|
63
|
+
<Box flexDirection="column" marginTop={1}>
|
|
64
|
+
{presets.map((p, i) => (
|
|
65
|
+
<Box key={`${p.source}:${p.name}`}>
|
|
66
|
+
<Text>{i === cursor ? '❯ ' : ' '}</Text>
|
|
67
|
+
<Text {...(p.source === 'project' ? { color: 'yellow' } : {})}>{p.name}</Text>
|
|
68
|
+
<Text dimColor> ({p.source})</Text>
|
|
69
|
+
</Box>
|
|
70
|
+
))}
|
|
71
|
+
</Box>
|
|
72
|
+
</Box>
|
|
73
|
+
<Box flexDirection="column" flexGrow={1} borderStyle="round" borderColor="green" paddingX={1}>
|
|
74
|
+
<Text bold color="green">{active?.name ?? 'Preview'}</Text>
|
|
75
|
+
<Text dimColor>{active?.source === 'project' ? 'Project preset' : 'Global preset'}</Text>
|
|
76
|
+
<Box flexDirection="column" marginTop={1}>
|
|
77
|
+
<EnvEntries entries={entries} />
|
|
78
|
+
</Box>
|
|
79
|
+
</Box>
|
|
80
|
+
</Box>
|
|
81
|
+
</Box>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { Box, Text, render } from 'ink'
|
|
4
|
+
|
|
5
|
+
const h = React.createElement
|
|
6
|
+
|
|
7
|
+
import { maskValue } from '../core/mask.js'
|
|
8
|
+
import type { EnvMap } from '../core/schema.js'
|
|
9
|
+
|
|
10
|
+
export function EnvEntries({ entries, mask }: { entries: [string, string][]; mask?: boolean }) {
|
|
11
|
+
if (entries.length === 0) {
|
|
12
|
+
return <Text dimColor>none</Text>
|
|
13
|
+
}
|
|
14
|
+
return <>{entries.map(([key, value]) => (
|
|
15
|
+
<Box key={key}>
|
|
16
|
+
<Text color="yellow">• </Text>
|
|
17
|
+
<Text color="magenta">{key}</Text>
|
|
18
|
+
<Text dimColor>=</Text>
|
|
19
|
+
<Text color="white">{mask ? maskValue(key, value) : value}</Text>
|
|
20
|
+
</Box>
|
|
21
|
+
))}</>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function EnvSummary({
|
|
25
|
+
title,
|
|
26
|
+
entries,
|
|
27
|
+
description,
|
|
28
|
+
fromFiles,
|
|
29
|
+
toFiles,
|
|
30
|
+
footer,
|
|
31
|
+
mask,
|
|
32
|
+
}: {
|
|
33
|
+
title: string
|
|
34
|
+
entries: [string, string][]
|
|
35
|
+
description?: string
|
|
36
|
+
fromFiles?: string[]
|
|
37
|
+
toFiles?: string[]
|
|
38
|
+
footer?: ReactNode
|
|
39
|
+
mask?: boolean
|
|
40
|
+
}) {
|
|
41
|
+
return (
|
|
42
|
+
<Box flexDirection="column">
|
|
43
|
+
{description ? <Text dimColor>{description}</Text> : null}
|
|
44
|
+
{fromFiles && fromFiles.length > 0 ? (
|
|
45
|
+
<Box flexDirection="column">
|
|
46
|
+
<Text dimColor>From:</Text>
|
|
47
|
+
{fromFiles.map((file) => (
|
|
48
|
+
<Text key={file} color="cyan"> {file}</Text>
|
|
49
|
+
))}
|
|
50
|
+
</Box>
|
|
51
|
+
) : null}
|
|
52
|
+
{toFiles && toFiles.length > 0 ? (
|
|
53
|
+
<Box flexDirection="column">
|
|
54
|
+
<Text dimColor>To:</Text>
|
|
55
|
+
{toFiles.map((file) => (
|
|
56
|
+
<Text key={file} color="cyan"> {file}</Text>
|
|
57
|
+
))}
|
|
58
|
+
</Box>
|
|
59
|
+
) : null}
|
|
60
|
+
<Box flexDirection="column" marginTop={1} borderStyle="round" borderColor="green" paddingX={1}>
|
|
61
|
+
<Text bold color="green">{title}</Text>
|
|
62
|
+
<EnvEntries entries={entries} {...(mask ? { mask } : {})} />
|
|
63
|
+
</Box>
|
|
64
|
+
{footer ?? null}
|
|
65
|
+
</Box>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function renderEnvSummary(props: {
|
|
70
|
+
title: string
|
|
71
|
+
description?: string
|
|
72
|
+
env: EnvMap
|
|
73
|
+
fromFiles?: string[]
|
|
74
|
+
toFiles?: string[]
|
|
75
|
+
footer?: ReactNode
|
|
76
|
+
}): Promise<void> {
|
|
77
|
+
const entries: [string, string][] = Object.entries(props.env).sort(([a], [b]) => a.localeCompare(b)) as [string, string][]
|
|
78
|
+
const app = render(
|
|
79
|
+
h(EnvSummary, {
|
|
80
|
+
title: props.title,
|
|
81
|
+
entries,
|
|
82
|
+
mask: true,
|
|
83
|
+
...(props.description ? { description: props.description } : {}),
|
|
84
|
+
...(props.fromFiles ? { fromFiles: props.fromFiles } : {}),
|
|
85
|
+
...(props.toFiles ? { toFiles: props.toFiles } : {}),
|
|
86
|
+
...(props.footer ? { footer: props.footer } : {}),
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
app.unmount()
|
|
90
|
+
await app.waitUntilExit()
|
|
91
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
import { atomicWriteFile } from '../core/fs.js'
|
|
4
|
+
import {
|
|
5
|
+
resolveClaudeSettingsLocalPath,
|
|
6
|
+
resolveClaudeSettingsPath,
|
|
7
|
+
resolveProjectSettingsLocalPath,
|
|
8
|
+
resolveProjectSettingsPath,
|
|
9
|
+
} from '../core/paths.js'
|
|
10
|
+
import { envMapSchema, type EnvMap } from '../core/schema.js'
|
|
11
|
+
|
|
12
|
+
export type ClaudeSettingsSource = {
|
|
13
|
+
path: string
|
|
14
|
+
exists: boolean
|
|
15
|
+
env: EnvMap
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createClaudeSettingsEnvService({ homeDir, cwd }: { homeDir?: string; cwd?: string } = {}) {
|
|
19
|
+
const paths = [
|
|
20
|
+
resolveClaudeSettingsPath(homeDir),
|
|
21
|
+
resolveClaudeSettingsLocalPath(homeDir),
|
|
22
|
+
resolveProjectSettingsPath(cwd),
|
|
23
|
+
resolveProjectSettingsLocalPath(cwd),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
async function readOne(path: string): Promise<ClaudeSettingsSource> {
|
|
27
|
+
try {
|
|
28
|
+
const content = await readFile(path, 'utf8')
|
|
29
|
+
const json = JSON.parse(content) as { env?: unknown }
|
|
30
|
+
return {
|
|
31
|
+
path,
|
|
32
|
+
exists: true,
|
|
33
|
+
env: envMapSchema.parse(json.env ?? {}),
|
|
34
|
+
}
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
37
|
+
return {
|
|
38
|
+
path,
|
|
39
|
+
exists: false,
|
|
40
|
+
env: envMapSchema.parse({}),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
throw error
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function writeOne(path: string, env: EnvMap): Promise<void> {
|
|
49
|
+
let json: Record<string, unknown> = {}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const content = await readFile(path, 'utf8')
|
|
53
|
+
json = JSON.parse(content) as Record<string, unknown>
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
56
|
+
throw error
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
json.env = envMapSchema.parse(env)
|
|
61
|
+
await atomicWriteFile(path, `${JSON.stringify(json, null, 2)}\n`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
read: () => Promise.all(paths.map(readOne)),
|
|
66
|
+
write: async (sources: Array<{ path: string; env: EnvMap }>) => {
|
|
67
|
+
for (const { path, env } of sources) {
|
|
68
|
+
await writeOne(path, env)
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { atomicWriteFile, ensureParentDir } from '../core/fs.js'
|
|
5
|
+
import { resolveHistoryPath } from '../core/paths.js'
|
|
6
|
+
import { historySchema } from '../core/schema.js'
|
|
7
|
+
import type { HistoryRecord } from '../core/schema.js'
|
|
8
|
+
|
|
9
|
+
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
|
10
|
+
return error instanceof Error && 'code' in error
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createHistoryService(globalRoot: string) {
|
|
14
|
+
return {
|
|
15
|
+
async write(record: HistoryRecord): Promise<HistoryRecord> {
|
|
16
|
+
const stored = historySchema.parse(record)
|
|
17
|
+
const filePath = resolveHistoryPath(globalRoot, stored.timestamp)
|
|
18
|
+
await ensureParentDir(filePath)
|
|
19
|
+
await atomicWriteFile(filePath, `${JSON.stringify(stored, null, 2)}\n`)
|
|
20
|
+
return stored
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
async list(): Promise<HistoryRecord[]> {
|
|
24
|
+
const dirPath = dirname(resolveHistoryPath(globalRoot, 'placeholder'))
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const entries = await readdir(dirPath, { withFileTypes: true })
|
|
28
|
+
const fileNames = entries
|
|
29
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
30
|
+
.map((entry) => entry.name)
|
|
31
|
+
.sort((a, b) => b.localeCompare(a))
|
|
32
|
+
|
|
33
|
+
return Promise.all(
|
|
34
|
+
fileNames.map(async (fileName) => {
|
|
35
|
+
const content = await readFile(join(dirPath, fileName), 'utf8')
|
|
36
|
+
return historySchema.parse(JSON.parse(content))
|
|
37
|
+
}),
|
|
38
|
+
)
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (isErrnoException(error) && error.code === 'ENOENT') {
|
|
41
|
+
return []
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
throw error
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { readdir, readFile, rm } from 'node:fs/promises'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { CliError } from '../core/errors.js'
|
|
5
|
+
import { atomicWriteFile } from '../core/fs.js'
|
|
6
|
+
import { resolvePresetPath } from '../core/paths.js'
|
|
7
|
+
import { presetSchema, type Preset } from '../core/schema.js'
|
|
8
|
+
|
|
9
|
+
type StoredPreset = Preset & { filePath: string }
|
|
10
|
+
|
|
11
|
+
export function createPresetService(globalRoot: string) {
|
|
12
|
+
function getPath(name: string): string {
|
|
13
|
+
return resolvePresetPath(globalRoot, name)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
getPath,
|
|
18
|
+
|
|
19
|
+
async write(preset: Preset): Promise<StoredPreset> {
|
|
20
|
+
const parsed = presetSchema.parse(preset)
|
|
21
|
+
const filePath = getPath(parsed.name)
|
|
22
|
+
await atomicWriteFile(filePath, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
23
|
+
return { ...parsed, filePath }
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
async read(name: string): Promise<StoredPreset> {
|
|
27
|
+
const filePath = getPath(name)
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const content = await readFile(filePath, 'utf8')
|
|
31
|
+
const preset = presetSchema.parse(JSON.parse(content))
|
|
32
|
+
return { ...preset, filePath }
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
35
|
+
throw new CliError(`Preset not found: ${name}`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
throw error
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
async listNames(): Promise<string[]> {
|
|
43
|
+
const dirPath = dirname(getPath('placeholder'))
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const entries = await readdir(dirPath, { withFileTypes: true })
|
|
47
|
+
return entries
|
|
48
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
49
|
+
.map((entry) => entry.name.slice(0, -'.json'.length))
|
|
50
|
+
.sort()
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
53
|
+
return []
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
throw error
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async remove(name: string): Promise<void> {
|
|
61
|
+
const filePath = getPath(name)
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await rm(filePath)
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
67
|
+
throw error
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
}
|