Skip to content

Commit 22f6dcd

Browse files
committed
feat(relay): improve init command UX
Fix frozen spinner during package install by replacing spawnSync with async spawn, keeping the event loop free so the clack spinner animates. Add gitignore step to the init wizard that ensures .domscribe is listed in the project's .gitignore (creates the file if needed, appends if missing, no-ops if already present).
1 parent dab1e26 commit 22f6dcd

6 files changed

Lines changed: 293 additions & 21 deletions

File tree

packages/domscribe-relay/src/cli/init/framework-step.spec.ts

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,31 @@
1-
import { spawnSync } from 'node:child_process';
1+
import { spawn } from 'node:child_process';
2+
import { EventEmitter } from 'node:events';
23

34
import * as clack from '@clack/prompts';
45

56
import { runFrameworkStep } from './framework-step.js';
67
import type { InitOptions } from './types.js';
78

9+
/**
10+
* Create a fake ChildProcess that emits 'close' on the next tick.
11+
*/
12+
function createFakeChild(exitCode = 0, stderrData = ''): EventEmitter {
13+
const child = new EventEmitter();
14+
const stderr = new EventEmitter();
15+
(child as unknown as Record<string, unknown>).stderr = stderr;
16+
17+
process.nextTick(() => {
18+
if (stderrData) {
19+
stderr.emit('data', Buffer.from(stderrData));
20+
}
21+
child.emit('close', exitCode);
22+
});
23+
24+
return child;
25+
}
26+
827
vi.mock('node:child_process', () => ({
9-
spawnSync: vi.fn().mockReturnValue({ status: 0 }),
28+
spawn: vi.fn(() => createFakeChild(0)),
1029
}));
1130

