feat: TypeScript installation wizard with @clack/prompts TUI (#1)

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-02-21 18:25:51 +00:00
committed by jason.woltje
parent e3ec3e32e5
commit 6a84f7e210
56 changed files with 20647 additions and 31 deletions

44
src/platform/detect.ts Normal file
View File

@@ -0,0 +1,44 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir, platform } from 'node:os';
export type ShellType = 'zsh' | 'bash' | 'fish' | 'powershell' | 'unknown';
export function detectShell(): ShellType {
const shell = process.env.SHELL ?? '';
if (shell.includes('zsh')) return 'zsh';
if (shell.includes('bash')) return 'bash';
if (shell.includes('fish')) return 'fish';
if (platform() === 'win32') return 'powershell';
return 'unknown';
}
export function getShellProfilePath(): string | null {
const home = homedir();
if (platform() === 'win32') {
return join(
home,
'Documents',
'PowerShell',
'Microsoft.PowerShell_profile.ps1',
);
}
const shell = detectShell();
switch (shell) {
case 'zsh': {
const zdotdir = process.env.ZDOTDIR ?? home;
return join(zdotdir, '.zshrc');
}
case 'bash': {
const bashrc = join(home, '.bashrc');
if (existsSync(bashrc)) return bashrc;
return join(home, '.profile');
}
case 'fish':
return join(home, '.config', 'fish', 'config.fish');
default:
return join(home, '.profile');
}
}

116
src/platform/file-ops.ts Normal file
View File

@@ -0,0 +1,116 @@
import {
readFileSync,
writeFileSync,
existsSync,
mkdirSync,
copyFileSync,
renameSync,
readdirSync,
unlinkSync,
cpSync,
statSync,
} from 'node:fs';
import { dirname, join, relative } from 'node:path';
const MAX_BACKUPS = 3;
/**
* Atomic write: write to temp file, then rename.
* Creates parent directories as needed.
*/
export function atomicWrite(filePath: string, content: string): void {
mkdirSync(dirname(filePath), { recursive: true });
const tmpPath = `${filePath}.tmp-${process.pid}`;
writeFileSync(tmpPath, content, 'utf-8');
renameSync(tmpPath, filePath);
}
/**
* Create a backup of a file before overwriting.
* Rotates backups to keep at most MAX_BACKUPS.
*/
export function backupFile(filePath: string): string | null {
if (!existsSync(filePath)) return null;
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, '')
.replace('T', '-')
.slice(0, 19);
const backupPath = `${filePath}.bak-${timestamp}`;
copyFileSync(filePath, backupPath);
rotateBackups(filePath);
return backupPath;
}
function rotateBackups(filePath: string): void {
const dir = dirname(filePath);
const baseName = filePath.split('/').pop()!;
const prefix = `${baseName}.bak-`;
try {
const backups = readdirSync(dir)
.filter((f) => f.startsWith(prefix))
.sort()
.reverse();
for (let i = MAX_BACKUPS; i < backups.length; i++) {
unlinkSync(join(dir, backups[i]));
}
} catch {
// Non-fatal: backup rotation failure doesn't block writes
}
}
/**
* Sync a source directory to a target, with optional preserve paths.
* Replaces the rsync/cp logic from install.sh.
*/
export function syncDirectory(
source: string,
target: string,
options: { preserve?: string[]; excludeGit?: boolean } = {},
): void {
const preserveSet = new Set(options.preserve ?? []);
// Collect files from source
function copyRecursive(src: string, dest: string, relBase: string): void {
if (!existsSync(src)) return;
const stat = statSync(src);
if (stat.isDirectory()) {
const relPath = relative(relBase, src);
// Skip .git
if (options.excludeGit && relPath === '.git') return;
// Skip preserved paths at top level
if (preserveSet.has(relPath) && existsSync(dest)) return;
mkdirSync(dest, { recursive: true });
for (const entry of readdirSync(src)) {
copyRecursive(join(src, entry), join(dest, entry), relBase);
}
} else {
const relPath = relative(relBase, src);
// Skip preserved files at top level
if (preserveSet.has(relPath) && existsSync(dest)) return;
mkdirSync(dirname(dest), { recursive: true });
copyFileSync(src, dest);
}
}
copyRecursive(source, target, source);
}
/**
* Safely read a file, returning null if it doesn't exist.
*/
export function safeReadFile(filePath: string): string | null {
try {
return readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}