diff --git a/TODO.txt b/TODO.txt index 1db6521..2054dc4 100644 --- a/TODO.txt +++ b/TODO.txt @@ -47,5 +47,9 @@ [x] list projects [x] start/stop/restart project [x] create project -[ ] todo.txt +[x] todo.txt +[x] tools +[x] code browser +[x] versioned pushes +[x] version browser [ ] ... diff --git a/bun.lock b/bun.lock index 59dbec1..a474b29 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "toes", "dependencies": { "@because/forge": "^0.0.1", - "@because/hype": "^0.0.1", + "@because/hype": "^0.0.2", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5", @@ -23,7 +23,7 @@ "packages": { "@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="], - "@because/hype": ["@because/hype@0.0.1", "https://npm.nose.space/@because/hype/-/hype-0.0.1.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-i92DNUXJOwt3J8dN1x8sh7i86blelcTCk8XDpwD839Ic8oe710lkDSVXJ7xYZb/i8YtzGhRg+L6eXDhaRiU2Pw=="], + "@because/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="], "@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], diff --git a/package.json b/package.json index edb192f..045ae6a 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "description": "personal web appliance - turn it on and forget about the cloud", "module": "src/index.ts", "type": "module", - "files": ["src"], + "files": [ + "src" + ], "exports": { ".": "./src/index.ts", "./tools": "./src/tools/index.ts" @@ -36,9 +38,9 @@ }, "dependencies": { "@because/forge": "^0.0.1", - "@because/hype": "^0.0.1", + "@because/hype": "^0.0.2", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" } -} +} \ No newline at end of file diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index fe687ce..a79022f 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -11,4 +11,4 @@ export { startApp, stopApp, } from './manage' -export { diffApp, getApp, pullApp, pushApp, statusApp, syncApp } from './sync' +export { cleanApp, diffApp, getApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync' diff --git a/src/cli/commands/manage.ts b/src/cli/commands/manage.ts index fce6190..022c872 100644 --- a/src/cli/commands/manage.ts +++ b/src/cli/commands/manage.ts @@ -53,7 +53,7 @@ export async function infoApp(arg?: string) { } const icon = STATE_ICONS[app.state] ?? '◯' - console.log(`${icon} ${color.bold(app.name)} ${app.tool && '[tool]'}`) + console.log(`${icon} ${color.bold(app.name)} ${app.tool ? '[tool]' : ''}`) console.log(` State: ${app.state}`) if (app.port) { console.log(` Port: ${app.port}`) diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index 3631f5d..122381e 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -3,11 +3,11 @@ import { loadGitignore } from '@gitignore' import { computeHash, generateManifest } from '%sync' import color from 'kleur' import { diffLines } from 'diff' -import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, watch, writeFileSync } from 'fs' +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, watch, writeFileSync } from 'fs' import { dirname, join } from 'path' import { del, download, get, getManifest, handleError, makeUrl, post, put } from '../http' -import { confirm } from '../prompts' -import { getAppName, isApp } from '../name' +import { confirm, prompt } from '../prompts' +import { getAppName, isApp, resolveAppName } from '../name' interface ManifestDiff { changed: string[] @@ -236,7 +236,7 @@ export async function diffApp() { const { changed, localOnly, remoteOnly } = diff if (changed.length === 0 && localOnly.length === 0 && remoteOnly.length === 0) { - console.log(color.green('✓ No differences')) + // console.log(color.green('✓ No differences')) return } @@ -329,7 +329,7 @@ export async function statusApp() { const hasLocalChanges = toPush.length > 0 // Display status - console.log(`Status for ${color.bold(appName)}:\n`) + // console.log(`Status for ${color.bold(appName)}:\n`) if (!remoteManifest) { console.log(color.yellow('App does not exist on server')) @@ -471,6 +471,364 @@ export async function syncApp() { } } +export async function versionsApp(name?: string) { + const appName = resolveAppName(name) + if (!appName) return + + const result = await getVersions(appName) + + if (!result) return + + if (result.versions.length === 0) { + console.log(`No versions found for ${color.bold(appName)}`) + return + } + + console.log(`Versions for ${color.bold(appName)}:\n`) + + for (const version of result.versions) { + const isCurrent = version === result.current + const date = formatVersion(version) + + if (isCurrent) { + console.log(` ${color.green('→')} ${color.bold(version)} ${color.gray(date)} ${color.green('(current)')}`) + } else { + console.log(` ${version} ${color.gray(date)}`) + } + } + + console.log() +} + +export async function rollbackApp(name?: string, version?: string) { + const appName = resolveAppName(name) + if (!appName) return + + // Get available versions + const result = await getVersions(appName) + + if (!result) return + + if (result.versions.length === 0) { + console.error(`No versions found for ${color.bold(appName)}`) + return + } + + // Filter out current version for rollback candidates + const candidates = result.versions.filter(v => v !== result.current) + + if (candidates.length === 0) { + console.error('No previous versions to rollback to') + return + } + + let targetVersion: string | undefined = version + + if (!targetVersion) { + // Show available versions and prompt + console.log(`Available versions for ${color.bold(appName)}:\n`) + + for (let i = 0; i < candidates.length; i++) { + const v = candidates[i]! + const date = formatVersion(v) + console.log(` ${color.cyan(String(i + 1))}. ${v} ${color.gray(date)}`) + } + + console.log() + const answer = await prompt('Enter version number or name: ') + + // Check if it's a number (index) or version name + const index = parseInt(answer, 10) + if (!isNaN(index) && index >= 1 && index <= candidates.length) { + targetVersion = candidates[index - 1]! + } else if (candidates.includes(answer)) { + targetVersion = answer + } else { + console.error('Invalid selection') + return + } + } + + // Validate version exists (handles both user-provided and selected versions) + if (!targetVersion || !result.versions.includes(targetVersion)) { + console.error(`Version ${color.bold(targetVersion ?? 'unknown')} not found`) + console.error(`Available versions: ${result.versions.join(', ')}`) + return + } + + if (targetVersion === result.current) { + console.log(`Version ${color.bold(targetVersion)} is already active`) + return + } + + const ok = await confirm(`Rollback ${color.bold(appName)} to version ${color.bold(targetVersion)}?`) + if (!ok) return + + console.log(`Rolling back to ${color.bold(targetVersion)}...`) + + type ActivateResponse = { ok: boolean } + const activateRes = await post(`/api/sync/apps/${appName}/activate?version=${targetVersion}`) + + if (!activateRes?.ok) { + console.error('Failed to activate version') + return + } + + console.log(color.green(`✓ Rolled back to version ${targetVersion}`)) +} + +const STASH_BASE = '/tmp/toes-stash' + +export async function stashApp() { + if (!isApp()) { + console.error('Not a toes app. Use `toes get ` to grab one.') + return + } + + const appName = getAppName() + const diff = await getManifestDiff(appName) + + if (diff === null) { + return + } + + const { changed, localOnly } = diff + const toStash = [...changed, ...localOnly] + + if (toStash.length === 0) { + console.log('No local changes to stash') + return + } + + const stashDir = join(STASH_BASE, appName) + + // Check if stash already exists + if (existsSync(stashDir)) { + console.error('Stash already exists. Use `toes stash-pop` first.') + return + } + + mkdirSync(stashDir, { recursive: true }) + + // Save stash metadata + const metadata = { + app: appName, + timestamp: new Date().toISOString(), + files: toStash, + changed, + localOnly, + } + writeFileSync(join(stashDir, 'metadata.json'), JSON.stringify(metadata, null, 2)) + + // Copy files to stash + for (const file of toStash) { + const srcPath = join(process.cwd(), file) + const destPath = join(stashDir, 'files', file) + const destDir = dirname(destPath) + + if (!existsSync(destDir)) { + mkdirSync(destDir, { recursive: true }) + } + + const content = readFileSync(srcPath) + writeFileSync(destPath, content) + console.log(` ${color.yellow('→')} ${file}`) + } + + // Restore changed files from server + if (changed.length > 0) { + console.log(`\nRestoring ${changed.length} changed files from server...`) + for (const file of changed) { + const content = await download(`/api/sync/apps/${appName}/files/${file}`) + if (content) { + writeFileSync(join(process.cwd(), file), content) + } + } + } + + // Delete local-only files + if (localOnly.length > 0) { + console.log(`Removing ${localOnly.length} local-only files...`) + for (const file of localOnly) { + unlinkSync(join(process.cwd(), file)) + } + } + + console.log(color.green(`\n✓ Stashed ${toStash.length} file(s)`)) +} + +export async function stashListApp() { + if (!existsSync(STASH_BASE)) { + console.log('No stashes') + return + } + + const entries = readdirSync(STASH_BASE, { withFileTypes: true }) + const stashes = entries.filter(e => e.isDirectory()) + + if (stashes.length === 0) { + console.log('No stashes') + return + } + + console.log('Stashes:\n') + for (const stash of stashes) { + const metadataPath = join(STASH_BASE, stash.name, 'metadata.json') + if (existsSync(metadataPath)) { + const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8')) as { + timestamp: string + files: string[] + } + const date = new Date(metadata.timestamp).toLocaleString() + console.log(` ${color.bold(stash.name)} ${color.gray(date)} ${color.gray(`(${metadata.files.length} files)`)}`) + } else { + console.log(` ${color.bold(stash.name)} ${color.gray('(invalid)')}`) + } + } + console.log() +} + +export async function stashPopApp() { + if (!isApp()) { + console.error('Not a toes app. Use `toes get ` to grab one.') + return + } + + const appName = getAppName() + const stashDir = join(STASH_BASE, appName) + + if (!existsSync(stashDir)) { + console.error(`No stash found for ${color.bold(appName)}`) + return + } + + // Read metadata + const metadataPath = join(stashDir, 'metadata.json') + if (!existsSync(metadataPath)) { + console.error('Invalid stash: missing metadata') + return + } + + const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8')) as { + app: string + timestamp: string + files: string[] + changed: string[] + localOnly: string[] + } + + console.log(`Restoring stash from ${new Date(metadata.timestamp).toLocaleString()}...\n`) + + // Restore files from stash + for (const file of metadata.files) { + const srcPath = join(stashDir, 'files', file) + const destPath = join(process.cwd(), file) + const destDir = dirname(destPath) + + if (!existsSync(srcPath)) { + console.log(` ${color.red('✗')} ${file} (missing from stash)`) + continue + } + + if (!existsSync(destDir)) { + mkdirSync(destDir, { recursive: true }) + } + + const content = readFileSync(srcPath) + writeFileSync(destPath, content) + console.log(` ${color.green('←')} ${file}`) + } + + // Remove stash directory + rmSync(stashDir, { recursive: true }) + + console.log(color.green(`\n✓ Restored ${metadata.files.length} file(s)`)) +} + +export async function cleanApp(options: { force?: boolean, dryRun?: boolean } = {}) { + if (!isApp()) { + console.error('Not a toes app. Use `toes get ` to grab one.') + return + } + + const appName = getAppName() + const diff = await getManifestDiff(appName) + + if (diff === null) { + return + } + + const { localOnly } = diff + + if (localOnly.length === 0) { + console.log('Nothing to clean') + return + } + + if (options.dryRun) { + console.log('Would remove:') + for (const file of localOnly) { + console.log(` ${color.red('✗')} ${file}`) + } + return + } + + if (!options.force) { + console.log('Files not on server:') + for (const file of localOnly) { + console.log(` ${color.red('✗')} ${file}`) + } + console.log() + const ok = await confirm(`Remove ${localOnly.length} file(s)?`) + if (!ok) return + } + + for (const file of localOnly) { + const fullPath = join(process.cwd(), file) + unlinkSync(fullPath) + console.log(` ${color.red('✗')} ${file}`) + } + + console.log(color.green(`✓ Removed ${localOnly.length} file(s)`)) +} + +interface VersionsResponse { + current: string | null + versions: string[] +} + +async function getVersions(appName: string): Promise { + try { + const res = await fetch(makeUrl(`/api/sync/apps/${appName}/versions`)) + if (res.status === 404) { + console.error(`App not found: ${appName}`) + return null + } + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) + return await res.json() + } catch (error) { + handleError(error) + return null + } +} + +function formatVersion(version: string): string { + // Parse YYYYMMDD-HHMMSS format + const match = version.match(/^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/) + if (!match) return '' + + const date = new Date( + parseInt(match[1]!, 10), + parseInt(match[2]!, 10) - 1, + parseInt(match[3]!, 10), + parseInt(match[4]!, 10), + parseInt(match[5]!, 10), + parseInt(match[6]!, 10) + ) + + return date.toLocaleString() +} + async function getManifestDiff(appName: string): Promise { const localManifest = generateManifest(process.cwd(), appName) const result = await getManifest(appName) diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 5865af1..265324b 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -2,6 +2,7 @@ import { program } from 'commander' import color from 'kleur' import { + cleanApp, configShow, diffApp, getApp, @@ -15,10 +16,15 @@ import { renameApp, restartApp, rmApp, + rollbackApp, + stashApp, + stashListApp, + stashPopApp, startApp, statusApp, stopApp, syncApp, + versionsApp, } from './commands' program @@ -141,6 +147,41 @@ program .description('Watch and sync changes bidirectionally') .action(syncApp) +program + .command('clean') + .description('Remove local files not on server') + .option('-f, --force', 'skip confirmation') + .option('-n, --dry-run', 'show what would be removed') + .action(cleanApp) + +const stash = program + .command('stash') + .description('Stash local changes') + .action(stashApp) + +stash + .command('pop') + .description('Restore stashed changes') + .action(stashPopApp) + +stash + .command('list') + .description('List all stashes') + .action(stashListApp) + +program + .command('versions') + .description('List deployed versions') + .argument('[name]', 'app name (uses current directory if omitted)') + .action(versionsApp) + +program + .command('rollback') + .description('Rollback to a previous version') + .argument('[name]', 'app name (uses current directory if omitted)') + .option('-v, --version ', 'version to rollback to (prompts if omitted)') + .action((name, options) => rollbackApp(name, options.version)) + program .command('rm') .description('Remove an app from the server') diff --git a/src/client/modals/NewApp.tsx b/src/client/modals/NewApp.tsx index c3978e6..971439a 100644 --- a/src/client/modals/NewApp.tsx +++ b/src/client/modals/NewApp.tsx @@ -1,12 +1,17 @@ import { closeModal, openModal, rerenderModal } from '../components/modal' import { apps, setSelectedApp } from '../state' -import { Button, Form, FormActions, FormError, FormField, FormInput, FormLabel } from '../styles' +import { Button, Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel, FormError, FormField, FormInput, FormLabel, FormSelect } from '../styles' + +type TemplateType = 'ssr' | 'spa' | 'bare' -let newAppError = '' let newAppCreating = false +let newAppError = '' +let newAppName = '' +let newAppTemplate: TemplateType = 'ssr' +let newAppTool = false -async function createNewApp(input: HTMLInputElement) { - const name = input.value.trim().toLowerCase().replace(/\s+/g, '-') +async function createNewApp() { + const name = newAppName.trim().toLowerCase().replace(/\s+/g, '-') if (!name) { newAppError = 'App name is required' @@ -34,7 +39,7 @@ async function createNewApp(input: HTMLInputElement) { const res = await fetch('/api/apps', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }), + body: JSON.stringify({ name, template: newAppTemplate, tool: newAppTool }), }) const data = await res.json() @@ -54,14 +59,16 @@ async function createNewApp(input: HTMLInputElement) { } export function openNewAppModal() { - newAppError = '' newAppCreating = false + newAppError = '' + newAppName = '' + newAppTemplate = 'ssr' + newAppTool = false openModal('New App', () => (
{ e.preventDefault() - const input = (e.target as HTMLFormElement).querySelector('input') as HTMLInputElement - createNewApp(input) + createNewApp() }}> App Name @@ -69,10 +76,39 @@ export function openNewAppModal() { id="app-name" type="text" placeholder="my-app" + value={newAppName} + onInput={(e: Event) => { + newAppName = (e.target as HTMLInputElement).value + }} autofocus /> {newAppError && {newAppError}} + + Template + { + newAppTemplate = (e.target as HTMLSelectElement).value as TemplateType + }} + > + + + + + + + { + newAppTool = (e.target as HTMLInputElement).checked + rerenderModal() + }} + /> + Tool +