Skip to content

Commit 04eaaa0

Browse files
committed
refactor: replace filesystem shell commands with node:fs/promises
Replaced all execFile calls to rm, mkdir, and cp in cleanupFiles.ts and cloneRepo.ts with native fs.rm, fs.mkdir, and fs.copyFile from node:fs/promises. Eliminates 15 unnecessary child process spawns and aligns with createEnvFile.ts which already uses fs.copyFile.
1 parent bf6067d commit 04eaaa0

File tree

5 files changed

+113
-91
lines changed

5 files changed

+113
-91
lines changed

architecture.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,10 @@ Plain async functions with no UI dependencies. Each operation receives explicit
8383

8484
| Function | What it does |
8585
|---|---|
86-
| `cloneRepo(projectName, onProgress?)` | Shallow clone, fetch tags, checkout latest tag, rm .git, git init. Uses `execFile` (no shell) for all commands except `git checkout $(...)` which needs shell substitution. |
87-
| `createEnvFile(projectFolder)` | Copy .env.example to .env.local |
86+
| `cloneRepo(projectName, onProgress?)` | Shallow clone, fetch tags, checkout latest tag, rm .git, git init. Uses `execFile` (no shell) for git commands except `git checkout $(...)` which needs shell substitution. Uses `fs.rm` for .git removal. |
87+
| `createEnvFile(projectFolder)` | Copy .env.example to .env.local via `fs.copyFile` |
8888
| `installPackages(projectFolder, mode, features, onProgress?)` | Full: `pnpm i`. Custom with packages to remove: `pnpm remove` + postinstall. Custom with all features: `pnpm i`. Uses `execFile` exclusively (no shell). |
89-
| `cleanupFiles(projectFolder, mode, features, onProgress?)` | Remove files/folders for deselected features, patch package.json scripts, remove .install-files. Uses `execFile` exclusively (no shell). |
89+
| `cleanupFiles(projectFolder, mode, features, onProgress?)` | Remove files/folders for deselected features, patch package.json scripts, remove .install-files. Uses `node:fs/promises` (`rm`, `mkdir`, `copyFile`) for async operations; `patchPackageJson` uses sync `node:fs`. |
9090

9191
### Shell Execution (`source/operations/exec.ts`)
9292

source/__tests__/operations/cleanupFiles.test.ts

Lines changed: 67 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { resolve } from 'node:path'
12
import { beforeEach, describe, expect, it, vi } from 'vitest'
23
import type { FeatureName } from '../../constants/config.js'
34

