Skip to content

Commit affbaad

Browse files
committed
refactor: gather all interactive answers before any disk work
Reorder the interactive flow so every question (stack, project name, mode, features) is answered before any operation runs — clone/install/cleanup now happen only at the end, mirroring the non-interactive path. Abandoning the wizard while answering therefore leaves nothing on disk. Add a Confirmation step that shows a one-line plan summary (describeInstallPlan) as the last side-effect-free moment. Choosing 'No' loops back to re-answer the questions from the top; 'Yes' starts the operations.
1 parent be01d39 commit affbaad

4 files changed

Lines changed: 160 additions & 18 deletions

File tree

source/__tests__/utils.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { stackDefinitions } from '../constants/config.js'
33
import {
44
applyFeatureToggle,
55
deriveStepDisplay,
6+
describeInstallPlan,
67
getPackagesToRemove,
78
getPostInstallMessages,
89
isFeatureSelected,
@@ -174,6 +175,26 @@ describe('resolveSelectedFeatures — evm (no requires)', () => {
174175
})
175176
})
176177

178+
describe('describeInstallPlan', () => {
179+
it('summarises a full-mode canton plan as all features', () => {
180+
expect(describeInstallPlan('canton', 'my_app', 'full', [])).toBe(
181+
'Stack: Canton · Project: my_app · Mode: full (all features)',
182+
)
183+
})
184+
185+
it('lists the selected features for a custom-mode plan', () => {
186+
expect(describeInstallPlan('canton', 'my_app', 'custom', ['counter', 'e2e'])).toBe(
187+
'Stack: Canton · Project: my_app · Mode: custom · Features: counter, e2e',
188+
)
189+
})
190+
191+
it('shows "none" when a custom plan selects no features', () => {
192+
expect(describeInstallPlan('evm', 'demo_app', 'custom', [])).toBe(
193+
'Stack: EVM · Project: demo_app · Mode: custom · Features: none',
194+
)
195+
})
196+
})
197+
177198
describe('applyFeatureToggle — canton (interactive cascade)', () => {
178199
it('selecting e2e pulls counter in', () => {
179200
expect(applyFeatureToggle('canton', ['carpincho'], 'e2e', 'select')).toEqual([

source/app.tsx

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,17 @@ import { Box } from 'ink'
22
import { type FC, type ReactNode, useCallback, useMemo, useState } from 'react'
33
import MainTitle from './components/MainTitle.js'
44
import CloneRepo from './components/steps/CloneRepo/CloneRepo.js'
5+
import Confirmation from './components/steps/Confirmation.js'
56
import FileCleanup from './components/steps/FileCleanup.js'
67
import Install from './components/steps/Install/Install.js'
78
import InstallationMode from './components/steps/InstallationMode.js'
89
import OptionalPackages from './components/steps/OptionalPackages.js'
910
import PostInstall from './components/steps/PostInstall.js'
1011
import ProjectName from './components/steps/ProjectName.js'
1112
import StackSelection from './components/steps/StackSelection.js'
12-
import type { Stack } from './constants/config.js'
13+
import type { FeatureName, Stack } from './constants/config.js'
1314
import type { InstallationSelectItem, MultiSelectItem } from './types/types.js'
14-
import { canShowStep } from './utils/utils.js'
15+
import { canShowStep, describeInstallPlan } from './utils/utils.js'
1516

1617
interface Props {
1718
preselectedStack?: Stack
@@ -23,6 +24,9 @@ const App: FC<Props> = ({ preselectedStack }) => {
2324
const [currentStep, setCurrentStep] = useState(1)
2425
const [setupType, setSetupType] = useState<InstallationSelectItem | undefined>()
2526
const [selectedFeatures, setSelectedFeatures] = useState<Array<MultiSelectItem> | undefined>()
27+
// Bumped when the user cancels at the confirmation step; re-keys every step so they re-mount
28+
// fresh for a clean re-run of the wizard.
29+
const [attempt, setAttempt] = useState(0)
2630

2731
const finishStep = useCallback(() => setCurrentStep((prevStep) => prevStep + 1), [])
2832
const onSelectStack = useCallback((value: Stack) => setStack(value), [])
@@ -32,14 +36,32 @@ const App: FC<Props> = ({ preselectedStack }) => {
3236
[],
3337
)
3438

39+
// Confirmation "No": discard the answers and return to the first question. No disk work has
40+
// happened yet, so this is a clean restart.
41+
const restart = useCallback(() => {
42+
setProjectName('')
43+
setSetupType(undefined)
44+
setSelectedFeatures(undefined)
45+
setStack(preselectedStack)
46+
setCurrentStep(1)
47+
setAttempt((prev) => prev + 1)
48+
}, [preselectedStack])
49+
3550
const skipFeatures = setupType?.value === 'full'
3651

52+
const mode = setupType?.value ?? 'full'
53+
const planFeatures = selectedFeatures?.map((item) => item.value as FeatureName) ?? []
54+
const planSummary =
55+
stack === undefined ? '' : describeInstallPlan(stack, projectName, mode, planFeatures)
56+
3757
const steps: Array<ReactNode> = useMemo(() => {
58+
// Questions first (no disk writes), operations last. This way an interrupt while answering
59+
// leaves nothing behind, and all cloning/installing happens only after the confirmation.
3860
const orderedSteps: Array<ReactNode> = [
3961
<ProjectName
4062
onCompletion={finishStep}
4163
onSubmit={setProjectName}
42-
key={'project-name'}
64+
key={`project-name-${attempt}`}
4365
/>,
4466
]
4567

@@ -48,7 +70,7 @@ const App: FC<Props> = ({ preselectedStack }) => {
4870
<StackSelection
4971
onCompletion={finishStep}
5072
onSelect={onSelectStack}
51-
key={'stack-selection'}
73+
key={`stack-selection-${attempt}`}
5274
/>,
5375
)
5476
}
@@ -57,20 +79,12 @@ const App: FC<Props> = ({ preselectedStack }) => {
5779
return orderedSteps
5880
}
5981

60-
orderedSteps.push(
61-
<CloneRepo
62-
stack={stack}
63-
onCompletion={finishStep}
64-
projectName={projectName}
65-
key={'clone-repo'}
66-
/>,
67-
)
68-
82+
// --- remaining questions (need the stack) ---
6983
orderedSteps.push(
7084
<InstallationMode
7185
onCompletion={finishStep}
7286
onSelect={onSelectSetupType}
73-
key={'installation-mode'}
87+
key={`installation-mode-${attempt}`}
7488
/>,
7589
)
7690

@@ -80,7 +94,26 @@ const App: FC<Props> = ({ preselectedStack }) => {
8094
onCompletion={finishStep}
8195
onSubmit={onSelectSelectedFeatures}
8296
skip={skipFeatures}
83-
key={'optional-packages'}
97+
key={`optional-packages-${attempt}`}
98+
/>,
99+
)
100+
101+
orderedSteps.push(
102+
<Confirmation
103+
summary={planSummary}
104+
onConfirm={finishStep}
105+
onCancel={restart}
106+
key={`confirmation-${attempt}`}
107+
/>,
108+
)
109+
110+
// --- operations (disk writes) ---
111+
orderedSteps.push(
112+
<CloneRepo
113+
stack={stack}
114+
onCompletion={finishStep}
115+
projectName={projectName}
116+
key={`clone-repo-${attempt}`}
84117
/>,
85118
)
86119

@@ -93,7 +126,7 @@ const App: FC<Props> = ({ preselectedStack }) => {
93126
}}
94127
onCompletion={finishStep}
95128
projectName={projectName}
96-
key={'install'}
129+
key={`install-${attempt}`}
97130
/>,
98131
)
99132

@@ -106,7 +139,7 @@ const App: FC<Props> = ({ preselectedStack }) => {
106139
}}
107140
onCompletion={finishStep}
108141
projectName={projectName}
109-
key={'file-cleanup'}
142+
key={`file-cleanup-${attempt}`}
110143
/>,
111144
)
112145

@@ -118,7 +151,7 @@ const App: FC<Props> = ({ preselectedStack }) => {
118151
installationType: setupType?.value,
119152
selectedFeatures: selectedFeatures,
120153
}}
121-
key={'post-install'}
154+
key={`post-install-${attempt}`}
122155
/>,
123156
)
124157

@@ -134,6 +167,9 @@ const App: FC<Props> = ({ preselectedStack }) => {
134167
skipFeatures,
135168
stack,
136169
preselectedStack,
170+
attempt,
171+
planSummary,
172+
restart,
137173
])
138174

139175
return (
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import figures from 'figures'
2+
import { Text } from 'ink'
3+
import SelectInput from 'ink-select-input'
4+
import { type FC, useState } from 'react'
5+
import Divider from '../Divider.js'
6+
7+
interface Props {
8+
summary: string
9+
onConfirm: () => void
10+
onCancel: () => void
11+
}
12+
13+
type ConfirmItem = { label: string; value: 'yes' | 'no' }
14+
15+
const confirmItems: Array<ConfirmItem> = [
16+
{ label: 'Yes, scaffold it', value: 'yes' },
17+
{ label: 'No, start over', value: 'no' },
18+
]
19+
20+
// Last side-effect-free step: nothing has touched the disk yet. Confirming starts the operations;
21+
// cancelling loops back to re-answer the questions.
22+
const Confirmation: FC<Props> = ({ summary, onConfirm, onCancel }) => {
23+
const [confirmed, setConfirmed] = useState(false)
24+
25+
const handleSelect = (item: ConfirmItem) => {
26+
if (item.value === 'yes') {
27+
setConfirmed(true)
28+
onConfirm()
29+
} else {
30+
onCancel()
31+
}
32+
}
33+
34+
return (
35+
<>
36+
<Divider title={'Review'} />
37+
<Text>{summary}</Text>
38+
{confirmed ? (
39+
<Text>
40+
<Text color={'green'}>{figures.tick}</Text> Scaffolding…
41+
</Text>
42+
) : (
43+
<>
44+
<Text color={'whiteBright'}>Proceed with these settings?</Text>
45+
<SelectInput
46+
indicatorComponent={({ isSelected }) => (
47+
<Text color="green">{isSelected ? `${figures.pointer} ` : ' '}</Text>
48+
)}
49+
itemComponent={({ label, isSelected }) => (
50+
<Text
51+
color={isSelected ? 'green' : 'white'}
52+
bold={isSelected}
53+
>
54+
{label}
55+
</Text>
56+
)}
57+
items={confirmItems}
58+
onSelect={handleSelect}
59+
/>
60+
</>
61+
)}
62+
</>
63+
)
64+
}
65+
66+
export default Confirmation

source/utils/utils.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,25 @@ export function applyFeatureToggle(
9999
)
100100
}
101101

102+
// One-line summary of an install plan, shown on the interactive confirmation step before any disk
103+
// work begins.
104+
export function describeInstallPlan(
105+
stack: Stack,
106+
projectName: string,
107+
mode: 'full' | 'custom',
108+
selectedFeatures: FeatureName[],
109+
): string {
110+
const stackLabel = getStackConfig(stack).label
111+
const head = `Stack: ${stackLabel} · Project: ${projectName}`
112+
113+
if (mode === 'full') {
114+
return `${head} · Mode: full (all features)`
115+
}
116+
117+
const features = selectedFeatures.length > 0 ? selectedFeatures.join(', ') : 'none'
118+
return `${head} · Mode: custom · Features: ${features}`
119+
}
120+
102121
export function getPackagesToRemove(stack: Stack, selectedFeatures: FeatureName[]): string[] {
103122
const features = getStackConfig(stack).features
104123
return Object.entries(features)

0 commit comments

Comments
 (0)