1231
vi.mock('@clack/prompts', () => ({
@@ -40,9 +59,9 @@ describe('runFrameworkStep', () => {
4059
beforeEach(() => {
4160
vi.clearAllMocks();
4261
vi.mocked(clack.isCancel).mockReturnValue(false);
43-
vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType<
44-
typeof spawnSync
45-
>);
62+
vi.mocked(spawn).mockImplementation(
63+
() => createFakeChild(0) as ReturnType<typeof spawn>,
64+
);
4665
});
4766

4867
describe('interactive mode', () => {
@@ -109,7 +128,7 @@ describe('runFrameworkStep', () => {
109128

110129
// Assert
111130
expect(clack.select).not.toHaveBeenCalled();
112-
expect(spawnSync).toHaveBeenCalledWith(
131+
expect(spawn).toHaveBeenCalledWith(
113132
'pnpm',
114133
['add', '-D', '@domscribe/nuxt'],
115134
expect.objectContaining({ cwd: '/project' }),
@@ -128,7 +147,7 @@ describe('runFrameworkStep', () => {
128147
await runFrameworkStep(baseOptions, '/project');
129148

130149
// Assert
131-
expect(spawnSync).toHaveBeenCalledWith(
150+
expect(spawn).toHaveBeenCalledWith(
132151
'npm',
133152
['install', '-D', '@domscribe/next'],
134153
expect.objectContaining({ cwd: '/project' }),
@@ -145,7 +164,7 @@ describe('runFrameworkStep', () => {
145164
await runFrameworkStep(baseOptions, '/project');
146165

147166
// Assert
148-
expect(spawnSync).toHaveBeenCalledWith(
167+
expect(spawn).toHaveBeenCalledWith(
149168
'pnpm',
150169
['add', '-D', '@domscribe/react'],
151170
expect.objectContaining({ cwd: '/project' }),
@@ -157,10 +176,9 @@ describe('runFrameworkStep', () => {
157176
vi.mocked(clack.select)
158177
.mockResolvedValueOnce('nuxt')
159178
.mockResolvedValueOnce('npm');
160-
vi.mocked(spawnSync).mockReturnValue({
161-
status: 1,
162-
stderr: Buffer.from('ERR'),
163-
} as unknown as ReturnType<typeof spawnSync>);
179+
vi.mocked(spawn).mockImplementation(
180+
() => createFakeChild(1, 'ERR') as ReturnType<typeof spawn>,
181+
);
164182

165183
// Act
166184
await runFrameworkStep(baseOptions, '/project');
@@ -224,7 +242,7 @@ describe('runFrameworkStep', () => {
224242
await runFrameworkStep(options, '/project');
225243

226244
// Assert
227-
expect(spawnSync).not.toHaveBeenCalled();
245+
expect(spawn).not.toHaveBeenCalled();
228246
expect(clack.log.info).toHaveBeenCalledWith(
229247
expect.stringContaining('npm install -D @domscribe/nuxt'),
230248
);

packages/domscribe-relay/src/cli/init/framework-step.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Framework selection, package installation, and config snippet step.
33
* @module @domscribe/relay/cli/init/framework-step
44
*/
5-
import { spawnSync } from 'node:child_process';
5+
import { spawn } from 'node:child_process';
66

77
import * as clack from '@clack/prompts';
88
import { highlight } from 'cli-highlight';
@@ -49,6 +49,38 @@ function buildInstallCommand(pm: PackageManagerId, pkg: string): string {
4949
return `${pmConfig?.installCmd ?? 'npm install -D'} ${pkg}`;
5050
}
5151

52+
/**
53+
* Run a package install command asynchronously, collecting stderr.
54+
* Using async spawn (not spawnSync) keeps the event loop free so the
55+
* clack spinner can animate during the install.
56+
*/
57+
function runInstall(
58+
bin: string,
59+
args: string[],
60+
cwd: string,
61+
): Promise<{ exitCode: number; stderr: string }> {
62+
return new Promise((resolve) => {
63+
const child = spawn(bin, args, {
64+
stdio: ['ignore', 'ignore', 'pipe'],
65+
cwd,
66+
});
67+
const chunks: Buffer[] = [];
68+
69+
child.stderr.on('data', (chunk: Buffer) => chunks.push(chunk));
70+
71+
child.on('close', (code) => {
72+
resolve({
73+
exitCode: code ?? 1,
74+
stderr: Buffer.concat(chunks).toString().trim(),
75+
});
76+
});
77+
78+
child.on('error', (err) => {
79+
resolve({ exitCode: 1, stderr: err.message });
80+
});
81+
});
82+
}
83+
5284
/**
5385
* Prompt the user to confirm or override the detected package manager.
5486
*/
@@ -122,16 +154,15 @@ export async function runFrameworkStep(
122154
return;
123155
}
124156

125-
// Run the install
157+
// Run the install asynchronously so the spinner can animate
126158
const spinner = clack.spinner();
127159
spinner.start(`Installing ${framework.package}...`);
128160

129161
const [bin, ...args] = installCmd.split(' ');
130-
const result = spawnSync(bin, args, { stdio: 'pipe', cwd });
162+
const { exitCode, stderr } = await runInstall(bin, args, cwd);
131163

132-
if (result.status !== 0) {
164+
if (exitCode !== 0) {
133165
spinner.stop(`Failed to install ${framework.package}.`);
134-
const stderr = result.stderr?.toString().trim();
135166
if (stderr) {
136167
clack.log.warn(stderr);
137168
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { readFileSync, writeFileSync } from 'node:fs';
2+
3+
import * as clack from '@clack/prompts';
4+
5+
import { runGitignoreStep } from './gitignore-step.js';
6+
import type { InitOptions } from './types.js';
7+
8+
vi.mock('node:fs', () => ({
9+
readFileSync: vi.fn(),
10+
writeFileSync: vi.fn(),
11+
}));
12+
13+
vi.mock('@clack/prompts', () => ({
14+
log: {
15+
info: vi.fn(),
16+
success: vi.fn(),
17+
},
18+
}));
19+
20+
const baseOptions: InitOptions = { force: false, dryRun: false };
21+
22+
describe('runGitignoreStep', () => {
23+
beforeEach(() => {
24+
vi.clearAllMocks();
25+
});
26+
27+
it('should create .gitignore when it does not exist', () => {
28+
// Arrange
29+
vi.mocked(readFileSync).mockImplementation(() => {
30+
throw new Error('ENOENT');
31+
});
32+
33+
// Act
34+
runGitignoreStep(baseOptions, '/project');
35+
36+
// Assert
37+
expect(writeFileSync).toHaveBeenCalledWith(
38+
'/project/.gitignore',
39+
'# Domscribe artifacts\n.domscribe\n',
40+
'utf-8',
41+
);
42+
expect(clack.log.success).toHaveBeenCalledWith(
43+
'Created .domscribe to .gitignore',
44+
);
45+
});
46+
47+
it('should append to existing .gitignore that lacks the entry', () => {
48+
// Arrange
49+
vi.mocked(readFileSync).mockReturnValue('node_modules\ndist\n');
50+
51+
// Act
52+
runGitignoreStep(baseOptions, '/project');
53+
54+
// Assert
55+
expect(writeFileSync).toHaveBeenCalledWith(
56+
'/project/.gitignore',
57+
'node_modules\ndist\n\n# Domscribe artifacts\n.domscribe\n',
58+
'utf-8',
59+
);
60+
expect(clack.log.success).toHaveBeenCalledWith(
61+
'Added .domscribe to .gitignore',
62+
);
63+
});
64+
65+
it('should add a newline separator when file does not end with one', () => {
66+
// Arrange
67+
vi.mocked(readFileSync).mockReturnValue('node_modules');
68+
69+
// Act
70+
runGitignoreStep(baseOptions, '/project');
71+
72+
// Assert
73+
expect(writeFileSync).toHaveBeenCalledWith(
74+
'/project/.gitignore',
75+
'node_modules\n\n# Domscribe artifacts\n.domscribe\n',
76+
'utf-8',
77+
);
78+
});
79+
80+
it('should skip when .domscribe is already present', () => {
81+
// Arrange
82+
vi.mocked(readFileSync).mockReturnValue('node_modules\n.domscribe\n');
83+
84+
// Act
85+
runGitignoreStep(baseOptions, '/project');
86+
87+
// Assert
88+
expect(writeFileSync).not.toHaveBeenCalled();
89+
expect(clack.log.info).toHaveBeenCalledWith(
90+
'.gitignore already contains .domscribe',
91+
);
92+
});
93+
94+
it('should skip when .domscribe/ (with trailing slash) is present', () => {
95+
// Arrange
96+
vi.mocked(readFileSync).mockReturnValue('.domscribe/\n');
97+
98+
// Act
99+
runGitignoreStep(baseOptions, '/project');
100+
101+
// Assert
102+
expect(writeFileSync).not.toHaveBeenCalled();
103+
expect(clack.log.info).toHaveBeenCalledWith(
104+
'.gitignore already contains .domscribe',
105+
);
106+
});
107+
108+
describe('dry-run', () => {
109+
const dryRunOptions: InitOptions = { ...baseOptions, dryRun: true };
110+
111+
it('should log without writing when file does not exist', () => {
112+
// Arrange
113+
vi.mocked(readFileSync).mockImplementation(() => {
114+
throw new Error('ENOENT');
115+
});
116+
117+
// Act
118+
runGitignoreStep(dryRunOptions, '/project');
119+
120+
// Assert
121+
expect(writeFileSync).not.toHaveBeenCalled();
122+
expect(clack.log.info).toHaveBeenCalledWith(
123+
'Would create .gitignore with .domscribe',
124+
);
125+
});
126+
127+
it('should log without writing when file exists but lacks entry', () => {
128+
// Arrange
129+
vi.mocked(readFileSync).mockReturnValue('node_modules\n');
130+
131+
// Act
132+
runGitignoreStep(dryRunOptions, '/project');
133+
134+
// Assert
135+
expect(writeFileSync).not.toHaveBeenCalled();
136+
expect(clack.log.info).toHaveBeenCalledWith(
137+
'Would append to .gitignore with .domscribe',
138+
);
139+
});
140+
});
141+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* Ensure `.domscribe` is listed in the project's `.gitignore`.
3+
* @module @domscribe/relay/cli/init/gitignore-step
4+
*/
5+
import { readFileSync, writeFileSync } from 'node:fs';
6+
import { join } from 'node:path';
7+
8+
import * as clack from '@clack/prompts';
9+
10+
import type { InitOptions } from './types.js';
11+
12+
const GITIGNORE = '.gitignore';
13+
const ENTRY = '.domscribe';
14+
15+
/**
16+
* Check whether a gitignore file already contains a `.domscribe` entry.
17+
*
18+
* @remarks
19+
* Matches `.domscribe` or `.domscribe/` as a standalone line,
20+
* ignoring leading whitespace and trailing slashes.
21+
*/
22+
function hasEntry(content: string): boolean {
23+
return content
24+
.split('\n')
25+
.some((line) => line.trim().replace(/\/$/, '') === ENTRY);
26+
}
27+
28+
/**
29+
* Ensure `.domscribe` is present in the project's `.gitignore`.
30+
*
31+
* - If the file doesn't exist, creates it with the entry.
32+
* - If the file exists but lacks the entry, appends it.
33+
* - If the entry already exists, does nothing.
34+
*/
35+
export function runGitignoreStep(options: InitOptions, cwd: string): void {
36+
const filePath = join(cwd, GITIGNORE);
37+
38+
let existing = '';
39+
let fileExists = false;
40+
41+
try {
42+
existing = readFileSync(filePath, 'utf-8');
43+
fileExists = true;
44+
} catch {
45+
// File doesn't exist — we'll create it.
46+
}
47+
48+
if (fileExists && hasEntry(existing)) {
49+
clack.log.info(`${GITIGNORE} already contains ${ENTRY}`);
50+
return;
51+
}
52+
53+
if (options.dryRun) {
54+
const verb = fileExists ? 'Append to' : 'Create';
55+
clack.log.info(`Would ${verb.toLowerCase()} ${GITIGNORE} with ${ENTRY}`);
56+
return;
57+
}
58+
59+
const block = `\n# Domscribe artifacts\n${ENTRY}\n`;
60+
61+
if (fileExists) {
62+
const separator = existing.endsWith('\n') ? '' : '\n';
63+
writeFileSync(filePath, existing + separator + block, 'utf-8');
64+
} else {
65+
writeFileSync(filePath, block.trimStart(), 'utf-8');
66+
}
67+
68+
const verb = fileExists ? 'Added' : 'Created';
69+
clack.log.success(`${verb} ${ENTRY} to ${GITIGNORE}`);
70+
}

0 commit comments

Comments
 (0)