From 56db56976ba980d311f328bf29c31dc5676ace93 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sun, 1 Mar 2026 13:29:01 -0800 Subject: [PATCH] re-do the whole thing on git --- apps/clock/{20260130-000000 => }/.npmrc | 0 apps/clock/{20260130-000000 => }/TODO.txt | 0 apps/clock/{20260130-000000 => }/bun.lock | 0 apps/clock/{20260130-000000 => }/index.tsx | 0 apps/clock/{20260130-000000 => }/package.json | 0 .../{20260130-000000 => }/pub/digital.ttf | Bin .../clock/{20260130-000000 => }/tsconfig.json | 0 apps/code/{20260130-000000 => }/.npmrc | 0 apps/code/{20260130-000000 => }/bun.lock | 0 apps/code/{20260130-000000 => }/index.tsx | 0 apps/code/{20260130-000000 => }/package.json | 0 .../{20260130-000000 => }/src/pages/index.tsx | 0 .../src/server/index.tsx | 0 apps/code/{20260130-000000 => }/tsconfig.json | 0 apps/cron/{20260201-000000 => }/.npmrc | 0 apps/cron/{20260201-000000 => }/bun.lock | 0 apps/cron/{20260201-000000 => }/index.tsx | 2 +- .../{20260201-000000 => }/lib/discovery.ts | 0 .../{20260201-000000 => }/lib/executor.ts | 0 apps/cron/{20260201-000000 => }/lib/runner.ts | 0 .../{20260201-000000 => }/lib/scheduler.ts | 0 .../{20260201-000000 => }/lib/schedules.ts | 0 apps/cron/{20260201-000000 => }/lib/state.ts | 0 apps/cron/{20260201-000000 => }/package.json | 0 apps/cron/{20260201-000000 => }/tsconfig.json | 0 apps/env/{20260130-000000 => }/.npmrc | 0 apps/env/{20260130-000000 => }/bun.lock | 0 apps/env/{20260130-000000 => }/index.tsx | 0 apps/env/{20260130-000000 => }/package.json | 0 apps/env/{20260130-000000 => }/tsconfig.json | 0 apps/git/{20260228-000000 => }/bun.lock | 0 apps/git/{20260228-000000 => }/index.tsx | 110 +- apps/git/{20260228-000000 => }/package.json | 0 apps/git/{20260228-000000 => }/tsconfig.json | 0 apps/metrics/{20260130-000000 => }/.npmrc | 0 apps/metrics/{20260130-000000 => }/bun.lock | 0 apps/metrics/{20260130-000000 => }/index.tsx | 0 .../{20260130-000000 => }/package.json | 0 .../{20260130-000000 => }/tsconfig.json | 0 apps/truisms/{20260130-000000 => }/.npmrc | 0 apps/truisms/{20260130-000000 => }/README.md | 0 apps/truisms/{20260130-000000 => }/bun.lock | 0 apps/truisms/{20260130-000000 => }/index.ts | 0 .../{20260130-000000 => }/package.json | 0 .../{20260130-000000 => }/tsconfig.json | 0 apps/versions/{20260130-000000 => }/.npmrc | 0 apps/versions/{20260130-000000 => }/bun.lock | 0 apps/versions/{20260130-000000 => }/index.tsx | 0 .../{20260130-000000 => }/package.json | 0 .../{20260130-000000 => }/tsconfig.json | 0 package.json | 1 + scripts/deploy.sh | 20 +- scripts/migrate.ts | 111 ++ src/cli/commands/index.ts | 2 - src/cli/commands/manage.ts | 27 +- src/cli/commands/sync.ts | 1224 ----------------- src/cli/http.ts | 7 +- src/cli/setup.ts | 110 +- src/cli/shell.ts | 4 +- src/lib/sync.ts | 20 +- src/server/api/apps.ts | 57 +- src/server/api/sync.ts | 277 +--- src/server/apps.ts | 44 +- src/shared/events.ts | 4 +- 64 files changed, 254 insertions(+), 1766 deletions(-) rename apps/clock/{20260130-000000 => }/.npmrc (100%) rename apps/clock/{20260130-000000 => }/TODO.txt (100%) rename apps/clock/{20260130-000000 => }/bun.lock (100%) rename apps/clock/{20260130-000000 => }/index.tsx (100%) rename apps/clock/{20260130-000000 => }/package.json (100%) rename apps/clock/{20260130-000000 => }/pub/digital.ttf (100%) rename apps/clock/{20260130-000000 => }/tsconfig.json (100%) rename apps/code/{20260130-000000 => }/.npmrc (100%) rename apps/code/{20260130-000000 => }/bun.lock (100%) rename apps/code/{20260130-000000 => }/index.tsx (100%) rename apps/code/{20260130-000000 => }/package.json (100%) rename apps/code/{20260130-000000 => }/src/pages/index.tsx (100%) rename apps/code/{20260130-000000 => }/src/server/index.tsx (100%) rename apps/code/{20260130-000000 => }/tsconfig.json (100%) rename apps/cron/{20260201-000000 => }/.npmrc (100%) rename apps/cron/{20260201-000000 => }/bun.lock (100%) rename apps/cron/{20260201-000000 => }/index.tsx (99%) rename apps/cron/{20260201-000000 => }/lib/discovery.ts (100%) rename apps/cron/{20260201-000000 => }/lib/executor.ts (100%) rename apps/cron/{20260201-000000 => }/lib/runner.ts (100%) rename apps/cron/{20260201-000000 => }/lib/scheduler.ts (100%) rename apps/cron/{20260201-000000 => }/lib/schedules.ts (100%) rename apps/cron/{20260201-000000 => }/lib/state.ts (100%) rename apps/cron/{20260201-000000 => }/package.json (100%) rename apps/cron/{20260201-000000 => }/tsconfig.json (100%) rename apps/env/{20260130-000000 => }/.npmrc (100%) rename apps/env/{20260130-000000 => }/bun.lock (100%) rename apps/env/{20260130-000000 => }/index.tsx (100%) rename apps/env/{20260130-000000 => }/package.json (100%) rename apps/env/{20260130-000000 => }/tsconfig.json (100%) rename apps/git/{20260228-000000 => }/bun.lock (100%) rename apps/git/{20260228-000000 => }/index.tsx (88%) rename apps/git/{20260228-000000 => }/package.json (100%) rename apps/git/{20260228-000000 => }/tsconfig.json (100%) rename apps/metrics/{20260130-000000 => }/.npmrc (100%) rename apps/metrics/{20260130-000000 => }/bun.lock (100%) rename apps/metrics/{20260130-000000 => }/index.tsx (100%) rename apps/metrics/{20260130-000000 => }/package.json (100%) rename apps/metrics/{20260130-000000 => }/tsconfig.json (100%) rename apps/truisms/{20260130-000000 => }/.npmrc (100%) rename apps/truisms/{20260130-000000 => }/README.md (100%) rename apps/truisms/{20260130-000000 => }/bun.lock (100%) rename apps/truisms/{20260130-000000 => }/index.ts (100%) rename apps/truisms/{20260130-000000 => }/package.json (100%) rename apps/truisms/{20260130-000000 => }/tsconfig.json (100%) rename apps/versions/{20260130-000000 => }/.npmrc (100%) rename apps/versions/{20260130-000000 => }/bun.lock (100%) rename apps/versions/{20260130-000000 => }/index.tsx (100%) rename apps/versions/{20260130-000000 => }/package.json (100%) rename apps/versions/{20260130-000000 => }/tsconfig.json (100%) create mode 100644 scripts/migrate.ts delete mode 100644 src/cli/commands/sync.ts diff --git a/apps/clock/20260130-000000/.npmrc b/apps/clock/.npmrc similarity index 100% rename from apps/clock/20260130-000000/.npmrc rename to apps/clock/.npmrc diff --git a/apps/clock/20260130-000000/TODO.txt b/apps/clock/TODO.txt similarity index 100% rename from apps/clock/20260130-000000/TODO.txt rename to apps/clock/TODO.txt diff --git a/apps/clock/20260130-000000/bun.lock b/apps/clock/bun.lock similarity index 100% rename from apps/clock/20260130-000000/bun.lock rename to apps/clock/bun.lock diff --git a/apps/clock/20260130-000000/index.tsx b/apps/clock/index.tsx similarity index 100% rename from apps/clock/20260130-000000/index.tsx rename to apps/clock/index.tsx diff --git a/apps/clock/20260130-000000/package.json b/apps/clock/package.json similarity index 100% rename from apps/clock/20260130-000000/package.json rename to apps/clock/package.json diff --git a/apps/clock/20260130-000000/pub/digital.ttf b/apps/clock/pub/digital.ttf similarity index 100% rename from apps/clock/20260130-000000/pub/digital.ttf rename to apps/clock/pub/digital.ttf diff --git a/apps/clock/20260130-000000/tsconfig.json b/apps/clock/tsconfig.json similarity index 100% rename from apps/clock/20260130-000000/tsconfig.json rename to apps/clock/tsconfig.json diff --git a/apps/code/20260130-000000/.npmrc b/apps/code/.npmrc similarity index 100% rename from apps/code/20260130-000000/.npmrc rename to apps/code/.npmrc diff --git a/apps/code/20260130-000000/bun.lock b/apps/code/bun.lock similarity index 100% rename from apps/code/20260130-000000/bun.lock rename to apps/code/bun.lock diff --git a/apps/code/20260130-000000/index.tsx b/apps/code/index.tsx similarity index 100% rename from apps/code/20260130-000000/index.tsx rename to apps/code/index.tsx diff --git a/apps/code/20260130-000000/package.json b/apps/code/package.json similarity index 100% rename from apps/code/20260130-000000/package.json rename to apps/code/package.json diff --git a/apps/code/20260130-000000/src/pages/index.tsx b/apps/code/src/pages/index.tsx similarity index 100% rename from apps/code/20260130-000000/src/pages/index.tsx rename to apps/code/src/pages/index.tsx diff --git a/apps/code/20260130-000000/src/server/index.tsx b/apps/code/src/server/index.tsx similarity index 100% rename from apps/code/20260130-000000/src/server/index.tsx rename to apps/code/src/server/index.tsx diff --git a/apps/code/20260130-000000/tsconfig.json b/apps/code/tsconfig.json similarity index 100% rename from apps/code/20260130-000000/tsconfig.json rename to apps/code/tsconfig.json diff --git a/apps/cron/20260201-000000/.npmrc b/apps/cron/.npmrc similarity index 100% rename from apps/cron/20260201-000000/.npmrc rename to apps/cron/.npmrc diff --git a/apps/cron/20260201-000000/bun.lock b/apps/cron/bun.lock similarity index 100% rename from apps/cron/20260201-000000/bun.lock rename to apps/cron/bun.lock diff --git a/apps/cron/20260201-000000/index.tsx b/apps/cron/index.tsx similarity index 99% rename from apps/cron/20260201-000000/index.tsx rename to apps/cron/index.tsx index 42e8e07..abf5322 100644 --- a/apps/cron/20260201-000000/index.tsx +++ b/apps/cron/index.tsx @@ -691,7 +691,7 @@ watch(APPS_DIR, { recursive: true }, (_event, filename) => { debounceTimer = setTimeout(rediscover, 100) }) -on(['app:activate', 'app:delete'], (event) => { +on(['app:reload', 'app:delete'], (event) => { console.log(`[cron] ${event.type} ${event.app}, rediscovering jobs...`) rediscover() }) diff --git a/apps/cron/20260201-000000/lib/discovery.ts b/apps/cron/lib/discovery.ts similarity index 100% rename from apps/cron/20260201-000000/lib/discovery.ts rename to apps/cron/lib/discovery.ts diff --git a/apps/cron/20260201-000000/lib/executor.ts b/apps/cron/lib/executor.ts similarity index 100% rename from apps/cron/20260201-000000/lib/executor.ts rename to apps/cron/lib/executor.ts diff --git a/apps/cron/20260201-000000/lib/runner.ts b/apps/cron/lib/runner.ts similarity index 100% rename from apps/cron/20260201-000000/lib/runner.ts rename to apps/cron/lib/runner.ts diff --git a/apps/cron/20260201-000000/lib/scheduler.ts b/apps/cron/lib/scheduler.ts similarity index 100% rename from apps/cron/20260201-000000/lib/scheduler.ts rename to apps/cron/lib/scheduler.ts diff --git a/apps/cron/20260201-000000/lib/schedules.ts b/apps/cron/lib/schedules.ts similarity index 100% rename from apps/cron/20260201-000000/lib/schedules.ts rename to apps/cron/lib/schedules.ts diff --git a/apps/cron/20260201-000000/lib/state.ts b/apps/cron/lib/state.ts similarity index 100% rename from apps/cron/20260201-000000/lib/state.ts rename to apps/cron/lib/state.ts diff --git a/apps/cron/20260201-000000/package.json b/apps/cron/package.json similarity index 100% rename from apps/cron/20260201-000000/package.json rename to apps/cron/package.json diff --git a/apps/cron/20260201-000000/tsconfig.json b/apps/cron/tsconfig.json similarity index 100% rename from apps/cron/20260201-000000/tsconfig.json rename to apps/cron/tsconfig.json diff --git a/apps/env/20260130-000000/.npmrc b/apps/env/.npmrc similarity index 100% rename from apps/env/20260130-000000/.npmrc rename to apps/env/.npmrc diff --git a/apps/env/20260130-000000/bun.lock b/apps/env/bun.lock similarity index 100% rename from apps/env/20260130-000000/bun.lock rename to apps/env/bun.lock diff --git a/apps/env/20260130-000000/index.tsx b/apps/env/index.tsx similarity index 100% rename from apps/env/20260130-000000/index.tsx rename to apps/env/index.tsx diff --git a/apps/env/20260130-000000/package.json b/apps/env/package.json similarity index 100% rename from apps/env/20260130-000000/package.json rename to apps/env/package.json diff --git a/apps/env/20260130-000000/tsconfig.json b/apps/env/tsconfig.json similarity index 100% rename from apps/env/20260130-000000/tsconfig.json rename to apps/env/tsconfig.json diff --git a/apps/git/20260228-000000/bun.lock b/apps/git/bun.lock similarity index 100% rename from apps/git/20260228-000000/bun.lock rename to apps/git/bun.lock diff --git a/apps/git/20260228-000000/index.tsx b/apps/git/index.tsx similarity index 88% rename from apps/git/20260228-000000/index.tsx rename to apps/git/index.tsx index 922cba9..e2ca0a2 100644 --- a/apps/git/20260228-000000/index.tsx +++ b/apps/git/index.tsx @@ -2,7 +2,7 @@ import { Hype } from '@because/hype' import { define, stylesToCSS } from '@because/forge' import { baseStyles, ToolScript, theme, on } from '@because/toes/tools' import { mkdirSync } from 'fs' -import { mkdir, readdir, readlink, rm, stat } from 'fs/promises' +import { mkdir, readdir, rename, rm, stat } from 'fs/promises' import { join, resolve } from 'path' import type { Child } from 'hono/jsx' @@ -10,7 +10,6 @@ const APPS_DIR = process.env.APPS_DIR! const DATA_DIR = process.env.DATA_DIR! const TOES_URL = process.env.TOES_URL! -const MAX_VERSIONS = 5 const REPOS_DIR = resolve(DATA_DIR, 'repos') const VALID_NAME = /^[a-zA-Z0-9_-]+$/ @@ -120,74 +119,42 @@ interface RepoListPageProps { const repoPath = (name: string) => join(REPOS_DIR, `${name}.git`) -const timestamp = () => { - const [date, time] = new Date().toISOString().slice(0, 19).split('T') - return `${date.replaceAll('-', '')}-${time.replaceAll(':', '')}` -} - // resolve() normalises ".." segments; if the result differs from join(), the name contains a path traversal const validRepoName = (name: string) => VALID_NAME.test(name) && resolve(REPOS_DIR, name) === join(REPOS_DIR, name) -async function activateApp(name: string, version: string): Promise { - const res = await fetch(`${TOES_URL}/api/sync/apps/${name}/activate?version=${version}`, { +async function activateApp(name: string): Promise { + const res = await fetch(`${TOES_URL}/api/sync/apps/${name}/reload`, { method: 'POST', }) if (!res.ok) { const body = await res.json().catch(() => ({})) - const msg = (body as Record).error ?? `activate returned ${res.status}` - console.error(`Activate failed for ${name}@${version}:`, msg) + const msg = (body as Record).error ?? `reload returned ${res.status}` + console.error(`Reload failed for ${name}:`, msg) return msg } return null } -async function cleanOldVersions(appDir: string): Promise { - if (!(await dirExists(appDir))) return - - // Read the current symlink target so we never delete the active version - let current: string | null = null - try { - const target = await readlink(join(appDir, 'current')) - current = target.split('/').pop() ?? null - } catch {} - - const entries = await readdir(appDir, { withFileTypes: true }) - const versions = entries - .filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name)) - .map(e => e.name) - .sort() - - if (versions.length <= MAX_VERSIONS) return - - const toRemove = versions - .slice(0, versions.length - MAX_VERSIONS) - .filter(v => v !== current) - for (const dir of toRemove) { - await rm(join(appDir, dir), { recursive: true, force: true }) - } -} - -async function deploy(repoName: string): Promise<{ ok: boolean; error?: string; version?: string }> { +async function deploy(repoName: string): Promise<{ ok: boolean; error?: string }> { const bare = repoPath(repoName) if (!(await hasCommits(bare))) { return { ok: false, error: 'No commits in repository' } } - const ts = timestamp() - const appDir = join(APPS_DIR, repoName) - const versionDir = join(appDir, ts) + // Validate in a temp dir before touching the real app dir + const tmpDir = join(APPS_DIR, `.${repoName}-deploy-tmp`) + await rm(tmpDir, { recursive: true, force: true }) + await mkdir(tmpDir, { recursive: true }) - await mkdir(versionDir, { recursive: true }) - - // Extract HEAD into the version directory — no shell, pipe git archive into tar + // Extract HEAD into the temp directory const archive = Bun.spawn(['git', '--git-dir', bare, 'archive', 'HEAD'], { stdout: 'pipe', stderr: 'pipe', }) - const tar = Bun.spawn(['tar', '-x', '-C', versionDir], { + const tar = Bun.spawn(['tar', '-x', '-C', tmpDir], { stdin: archive.stdout, stdout: 'ignore', stderr: 'pipe', @@ -202,32 +169,37 @@ async function deploy(repoName: string): Promise<{ ok: boolean; error?: string; ]) if (archiveExit !== 0 || tarExit !== 0) { - await rm(versionDir, { recursive: true, force: true }) + await rm(tmpDir, { recursive: true, force: true }) return { ok: false, error: `git archive failed: ${archiveErr || tarErr}` } } // Verify package.json with scripts.toes exists - const pkgPath = join(versionDir, 'package.json') + const pkgPath = join(tmpDir, 'package.json') if (!(await Bun.file(pkgPath).exists())) { - await rm(versionDir, { recursive: true, force: true }) + await rm(tmpDir, { recursive: true, force: true }) return { ok: false, error: 'No package.json found in repository' } } try { const pkg = JSON.parse(await Bun.file(pkgPath).text()) if (!pkg.scripts?.toes) { - await rm(versionDir, { recursive: true, force: true }) + await rm(tmpDir, { recursive: true, force: true }) return { ok: false, error: 'package.json missing scripts.toes entry' } } } catch { - await rm(versionDir, { recursive: true, force: true }) + await rm(tmpDir, { recursive: true, force: true }) return { ok: false, error: 'Invalid package.json' } } - // Clean up old versions beyond MAX_VERSIONS - await cleanOldVersions(appDir) + // Stop the app before swapping directories + await stopIfRunning(repoName) - return { ok: true, version: ts } + // Validation passed — swap directories (reload endpoint handles restart) + const appDir = join(APPS_DIR, repoName) + await rm(appDir, { recursive: true, force: true }) + await rename(tmpDir, appDir) + + return { ok: true } } // Bun.file().exists() is for files only — it returns false for directories. @@ -400,6 +372,28 @@ function serviceHeader(service: string): Uint8Array { return new TextEncoder().encode(header) } +async function stopIfRunning(name: string): Promise { + const res = await fetch(`${TOES_URL}/api/apps/${name}`) + if (!res.ok) return + + const app = await res.json() as { state: string } + if (app.state !== 'running' && app.state !== 'starting') return + + await fetch(`${TOES_URL}/api/apps/${name}/stop`, { method: 'POST' }) + + const maxWait = 10000 + const poll = 100 + let waited = 0 + while (waited < maxWait) { + await new Promise(r => setTimeout(r, poll)) + waited += poll + const check = await fetch(`${TOES_URL}/api/apps/${name}`) + if (!check.ok) break + const { state } = await check.json() as { state: string } + if (state !== 'running' && state !== 'stopping' && state !== 'starting') break + } +} + async function withDeployLock(repo: string, fn: () => Promise): Promise { const prev = deployLocks.get(repo) ?? Promise.resolve() const { promise: lock, resolve: release } = Promise.withResolvers() @@ -608,13 +602,13 @@ app.on('POST', ['/:repo{.+\\.git}/git-receive-pack', '/:repo/git-receive-pack'], const deployError = await withDeployLock(repoParam, async () => { try { const result = await deploy(repoParam) - if (result.ok && result.version) { - const err = await activateApp(repoParam, result.version) + if (result.ok) { + const err = await activateApp(repoParam) if (err) { - console.error(`Activate failed for ${repoParam}: ${err}`) - return `Deploy succeeded but activation failed: ${err}` + console.error(`Reload failed for ${repoParam}: ${err}`) + return `Deploy succeeded but reload failed: ${err}` } - console.log(`Deployed ${repoParam}@${result.version}`) + console.log(`Deployed ${repoParam}`) return null } console.error(`Deploy failed for ${repoParam}: ${result.error}`) diff --git a/apps/git/20260228-000000/package.json b/apps/git/package.json similarity index 100% rename from apps/git/20260228-000000/package.json rename to apps/git/package.json diff --git a/apps/git/20260228-000000/tsconfig.json b/apps/git/tsconfig.json similarity index 100% rename from apps/git/20260228-000000/tsconfig.json rename to apps/git/tsconfig.json diff --git a/apps/metrics/20260130-000000/.npmrc b/apps/metrics/.npmrc similarity index 100% rename from apps/metrics/20260130-000000/.npmrc rename to apps/metrics/.npmrc diff --git a/apps/metrics/20260130-000000/bun.lock b/apps/metrics/bun.lock similarity index 100% rename from apps/metrics/20260130-000000/bun.lock rename to apps/metrics/bun.lock diff --git a/apps/metrics/20260130-000000/index.tsx b/apps/metrics/index.tsx similarity index 100% rename from apps/metrics/20260130-000000/index.tsx rename to apps/metrics/index.tsx diff --git a/apps/metrics/20260130-000000/package.json b/apps/metrics/package.json similarity index 100% rename from apps/metrics/20260130-000000/package.json rename to apps/metrics/package.json diff --git a/apps/metrics/20260130-000000/tsconfig.json b/apps/metrics/tsconfig.json similarity index 100% rename from apps/metrics/20260130-000000/tsconfig.json rename to apps/metrics/tsconfig.json diff --git a/apps/truisms/20260130-000000/.npmrc b/apps/truisms/.npmrc similarity index 100% rename from apps/truisms/20260130-000000/.npmrc rename to apps/truisms/.npmrc diff --git a/apps/truisms/20260130-000000/README.md b/apps/truisms/README.md similarity index 100% rename from apps/truisms/20260130-000000/README.md rename to apps/truisms/README.md diff --git a/apps/truisms/20260130-000000/bun.lock b/apps/truisms/bun.lock similarity index 100% rename from apps/truisms/20260130-000000/bun.lock rename to apps/truisms/bun.lock diff --git a/apps/truisms/20260130-000000/index.ts b/apps/truisms/index.ts similarity index 100% rename from apps/truisms/20260130-000000/index.ts rename to apps/truisms/index.ts diff --git a/apps/truisms/20260130-000000/package.json b/apps/truisms/package.json similarity index 100% rename from apps/truisms/20260130-000000/package.json rename to apps/truisms/package.json diff --git a/apps/truisms/20260130-000000/tsconfig.json b/apps/truisms/tsconfig.json similarity index 100% rename from apps/truisms/20260130-000000/tsconfig.json rename to apps/truisms/tsconfig.json diff --git a/apps/versions/20260130-000000/.npmrc b/apps/versions/.npmrc similarity index 100% rename from apps/versions/20260130-000000/.npmrc rename to apps/versions/.npmrc diff --git a/apps/versions/20260130-000000/bun.lock b/apps/versions/bun.lock similarity index 100% rename from apps/versions/20260130-000000/bun.lock rename to apps/versions/bun.lock diff --git a/apps/versions/20260130-000000/index.tsx b/apps/versions/index.tsx similarity index 100% rename from apps/versions/20260130-000000/index.tsx rename to apps/versions/index.tsx diff --git a/apps/versions/20260130-000000/package.json b/apps/versions/package.json similarity index 100% rename from apps/versions/20260130-000000/package.json rename to apps/versions/package.json diff --git a/apps/versions/20260130-000000/tsconfig.json b/apps/versions/tsconfig.json similarity index 100% rename from apps/versions/20260130-000000/tsconfig.json rename to apps/versions/tsconfig.json diff --git a/package.json b/package.json index 1b9ebb9..04dd7ad 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "debug": "DEBUG=1 bun run dev", "dev": "rm -f pub/client/index.js && bun run --hot src/server/index.tsx", "remote:deploy": "./scripts/deploy.sh", + "remote:migrate": "bun run scripts/migrate.ts", "remote:install": "./scripts/remote-install.sh", "remote:logs": "./scripts/remote-logs.sh", "remote:restart": "./scripts/remote-restart.sh", diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 78de63c..8150d5c 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -19,20 +19,18 @@ APPS_DIR="${APPS_DIR:-$HOME/apps}" cd "$DEST" && git checkout -- bun.lock && git pull origin main && bun install && rm -rf dist && bun run build +echo "=> Migrating apps to flat structure..." +bun run scripts/migrate.ts + echo "=> Syncing default apps..." for app_dir in "$DEST"/apps/*/; do app=$(basename "$app_dir") - for version_dir in "$app_dir"*/; do - [ -d "$version_dir" ] || continue - version=$(basename "$version_dir") - [ -f "$version_dir/package.json" ] || continue - target="$APPS_DIR/$app/$version" - mkdir -p "$target" - cp -a "$version_dir"/. "$target"/ - rm -f "$APPS_DIR/$app/current" - echo " $app/$version" - (cd "$target" && bun install --frozen-lockfile 2>/dev/null || bun install) - done + [ -f "$app_dir/package.json" ] || continue + target="$APPS_DIR/$app" + mkdir -p "$target" + cp -a "$app_dir"/. "$target"/ + echo " $app" + (cd "$target" && bun install --frozen-lockfile 2>/dev/null || bun install) done sudo systemctl restart toes.service diff --git a/scripts/migrate.ts b/scripts/migrate.ts new file mode 100644 index 0000000..6249e9f --- /dev/null +++ b/scripts/migrate.ts @@ -0,0 +1,111 @@ +#!/usr/bin/env bun + +// Migration script: converts apps from versioned directory structure +// (apps/// with `current` symlink) to flat structure (apps//). +// +// Usage: bun run remote:migrate +// +// What it does: +// 1. Scans APPS_DIR for apps with a `current` symlink +// 2. Resolves the symlink to find the active version directory +// 3. Moves files from the active version into the app root (preserving node_modules/logs) +// 4. Removes old version directories and the symlink +// +// Safe to run multiple times -- skips apps already in flat structure. + +import { existsSync, mkdirSync, readdirSync, renameSync, lstatSync, readlinkSync, rmSync } from 'fs' +import { join, resolve } from 'path' + +const APPS_DIR = process.env.APPS_DIR ?? join(process.env.HOME!, 'apps') +const VERSION_RE = /^\d{8}-\d{6}$/ + +if (!existsSync(APPS_DIR)) { + console.error(`APPS_DIR does not exist: ${APPS_DIR}`) + process.exit(1) +} + +const apps = readdirSync(APPS_DIR, { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => e.name) + +let migrated = 0 +let skipped = 0 + +for (const name of apps) { + const appDir = join(APPS_DIR, name) + const currentLink = join(appDir, 'current') + + // Already flat -- has package.json at root with no current symlink + if (!existsSync(currentLink)) { + if (existsSync(join(appDir, 'package.json'))) { + skipped++ + } + continue + } + + // Verify it's actually a symlink + let stat + try { + stat = lstatSync(currentLink) + } catch { + continue + } + + if (!stat.isSymbolicLink()) { + console.warn(` [skip] ${name}: 'current' exists but is not a symlink`) + skipped++ + continue + } + + // Resolve the symlink to get the active version directory + const target = readlinkSync(currentLink) + const activeDir = resolve(appDir, target) + + if (!existsSync(activeDir)) { + console.warn(` [skip] ${name}: symlink target does not exist: ${target}`) + skipped++ + continue + } + + console.log(` [migrate] ${name} (active version: ${target})`) + + // Collect all version directories and other entries + const entries = readdirSync(appDir, { withFileTypes: true }) + const versionDirs = entries + .filter(e => e.isDirectory() && VERSION_RE.test(e.name) && e.name !== target) + .map(e => e.name) + + // Remove old (non-active) version directories first + for (const ver of versionDirs) { + const verPath = join(appDir, ver) + rmSync(verPath, { recursive: true, force: true }) + console.log(` removed old version: ${ver}`) + } + + // Remove the current symlink + rmSync(currentLink) + + // Move files from active version directory into app root + const activeEntries = readdirSync(activeDir) + for (const entry of activeEntries) { + const src = join(activeDir, entry) + const dest = join(appDir, entry) + + // Skip if destination already exists (e.g. node_modules, logs at root level) + if (existsSync(dest)) { + console.log(` skip (exists): ${entry}`) + continue + } + + renameSync(src, dest) + } + + // Clean up the now-empty active version directory + rmSync(activeDir, { recursive: true, force: true }) + + migrated++ + console.log(` done`) +} + +console.log() +console.log(`Migration complete: ${migrated} migrated, ${skipped} skipped`) diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index f546c94..6c8785f 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -2,7 +2,6 @@ export { cronList, cronLog, cronRun, cronStatus } from './cron' export { envList, envRm, envSet } from './env' export { logApp } from './logs' export { - configShow, infoApp, listApps, newApp, @@ -16,4 +15,3 @@ export { unshareApp, } from './manage' export { metricsApp } from './metrics' -export { cleanApp, diffApp, getApp, historyApp, 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 e6485bc..de7a966 100644 --- a/src/cli/commands/manage.ts +++ b/src/cli/commands/manage.ts @@ -1,6 +1,5 @@ import type { App } from '@types' import { generateTemplates, type TemplateType } from '%templates' -import { readSyncState } from '%sync' import color from 'kleur' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { basename, join } from 'path' @@ -8,7 +7,6 @@ import { buildAppUrl } from '@urls' import { del, get, getManifest, HOST, post } from '../http' import { confirm, prompt } from '../prompts' import { resolveAppName } from '../name' -import { pushApp } from './sync' export const STATE_ICONS: Record = { error: color.red('●'), @@ -36,15 +34,6 @@ async function waitForState(name: string, target: string, timeout: number): Prom return app?.state } -export async function configShow() { - console.log(`Host: ${color.bold(HOST)}`) - - const syncState = readSyncState(process.cwd()) - if (syncState) { - console.log(`Version: ${color.bold(syncState.version)}`) - } -} - export async function infoApp(arg?: string) { const name = resolveAppName(arg) if (!name) return @@ -184,17 +173,19 @@ export async function newApp(name: string | undefined, options: NewAppOptions) { writeFileSync(join(appPath, filename), content) } - process.chdir(appPath) - await pushApp() + // Initialize git repo and push to server (git push creates the app via the git tool) + const run = (cmd: string[]) => Bun.spawn(cmd, { cwd: appPath, stdout: 'ignore', stderr: 'ignore' }).exited + + await run(['git', 'init']) + await run(['git', 'add', '.']) + await run(['git', 'commit', '-m', 'init']) + await run(['git', 'remote', 'add', 'toes', `${HOST}/tool/git/${appName}`]) + await run(['git', 'push', 'toes', 'main']) console.log(color.green(`✓ Created ${appName}`)) - console.log() - console.log('Next steps:') if (name) { - console.log(` cd ${name}`) + console.log(`\n cd ${name}`) } - console.log(' bun install') - console.log(' bun dev') } export async function openApp(arg?: string) { diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts deleted file mode 100644 index 178e13d..0000000 --- a/src/cli/commands/sync.ts +++ /dev/null @@ -1,1224 +0,0 @@ -import type { Manifest } from '@types' -import { loadGitignore } from '@gitignore' -import { generateManifest, readSyncState, writeSyncState } from '%sync' -import color from 'kleur' -import { diffLines } from 'diff' -import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, watch, writeFileSync } from 'fs' -import { dirname, join } from 'path' -import { del, download, get, getManifest, getSignal, handleError, makeUrl, post, put } from '../http' -import { confirm, prompt } from '../prompts' -import { getAppName, getAppPackage, isApp, resolveAppName } from '../name' - -const s = (n: number) => n === 1 ? '' : 's' - -function notAppError(): string { - const pkg = getAppPackage() - if (!pkg) return 'No package.json found. Use `toes get ` to grab one.' - if (!pkg.scripts?.toes) return 'Missing scripts.toes in package.json. Use `toes new` to add it.' - return 'Not a toes app' -} - -interface Rename { - from: string - to: string -} - -interface ManifestDiff { - changed: string[] - localOnly: string[] - remoteOnly: string[] - renamed: Rename[] - localManifest: Manifest - remoteManifest: Manifest | null - remoteVersion: string | null - serverChanged: boolean -} - -export async function historyApp(name?: string) { - const appName = resolveAppName(name) - if (!appName) return - - type HistoryEntry = { - version: string - current: boolean - added: string[] - modified: string[] - deleted: string[] - renamed: string[] - } - - type HistoryResponse = { history: HistoryEntry[] } - - const result = await get(`/api/sync/apps/${appName}/history`) - if (!result) return - - if (result.history.length === 0) { - console.log(`No versions found for ${color.bold(appName)}`) - return - } - - console.log(`History for ${color.bold(appName)}:\n`) - - for (const entry of result.history) { - const date = formatVersion(entry.version) - const label = entry.current ? ` ${color.green('→')} ${color.bold(entry.version)}` : ` ${entry.version}` - const suffix = entry.current ? ` ${color.green('(current)')}` : '' - console.log(`${label} ${color.gray(date)}${suffix}`) - - const renamed = entry.renamed ?? [] - const hasChanges = entry.added.length > 0 || entry.modified.length > 0 || entry.deleted.length > 0 || renamed.length > 0 - if (!hasChanges) { - console.log(color.gray(' No changes')) - } - - for (const rename of renamed) { - console.log(` ${color.cyan('→')} ${rename}`) - } - for (const file of entry.added) { - console.log(` ${color.green('+')} ${file}`) - } - for (const file of entry.modified) { - console.log(` ${color.magenta('*')} ${file}`) - } - for (const file of entry.deleted) { - console.log(` ${color.red('-')} ${file}`) - } - - console.log() - } -} - -export async function getApp(name: string) { - console.log(`Fetching ${color.bold(name)} from server...`) - - const result = await getManifest(name) - if (!result || !result.exists || !result.manifest) { - console.error(`App not found: ${name}`) - return - } - - const appPath = join(process.cwd(), name) - if (existsSync(appPath)) { - console.error(`Directory already exists: ${name}`) - return - } - - mkdirSync(appPath, { recursive: true }) - - const files = Object.keys(result.manifest.files) - console.log(`Downloading ${files.length} file${s(files.length)}...`) - - for (const file of files) { - const content = await download(`/api/sync/apps/${name}/files/${file}`) - if (!content) { - console.error(`Failed to download: ${file}`) - continue - } - - const fullPath = join(appPath, file) - const dir = dirname(fullPath) - - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - - writeFileSync(fullPath, content) - } - - if (result.version) { - writeSyncState(appPath, { version: result.version }) - } - - console.log(color.green(`✓ Downloaded ${name}`)) -} - -export async function pushApp(options: { quiet?: boolean, force?: boolean } = {}) { - if (!isApp()) { - console.error(notAppError()) - return - } - - const appName = getAppName() - const diff = await getManifestDiff(appName) - - if (diff === null) { - return - } - - const { changed, localOnly, remoteOnly, renamed, localManifest, remoteManifest, serverChanged } = diff - - if (!remoteManifest) { - const ok = await confirm(`App ${color.bold(appName)} doesn't exist on server. Create it?`) - if (!ok) return - } - - // If server changed, abort unless --force (skip for new apps) - if (remoteManifest && serverChanged && !options.force) { - console.error('Cannot push: server has changed since last sync') - console.error('\nRun `toes pull` first, or `toes push --force` to overwrite') - return - } - - // Files to upload: changed + localOnly - const toUpload = [...changed, ...localOnly] - // Files to delete on server: remoteOnly (local deletions when version matches, or forced) - const toDelete = !serverChanged || options.force ? [...remoteOnly] : [] - - // Detect renames among upload/delete pairs (same hash, different path) - const renames: Rename[] = [...renamed] - const remoteByHash = new Map() - if (remoteManifest) { - for (const file of toDelete) { - const info = remoteManifest.files[file] - if (info) remoteByHash.set(info.hash, file) - } - } - - const renamedUploads = new Set() - const renamedDeletes = new Set() - for (const file of toUpload) { - const hash = localManifest.files[file]?.hash - if (!hash) continue - const remoteFile = remoteByHash.get(hash) - if (remoteFile && !renamedDeletes.has(remoteFile)) { - renames.push({ from: remoteFile, to: file }) - renamedUploads.add(file) - renamedDeletes.add(remoteFile) - } - } - - if (toUpload.length === 0 && toDelete.length === 0) { - if (!options.quiet) console.log('Already up to date') - return - } - - console.log(`Pushing ${color.bold(appName)} to server...`) - - // 1. Request new deployment version - type DeployResponse = { ok: boolean, version: string } - const deployRes = await post(`/api/sync/apps/${appName}/deploy`) - if (!deployRes?.ok) { - console.error('Failed to start deployment') - return - } - - const version = deployRes.version - console.log(`Deploying version ${color.bold(version)}...`) - - // 2. Upload changed files to new version - const actualUploads = toUpload.filter(f => !renamedUploads.has(f)) - const actualDeletes = toDelete.filter(f => !renamedDeletes.has(f)) - - if (renames.length > 0) { - console.log(`Renaming ${renames.length} file${s(renames.length)}...`) - for (const { from, to } of renames) { - const content = readFileSync(join(process.cwd(), to)) - const uploadOk = await put(`/api/sync/apps/${appName}/files/${to}?version=${version}`, content) - const deleteOk = await del(`/api/sync/apps/${appName}/files/${from}?version=${version}`) - if (uploadOk && deleteOk) { - console.log(` ${color.cyan('→')} ${from} → ${to}`) - } else { - console.log(` ${color.red('✗')} ${from} → ${to} (failed)`) - } - } - } - - if (actualUploads.length > 0) { - console.log(`Uploading ${actualUploads.length} file${s(actualUploads.length)}...`) - let failedUploads = 0 - - for (const file of actualUploads) { - const content = readFileSync(join(process.cwd(), file)) - const success = await put(`/api/sync/apps/${appName}/files/${file}?version=${version}`, content) - if (success) { - console.log(` ${color.green('↑')} ${file}`) - } else { - console.log(` ${color.red('✗')} ${file}`) - failedUploads++ - } - } - - if (failedUploads > 0) { - console.error(`Failed to upload ${failedUploads} file${s(failedUploads)}. Deployment aborted.`) - console.error(`Incomplete version ${version} left on server (not activated).`) - return - } - } - - // 3. Delete files that no longer exist locally - if (actualDeletes.length > 0) { - console.log(`Deleting ${actualDeletes.length} file${s(actualDeletes.length)}...`) - for (const file of actualDeletes) { - const success = await del(`/api/sync/apps/${appName}/files/${file}?version=${version}`) - if (success) { - console.log(` ${color.red('-')} ${file}`) - } else { - console.log(` ${color.red('✗')} ${file} (failed)`) - } - } - } - - // 4. Activate new version (updates symlink and restarts app) - type ActivateResponse = { ok: boolean } - const activateRes = await post(`/api/sync/apps/${appName}/activate?version=${version}`) - if (!activateRes?.ok) { - console.error('Failed to activate new version') - return - } - - // 5. Write sync version after successful push - writeSyncState(process.cwd(), { version }) - - console.log(color.green(`✓ Deployed and activated version ${version}`)) -} - -export async function pullApp(options: { force?: boolean, quiet?: boolean } = {}) { - if (!isApp()) { - console.error(notAppError()) - return - } - - const appName = getAppName() - const diff = await getManifestDiff(appName) - - if (diff === null) { - return - } - - const { changed, localOnly, remoteOnly, remoteManifest, remoteVersion, serverChanged } = diff - - if (!remoteManifest) { - console.error('App not found on server') - return - } - - if (!serverChanged) { - // Server hasn't changed — all diffs are local, nothing to pull - if (!options.quiet) { - if (changed.length > 0 || localOnly.length > 0 || remoteOnly.length > 0) { - console.log('Server is up to date. You have local changes — use `toes push`.') - } else { - console.log('Already up to date') - } - } - return - } - - // Server changed — download diffs from remote - const hasDiffs = changed.length > 0 || localOnly.length > 0 - if (hasDiffs && !options.force) { - console.error('Cannot pull: you have local changes that would be overwritten') - for (const file of changed) { - console.error(` ${color.magenta('*')} ${file}`) - } - for (const file of localOnly) { - console.error(` ${color.green('+')} ${file} (local only)`) - } - console.error('\nUse `toes pull --force` to overwrite local changes') - return - } - - // Files to download: changed + remoteOnly - const toDownload = [...changed, ...remoteOnly] - // Files to delete locally: only when forcing - const toDelete = options.force ? localOnly : [] - - if (toDownload.length === 0 && toDelete.length === 0) { - // Server version changed but files are identical — just update stored version - if (remoteVersion) { - writeSyncState(process.cwd(), { version: remoteVersion }) - } - if (!options.quiet) console.log('Already up to date') - return - } - - console.log(`Pulling ${color.bold(appName)} from server...`) - - if (toDownload.length > 0) { - console.log(`Downloading ${toDownload.length} file${s(toDownload.length)}...`) - for (const file of toDownload) { - const content = await download(`/api/sync/apps/${appName}/files/${file}`) - if (!content) { - console.log(` ${color.red('✗')} ${file}`) - continue - } - - const fullPath = join(process.cwd(), file) - const dir = dirname(fullPath) - - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - - writeFileSync(fullPath, content) - console.log(` ${color.green('↓')} ${file}`) - } - } - - if (toDelete.length > 0) { - console.log(`Deleting ${toDelete.length} local file${s(toDelete.length)}...`) - for (const file of toDelete) { - const fullPath = join(process.cwd(), file) - if (existsSync(fullPath)) { - unlinkSync(fullPath) - console.log(` ${color.red('-')} ${file}`) - } - } - } - - if (remoteVersion) { - writeSyncState(process.cwd(), { version: remoteVersion }) - } - - console.log(color.green('✓ Pull complete')) -} - -export async function diffApp() { - if (!isApp()) { - console.error(notAppError()) - return - } - - const appName = getAppName() - const diff = await getManifestDiff(appName) - - if (diff === null) { - return - } - - const { changed, localOnly, remoteOnly, renamed } = diff - - if (changed.length === 0 && localOnly.length === 0 && remoteOnly.length === 0 && renamed.length === 0) { - return - } - - // Show renames - for (const { from, to } of renamed) { - console.log(color.cyan('\nRenamed')) - console.log(color.bold(`${from} → ${to}`)) - console.log(color.gray('─'.repeat(60))) - } - - // Fetch all changed files in parallel (skip binary files) - const remoteContents = await Promise.all( - changed.map(file => isBinary(file) ? null : download(`/api/sync/apps/${appName}/files/${file}`)) - ) - - // Show diffs for changed files - for (let i = 0; i < changed.length; i++) { - const file = changed[i]! - - console.log(color.bold(`\n${file}`)) - console.log(color.gray('─'.repeat(60))) - - if (isBinary(file)) { - console.log(color.gray('Binary file changed')) - continue - } - - const remoteContent = remoteContents[i] - const localContent = readFileSync(join(process.cwd(), file), 'utf-8') - - if (!remoteContent) { - console.log(color.red(`Failed to fetch remote version of ${file}`)) - continue - } - - const remoteText = new TextDecoder().decode(remoteContent) - showDiff(remoteText, localContent) - } - - // Show local-only files - for (const file of localOnly) { - console.log(color.green('\nNew file (local only)')) - console.log(color.bold(`${file}`)) - console.log(color.gray('─'.repeat(60))) - - if (isBinary(file)) { - console.log(color.gray('Binary file')) - continue - } - - const content = readFileSync(join(process.cwd(), file), 'utf-8') - const lines = content.split('\n') - for (let i = 0; i < Math.min(lines.length, 10); i++) { - console.log(color.green(`+ ${lines[i]}`)) - } - if (lines.length > 10) { - console.log(color.gray(`... ${lines.length - 10} more lines`)) - } - } - - // Fetch all remote-only files in parallel (skip binary files) - const remoteOnlyContents = await Promise.all( - remoteOnly.map(file => isBinary(file) ? null : download(`/api/sync/apps/${appName}/files/${file}`)) - ) - - // Show remote-only files - for (let i = 0; i < remoteOnly.length; i++) { - const file = remoteOnly[i]! - const content = remoteOnlyContents[i] - - console.log(color.bold(`\n${file}`)) - console.log(color.gray('─'.repeat(60))) - console.log(color.red('Remote only')) - - if (isBinary(file)) { - console.log(color.gray('Binary file')) - continue - } - - if (content) { - const text = new TextDecoder().decode(content) - const lines = text.split('\n') - for (let i = 0; i < Math.min(lines.length, 10); i++) { - console.log(color.red(`- ${lines[i]}`)) - } - if (lines.length > 10) { - console.log(color.gray(`... ${lines.length - 10} more lines`)) - } - } - } - - console.log() -} - - -export async function statusApp() { - if (!isApp()) { - console.error(notAppError()) - return - } - - const appName = getAppName() - const diff = await getManifestDiff(appName) - - if (diff === null) { - return - } - - const { changed, localOnly, remoteOnly, renamed, localManifest, remoteManifest, serverChanged } = diff - - if (!remoteManifest) { - console.log(color.yellow('App does not exist on server')) - const localFileCount = Object.keys(localManifest.files).length - console.log(`\nWould create new app with ${localFileCount} file${s(localFileCount)} on push\n`) - return - } - - const hasDiffs = changed.length > 0 || localOnly.length > 0 || remoteOnly.length > 0 || renamed.length > 0 - - if (!hasDiffs) { - if (serverChanged) { - // Files identical but version changed — update stored version silently - const { remoteVersion } = diff - if (remoteVersion) { - writeSyncState(process.cwd(), { version: remoteVersion }) - } - } - console.log(color.green('✓ In sync with server')) - return - } - - if (!serverChanged) { - // Server hasn't moved — all diffs are local changes to push - console.log(color.bold('Changes to push:')) - for (const { from, to } of renamed) { - console.log(` ${color.cyan('→')} ${from} → ${to}`) - } - for (const file of changed) { - console.log(` ${color.magenta('*')} ${file}`) - } - for (const file of localOnly) { - console.log(` ${color.green('+')} ${file}`) - } - for (const file of remoteOnly) { - console.log(` ${color.red('-')} ${file}`) - } - console.log() - } else { - // Server changed — show diffs neutrally - console.log(color.yellow('Server has changed since last sync\n')) - console.log(color.bold('Differences:')) - for (const { from, to } of renamed) { - console.log(` ${color.cyan('→')} ${from} → ${to}`) - } - for (const file of changed) { - console.log(` ${color.magenta('*')} ${file}`) - } - for (const file of localOnly) { - console.log(` ${color.green('+')} ${file} (local only)`) - } - for (const file of remoteOnly) { - console.log(` ${color.green('+')} ${file} (remote only)`) - } - console.log(`\nRun ${color.bold('toes pull')} to update, or ${color.bold('toes push --force')} to overwrite server`) - console.log() - } -} - -export async function syncApp() { - if (!isApp()) { - console.error(notAppError()) - return - } - - const appName = getAppName() - - // Verify app exists on server - const result = await getManifest(appName) - if (result === null) return - if (!result.exists) { - console.error(`App ${color.bold(appName)} doesn't exist on server. Run ${color.bold('toes push')} first.`) - return - } - - console.log(`Syncing ${color.bold(appName)}...`) - - // Initial sync: pull remote changes, then push local - await mergeSync(appName) - - const gitignore = loadGitignore(process.cwd()) - - // Watch local files with debounce → push - let pushTimer: Timer | null = null - const watcher = watch(process.cwd(), { recursive: true }, (_event, filename) => { - if (!filename || gitignore.shouldExclude(filename)) return - if (pushTimer) clearTimeout(pushTimer) - pushTimer = setTimeout(() => pushApp({ quiet: true }), 500) - }) - - // Connect to SSE for remote changes → pull - const url = makeUrl(`/api/sync/apps/${appName}/watch`) - let res: Response - try { - res = await fetch(url, { signal: getSignal() }) - if (!res.ok) { - console.error(`Failed to connect to server: ${res.status} ${res.statusText}`) - watcher.close() - return - } - } catch (error) { - handleError(error) - watcher.close() - return - } - - if (!res.body) { - console.error('No response body from server') - watcher.close() - return - } - - console.log(` Connected, watching for changes...`) - - const reader = res.body.getReader() - const decoder = new TextDecoder() - let buffer = '' - let pullTimer: Timer | null = null - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n\n') - buffer = lines.pop() ?? '' - - for (const line of lines) { - if (!line.startsWith('data: ')) continue - if (pullTimer) clearTimeout(pullTimer) - pullTimer = setTimeout(() => mergeSync(appName), 500) - } - } - } finally { - if (pushTimer) clearTimeout(pushTimer) - if (pullTimer) clearTimeout(pullTimer) - watcher.close() - } -} - -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(notAppError()) - 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.magenta('*')} ${file}`) - } - - // Restore changed files from server - if (changed.length > 0) { - console.log(`\nRestoring ${changed.length} changed file${s(changed.length)} 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 file${s(localOnly.length)}...`) - for (const file of localOnly) { - unlinkSync(join(process.cwd(), file)) - } - } - - console.log(color.green(`\n✓ Stashed ${toStash.length} file${s(toStash.length)}`)) -} - -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} file${s(metadata.files.length)})`)}`) - } else { - console.log(` ${color.bold(stash.name)} ${color.gray('(invalid)')}`) - } - } - console.log() -} - -export async function stashPopApp() { - if (!isApp()) { - console.error(notAppError()) - 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(metadata.files.length)}`)) -} - -export async function cleanApp(options: { force?: boolean, dryRun?: boolean } = {}) { - if (!isApp()) { - console.error(notAppError()) - 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(localOnly.length)}?`) - 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(localOnly.length)}`)) -} - -interface VersionsResponse { - current: string | null - versions: string[] -} - -async function getVersions(appName: string): Promise { - try { - const res = await fetch(makeUrl(`/api/sync/apps/${appName}/versions`), { signal: getSignal() }) - 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 mergeSync(appName: string): Promise { - const diff = await getManifestDiff(appName) - if (!diff) return - - const { changed, remoteOnly, remoteManifest, serverChanged } = diff - if (!remoteManifest) return - - if (serverChanged) { - // Pull remote changes - const toPull = [...changed, ...remoteOnly] - - if (toPull.length > 0) { - for (const file of toPull) { - const content = await download(`/api/sync/apps/${appName}/files/${file}`) - if (!content) continue - - const fullPath = join(process.cwd(), file) - const dir = dirname(fullPath) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - - writeFileSync(fullPath, content) - console.log(` ${color.green('↓')} ${file}`) - } - } - } - - // Push merged state to server - await pushApp({ quiet: true }) -} - -async function getManifestDiff(appName: string): Promise { - const localManifest = generateManifest(process.cwd(), appName) - const result = await getManifest(appName) - - if (result === null) { - // Connection error - already printed - return null - } - - const remoteManifest = result.manifest ?? null - const remoteVersion = result.version ?? null - const syncState = readSyncState(process.cwd()) - const serverChanged = !syncState || syncState.version !== remoteVersion - - const localFiles = new Set(Object.keys(localManifest.files)) - const remoteFiles = new Set(Object.keys(remoteManifest?.files ?? {})) - - // Files that differ - const changed: string[] = [] - for (const file of localFiles) { - if (remoteFiles.has(file)) { - const local = localManifest.files[file]! - const remote = remoteManifest!.files[file]! - if (local.hash !== remote.hash) { - changed.push(file) - } - } - } - - // Files only in local - const localOnly: string[] = [] - for (const file of localFiles) { - if (!remoteFiles.has(file)) { - localOnly.push(file) - } - } - - // Files only in remote (filtered by local gitignore) - const gitignore = loadGitignore(process.cwd()) - const remoteOnly: string[] = [] - for (const file of remoteFiles) { - if (!localFiles.has(file) && !gitignore.shouldExclude(file)) { - remoteOnly.push(file) - } - } - - // Detect renames: localOnly + remoteOnly files with matching hashes - const renamed: Rename[] = [] - const remoteByHash = new Map() - for (const file of remoteOnly) { - const hash = remoteManifest!.files[file]!.hash - remoteByHash.set(hash, file) - } - - const matchedLocal = new Set() - const matchedRemote = new Set() - for (const file of localOnly) { - const hash = localManifest.files[file]!.hash - const remoteFile = remoteByHash.get(hash) - if (remoteFile && !matchedRemote.has(remoteFile)) { - renamed.push({ from: remoteFile, to: file }) - matchedLocal.add(file) - matchedRemote.add(remoteFile) - } - } - - return { - changed, - localOnly: localOnly.filter(f => !matchedLocal.has(f)), - remoteOnly: remoteOnly.filter(f => !matchedRemote.has(f)), - renamed, - localManifest, - remoteManifest, - remoteVersion, - serverChanged, - } -} - -const BINARY_EXTENSIONS = new Set([ - '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.avif', '.heic', '.tiff', - '.woff', '.woff2', '.ttf', '.eot', '.otf', - '.mp3', '.mp4', '.wav', '.ogg', '.webm', '.avi', '.mov', - '.pdf', '.zip', '.tar', '.gz', '.br', '.zst', - '.wasm', '.exe', '.dll', '.so', '.dylib', -]) - -const isBinary = (filename: string) => { - const ext = filename.slice(filename.lastIndexOf('.')).toLowerCase() - return BINARY_EXTENSIONS.has(ext) -} - -function showDiff(remote: string, local: string) { - const changes = diffLines(remote, local) - let lineCount = 0 - const maxLines = 50 - const contextLines = 3 - let remoteLine = 1 - let localLine = 1 - let needsHeader = true - - let hunkCount = 0 - - const printHeader = (_rStart: number, lStart: number) => { - if (hunkCount > 0) console.log() - if (lStart > 1) { - console.log(color.cyan(`Line ${lStart}:`)) - lineCount++ - } - needsHeader = false - hunkCount++ - } - - for (let i = 0; i < changes.length; i++) { - const part = changes[i]! - const lines = part.value.replace(/\n$/, '').split('\n') - - if (part.added) { - if (needsHeader) printHeader(remoteLine, localLine) - for (const line of lines) { - if (lineCount >= maxLines) { - console.log(color.gray('... diff truncated')) - return - } - console.log(color.green(`+ ${line}`)) - lineCount++ - } - localLine += lines.length - } else if (part.removed) { - if (needsHeader) printHeader(remoteLine, localLine) - for (const line of lines) { - if (lineCount >= maxLines) { - console.log(color.gray('... diff truncated')) - return - } - console.log(color.red(`- ${line}`)) - lineCount++ - } - remoteLine += lines.length - } else { - // Context: show lines near changes - const prevHasChange = i > 0 && (changes[i - 1]!.added || changes[i - 1]!.removed) - const nextHasChange = i < changes.length - 1 && (changes[i + 1]!.added || changes[i + 1]!.removed) - - if (prevHasChange && nextHasChange && lines.length <= contextLines * 2) { - // Small gap between changes - show all - for (const line of lines) { - if (lineCount >= maxLines) { - console.log(color.gray('... diff truncated')) - return - } - console.log(color.gray(` ${line}`)) - lineCount++ - } - } else { - // Show context before next change - if (nextHasChange) { - const start = Math.max(0, lines.length - contextLines) - if (start > 0) { - needsHeader = true - } - const headerLine = remoteLine + start - const headerLocalLine = localLine + start - if (needsHeader) printHeader(headerLine, headerLocalLine) - for (let j = start; j < lines.length; j++) { - if (lineCount >= maxLines) { - console.log(color.gray('... diff truncated')) - return - } - console.log(color.gray(` ${lines[j]}`)) - lineCount++ - } - } - // Show context after previous change - if (prevHasChange && !nextHasChange) { - const end = Math.min(lines.length, contextLines) - for (let j = 0; j < end; j++) { - if (lineCount >= maxLines) { - console.log(color.gray('... diff truncated')) - return - } - console.log(color.gray(` ${lines[j]}`)) - lineCount++ - } - if (end < lines.length) { - needsHeader = true - } - } - } - remoteLine += lines.length - localLine += lines.length - } - } -} diff --git a/src/cli/http.ts b/src/cli/http.ts index a955b03..3801184 100644 --- a/src/cli/http.ts +++ b/src/cli/http.ts @@ -57,14 +57,13 @@ export async function get(url: string): Promise { } } -export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest, version?: string } | null> { +export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest } | null> { try { const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`), { signal: getSignal() }) if (res.status === 404) return { exists: false } if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) - const data = await res.json() - const { version, ...manifest } = data - return { exists: true, manifest, version } + const manifest = await res.json() + return { exists: true, manifest } } catch (error) { handleError(error) return null diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 46279e4..5e03f02 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -3,42 +3,27 @@ import { program } from 'commander' import color from 'kleur' import pkg from '../../package.json' -import { withPager } from './pager' import { - cleanApp, - configShow, cronList, cronLog, cronRun, cronStatus, - diffApp, envList, envRm, envSet, - getApp, - historyApp, infoApp, listApps, logApp, newApp, openApp, - pullApp, - pushApp, renameApp, restartApp, rmApp, - rollbackApp, - stashApp, - stashListApp, - stashPopApp, + shareApp, startApp, metricsApp, - shareApp, - statusApp, stopApp, - syncApp, unshareApp, - versionsApp, } from './commands' program @@ -90,13 +75,6 @@ program .option('--spa', 'single-page app with client-side rendering') .action(newApp) -program - .command('get') - .helpGroup('Apps:') - .description('Download an app from server') - .argument('', 'app name') - .action(getApp) - program .command('open') .helpGroup('Apps:') @@ -209,72 +187,8 @@ cron .argument('', 'job identifier (app:name)') .action(cronRun) -// Sync - -program - .command('push') - .helpGroup('Sync:') - .description('Push local changes to server') - .option('-f, --force', 'overwrite remote changes') - .action(pushApp) - -program - .command('pull') - .helpGroup('Sync:') - .description('Pull changes from server') - .option('-f, --force', 'overwrite local changes') - .action(pullApp) - -program - .command('status') - .helpGroup('Sync:') - .description('Show what would be pushed/pulled') - .action(statusApp) - -program - .command('diff') - .helpGroup('Sync:') - .description('Show diff of changed files') - .action(() => withPager(diffApp)) - -program - .command('sync') - .helpGroup('Sync:') - .description('Watch and sync changes bidirectionally') - .action(syncApp) - -program - .command('clean') - .helpGroup('Sync:') - .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') - .helpGroup('Sync:') - .description('Stash local changes') - .action(stashApp) - -stash - .command('pop') - .description('Restore stashed changes') - .action(stashPopApp) - -stash - .command('list') - .description('List all stashes') - .action(stashListApp) - // Config -program - .command('config') - .helpGroup('Config:') - .description('Show current host configuration') - .action(configShow) - const env = program .command('env') .helpGroup('Config:') @@ -300,28 +214,6 @@ env .option('-g, --global', 'remove a global variable') .action(envRm) -program - .command('versions') - .helpGroup('Config:') - .description('List deployed versions') - .argument('[name]', 'app name (uses current directory if omitted)') - .action(versionsApp) - -program - .command('history') - .helpGroup('Config:') - .description('Show file changes between versions') - .argument('[name]', 'app name (uses current directory if omitted)') - .action(historyApp) - -program - .command('rollback') - .helpGroup('Config:') - .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)) - // Shell program diff --git a/src/cli/shell.ts b/src/cli/shell.ts index 904325d..d9e1c78 100644 --- a/src/cli/shell.ts +++ b/src/cli/shell.ts @@ -59,8 +59,8 @@ async function fetchAppNames(): Promise { function getCommandNames(): string[] { return program.commands - .filter((cmd: { _hidden?: boolean }) => !cmd._hidden) - .map((cmd: { name: () => string }) => cmd.name()) + .filter((cmd) => !(cmd as any)._hidden) + .map((cmd) => cmd.name()) } async function printBanner(): Promise { diff --git a/src/lib/sync.ts b/src/lib/sync.ts index 5da375b..50e634a 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -3,27 +3,9 @@ export type { FileInfo, Manifest } from '@types' import type { FileInfo, Manifest } from '@types' import { loadGitignore } from '@gitignore' import { createHash } from 'crypto' -import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs' +import { readdirSync, readFileSync, statSync } from 'fs' import { join, relative } from 'path' -export interface SyncState { - version: string -} - -export function readSyncState(appPath: string): SyncState | null { - const filePath = join(appPath, '.toes') - if (!existsSync(filePath)) return null - try { - return JSON.parse(readFileSync(filePath, 'utf-8')) - } catch { - return null - } -} - -export function writeSyncState(appPath: string, state: SyncState): void { - writeFileSync(join(appPath, '.toes'), JSON.stringify(state, null, 2)) -} - export function computeHash(content: Buffer | string): string { return createHash('sha256').update(content).digest('hex') } diff --git a/src/server/api/apps.ts b/src/server/api/apps.ts index 174aa6c..d76bc90 100644 --- a/src/server/api/apps.ts +++ b/src/server/api/apps.ts @@ -1,18 +1,12 @@ -import { APPS_DIR, TOES_DIR, allApps, appendLog, getLogDates, onChange, readLogs, registerApp, renameApp, restartApp, startApp, stopApp, updateAppIcon } from '$apps' +import { APPS_DIR, TOES_DIR, TOES_URL, allApps, appendLog, getLogDates, onChange, readLogs, registerApp, renameApp, restartApp, startApp, stopApp, updateAppIcon } from '$apps' import { isTunnelsAvailable, shareApp, unshareApp } from '../tunnels' import type { App as BackendApp } from '$apps' import type { App as SharedApp } from '@types' import { generateTemplates, type TemplateType } from '%templates' import { Hype } from '@because/hype' -import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'fs' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' import { dirname, join } from 'path' -const timestamp = () => { - const d = new Date() - const pad = (n: number) => String(n).padStart(2, '0') - return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}` -} - const router = Hype.router() // BackendApp -> SharedApp @@ -131,28 +125,35 @@ router.post('/', async c => { const template = body.template ?? 'ssr' const templates = generateTemplates(name, template, { tool: body.tool }) - // Create versioned directory structure - const ts = timestamp() - const versionPath = join(appPath, ts) - const currentPath = join(appPath, 'current') - - // Create directories and write files into version directory - for (const [filename, content] of Object.entries(templates)) { - const fullPath = join(versionPath, filename) - const dir = dirname(fullPath) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) + // Write templates to a temp dir, init git, and push to the git tool. + // The git push triggers deploy + activate which registers and starts the app. + const tmpDir = join(APPS_DIR, `.${name}-init-tmp`) + try { + for (const [filename, content] of Object.entries(templates)) { + const fullPath = join(tmpDir, filename) + const dir = dirname(fullPath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + writeFileSync(fullPath, content) } - writeFileSync(fullPath, content) + + const run = (cmd: string[]) => Bun.spawn(cmd, { cwd: tmpDir, stdout: 'ignore', stderr: 'ignore' }).exited + + await run(['git', 'init']) + await run(['git', 'add', '.']) + await run(['git', 'commit', '-m', 'init']) + await run(['git', 'remote', 'add', 'toes', `${TOES_URL}/tool/git/${name}`]) + const exitCode = await run(['git', 'push', 'toes', 'main']) + + if (exitCode !== 0) { + return c.json({ ok: false, error: 'Failed to push to git' }, 500) + } + + return c.json({ ok: true, name }) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) } - - // Create current symlink - symlinkSync(ts, currentPath) - - // Register and start the app - registerApp(name) - - return c.json({ ok: true, name }) }) router.sse('/:app/logs/stream', (send, c) => { diff --git a/src/server/api/sync.ts b/src/server/api/sync.ts index 09c0b45..e82bf6b 100644 --- a/src/server/api/sync.ts +++ b/src/server/api/sync.ts @@ -1,12 +1,10 @@ import { APPS_DIR, allApps, emit, registerApp, removeApp, restartApp, startApp } from '$apps' import { computeHash, generateManifest } from '../sync' import { loadGitignore } from '@gitignore' -import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, watch, writeFileSync } from 'fs' +import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, unlinkSync, watch, writeFileSync } from 'fs' import { dirname, join } from 'path' import { Hype } from '@because/hype' -const MAX_VERSIONS = 5 - interface FileChangeEvent { hash?: string path: string @@ -14,7 +12,6 @@ interface FileChangeEvent { } function safePath(base: string, ...parts: string[]): string | null { - // Resolve base to canonical path (follows symlinks) if it exists const canonicalBase = existsSync(base) ? realpathSync(base) : base const resolved = join(canonicalBase, ...parts) @@ -29,129 +26,18 @@ const router = Hype.router() router.get('/apps', c => c.json(allApps().map(a => a.name))) -router.get('/apps/:app/versions', c => { - const appName = c.req.param('app') - if (!appName) return c.json({ error: 'App not found' }, 404) - - const appDir = safePath(APPS_DIR, appName) - if (!appDir) return c.json({ error: 'Invalid path' }, 400) - if (!existsSync(appDir)) return c.json({ error: 'App not found' }, 404) - - const currentLink = join(appDir, 'current') - const currentVersion = existsSync(currentLink) - ? realpathSync(currentLink).split('/').pop() - : null - - const entries = readdirSync(appDir, { withFileTypes: true }) - const versions = entries - .filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name)) - .map(e => e.name) - .sort() - .reverse() // Newest first - - return c.json({ versions, current: currentVersion }) -}) - -router.get('/apps/:app/history', c => { - const appName = c.req.param('app') - if (!appName) return c.json({ error: 'App not found' }, 404) - - const appDir = safePath(APPS_DIR, appName) - if (!appDir) return c.json({ error: 'Invalid path' }, 400) - if (!existsSync(appDir)) return c.json({ error: 'App not found' }, 404) - - const currentLink = join(appDir, 'current') - const currentVersion = existsSync(currentLink) - ? realpathSync(currentLink).split('/').pop() - : null - - const entries = readdirSync(appDir, { withFileTypes: true }) - const versions = entries - .filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name)) - .map(e => e.name) - .sort() - .reverse() // Newest first - - // Generate manifests for each version - const manifests = new Map>() - for (const version of versions) { - const versionPath = join(appDir, version) - const manifest = generateManifest(versionPath, appName) - manifests.set(version, manifest.files) - } - - // Diff consecutive pairs - const history = versions.map((version, i) => { - const current = version === currentVersion - const files = manifests.get(version)! - const olderVersion = versions[i + 1] - const olderFiles = olderVersion ? manifests.get(olderVersion)! : {} - - const added: string[] = [] - const modified: string[] = [] - const deleted: string[] = [] - - // Files in this version - for (const [path, info] of Object.entries(files)) { - const older = olderFiles[path] - if (!older) { - added.push(path) - } else if (older.hash !== info.hash) { - modified.push(path) - } - } - - // Files removed in this version - for (const path of Object.keys(olderFiles)) { - if (!files[path]) { - deleted.push(path) - } - } - - // Detect renames: added + deleted files with matching hashes - const renamed: string[] = [] - const deletedByHash = new Map() - for (const path of deleted) { - deletedByHash.set(olderFiles[path]!.hash, path) - } - - const matchedAdded = new Set() - const matchedDeleted = new Set() - for (const path of added) { - const oldPath = deletedByHash.get(files[path]!.hash) - if (oldPath && !matchedDeleted.has(oldPath)) { - renamed.push(`${oldPath} → ${path}`) - matchedAdded.add(path) - matchedDeleted.add(oldPath) - } - } - - return { - version, - current, - added: added.filter(f => !matchedAdded.has(f)).sort(), - modified: modified.sort(), - deleted: deleted.filter(f => !matchedDeleted.has(f)).sort(), - renamed: renamed.sort(), - } - }) - - return c.json({ history }) -}) - router.get('/apps/:app/manifest', c => { const appName = c.req.param('app') if (!appName) return c.json({ error: 'App not found' }, 404) - const appPath = join(APPS_DIR, appName, 'current') + const appPath = join(APPS_DIR, appName) const safeAppPath = safePath(APPS_DIR, appName) if (!safeAppPath) return c.json({ error: 'Invalid path' }, 400) - if (!existsSync(appPath)) return c.json({ error: 'App not found' }, 404) + if (!existsSync(join(appPath, 'package.json'))) return c.json({ error: 'App not found' }, 404) - const version = realpathSync(appPath).split('/').pop() const manifest = generateManifest(appPath, appName) - return c.json({ ...manifest, version }) + return c.json(manifest) }) router.get('/apps/:app/files/:path{.+}', c => { @@ -160,7 +46,7 @@ router.get('/apps/:app/files/:path{.+}', c => { if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400) - const basePath = join(APPS_DIR, appName, 'current') + const basePath = join(APPS_DIR, appName) const fullPath = safePath(basePath, filePath) if (!fullPath) return c.json({ error: 'Invalid path' }, 400) @@ -175,14 +61,10 @@ router.get('/apps/:app/files/:path{.+}', c => { router.put('/apps/:app/files/:path{.+}', async c => { const appName = c.req.param('app') const filePath = c.req.param('path') - const version = c.req.query('version') // Optional version parameter if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400) - // Determine base path: specific version or current - const basePath = version - ? join(APPS_DIR, appName, version) - : join(APPS_DIR, appName, 'current') + const basePath = join(APPS_DIR, appName) const fullPath = safePath(basePath, filePath) if (!fullPath) return c.json({ error: 'Invalid path' }, 400) @@ -216,13 +98,10 @@ router.delete('/apps/:app', c => { router.delete('/apps/:app/files/:path{.+}', c => { const appName = c.req.param('app') const filePath = c.req.param('path') - const version = c.req.query('version') if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400) - const basePath = version - ? join(APPS_DIR, appName, version) - : join(APPS_DIR, appName, 'current') + const basePath = join(APPS_DIR, appName) const fullPath = safePath(basePath, filePath) if (!fullPath) return c.json({ error: 'Invalid path' }, 400) @@ -232,148 +111,48 @@ router.delete('/apps/:app/files/:path{.+}', c => { return c.json({ ok: true }) }) -router.post('/apps/:app/deploy', c => { +router.post('/apps/:app/reload', async c => { const appName = c.req.param('app') if (!appName) return c.json({ error: 'App name required' }, 400) - const appDir = join(APPS_DIR, appName) + emit({ type: 'app:reload', app: appName }) - // Generate timestamp: YYYYMMDD-HHMMSS format - const now = new Date() - const pad = (n: number) => String(n).padStart(2, '0') - const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}` - - const newVersion = join(appDir, timestamp) - const currentLink = join(appDir, 'current') - - try { - // 1. If current exists, copy it to new timestamp - const currentReal = existsSync(currentLink) ? realpathSync(currentLink) : null - - if (currentReal) { - cpSync(currentReal, newVersion, { - recursive: true, - filter: (src) => !src.split('/').includes('node_modules'), - }) - } else { - // First deployment - create directory - mkdirSync(newVersion, { recursive: true }) - } - - return c.json({ ok: true, version: timestamp }) - } catch (e) { - return c.json({ error: `Failed to create deployment: ${e instanceof Error ? e.message : String(e)}` }, 500) - } -}) - -router.post('/apps/:app/activate', async c => { - const appName = c.req.param('app') - const version = c.req.query('version') - - if (!appName) return c.json({ error: 'App name required' }, 400) - if (!version) return c.json({ error: 'Version required' }, 400) - - const appDir = join(APPS_DIR, appName) - const versionDir = join(appDir, version) - const currentLink = join(appDir, 'current') - - if (!existsSync(versionDir)) { - return c.json({ error: 'Version not found' }, 404) - } - - try { - // Atomic symlink update - const tempLink = join(appDir, '.current.tmp') - - // Remove temp link if it exists from previous failed attempt - if (existsSync(tempLink)) { - unlinkSync(tempLink) - } - - // Create new symlink pointing to version directory (relative) - symlinkSync(version, tempLink, 'dir') - - // Atomic rename over old symlink/directory - renameSync(tempLink, currentLink) - - // Clean up old versions + // Register new app or restart existing + const app = allApps().find(a => a.name === appName) + if (!app) { + // New app - register it + registerApp(appName) + } else if (app.state === 'running') { + // Existing app - restart it try { - const entries = readdirSync(appDir, { withFileTypes: true }) - - // Get all timestamp directories (exclude current, .current.tmp, etc) - const versionDirs = entries - .filter(e => e.isDirectory() && /^\d{8}-\d{6}$/.test(e.name)) - .map(e => e.name) - .sort() - .reverse() // Newest first - - // Keep most recent, delete the rest - const toDelete = versionDirs.slice(MAX_VERSIONS) - for (const dir of toDelete) { - const dirPath = join(appDir, dir) - rmSync(dirPath, { recursive: true, force: true }) - console.log(`Cleaned up old version: ${dir}`) - } - - // Delete node_modules from old kept versions (not the active one) - const toKeep = versionDirs.slice(0, MAX_VERSIONS) - for (const dir of toKeep) { - if (dir === version) continue - const nm = join(appDir, dir, 'node_modules') - if (existsSync(nm)) { - rmSync(nm, { recursive: true, force: true }) - console.log(`Removed node_modules from old version: ${dir}`) - } - } + await restartApp(appName) } catch (e) { - // Log but don't fail activation if cleanup fails - console.error(`Failed to clean up old versions: ${e}`) + return c.json({ error: `Failed to restart app: ${e instanceof Error ? e.message : String(e)}` }, 500) } - - emit({ type: 'app:activate', app: appName, version }) - - // Register new app or restart existing - const app = allApps().find(a => a.name === appName) - if (!app) { - // New app - register it - registerApp(appName) - } else if (app.state === 'running') { - // Existing app - restart it - try { - await restartApp(appName) - } catch (e) { - return c.json({ error: `Failed to restart app: ${e instanceof Error ? e.message : String(e)}` }, 500) - } - } else if (app.state === 'stopped' || app.state === 'invalid') { - // App not running (possibly due to error) - try to start it - startApp(appName) - } - - return c.json({ ok: true }) - } catch (e) { - return c.json({ error: `Failed to activate version: ${e instanceof Error ? e.message : String(e)}` }, 500) + } else if (app.state === 'stopped' || app.state === 'invalid') { + // App not running - try to start it + startApp(appName) } + + return c.json({ ok: true }) }) router.sse('/apps/:app/watch', (send, c) => { const appName = c.req.param('app') - const appPath = join(APPS_DIR, appName, 'current') + const appPath = join(APPS_DIR, appName) const safeAppPath = safePath(APPS_DIR, appName) if (!safeAppPath || !existsSync(appPath)) return - // Resolve to canonical path for consistent watch events - const canonicalPath = realpathSync(appPath) - - const gitignore = loadGitignore(canonicalPath) + const gitignore = loadGitignore(appPath) let debounceTimer: Timer | null = null const pendingChanges = new Map() - const watcher = watch(canonicalPath, { recursive: true }, (_event, filename) => { + const watcher = watch(appPath, { recursive: true }, (_event, filename) => { if (!filename || gitignore.shouldExclude(filename)) return - const fullPath = join(canonicalPath, filename) + const fullPath = join(appPath, filename) const type = existsSync(fullPath) ? 'change' : 'delete' pendingChanges.set(filename, type) @@ -383,7 +162,7 @@ router.sse('/apps/:app/watch', (send, c) => { const evt: FileChangeEvent = { type: changeType, path } if (changeType === 'change') { try { - const content = readFileSync(join(canonicalPath, path)) + const content = readFileSync(join(appPath, path)) evt.hash = computeHash(content) } catch { continue // File was deleted between check and read diff --git a/src/server/apps.ts b/src/server/apps.ts index 03de5df..856e9b4 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -3,7 +3,7 @@ import type { ToesEvent, ToesEventInput, ToesEventType } from '../shared/events' import type { Subprocess } from 'bun' import { DEFAULT_EMOJI } from '@types' import { buildAppUrl, toSubdomain } from '@urls' -import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from 'fs' +import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs' import { LOCAL_HOST } from '%config' import { join, resolve } from 'path' import { loadAppEnv } from '../tools/env' @@ -108,7 +108,6 @@ export async function initApps() { initPortPool() setupShutdownHandlers() rotateLogs() - createAppSymlinks() discoverApps() runApps() } @@ -339,42 +338,11 @@ export const update = () => { function allAppDirs() { return readdirSync(APPS_DIR, { withFileTypes: true }) - .filter(e => e.isDirectory() && existsSync(join(APPS_DIR, e.name, 'current'))) + .filter(e => e.isDirectory() && existsSync(join(APPS_DIR, e.name, 'package.json'))) .map(e => e.name) .sort() } -function createAppSymlinks() { - for (const app of readdirSync(APPS_DIR, { withFileTypes: true })) { - if (!app.isDirectory()) continue - const appDir = join(APPS_DIR, app.name) - const currentPath = join(appDir, 'current') - if (existsSync(currentPath)) continue - - // Find valid version directories - const versions = readdirSync(appDir, { withFileTypes: true }) - .filter(e => { - if (!e.isDirectory()) return false - const pkgPath = join(appDir, e.name, 'package.json') - if (!existsSync(pkgPath)) return false - try { - const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) - return !!pkg.scripts?.toes - } catch { - return false - } - }) - .map(e => e.name) - .sort() - .reverse() - - const latest = versions[0] - if (latest) { - symlinkSync(latest, currentPath) - } - } -} - function discoverApps() { for (const dir of allAppDirs()) { const { pkg, error } = loadApp(dir) @@ -553,7 +521,7 @@ function markAsRunning(app: App, port: number) { function loadApp(dir: string): LoadResult { try { - const pkgPath = join(APPS_DIR, dir, 'current', 'package.json') + const pkgPath = join(APPS_DIR, dir, 'package.json') const file = readFileSync(pkgPath, 'utf-8') try { @@ -637,9 +605,7 @@ async function runApp(dir: string, port: number) { } }, STARTUP_TIMEOUT) - // Resolve symlink to actual timestamp directory - const currentLink = join(APPS_DIR, dir, 'current') - const cwd = realpathSync(currentLink) + const cwd = join(APPS_DIR, dir) const needsInstall = !existsSync(join(cwd, 'node_modules')) if (needsInstall) info(app, 'Installing dependencies...') @@ -769,7 +735,7 @@ async function runApp(dir: string, port: number) { } function saveApp(dir: string, pkg: any) { - const path = join(APPS_DIR, dir, 'current', 'package.json') + const path = join(APPS_DIR, dir, 'package.json') writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n') } diff --git a/src/shared/events.ts b/src/shared/events.ts index 744caee..133213c 100644 --- a/src/shared/events.ts +++ b/src/shared/events.ts @@ -1,4 +1,4 @@ -export type ToesEventType = 'app:activate' | 'app:create' | 'app:delete' | 'app:start' | 'app:stop' +export type ToesEventType = 'app:create' | 'app:delete' | 'app:reload' | 'app:start' | 'app:stop' interface BaseEvent { app: string @@ -6,9 +6,9 @@ interface BaseEvent { } export type ToesEvent = - | BaseEvent & { type: 'app:activate'; version: string } | BaseEvent & { type: 'app:create' } | BaseEvent & { type: 'app:delete' } + | BaseEvent & { type: 'app:reload' } | BaseEvent & { type: 'app:start' } | BaseEvent & { type: 'app:stop' }