From ca6ad1b501c113fd9123e5ac9ae04035347f11c7 Mon Sep 17 00:00:00 2001 From: Sumit Kumar Date: Thu, 6 Aug 2026 17:13:31 +0530 Subject: [PATCH 1/2] fix(create-termui-app): refuse non-empty scaffold dirs without --force Co-authored-by: Cursor --- packages/create-termui-app/src/args.test.ts | 11 ++ packages/create-termui-app/src/args.ts | 6 ++ packages/create-termui-app/src/index.test.ts | 80 +++++++++++++- packages/create-termui-app/src/index.ts | 104 ++++++++++++++++--- 4 files changed, 186 insertions(+), 15 deletions(-) diff --git a/packages/create-termui-app/src/args.test.ts b/packages/create-termui-app/src/args.test.ts index cf515b8c2..23ad86b4e 100644 --- a/packages/create-termui-app/src/args.test.ts +++ b/packages/create-termui-app/src/args.test.ts @@ -32,6 +32,17 @@ describe("CLI args", () => { expect(res.yes).toBe(true); }); + it("parses --force", () => { + const res = parseArgs(["app", "--force", "--yes"]); + expect(res.force).toBe(true); + expect(res.yes).toBe(true); + }); + + it("defaults force to false", () => { + const res = parseArgs(["app", "--yes"]); + expect(res.force).toBe(false); + }); + it("first positional becomes name", () => { const res = parseArgs(["my-app"]); expect(res.name).toBe("my-app"); diff --git a/packages/create-termui-app/src/args.ts b/packages/create-termui-app/src/args.ts index 7be14ddc6..54522fd6f 100644 --- a/packages/create-termui-app/src/args.ts +++ b/packages/create-termui-app/src/args.ts @@ -3,6 +3,7 @@ export interface CliArgs { template?: string; theme?: string; yes: boolean; + force: boolean; version?: boolean; dir?: string; @@ -71,6 +72,7 @@ function getFirstPositional(argv: string[]): string | undefined { export function parseArgs(argv: string[]): CliArgs { const args: CliArgs = { yes: false, + force: false, dryRun: false, }; @@ -118,6 +120,10 @@ export function parseArgs(argv: string[]): CliArgs { args.yes = true; } + if (argv.includes("--force")) { + args.force = true; + } + const template = getValue(argv, "--template"); if (template) { if (!TEMPLATE_KEYS.includes(template as any)) { diff --git a/packages/create-termui-app/src/index.test.ts b/packages/create-termui-app/src/index.test.ts index d8d32b57a..bc7a4b12c 100644 --- a/packages/create-termui-app/src/index.test.ts +++ b/packages/create-termui-app/src/index.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; import * as prompts from './prompts.js'; import * as templates from './templates.js'; import * as addModule from './commands/add.js'; @@ -107,4 +107,82 @@ describe('CLI integration', () => { expect(logSpy).toHaveBeenCalledWith(expect.any(String)); expect(generateSpy).not.toHaveBeenCalled(); }); + + it('rejects non-interactive scaffold into a non-empty directory without --force', async () => { + const existingDir = join(tempDir, 'existing-app'); + mkdirSync(existingDir, { recursive: true }); + writeFileSync(join(existingDir, 'package.json'), '{ "name": "keep-me" }', 'utf-8'); + + const generateSpy = vi.spyOn(templates, 'generateProject'); + const indexModule = await import('./index'); + + await expect(indexModule.runCli(['existing-app', '--yes'])).rejects.toThrow( + 'Directory "existing-app" is not empty. Re-run with --force to overwrite.', + ); + + expect(generateSpy).not.toHaveBeenCalled(); + expect(readFileSync(join(existingDir, 'package.json'), 'utf-8')).toBe('{ "name": "keep-me" }'); + }); + + it('overwrites a non-empty directory when --force is passed', async () => { + const existingDir = join(tempDir, 'force-app'); + mkdirSync(existingDir, { recursive: true }); + writeFileSync(join(existingDir, 'package.json'), '{ "name": "keep-me" }', 'utf-8'); + + vi.spyOn(templates, 'generateProject').mockReturnValue(createProjectFiles as any); + const indexModule = await import('./index'); + + await indexModule.runCli(['force-app', '--yes', '--force']); + + expect(readFileSync(join(existingDir, 'package.json'), 'utf-8')).toBe('{ }'); + }); + + it('allows scaffolding into an empty existing directory without --force', async () => { + const emptyDir = join(tempDir, 'empty-app'); + mkdirSync(emptyDir, { recursive: true }); + + vi.spyOn(templates, 'generateProject').mockReturnValue(createProjectFiles as any); + const indexModule = await import('./index'); + + await indexModule.runCli(['empty-app', '--yes']); + + expect(existsSync(join(emptyDir, 'package.json'))).toBe(true); + }); + + it('aborts interactive overwrite when the user declines', async () => { + const existingDir = join(tempDir, 'interactive-app'); + mkdirSync(existingDir, { recursive: true }); + writeFileSync(join(existingDir, 'package.json'), '{ "name": "keep-me" }', 'utf-8'); + + vi.spyOn(prompts, 'selectPrompt').mockResolvedValue(0); + vi.spyOn(prompts, 'multiSelectPrompt').mockResolvedValue([false, false, true]); + vi.spyOn(prompts, 'confirmPrompt').mockResolvedValue(false); + const generateSpy = vi.spyOn(templates, 'generateProject'); + + const indexModule = await import('./index'); + await indexModule.runCli(['interactive-app']); + + expect(generateSpy).not.toHaveBeenCalled(); + expect(readFileSync(join(existingDir, 'package.json'), 'utf-8')).toBe('{ "name": "keep-me" }'); + }); + + it('restores overwritten files when a later write fails', async () => { + const existingDir = join(tempDir, 'rollback-app'); + mkdirSync(existingDir, { recursive: true }); + writeFileSync(join(existingDir, 'package.json'), '{ "name": "keep-me" }', 'utf-8'); + // A file named `src` makes mkdirSync(src/) fail after package.json is overwritten. + writeFileSync(join(existingDir, 'src'), 'not-a-directory', 'utf-8'); + + vi.spyOn(templates, 'generateProject').mockReturnValue([ + { path: 'package.json', content: '{ "name": "new" }' }, + { path: 'src/index.tsx', content: 'export {}' }, + ] as any); + + const indexModule = await import('./index'); + + await expect(indexModule.runCli(['rollback-app', '--yes', '--force'])).rejects.toThrow(); + + expect(readFileSync(join(existingDir, 'package.json'), 'utf-8')).toBe('{ "name": "keep-me" }'); + expect(readFileSync(join(existingDir, 'src'), 'utf-8')).toBe('not-a-directory'); + }); }); diff --git a/packages/create-termui-app/src/index.ts b/packages/create-termui-app/src/index.ts index 1d921925a..83cf89396 100644 --- a/packages/create-termui-app/src/index.ts +++ b/packages/create-termui-app/src/index.ts @@ -4,9 +4,17 @@ import { dirname, resolve, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; +import { + mkdirSync, + writeFileSync, + existsSync, + readFileSync, + readdirSync, + unlinkSync, + rmSync, +} from 'node:fs'; import { getBuiltinThemeNames } from '@termuijs/tss'; -import { textPrompt, selectPrompt, multiSelectPrompt } from './prompts.js'; +import { textPrompt, selectPrompt, multiSelectPrompt, confirmPrompt } from './prompts.js'; import { generateProject, type ProjectConfig } from './templates.js'; import { parseArgs, isNonInteractive, TEMPLATE_KEYS, type CliArgs } from './args.js'; import { runAddCommand } from './commands/add.js'; @@ -121,23 +129,29 @@ async function runProjectScaffold(args: CliArgs): Promise { // ── Generate project ── const projectDir = resolve(process.cwd(), projectName); - if (existsSync(projectDir)) { - console.log(`\n ⚠ Directory "${projectName}" already exists. Files may be overwritten.\n`); + if (existsSync(projectDir) && isNonEmptyDirectory(projectDir)) { + if (args.force) { + console.log(`\n ⚠ Directory "${projectName}" is not empty. Overwriting with --force.\n`); + } else if (nonInteractive) { + throw new Error( + `Directory "${projectName}" is not empty. Re-run with --force to overwrite.`, + ); + } else { + const overwrite = await confirmPrompt( + `Directory "${projectName}" is not empty. Overwrite existing files?`, + false, + ); + if (!overwrite) { + console.log('\n Aborted. Existing files were left unchanged.\n'); + return; + } + } } console.log(`\n Creating ${projectName}...`); const files = generateProject(config); - - for (const file of files) { - const fullPath = join(projectDir, file.path); - const dir = dirname(fullPath); - - mkdirSync(dir, { recursive: true }); - writeFileSync(fullPath, file.content, 'utf-8'); - - console.log(` ✓ ${file.path}`); - } + writeProjectFiles(projectDir, files); console.log(); console.log(' ┌──────────────────────────────────┐'); @@ -151,6 +165,68 @@ async function runProjectScaffold(args: CliArgs): Promise { console.log(); } +function isNonEmptyDirectory(dir: string): boolean { + try { + return readdirSync(dir).some(entry => entry !== '.git'); + } catch { + return true; + } +} + +function writeProjectFiles( + projectDir: string, + files: Array<{ path: string; content: string }>, +): void { + const projectDirExisted = existsSync(projectDir); + const backups = new Map(); + const written: string[] = []; + + try { + for (const file of files) { + const fullPath = join(projectDir, file.path); + const dir = dirname(fullPath); + + if (existsSync(fullPath)) { + backups.set(fullPath, readFileSync(fullPath, 'utf-8')); + } else { + backups.set(fullPath, null); + } + + mkdirSync(dir, { recursive: true }); + writeFileSync(fullPath, file.content, 'utf-8'); + written.push(fullPath); + console.log(` ✓ ${file.path}`); + } + } catch (error) { + for (const path of written.reverse()) { + const previous = backups.get(path); + if (previous === null || previous === undefined) { + try { + unlinkSync(path); + } catch { + // Best-effort rollback of newly created files. + } + } else { + try { + writeFileSync(path, previous, 'utf-8'); + } catch { + // Best-effort restore of overwritten content. + } + } + } + + if (!projectDirExisted && existsSync(projectDir)) { + try { + rmSync(projectDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup failures after rollback. + } + } + + throw error; + } +} + if (process.argv[1] === fileURLToPath(import.meta.url)) { runCli(process.argv.slice(2)).catch(err => { console.error('Error:', err.message); From c9c638b0525cf2daf23ea1dccc780d501e43d4f8 Mon Sep 17 00:00:00 2001 From: Sumit Kumar Date: Sat, 15 Aug 2026 03:48:50 +0530 Subject: [PATCH 2/2] fix(create-app): harden scaffold rollback --- packages/create-termui-app/src/index.test.ts | 63 +++++++-- packages/create-termui-app/src/index.ts | 133 +++++++++++++------ 2 files changed, 151 insertions(+), 45 deletions(-) diff --git a/packages/create-termui-app/src/index.test.ts b/packages/create-termui-app/src/index.test.ts index bc7a4b12c..3cae72d65 100644 --- a/packages/create-termui-app/src/index.test.ts +++ b/packages/create-termui-app/src/index.test.ts @@ -1,7 +1,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { + mkdirSync, + rmSync, + existsSync, + readFileSync, + writeFileSync, + symlinkSync, +} from 'node:fs'; import * as prompts from './prompts.js'; import * as templates from './templates.js'; import * as addModule from './commands/add.js'; @@ -45,7 +52,7 @@ describe('CLI integration', () => { vi.spyOn(prompts, 'textPrompt').mockResolvedValue('my-app'); vi.spyOn(prompts, 'selectPrompt').mockResolvedValue(0); vi.spyOn(prompts, 'multiSelectPrompt').mockResolvedValue([false, false, true]); - vi.spyOn(templates, 'generateProject').mockReturnValue(createProjectFiles as any); + vi.spyOn(templates, 'generateProject').mockReturnValue(createProjectFiles); const indexModule = await import('./index'); await indexModule.runCli(['my-app']); @@ -74,7 +81,7 @@ describe('CLI integration', () => { const textPromptSpy = vi.spyOn(prompts, 'textPrompt'); const selectPromptSpy = vi.spyOn(prompts, 'selectPrompt'); const multiSelectPromptSpy = vi.spyOn(prompts, 'multiSelectPrompt'); - const generateSpy = vi.spyOn(templates, 'generateProject').mockReturnValue(createProjectFiles as any); + const generateSpy = vi.spyOn(templates, 'generateProject').mockReturnValue(createProjectFiles); const indexModule = await import('./index'); await indexModule.runCli(['non-interactive-app', '--yes']); @@ -97,14 +104,14 @@ describe('CLI integration', () => { expect(addSpy).not.toHaveBeenCalled(); }); it('prints version and exits before scaffolding', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const outputSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); const generateSpy = vi.spyOn(templates, 'generateProject'); const indexModule = await import('./index'); await indexModule.runCli(['--version']); - expect(logSpy).toHaveBeenCalledWith(expect.any(String)); + expect(outputSpy).toHaveBeenCalledWith(expect.any(String)); expect(generateSpy).not.toHaveBeenCalled(); }); @@ -129,7 +136,7 @@ describe('CLI integration', () => { mkdirSync(existingDir, { recursive: true }); writeFileSync(join(existingDir, 'package.json'), '{ "name": "keep-me" }', 'utf-8'); - vi.spyOn(templates, 'generateProject').mockReturnValue(createProjectFiles as any); + vi.spyOn(templates, 'generateProject').mockReturnValue(createProjectFiles); const indexModule = await import('./index'); await indexModule.runCli(['force-app', '--yes', '--force']); @@ -141,7 +148,7 @@ describe('CLI integration', () => { const emptyDir = join(tempDir, 'empty-app'); mkdirSync(emptyDir, { recursive: true }); - vi.spyOn(templates, 'generateProject').mockReturnValue(createProjectFiles as any); + vi.spyOn(templates, 'generateProject').mockReturnValue(createProjectFiles); const indexModule = await import('./index'); await indexModule.runCli(['empty-app', '--yes']); @@ -176,7 +183,7 @@ describe('CLI integration', () => { vi.spyOn(templates, 'generateProject').mockReturnValue([ { path: 'package.json', content: '{ "name": "new" }' }, { path: 'src/index.tsx', content: 'export {}' }, - ] as any); + ]); const indexModule = await import('./index'); @@ -185,4 +192,44 @@ describe('CLI integration', () => { expect(readFileSync(join(existingDir, 'package.json'), 'utf-8')).toBe('{ "name": "keep-me" }'); expect(readFileSync(join(existingDir, 'src'), 'utf-8')).toBe('not-a-directory'); }); + + it('restores binary files byte-for-byte and removes created directories on rollback', async () => { + const existingDir = join(tempDir, 'binary-rollback-app'); + const original = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]); + mkdirSync(existingDir, { recursive: true }); + writeFileSync(join(existingDir, 'asset.bin'), original); + writeFileSync(join(existingDir, 'blocked'), 'not-a-directory', 'utf-8'); + + vi.spyOn(templates, 'generateProject').mockReturnValue([ + { path: 'asset.bin', content: 'replacement' }, + { path: 'nested/created.txt', content: 'created' }, + { path: 'blocked/child.txt', content: 'fails' }, + ]); + + const indexModule = await import('./index'); + await expect(indexModule.runCli(['binary-rollback-app', '--yes', '--force'])).rejects.toThrow(); + + expect(readFileSync(join(existingDir, 'asset.bin'))).toEqual(original); + expect(existsSync(join(existingDir, 'nested'))).toBe(false); + }); + + it('rejects generated paths that traverse a symbolic link', async () => { + const existingDir = join(tempDir, 'symlink-app'); + const outsideDir = join(tempDir, 'outside'); + mkdirSync(existingDir, { recursive: true }); + mkdirSync(outsideDir, { recursive: true }); + writeFileSync(join(outsideDir, 'keep.txt'), 'keep', 'utf-8'); + symlinkSync(outsideDir, join(existingDir, 'linked'), 'dir'); + + vi.spyOn(templates, 'generateProject').mockReturnValue([ + { path: 'linked/keep.txt', content: 'overwritten' }, + ]); + + const indexModule = await import('./index'); + await expect(indexModule.runCli(['symlink-app', '--yes', '--force'])).rejects.toThrow( + 'Refusing to write through symbolic link', + ); + + expect(readFileSync(join(outsideDir, 'keep.txt'), 'utf-8')).toBe('keep'); + }); }); diff --git a/packages/create-termui-app/src/index.ts b/packages/create-termui-app/src/index.ts index 83cf89396..37cb53c1c 100644 --- a/packages/create-termui-app/src/index.ts +++ b/packages/create-termui-app/src/index.ts @@ -11,7 +11,8 @@ import { readFileSync, readdirSync, unlinkSync, - rmSync, + lstatSync, + rmdirSync, } from 'node:fs'; import { getBuiltinThemeNames } from '@termuijs/tss'; import { textPrompt, selectPrompt, multiSelectPrompt, confirmPrompt } from './prompts.js'; @@ -41,12 +42,15 @@ const packageVersion = JSON.parse( ).version as string; const FEATURES = ['Screen Router', 'Data Providers', 'Hot Reload']; +const report = (message = ''): void => { + process.stdout.write(`${message}\n`); +}; export async function runCli(argv: string[]): Promise { const args = parseArgs(argv); if (args.version) { - console.log(packageVersion); + report(packageVersion); return; } @@ -64,12 +68,12 @@ export async function runCli(argv: string[]): Promise { } async function runProjectScaffold(args: CliArgs): Promise { - console.log(); - console.log(' ┌──────────────────────────────────┐'); - console.log(' │ create-termui-app │'); - console.log(' │ The React/Next.js for CLI apps │'); - console.log(' └──────────────────────────────────┘'); - console.log(); + report(); + report(' ┌──────────────────────────────────┐'); + report(' │ create-termui-app │'); + report(' │ The React/Next.js for CLI apps │'); + report(' └──────────────────────────────────┘'); + report(); const nonInteractive = isNonInteractive(args); @@ -131,7 +135,7 @@ async function runProjectScaffold(args: CliArgs): Promise { const projectDir = resolve(process.cwd(), projectName); if (existsSync(projectDir) && isNonEmptyDirectory(projectDir)) { if (args.force) { - console.log(`\n ⚠ Directory "${projectName}" is not empty. Overwriting with --force.\n`); + report(`\n ⚠ Directory "${projectName}" is not empty. Overwriting with --force.\n`); } else if (nonInteractive) { throw new Error( `Directory "${projectName}" is not empty. Re-run with --force to overwrite.`, @@ -142,27 +146,27 @@ async function runProjectScaffold(args: CliArgs): Promise { false, ); if (!overwrite) { - console.log('\n Aborted. Existing files were left unchanged.\n'); + report('\n Aborted. Existing files were left unchanged.\n'); return; } } } - console.log(`\n Creating ${projectName}...`); + report(`\n Creating ${projectName}...`); const files = generateProject(config); writeProjectFiles(projectDir, files); - console.log(); - console.log(' ┌──────────────────────────────────┐'); - console.log(' │ ✅ Project created successfully! │'); - console.log(' └──────────────────────────────────┘'); - console.log(); - console.log(` Next steps:`); - console.log(` cd ${projectName}`); - console.log(` bun install`); - console.log(` bun run dev`); - console.log(); + report(); + report(' ┌──────────────────────────────────┐'); + report(' │ ✅ Project created successfully! │'); + report(' └──────────────────────────────────┘'); + report(); + report(' Next steps:'); + report(` cd ${projectName}`); + report(' bun install'); + report(' bun run dev'); + report(); } function isNonEmptyDirectory(dir: string): boolean { @@ -173,32 +177,86 @@ function isNonEmptyDirectory(dir: string): boolean { } } +function assertNoSymbolicLinks(projectDir: string, targetPath: string): void { + let current = targetPath; + + while (true) { + if (existsSync(current) && lstatSync(current).isSymbolicLink()) { + throw new Error(`Refusing to write through symbolic link: ${current}`); + } + if (current === projectDir) return; + + const parent = dirname(current); + if (parent === current) return; + current = parent; + } +} + +function createDirectories( + projectDir: string, + dir: string, + createdDirectories: string[], +): void { + const missing: string[] = []; + let current = dir; + + while (!existsSync(current)) { + missing.push(current); + if (current === projectDir) break; + current = dirname(current); + } + + if (existsSync(current)) { + const entry = lstatSync(current); + if (entry.isSymbolicLink() || !entry.isDirectory()) { + throw new Error(`Refusing to create files below non-directory path: ${current}`); + } + } + + for (const path of missing.reverse()) { + try { + mkdirSync(path); + createdDirectories.push(path); + } catch (error) { + // Another process may have created the directory after the existence check. + if (!existsSync(path)) throw error; + const entry = lstatSync(path); + if (entry.isSymbolicLink() || !entry.isDirectory()) throw error; + } + } +} + function writeProjectFiles( projectDir: string, files: Array<{ path: string; content: string }>, ): void { - const projectDirExisted = existsSync(projectDir); - const backups = new Map(); - const written: string[] = []; + const backups = new Map(); + const attemptedWrites: string[] = []; + const attemptedPaths = new Set(); + const createdDirectories: string[] = []; try { + assertNoSymbolicLinks(projectDir, projectDir); + for (const file of files) { const fullPath = join(projectDir, file.path); const dir = dirname(fullPath); - if (existsSync(fullPath)) { - backups.set(fullPath, readFileSync(fullPath, 'utf-8')); - } else { - backups.set(fullPath, null); + assertNoSymbolicLinks(projectDir, fullPath); + if (!backups.has(fullPath)) { + backups.set(fullPath, existsSync(fullPath) ? readFileSync(fullPath) : null); } - mkdirSync(dir, { recursive: true }); + createDirectories(projectDir, dir, createdDirectories); + if (!attemptedPaths.has(fullPath)) { + attemptedPaths.add(fullPath); + attemptedWrites.push(fullPath); + } writeFileSync(fullPath, file.content, 'utf-8'); - written.push(fullPath); - console.log(` ✓ ${file.path}`); + report(` ✓ ${file.path}`); } } catch (error) { - for (const path of written.reverse()) { + for (const path of attemptedWrites.reverse()) { const previous = backups.get(path); if (previous === null || previous === undefined) { try { @@ -208,18 +266,20 @@ function writeProjectFiles( } } else { try { - writeFileSync(path, previous, 'utf-8'); + writeFileSync(path, previous); } catch { // Best-effort restore of overwritten content. } } } - if (!projectDirExisted && existsSync(projectDir)) { + for (const path of createdDirectories.reverse()) { try { - rmSync(projectDir, { recursive: true, force: true }); + // Only empty directories created by this invocation are removed. Any + // concurrent content makes rmdirSync fail and is preserved. + rmdirSync(path); } catch { - // Ignore cleanup failures after rollback. + // Best-effort rollback of directories created by this invocation. } } @@ -234,4 +294,3 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { }); } -