4-
vi.mock('../../operations/exec.js', () => ({
5-
exec: vi.fn().mockResolvedValue(undefined),
6-
execFile: vi.fn().mockResolvedValue(undefined),
5+
vi.mock('node:fs/promises', () => ({
6+
rm: vi.fn().mockResolvedValue(undefined),
7+
mkdir: vi.fn().mockResolvedValue(undefined),
8+
copyFile: vi.fn().mockResolvedValue(undefined),
79
}))
810

911
vi.mock('node:fs', () => ({
@@ -24,14 +26,23 @@ vi.mock('node:fs', () => ({
2426
writeFileSync: vi.fn(),
2527
}))
2628

27-
const { execFile } = await import('../../operations/exec.js')
29+
const { rm, mkdir, copyFile } = await import('node:fs/promises')
2830
const { readFileSync, writeFileSync } = await import('node:fs')
2931
const { cleanupFiles } = await import('../../operations/cleanupFiles.js')
3032

31-
function getExecFileCommands(): string[] {
32-
return vi
33-
.mocked(execFile)
34-
.mock.calls.map((call) => `${call[0]} ${(call[1] as string[]).join(' ')}`)
33+
function getRmPaths(): string[] {
34+
return vi.mocked(rm).mock.calls.map((call) => call[0] as string)
35+
}
36+
37+
function getMkdirPaths(): string[] {
38+
return vi.mocked(mkdir).mock.calls.map((call) => call[0] as string)
39+
}
40+
41+
function getCopyFileCalls(): Array<{ src: string; dst: string }> {
42+
return vi.mocked(copyFile).mock.calls.map((call) => ({
43+
src: call[0] as string,
44+
dst: call[1] as string,
45+
}))
3546
}
3647

3748
function getWrittenPackageJson(): Record<string, unknown> {
@@ -65,9 +76,8 @@ describe('cleanupFiles', () => {
6576
it('only removes .install-files', async () => {
6677
await cleanupFiles('/project/my_app', 'full')
6778

68-
const commands = getExecFileCommands()
69-
expect(commands).toHaveLength(1)
70-
expect(commands[0]).toBe('rm -rf .install-files')
79+
expect(rm).toHaveBeenCalledTimes(1)
80+
expect(getRmPaths()[0]).toBe(resolve('/project/my_app', '.install-files'))
7181
})
7282

7383
it('does not patch package.json', async () => {
@@ -82,8 +92,8 @@ describe('cleanupFiles', () => {
8292
const allFeatures: FeatureName[] = ['demo', 'subgraph', 'typedoc', 'vocs', 'husky']
8393
await cleanupFiles('/project/my_app', 'custom', allFeatures)
8494

85-
const commands = getExecFileCommands()
86-
expect(commands).toEqual(['rm -rf .install-files'])
95+
expect(rm).toHaveBeenCalledTimes(1)
96+
expect(getRmPaths()[0]).toBe(resolve('/project/my_app', '.install-files'))
8797
expect(writeFileSync).toHaveBeenCalled()
8898
})
8999

@@ -104,41 +114,47 @@ describe('cleanupFiles', () => {
104114
it('removes home folder, recreates it, copies replacement', async () => {
105115
await cleanupFiles('/project/my_app', 'custom', ['subgraph', 'typedoc', 'vocs', 'husky'])
106116

107-
const commands = getExecFileCommands()
108-
expect(commands).toContain('rm -rf src/components/pageComponents/home')
109-
expect(commands).toContain('mkdir -p src/components/pageComponents/home')
110-
expect(commands).toContain(
111-
'cp .install-files/home/index.tsx src/components/pageComponents/home/',
112-
)
117+
const homeFolder = resolve('/project/my_app', 'src/components/pageComponents/home')
118+
expect(getRmPaths()).toContain(homeFolder)
119+
expect(getMkdirPaths()).toContain(homeFolder)
120+
121+
const copies = getCopyFileCalls()
122+
expect(copies).toContainEqual({
123+
src: resolve('/project/my_app', '.install-files/home/index.tsx'),
124+
dst: resolve(homeFolder, 'index.tsx'),
125+
})
113126
})
114127
})
115128

116129
describe('custom mode — subgraph deselected', () => {
117130
it('removes src/subgraphs', async () => {
118131
await cleanupFiles('/project/my_app', 'custom', ['demo', 'typedoc', 'vocs', 'husky'])
119132

120-
const commands = getExecFileCommands()
121-
expect(commands).toContain('rm -rf src/subgraphs')
133+
expect(getRmPaths()).toContain(resolve('/project/my_app', 'src/subgraphs'))
122134
})
123135

124136
it('cleans up subgraph demos when demo IS selected', async () => {
125137
await cleanupFiles('/project/my_app', 'custom', ['demo', 'typedoc', 'vocs', 'husky'])
126138

127-
const commands = getExecFileCommands()
128-
const homeFolder = 'src/components/pageComponents/home'
129-
expect(commands).toContain(`rm -rf ${homeFolder}/Examples/demos/subgraphs`)
130-
expect(commands).toContain(`rm -f ${homeFolder}/Examples/index.tsx`)
131-
expect(commands).toContain(
132-
`cp .install-files/home/Examples/index.tsx ${homeFolder}/Examples/index.tsx`,
133-
)
139+
const homeFolder = resolve('/project/my_app', 'src/components/pageComponents/home')
140+
expect(getRmPaths()).toContain(resolve(homeFolder, 'Examples/demos/subgraphs'))
141+
expect(getRmPaths()).toContain(resolve(homeFolder, 'Examples/index.tsx'))
142+
143+
const copies = getCopyFileCalls()
144+
expect(copies).toContainEqual({
145+
src: resolve('/project/my_app', '.install-files/home/Examples/index.tsx'),
146+
dst: resolve(homeFolder, 'Examples/index.tsx'),
147+
})
134148
})
135149

136150
it('does NOT clean up subgraph demos when demo is also deselected', async () => {
137151
await cleanupFiles('/project/my_app', 'custom', ['typedoc', 'vocs', 'husky'])
138152

139-
const commands = getExecFileCommands()
140-
const demoCleanupCommands = commands.filter((cmd) => cmd.includes('Examples/demos/subgraphs'))
141-
expect(demoCleanupCommands).toHaveLength(0)
153+
const subgraphDemosPath = resolve(
154+
'/project/my_app',
155+
'src/components/pageComponents/home/Examples/demos/subgraphs',
156+
)
157+
expect(getRmPaths()).not.toContain(subgraphDemosPath)
142158
})
143159

144160
it('removes subgraph-codegen from package.json scripts', async () => {
@@ -154,8 +170,7 @@ describe('cleanupFiles', () => {
154170
it('removes typedoc.json', async () => {
155171
await cleanupFiles('/project/my_app', 'custom', ['demo', 'subgraph', 'vocs', 'husky'])
156172

157-
const commands = getExecFileCommands()
158-
expect(commands).toContain('rm -f typedoc.json')
173+
expect(getRmPaths()).toContain(resolve('/project/my_app', 'typedoc.json'))
159174
})
160175

161176
it('removes typedoc:build from package.json scripts', async () => {
@@ -171,9 +186,8 @@ describe('cleanupFiles', () => {
171186
it('removes vocs.config.ts and docs folder', async () => {
172187
await cleanupFiles('/project/my_app', 'custom', ['demo', 'subgraph', 'typedoc', 'husky'])
173188

174-
const commands = getExecFileCommands()
175-
expect(commands).toContain('rm -f vocs.config.ts')
176-
expect(commands).toContain('rm -rf docs')
189+
expect(getRmPaths()).toContain(resolve('/project/my_app', 'vocs.config.ts'))
190+
expect(getRmPaths()).toContain(resolve('/project/my_app', 'docs'))
177191
})
178192

179193
it('removes docs scripts from package.json', async () => {
@@ -191,10 +205,9 @@ describe('cleanupFiles', () => {
191205
it('removes husky folder and config files', async () => {
192206
await cleanupFiles('/project/my_app', 'custom', ['demo', 'subgraph', 'typedoc', 'vocs'])
193207

194-
const commands = getExecFileCommands()
195-
expect(commands).toContain('rm -rf .husky')
196-
expect(commands).toContain('rm -f .lintstagedrc.mjs')
197-
expect(commands).toContain('rm -f commitlint.config.js')
208+
expect(getRmPaths()).toContain(resolve('/project/my_app', '.husky'))
209+
expect(getRmPaths()).toContain(resolve('/project/my_app', '.lintstagedrc.mjs'))
210+
expect(getRmPaths()).toContain(resolve('/project/my_app', 'commitlint.config.js'))
198211
})
199212

200213
it('removes prepare from package.json scripts', async () => {
@@ -210,13 +223,13 @@ describe('cleanupFiles', () => {
210223
it('runs all cleanup operations', async () => {
211224
await cleanupFiles('/project/my_app', 'custom', [])
212225

213-
const commands = getExecFileCommands()
214-
expect(commands).toContain('rm -rf src/components/pageComponents/home')
215-
expect(commands).toContain('rm -rf src/subgraphs')
216-
expect(commands).toContain('rm -f typedoc.json')
217-
expect(commands).toContain('rm -f vocs.config.ts')
218-
expect(commands).toContain('rm -rf .husky')
219-
expect(commands).toContain('rm -rf .install-files')
226+
const paths = getRmPaths()
227+
expect(paths).toContain(resolve('/project/my_app', 'src/components/pageComponents/home'))
228+
expect(paths).toContain(resolve('/project/my_app', 'src/subgraphs'))
229+
expect(paths).toContain(resolve('/project/my_app', 'typedoc.json'))
230+
expect(paths).toContain(resolve('/project/my_app', 'vocs.config.ts'))
231+
expect(paths).toContain(resolve('/project/my_app', '.husky'))
232+
expect(paths).toContain(resolve('/project/my_app', '.install-files'))
220233
})
221234

222235
it('removes all optional scripts from package.json', async () => {
@@ -230,26 +243,24 @@ describe('cleanupFiles', () => {
230243
expect(scripts['docs:dev']).toBeUndefined()
231244
expect(scripts['docs:preview']).toBeUndefined()
232245
expect(scripts.prepare).toBeUndefined()
233-
// Preserved scripts
234246
expect(scripts.dev).toBe('next dev')
235247
expect(scripts.build).toBe('next build')
236248
})
237249
})
238250

239-
it('always removes .install-files as the last step', async () => {
251+
it('always removes .install-files as the last rm call', async () => {
240252
await cleanupFiles('/project/my_app', 'custom', ['demo'])
241253

242-
const commands = getExecFileCommands()
243-
expect(commands.at(-1)).toBe('rm -rf .install-files')
254+
const paths = getRmPaths()
255+
expect(paths.at(-1)).toBe(resolve('/project/my_app', '.install-files'))
244256
})
245257

246-
it('uses -f flag on all single-file rm calls for idempotent cleanup', async () => {
258+
it('uses force option on all rm calls', async () => {
247259
await cleanupFiles('/project/my_app', 'custom', [])
248260

249-
const commands = getExecFileCommands()
250-
const rmCommands = commands.filter((cmd) => cmd.startsWith('rm '))
251-
for (const cmd of rmCommands) {
252-
expect(cmd).toMatch(/^rm -[rf]/)
261+
for (const call of vi.mocked(rm).mock.calls) {
262+
const options = call[1] as { force?: boolean }
263+
expect(options.force).toBe(true)
253264
}
254265
})
255266

source/__tests__/operations/cloneRepo.test.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,26 @@ vi.mock('../../operations/exec.js', () => ({
66
execFile: vi.fn().mockResolvedValue(undefined),
77
}))
88

9+
vi.mock('node:fs/promises', () => ({
10+
rm: vi.fn().mockResolvedValue(undefined),
11+
}))
12+
913
const { exec, execFile } = await import('../../operations/exec.js')
14+
const { rm } = await import('node:fs/promises')
1015
const { cloneRepo } = await import('../../operations/cloneRepo.js')
1116

1217
describe('cloneRepo', () => {
1318
beforeEach(() => {
1419
vi.clearAllMocks()
1520
})
1621

17-
it('calls 5 commands in sequence', async () => {
22+
it('calls 5 operations in sequence', async () => {
1823
await cloneRepo('my_app')
1924

2025
const execFileCalls = vi.mocked(execFile).mock.calls
2126
const execCalls = vi.mocked(exec).mock.calls
22-
expect(execFileCalls.length + execCalls.length).toBe(5)
27+
const rmCalls = vi.mocked(rm).mock.calls
28+
expect(execFileCalls.length + execCalls.length + rmCalls.length).toBe(5)
2329
})
2430

2531
it('clones with execFile passing projectName as arg', async () => {
@@ -51,11 +57,12 @@ describe('cloneRepo', () => {
5157
})
5258
})
5359

54-
it('removes .git with execFile', async () => {
60+
it('removes .git with fs.rm', async () => {
5561
await cloneRepo('my_app')
5662

57-
expect(execFile).toHaveBeenCalledWith('rm', ['-rf', '.git'], {
58-
cwd: expect.stringContaining('my_app'),
63+
expect(rm).toHaveBeenCalledWith(expect.stringContaining('my_app/.git'), {
64+
recursive: true,
65+
force: true,
5966
})
6067
})
6168

@@ -67,18 +74,21 @@ describe('cloneRepo', () => {
6774
})
6875
})
6976

70-
it('executes commands in correct order', async () => {
77+
it('executes operations in correct order', async () => {
7178
const callOrder: string[] = []
7279
vi.mocked(execFile).mockImplementation(async (file, args) => {
7380
callOrder.push(`${file} ${args[0]}`)
7481
})
7582
vi.mocked(exec).mockImplementation(async (_cmd) => {
7683
callOrder.push('git checkout')
7784
})
85+
vi.mocked(rm).mockImplementation(async () => {
86+
callOrder.push('fs.rm .git')
87+
})
7888

7989
await cloneRepo('my_app')
8090

81-
expect(callOrder).toEqual(['git clone', 'git fetch', 'git checkout', 'rm -rf', 'git init'])
91+
expect(callOrder).toEqual(['git clone', 'git fetch', 'git checkout', 'fs.rm .git', 'git init'])
8292
})
8393

8494
it('does not interpolate projectName into shell strings', async () => {

source/operations/cleanupFiles.ts

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { readFileSync, writeFileSync } from 'node:fs'
2+
import { copyFile, mkdir, rm } from 'node:fs/promises'
23
import { resolve } from 'node:path'
34
import type { FeatureName } from '../constants/config.js'
45
import type { InstallationType } from '../types/types.js'
56
import { isFeatureSelected } from '../utils/utils.js'
6-
import { execFile } from './exec.js'
77

88
function patchPackageJson(projectFolder: string, features: FeatureName[]): void {
99
const packageJsonPath = resolve(projectFolder, 'package.json')
@@ -31,44 +31,43 @@ function patchPackageJson(projectFolder: string, features: FeatureName[]): void
3131
}
3232

3333
async function cleanupDemo(projectFolder: string): Promise<void> {
34-
await execFile('rm', ['-rf', 'src/components/pageComponents/home'], { cwd: projectFolder })
35-
await execFile('mkdir', ['-p', 'src/components/pageComponents/home'], { cwd: projectFolder })
36-
await execFile('cp', ['.install-files/home/index.tsx', 'src/components/pageComponents/home/'], {
37-
cwd: projectFolder,
38-
})
34+
const homeFolder = resolve(projectFolder, 'src/components/pageComponents/home')
35+
await rm(homeFolder, { recursive: true, force: true })
36+
await mkdir(homeFolder, { recursive: true })
37+
await copyFile(
38+
resolve(projectFolder, '.install-files/home/index.tsx'),
39+
resolve(homeFolder, 'index.tsx'),
40+
)
3941
}
4042

4143
async function cleanupSubgraph(projectFolder: string, features: FeatureName[]): Promise<void> {
42-
await execFile('rm', ['-rf', 'src/subgraphs'], { cwd: projectFolder })
44+
await rm(resolve(projectFolder, 'src/subgraphs'), { recursive: true, force: true })
4345

4446
if (isFeatureSelected('demo', features)) {
45-
const homeFolder = 'src/components/pageComponents/home'
46-
47-
await execFile('rm', ['-rf', `${homeFolder}/Examples/demos/subgraphs`], {
48-
cwd: projectFolder,
49-
})
50-
await execFile('rm', ['-f', `${homeFolder}/Examples/index.tsx`], { cwd: projectFolder })
51-
await execFile(
52-
'cp',
53-
['.install-files/home/Examples/index.tsx', `${homeFolder}/Examples/index.tsx`],
54-
{ cwd: projectFolder },
47+
const homeFolder = resolve(projectFolder, 'src/components/pageComponents/home')
48+
49+
await rm(resolve(homeFolder, 'Examples/demos/subgraphs'), { recursive: true, force: true })
50+
await rm(resolve(homeFolder, 'Examples/index.tsx'), { force: true })
51+
await copyFile(
52+
resolve(projectFolder, '.install-files/home/Examples/index.tsx'),
53+
resolve(homeFolder, 'Examples/index.tsx'),
5554
)
5655
}
5756
}
5857

5958
async function cleanupTypedoc(projectFolder: string): Promise<void> {
60-
await execFile('rm', ['-f', 'typedoc.json'], { cwd: projectFolder })
59+
await rm(resolve(projectFolder, 'typedoc.json'), { force: true })
6160
}
6261

6362
async function cleanupVocs(projectFolder: string): Promise<void> {
64-
await execFile('rm', ['-f', 'vocs.config.ts'], { cwd: projectFolder })
65-
await execFile('rm', ['-rf', 'docs'], { cwd: projectFolder })
63+
await rm(resolve(projectFolder, 'vocs.config.ts'), { force: true })
64+
await rm(resolve(projectFolder, 'docs'), { recursive: true, force: true })
6665
}
6766

6867
async function cleanupHusky(projectFolder: string): Promise<void> {
69-
await execFile('rm', ['-rf', '.husky'], { cwd: projectFolder })
70-
await execFile('rm', ['-f', '.lintstagedrc.mjs'], { cwd: projectFolder })
71-
await execFile('rm', ['-f', 'commitlint.config.js'], { cwd: projectFolder })
68+
await rm(resolve(projectFolder, '.husky'), { recursive: true, force: true })
69+
await rm(resolve(projectFolder, '.lintstagedrc.mjs'), { force: true })
70+
await rm(resolve(projectFolder, 'commitlint.config.js'), { force: true })
7271
}
7372

7473
export async function cleanupFiles(
@@ -107,5 +106,5 @@ export async function cleanupFiles(
107106
}
108107

109108
onProgress?.('Install script')
110-
await execFile('rm', ['-rf', '.install-files'], { cwd: projectFolder })
109+
await rm(resolve(projectFolder, '.install-files'), { recursive: true, force: true })
111110
}

0 commit comments

Comments
 (0)