Skip to content

Commit eec8d8d

Browse files
committed
feat: remove a partially-scaffolded project when interrupted
Add installGuard: beginInstall records the project directory the moment disk work starts and registers SIGINT/SIGTERM handlers; completeInstall clears it once the scaffold is done (so Ctrl+C on the finished project is safe). On interrupt while a scaffold is in progress, the partial directory is removed. It only ever removes a directory the installer created this run — both paths reject a pre-existing directory up front — so user data is never touched. Wired into the interactive path (CloneRepo begin, FileCleanup complete) and the non-interactive path (around its operation block).
1 parent affbaad commit eec8d8d

5 files changed

Lines changed: 119 additions & 4 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
const { beginInstall, completeInstall, removeActiveProject } = await import(
4+
'../../operations/installGuard.js'
5+
)
6+
7+
describe('installGuard', () => {
8+
// Clear any active state left over from a previous test (module-level singleton).
9+
beforeEach(() => {
10+
completeInstall()
11+
})
12+
13+
it('removes the active project folder when an install is in progress', () => {
14+
const rm = vi.fn()
15+
beginInstall('/tmp/proj')
16+
17+
removeActiveProject(rm)
18+
19+
expect(rm).toHaveBeenCalledWith('/tmp/proj', { recursive: true, force: true })
20+
})
21+
22+
it('does nothing when no install is active', () => {
23+
const rm = vi.fn()
24+
25+
removeActiveProject(rm)
26+
27+
expect(rm).not.toHaveBeenCalled()
28+
})
29+
30+
it('does not remove after completeInstall — a finished project is safe', () => {
31+
const rm = vi.fn()
32+
beginInstall('/tmp/proj')
33+
completeInstall()
34+
35+
removeActiveProject(rm)
36+
37+
expect(rm).not.toHaveBeenCalled()
38+
})
39+
40+
it('removes only once, then clears the active folder', () => {
41+
const rm = vi.fn()
42+
beginInstall('/tmp/proj')
43+
44+
removeActiveProject(rm)
45+
removeActiveProject(rm)
46+
47+
expect(rm).toHaveBeenCalledTimes(1)
48+
})
49+
50+
it('tracks the most recent project folder', () => {
51+
const rm = vi.fn()
52+
beginInstall('/tmp/a')
53+
beginInstall('/tmp/b')
54+
55+
removeActiveProject(rm)
56+
57+
expect(rm).toHaveBeenCalledWith('/tmp/b', { recursive: true, force: true })
58+
})
59+
})

source/components/steps/CloneRepo/CloneRepo.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { Text } from 'ink'
22
import { type FC, useCallback, useEffect, useState } from 'react'
33
import type { Stack } from '../../../constants/config.js'
44
import { cloneRepo } from '../../../operations/index.js'
5-
import { deriveStepDisplay } from '../../../utils/utils.js'
5+
import { beginInstall } from '../../../operations/installGuard.js'
6+
import { deriveStepDisplay, getProjectFolder } from '../../../utils/utils.js'
67
import Divider from '../../Divider.js'
78

89
interface Props {
@@ -21,6 +22,9 @@ const CloneRepo: FC<Props> = ({ stack, projectName, onCompletion }) => {
2122
}, [])
2223

2324
useEffect(() => {
25+
// Disk work starts here, so arm the interrupt guard before cloning.
26+
beginInstall(getProjectFolder(projectName))
27+
2428
cloneRepo(stack, projectName, handleProgress)
2529
.then(() => {
2630
setStatus('done')

source/components/steps/FileCleanup.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Text } from 'ink'
22
import { type FC, useCallback, useEffect, useMemo, useState } from 'react'
33
import type { FeatureName, Stack } from '../../constants/config.js'
44
import { cleanupFiles } from '../../operations/index.js'
5+
import { completeInstall } from '../../operations/installGuard.js'
56
import type { InstallationType, MultiSelectItem } from '../../types/types.js'
67
import { deriveStepDisplay, getProjectFolder } from '../../utils/utils.js'
78
import Divider from '../Divider.js'
@@ -32,6 +33,8 @@ const FileCleanup: FC<Props> = ({ stack, onCompletion, installationConfig, proje
3233

3334
cleanupFiles(stack, projectFolder, installationType ?? 'full', features, handleProgress)
3435
.then(() => {
36+
// Scaffold is complete — an interrupt from here on must not delete the finished project.
37+
completeInstall()
3538
setStatus('done')
3639
onCompletion()
3740
})

source/nonInteractive.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
stackNames,
99
} from './constants/config.js'
1010
import { cleanupFiles, cloneRepo, createEnvFile, installPackages } from './operations/index.js'
11+
import { beginInstall, completeInstall } from './operations/installGuard.js'
1112
import type { InstallationType } from './types/types.js'
1213
import {
1314
getPostInstallMessages,
@@ -141,15 +142,19 @@ export async function runNonInteractive(flags: {
141142
}): Promise<void> {
142143
const { stack, name, mode, features } = validate(flags)
143144

144-
try {
145-
await cloneRepo(stack, name)
145+
const projectFolder = getProjectFolder(name)
146146

147-
const projectFolder = getProjectFolder(name)
147+
try {
148+
// From here on a project directory exists on disk; an interrupt removes the partial scaffold.
149+
beginInstall(projectFolder)
148150

151+
await cloneRepo(stack, name)
149152
await createEnvFile(stack, projectFolder, features)
150153
await installPackages(stack, projectFolder, mode, features)
151154
await cleanupFiles(stack, projectFolder, mode, features)
152155

156+
completeInstall()
157+
153158
const result: SuccessResult = {
154159
success: true,
155160
stack,

source/operations/installGuard.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { rmSync } from 'node:fs'
2+
import process from 'node:process'
3+
4+
// Tracks the project folder currently being scaffolded so an interrupt can remove the partial
5+
// directory. Only ever holds a folder the installer created this run (callers validate that the
6+
// directory did not exist before starting), so removing it on abort never touches user data.
7+
let activeProjectFolder: string | undefined
8+
let signalHandlersRegistered = false
9+
10+
type RemoveDirectory = (path: string, options: { recursive: boolean; force: boolean }) => void
11+
12+
// Removes the in-progress project folder, if any, then clears the active reference so a finished
13+
// or already-removed project is never deleted. `rm` is injectable for testing.
14+
export function removeActiveProject(rm: RemoveDirectory = rmSync): void {
15+
if (activeProjectFolder === undefined) {
16+
return
17+
}
18+
19+
const folder = activeProjectFolder
20+
activeProjectFolder = undefined
21+
rm(folder, { recursive: true, force: true })
22+
}
23+
24+
function handleAbort(signal: NodeJS.Signals): void {
25+
removeActiveProject()
26+
// Conventional exit code for a signal is 128 + signal number (SIGINT 2 → 130, SIGTERM 15 → 143).
27+
process.exit(signal === 'SIGTERM' ? 143 : 130)
28+
}
29+
30+
// Marks the start of disk-writing work. Registers interrupt handlers on first use.
31+
export function beginInstall(projectFolder: string): void {
32+
activeProjectFolder = projectFolder
33+
34+
if (!signalHandlersRegistered) {
35+
process.on('SIGINT', handleAbort)
36+
process.on('SIGTERM', handleAbort)
37+
signalHandlersRegistered = true
38+
}
39+
}
40+
41+
// Marks the scaffold complete; an interrupt after this point leaves the finished project intact.
42+
export function completeInstall(): void {
43+
activeProjectFolder = undefined
44+
}

0 commit comments

Comments
 (0)