Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/create-termui-app/src/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
6 changes: 6 additions & 0 deletions packages/create-termui-app/src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export interface CliArgs {
template?: string;
theme?: string;
yes: boolean;
force: boolean;
version?: boolean;
dir?: string;

Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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)) {
Expand Down
135 changes: 130 additions & 5 deletions packages/create-termui-app/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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 } 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';
Expand Down Expand Up @@ -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']);
Expand Down Expand Up @@ -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']);
Expand All @@ -97,14 +104,132 @@ 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();
});

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);
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);
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 {}' },
]);

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');
});

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');
});
});
Loading
Loading