re-do the whole thing on git
This commit is contained in:
parent
9b150543b0
commit
56db56976b
|
|
@ -691,7 +691,7 @@ watch(APPS_DIR, { recursive: true }, (_event, filename) => {
|
||||||
debounceTimer = setTimeout(rediscover, 100)
|
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...`)
|
console.log(`[cron] ${event.type} ${event.app}, rediscovering jobs...`)
|
||||||
rediscover()
|
rediscover()
|
||||||
})
|
})
|
||||||
|
|
@ -2,7 +2,7 @@ import { Hype } from '@because/hype'
|
||||||
import { define, stylesToCSS } from '@because/forge'
|
import { define, stylesToCSS } from '@because/forge'
|
||||||
import { baseStyles, ToolScript, theme, on } from '@because/toes/tools'
|
import { baseStyles, ToolScript, theme, on } from '@because/toes/tools'
|
||||||
import { mkdirSync } from 'fs'
|
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 { join, resolve } from 'path'
|
||||||
import type { Child } from 'hono/jsx'
|
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 DATA_DIR = process.env.DATA_DIR!
|
||||||
const TOES_URL = process.env.TOES_URL!
|
const TOES_URL = process.env.TOES_URL!
|
||||||
|
|
||||||
const MAX_VERSIONS = 5
|
|
||||||
const REPOS_DIR = resolve(DATA_DIR, 'repos')
|
const REPOS_DIR = resolve(DATA_DIR, 'repos')
|
||||||
const VALID_NAME = /^[a-zA-Z0-9_-]+$/
|
const VALID_NAME = /^[a-zA-Z0-9_-]+$/
|
||||||
|
|
||||||
|
|
@ -120,74 +119,42 @@ interface RepoListPageProps {
|
||||||
|
|
||||||
const repoPath = (name: string) => join(REPOS_DIR, `${name}.git`)
|
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
|
// resolve() normalises ".." segments; if the result differs from join(), the name contains a path traversal
|
||||||
const validRepoName = (name: string) =>
|
const validRepoName = (name: string) =>
|
||||||
VALID_NAME.test(name) && resolve(REPOS_DIR, name) === join(REPOS_DIR, name)
|
VALID_NAME.test(name) && resolve(REPOS_DIR, name) === join(REPOS_DIR, name)
|
||||||
|
|
||||||
async function activateApp(name: string, version: string): Promise<string | null> {
|
async function activateApp(name: string): Promise<string | null> {
|
||||||
const res = await fetch(`${TOES_URL}/api/sync/apps/${name}/activate?version=${version}`, {
|
const res = await fetch(`${TOES_URL}/api/sync/apps/${name}/reload`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({}))
|
const body = await res.json().catch(() => ({}))
|
||||||
const msg = (body as Record<string, string>).error ?? `activate returned ${res.status}`
|
const msg = (body as Record<string, string>).error ?? `reload returned ${res.status}`
|
||||||
console.error(`Activate failed for ${name}@${version}:`, msg)
|
console.error(`Reload failed for ${name}:`, msg)
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanOldVersions(appDir: string): Promise<void> {
|
async function deploy(repoName: string): Promise<{ ok: boolean; error?: string }> {
|
||||||
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 }> {
|
|
||||||
const bare = repoPath(repoName)
|
const bare = repoPath(repoName)
|
||||||
|
|
||||||
if (!(await hasCommits(bare))) {
|
if (!(await hasCommits(bare))) {
|
||||||
return { ok: false, error: 'No commits in repository' }
|
return { ok: false, error: 'No commits in repository' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const ts = timestamp()
|
// Validate in a temp dir before touching the real app dir
|
||||||
const appDir = join(APPS_DIR, repoName)
|
const tmpDir = join(APPS_DIR, `.${repoName}-deploy-tmp`)
|
||||||
const versionDir = join(appDir, ts)
|
await rm(tmpDir, { recursive: true, force: true })
|
||||||
|
await mkdir(tmpDir, { recursive: true })
|
||||||
|
|
||||||
await mkdir(versionDir, { recursive: true })
|
// Extract HEAD into the temp directory
|
||||||
|
|
||||||
// Extract HEAD into the version directory — no shell, pipe git archive into tar
|
|
||||||
const archive = Bun.spawn(['git', '--git-dir', bare, 'archive', 'HEAD'], {
|
const archive = Bun.spawn(['git', '--git-dir', bare, 'archive', 'HEAD'], {
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
})
|
})
|
||||||
|
|
||||||
const tar = Bun.spawn(['tar', '-x', '-C', versionDir], {
|
const tar = Bun.spawn(['tar', '-x', '-C', tmpDir], {
|
||||||
stdin: archive.stdout,
|
stdin: archive.stdout,
|
||||||
stdout: 'ignore',
|
stdout: 'ignore',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
|
|
@ -202,32 +169,37 @@ async function deploy(repoName: string): Promise<{ ok: boolean; error?: string;
|
||||||
])
|
])
|
||||||
|
|
||||||
if (archiveExit !== 0 || tarExit !== 0) {
|
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}` }
|
return { ok: false, error: `git archive failed: ${archiveErr || tarErr}` }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify package.json with scripts.toes exists
|
// 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())) {
|
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' }
|
return { ok: false, error: 'No package.json found in repository' }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pkg = JSON.parse(await Bun.file(pkgPath).text())
|
const pkg = JSON.parse(await Bun.file(pkgPath).text())
|
||||||
if (!pkg.scripts?.toes) {
|
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' }
|
return { ok: false, error: 'package.json missing scripts.toes entry' }
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await rm(versionDir, { recursive: true, force: true })
|
await rm(tmpDir, { recursive: true, force: true })
|
||||||
return { ok: false, error: 'Invalid package.json' }
|
return { ok: false, error: 'Invalid package.json' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up old versions beyond MAX_VERSIONS
|
// Stop the app before swapping directories
|
||||||
await cleanOldVersions(appDir)
|
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.
|
// 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)
|
return new TextEncoder().encode(header)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function stopIfRunning(name: string): Promise<void> {
|
||||||
|
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<T>(repo: string, fn: () => Promise<T>): Promise<T> {
|
async function withDeployLock<T>(repo: string, fn: () => Promise<T>): Promise<T> {
|
||||||
const prev = deployLocks.get(repo) ?? Promise.resolve()
|
const prev = deployLocks.get(repo) ?? Promise.resolve()
|
||||||
const { promise: lock, resolve: release } = Promise.withResolvers<void>()
|
const { promise: lock, resolve: release } = Promise.withResolvers<void>()
|
||||||
|
|
@ -608,13 +602,13 @@ app.on('POST', ['/:repo{.+\\.git}/git-receive-pack', '/:repo/git-receive-pack'],
|
||||||
const deployError = await withDeployLock(repoParam, async () => {
|
const deployError = await withDeployLock(repoParam, async () => {
|
||||||
try {
|
try {
|
||||||
const result = await deploy(repoParam)
|
const result = await deploy(repoParam)
|
||||||
if (result.ok && result.version) {
|
if (result.ok) {
|
||||||
const err = await activateApp(repoParam, result.version)
|
const err = await activateApp(repoParam)
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error(`Activate failed for ${repoParam}: ${err}`)
|
console.error(`Reload failed for ${repoParam}: ${err}`)
|
||||||
return `Deploy succeeded but activation failed: ${err}`
|
return `Deploy succeeded but reload failed: ${err}`
|
||||||
}
|
}
|
||||||
console.log(`Deployed ${repoParam}@${result.version}`)
|
console.log(`Deployed ${repoParam}`)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
console.error(`Deploy failed for ${repoParam}: ${result.error}`)
|
console.error(`Deploy failed for ${repoParam}: ${result.error}`)
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
"debug": "DEBUG=1 bun run dev",
|
"debug": "DEBUG=1 bun run dev",
|
||||||
"dev": "rm -f pub/client/index.js && bun run --hot src/server/index.tsx",
|
"dev": "rm -f pub/client/index.js && bun run --hot src/server/index.tsx",
|
||||||
"remote:deploy": "./scripts/deploy.sh",
|
"remote:deploy": "./scripts/deploy.sh",
|
||||||
|
"remote:migrate": "bun run scripts/migrate.ts",
|
||||||
"remote:install": "./scripts/remote-install.sh",
|
"remote:install": "./scripts/remote-install.sh",
|
||||||
"remote:logs": "./scripts/remote-logs.sh",
|
"remote:logs": "./scripts/remote-logs.sh",
|
||||||
"remote:restart": "./scripts/remote-restart.sh",
|
"remote:restart": "./scripts/remote-restart.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
|
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..."
|
echo "=> Syncing default apps..."
|
||||||
for app_dir in "$DEST"/apps/*/; do
|
for app_dir in "$DEST"/apps/*/; do
|
||||||
app=$(basename "$app_dir")
|
app=$(basename "$app_dir")
|
||||||
for version_dir in "$app_dir"*/; do
|
[ -f "$app_dir/package.json" ] || continue
|
||||||
[ -d "$version_dir" ] || continue
|
target="$APPS_DIR/$app"
|
||||||
version=$(basename "$version_dir")
|
mkdir -p "$target"
|
||||||
[ -f "$version_dir/package.json" ] || continue
|
cp -a "$app_dir"/. "$target"/
|
||||||
target="$APPS_DIR/$app/$version"
|
echo " $app"
|
||||||
mkdir -p "$target"
|
(cd "$target" && bun install --frozen-lockfile 2>/dev/null || bun install)
|
||||||
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
|
|
||||||
done
|
done
|
||||||
|
|
||||||
sudo systemctl restart toes.service
|
sudo systemctl restart toes.service
|
||||||
|
|
|
||||||
111
scripts/migrate.ts
Normal file
111
scripts/migrate.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
// Migration script: converts apps from versioned directory structure
|
||||||
|
// (apps/<name>/<timestamp>/ with `current` symlink) to flat structure (apps/<name>/).
|
||||||
|
//
|
||||||
|
// 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`)
|
||||||
|
|
@ -2,7 +2,6 @@ export { cronList, cronLog, cronRun, cronStatus } from './cron'
|
||||||
export { envList, envRm, envSet } from './env'
|
export { envList, envRm, envSet } from './env'
|
||||||
export { logApp } from './logs'
|
export { logApp } from './logs'
|
||||||
export {
|
export {
|
||||||
configShow,
|
|
||||||
infoApp,
|
infoApp,
|
||||||
listApps,
|
listApps,
|
||||||
newApp,
|
newApp,
|
||||||
|
|
@ -16,4 +15,3 @@ export {
|
||||||
unshareApp,
|
unshareApp,
|
||||||
} from './manage'
|
} from './manage'
|
||||||
export { metricsApp } from './metrics'
|
export { metricsApp } from './metrics'
|
||||||
export { cleanApp, diffApp, getApp, historyApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync'
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import type { App } from '@types'
|
import type { App } from '@types'
|
||||||
import { generateTemplates, type TemplateType } from '%templates'
|
import { generateTemplates, type TemplateType } from '%templates'
|
||||||
import { readSyncState } from '%sync'
|
|
||||||
import color from 'kleur'
|
import color from 'kleur'
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||||
import { basename, join } from 'path'
|
import { basename, join } from 'path'
|
||||||
|
|
@ -8,7 +7,6 @@ import { buildAppUrl } from '@urls'
|
||||||
import { del, get, getManifest, HOST, post } from '../http'
|
import { del, get, getManifest, HOST, post } from '../http'
|
||||||
import { confirm, prompt } from '../prompts'
|
import { confirm, prompt } from '../prompts'
|
||||||
import { resolveAppName } from '../name'
|
import { resolveAppName } from '../name'
|
||||||
import { pushApp } from './sync'
|
|
||||||
|
|
||||||
export const STATE_ICONS: Record<string, string> = {
|
export const STATE_ICONS: Record<string, string> = {
|
||||||
error: color.red('●'),
|
error: color.red('●'),
|
||||||
|
|
@ -36,15 +34,6 @@ async function waitForState(name: string, target: string, timeout: number): Prom
|
||||||
return app?.state
|
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) {
|
export async function infoApp(arg?: string) {
|
||||||
const name = resolveAppName(arg)
|
const name = resolveAppName(arg)
|
||||||
if (!name) return
|
if (!name) return
|
||||||
|
|
@ -184,17 +173,19 @@ export async function newApp(name: string | undefined, options: NewAppOptions) {
|
||||||
writeFileSync(join(appPath, filename), content)
|
writeFileSync(join(appPath, filename), content)
|
||||||
}
|
}
|
||||||
|
|
||||||
process.chdir(appPath)
|
// Initialize git repo and push to server (git push creates the app via the git tool)
|
||||||
await pushApp()
|
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(color.green(`✓ Created ${appName}`))
|
||||||
console.log()
|
|
||||||
console.log('Next steps:')
|
|
||||||
if (name) {
|
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) {
|
export async function openApp(arg?: string) {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -57,14 +57,13 @@ export async function get<T>(url: string): Promise<T | undefined> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`), { signal: getSignal() })
|
const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`), { signal: getSignal() })
|
||||||
if (res.status === 404) return { exists: false }
|
if (res.status === 404) return { exists: false }
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||||
const data = await res.json()
|
const manifest = await res.json()
|
||||||
const { version, ...manifest } = data
|
return { exists: true, manifest }
|
||||||
return { exists: true, manifest, version }
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error)
|
handleError(error)
|
||||||
return null
|
return null
|
||||||
|
|
|
||||||
110
src/cli/setup.ts
110
src/cli/setup.ts
|
|
@ -3,42 +3,27 @@ import { program } from 'commander'
|
||||||
import color from 'kleur'
|
import color from 'kleur'
|
||||||
|
|
||||||
import pkg from '../../package.json'
|
import pkg from '../../package.json'
|
||||||
import { withPager } from './pager'
|
|
||||||
import {
|
import {
|
||||||
cleanApp,
|
|
||||||
configShow,
|
|
||||||
cronList,
|
cronList,
|
||||||
cronLog,
|
cronLog,
|
||||||
cronRun,
|
cronRun,
|
||||||
cronStatus,
|
cronStatus,
|
||||||
diffApp,
|
|
||||||
envList,
|
envList,
|
||||||
envRm,
|
envRm,
|
||||||
envSet,
|
envSet,
|
||||||
getApp,
|
|
||||||
historyApp,
|
|
||||||
infoApp,
|
infoApp,
|
||||||
listApps,
|
listApps,
|
||||||
logApp,
|
logApp,
|
||||||
newApp,
|
newApp,
|
||||||
openApp,
|
openApp,
|
||||||
pullApp,
|
|
||||||
pushApp,
|
|
||||||
renameApp,
|
renameApp,
|
||||||
restartApp,
|
restartApp,
|
||||||
rmApp,
|
rmApp,
|
||||||
rollbackApp,
|
shareApp,
|
||||||
stashApp,
|
|
||||||
stashListApp,
|
|
||||||
stashPopApp,
|
|
||||||
startApp,
|
startApp,
|
||||||
metricsApp,
|
metricsApp,
|
||||||
shareApp,
|
|
||||||
statusApp,
|
|
||||||
stopApp,
|
stopApp,
|
||||||
syncApp,
|
|
||||||
unshareApp,
|
unshareApp,
|
||||||
versionsApp,
|
|
||||||
} from './commands'
|
} from './commands'
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|
@ -90,13 +75,6 @@ program
|
||||||
.option('--spa', 'single-page app with client-side rendering')
|
.option('--spa', 'single-page app with client-side rendering')
|
||||||
.action(newApp)
|
.action(newApp)
|
||||||
|
|
||||||
program
|
|
||||||
.command('get')
|
|
||||||
.helpGroup('Apps:')
|
|
||||||
.description('Download an app from server')
|
|
||||||
.argument('<name>', 'app name')
|
|
||||||
.action(getApp)
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('open')
|
.command('open')
|
||||||
.helpGroup('Apps:')
|
.helpGroup('Apps:')
|
||||||
|
|
@ -209,72 +187,8 @@ cron
|
||||||
.argument('<job>', 'job identifier (app:name)')
|
.argument('<job>', 'job identifier (app:name)')
|
||||||
.action(cronRun)
|
.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
|
// Config
|
||||||
|
|
||||||
program
|
|
||||||
.command('config')
|
|
||||||
.helpGroup('Config:')
|
|
||||||
.description('Show current host configuration')
|
|
||||||
.action(configShow)
|
|
||||||
|
|
||||||
const env = program
|
const env = program
|
||||||
.command('env')
|
.command('env')
|
||||||
.helpGroup('Config:')
|
.helpGroup('Config:')
|
||||||
|
|
@ -300,28 +214,6 @@ env
|
||||||
.option('-g, --global', 'remove a global variable')
|
.option('-g, --global', 'remove a global variable')
|
||||||
.action(envRm)
|
.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>', 'version to rollback to (prompts if omitted)')
|
|
||||||
.action((name, options) => rollbackApp(name, options.version))
|
|
||||||
|
|
||||||
// Shell
|
// Shell
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,8 @@ async function fetchAppNames(): Promise<string[]> {
|
||||||
|
|
||||||
function getCommandNames(): string[] {
|
function getCommandNames(): string[] {
|
||||||
return program.commands
|
return program.commands
|
||||||
.filter((cmd: { _hidden?: boolean }) => !cmd._hidden)
|
.filter((cmd) => !(cmd as any)._hidden)
|
||||||
.map((cmd: { name: () => string }) => cmd.name())
|
.map((cmd) => cmd.name())
|
||||||
}
|
}
|
||||||
|
|
||||||
async function printBanner(): Promise<void> {
|
async function printBanner(): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -3,27 +3,9 @@ export type { FileInfo, Manifest } from '@types'
|
||||||
import type { FileInfo, Manifest } from '@types'
|
import type { FileInfo, Manifest } from '@types'
|
||||||
import { loadGitignore } from '@gitignore'
|
import { loadGitignore } from '@gitignore'
|
||||||
import { createHash } from 'crypto'
|
import { createHash } from 'crypto'
|
||||||
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs'
|
import { readdirSync, readFileSync, statSync } from 'fs'
|
||||||
import { join, relative } from 'path'
|
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 {
|
export function computeHash(content: Buffer | string): string {
|
||||||
return createHash('sha256').update(content).digest('hex')
|
return createHash('sha256').update(content).digest('hex')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { isTunnelsAvailable, shareApp, unshareApp } from '../tunnels'
|
||||||
import type { App as BackendApp } from '$apps'
|
import type { App as BackendApp } from '$apps'
|
||||||
import type { App as SharedApp } from '@types'
|
import type { App as SharedApp } from '@types'
|
||||||
import { generateTemplates, type TemplateType } from '%templates'
|
import { generateTemplates, type TemplateType } from '%templates'
|
||||||
import { Hype } from '@because/hype'
|
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'
|
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()
|
const router = Hype.router()
|
||||||
|
|
||||||
// BackendApp -> SharedApp
|
// BackendApp -> SharedApp
|
||||||
|
|
@ -131,28 +125,35 @@ router.post('/', async c => {
|
||||||
const template = body.template ?? 'ssr'
|
const template = body.template ?? 'ssr'
|
||||||
const templates = generateTemplates(name, template, { tool: body.tool })
|
const templates = generateTemplates(name, template, { tool: body.tool })
|
||||||
|
|
||||||
// Create versioned directory structure
|
// Write templates to a temp dir, init git, and push to the git tool.
|
||||||
const ts = timestamp()
|
// The git push triggers deploy + activate which registers and starts the app.
|
||||||
const versionPath = join(appPath, ts)
|
const tmpDir = join(APPS_DIR, `.${name}-init-tmp`)
|
||||||
const currentPath = join(appPath, 'current')
|
try {
|
||||||
|
for (const [filename, content] of Object.entries(templates)) {
|
||||||
// Create directories and write files into version directory
|
const fullPath = join(tmpDir, filename)
|
||||||
for (const [filename, content] of Object.entries(templates)) {
|
const dir = dirname(fullPath)
|
||||||
const fullPath = join(versionPath, filename)
|
if (!existsSync(dir)) {
|
||||||
const dir = dirname(fullPath)
|
mkdirSync(dir, { recursive: true })
|
||||||
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) => {
|
router.sse('/:app/logs/stream', (send, c) => {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import { APPS_DIR, allApps, emit, registerApp, removeApp, restartApp, startApp } from '$apps'
|
import { APPS_DIR, allApps, emit, registerApp, removeApp, restartApp, startApp } from '$apps'
|
||||||
import { computeHash, generateManifest } from '../sync'
|
import { computeHash, generateManifest } from '../sync'
|
||||||
import { loadGitignore } from '@gitignore'
|
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 { dirname, join } from 'path'
|
||||||
import { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
|
|
||||||
const MAX_VERSIONS = 5
|
|
||||||
|
|
||||||
interface FileChangeEvent {
|
interface FileChangeEvent {
|
||||||
hash?: string
|
hash?: string
|
||||||
path: string
|
path: string
|
||||||
|
|
@ -14,7 +12,6 @@ interface FileChangeEvent {
|
||||||
}
|
}
|
||||||
|
|
||||||
function safePath(base: string, ...parts: string[]): string | null {
|
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 canonicalBase = existsSync(base) ? realpathSync(base) : base
|
||||||
const resolved = join(canonicalBase, ...parts)
|
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', 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<string, Record<string, { hash: string }>>()
|
|
||||||
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<string, string>()
|
|
||||||
for (const path of deleted) {
|
|
||||||
deletedByHash.set(olderFiles[path]!.hash, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchedAdded = new Set<string>()
|
|
||||||
const matchedDeleted = new Set<string>()
|
|
||||||
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 => {
|
router.get('/apps/:app/manifest', c => {
|
||||||
const appName = c.req.param('app')
|
const appName = c.req.param('app')
|
||||||
if (!appName) return c.json({ error: 'App not found' }, 404)
|
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)
|
const safeAppPath = safePath(APPS_DIR, appName)
|
||||||
if (!safeAppPath) return c.json({ error: 'Invalid path' }, 400)
|
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)
|
const manifest = generateManifest(appPath, appName)
|
||||||
return c.json({ ...manifest, version })
|
return c.json(manifest)
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get('/apps/:app/files/:path{.+}', c => {
|
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)
|
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)
|
const fullPath = safePath(basePath, filePath)
|
||||||
if (!fullPath) return c.json({ error: 'Invalid path' }, 400)
|
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 => {
|
router.put('/apps/:app/files/:path{.+}', async c => {
|
||||||
const appName = c.req.param('app')
|
const appName = c.req.param('app')
|
||||||
const filePath = c.req.param('path')
|
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)
|
if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400)
|
||||||
|
|
||||||
// Determine base path: specific version or current
|
const basePath = join(APPS_DIR, appName)
|
||||||
const basePath = version
|
|
||||||
? join(APPS_DIR, appName, version)
|
|
||||||
: join(APPS_DIR, appName, 'current')
|
|
||||||
|
|
||||||
const fullPath = safePath(basePath, filePath)
|
const fullPath = safePath(basePath, filePath)
|
||||||
if (!fullPath) return c.json({ error: 'Invalid path' }, 400)
|
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 => {
|
router.delete('/apps/:app/files/:path{.+}', c => {
|
||||||
const appName = c.req.param('app')
|
const appName = c.req.param('app')
|
||||||
const filePath = c.req.param('path')
|
const filePath = c.req.param('path')
|
||||||
const version = c.req.query('version')
|
|
||||||
|
|
||||||
if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400)
|
if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400)
|
||||||
|
|
||||||
const basePath = version
|
const basePath = join(APPS_DIR, appName)
|
||||||
? join(APPS_DIR, appName, version)
|
|
||||||
: join(APPS_DIR, appName, 'current')
|
|
||||||
|
|
||||||
const fullPath = safePath(basePath, filePath)
|
const fullPath = safePath(basePath, filePath)
|
||||||
if (!fullPath) return c.json({ error: 'Invalid path' }, 400)
|
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 })
|
return c.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/apps/:app/deploy', c => {
|
router.post('/apps/:app/reload', async c => {
|
||||||
const appName = c.req.param('app')
|
const appName = c.req.param('app')
|
||||||
if (!appName) return c.json({ error: 'App name required' }, 400)
|
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
|
// Register new app or restart existing
|
||||||
const now = new Date()
|
const app = allApps().find(a => a.name === appName)
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
if (!app) {
|
||||||
const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
// New app - register it
|
||||||
|
registerApp(appName)
|
||||||
const newVersion = join(appDir, timestamp)
|
} else if (app.state === 'running') {
|
||||||
const currentLink = join(appDir, 'current')
|
// Existing app - restart it
|
||||||
|
|
||||||
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
|
|
||||||
try {
|
try {
|
||||||
const entries = readdirSync(appDir, { withFileTypes: true })
|
await restartApp(appName)
|
||||||
|
|
||||||
// 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}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Log but don't fail activation if cleanup fails
|
return c.json({ error: `Failed to restart app: ${e instanceof Error ? e.message : String(e)}` }, 500)
|
||||||
console.error(`Failed to clean up old versions: ${e}`)
|
|
||||||
}
|
}
|
||||||
|
} else if (app.state === 'stopped' || app.state === 'invalid') {
|
||||||
emit({ type: 'app:activate', app: appName, version })
|
// App not running - try to start it
|
||||||
|
startApp(appName)
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return c.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.sse('/apps/:app/watch', (send, c) => {
|
router.sse('/apps/:app/watch', (send, c) => {
|
||||||
const appName = c.req.param('app')
|
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)
|
const safeAppPath = safePath(APPS_DIR, appName)
|
||||||
if (!safeAppPath || !existsSync(appPath)) return
|
if (!safeAppPath || !existsSync(appPath)) return
|
||||||
|
|
||||||
// Resolve to canonical path for consistent watch events
|
const gitignore = loadGitignore(appPath)
|
||||||
const canonicalPath = realpathSync(appPath)
|
|
||||||
|
|
||||||
const gitignore = loadGitignore(canonicalPath)
|
|
||||||
let debounceTimer: Timer | null = null
|
let debounceTimer: Timer | null = null
|
||||||
const pendingChanges = new Map<string, 'change' | 'delete'>()
|
const pendingChanges = new Map<string, 'change' | 'delete'>()
|
||||||
|
|
||||||
const watcher = watch(canonicalPath, { recursive: true }, (_event, filename) => {
|
const watcher = watch(appPath, { recursive: true }, (_event, filename) => {
|
||||||
if (!filename || gitignore.shouldExclude(filename)) return
|
if (!filename || gitignore.shouldExclude(filename)) return
|
||||||
|
|
||||||
const fullPath = join(canonicalPath, filename)
|
const fullPath = join(appPath, filename)
|
||||||
const type = existsSync(fullPath) ? 'change' : 'delete'
|
const type = existsSync(fullPath) ? 'change' : 'delete'
|
||||||
pendingChanges.set(filename, type)
|
pendingChanges.set(filename, type)
|
||||||
|
|
||||||
|
|
@ -383,7 +162,7 @@ router.sse('/apps/:app/watch', (send, c) => {
|
||||||
const evt: FileChangeEvent = { type: changeType, path }
|
const evt: FileChangeEvent = { type: changeType, path }
|
||||||
if (changeType === 'change') {
|
if (changeType === 'change') {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(join(canonicalPath, path))
|
const content = readFileSync(join(appPath, path))
|
||||||
evt.hash = computeHash(content)
|
evt.hash = computeHash(content)
|
||||||
} catch {
|
} catch {
|
||||||
continue // File was deleted between check and read
|
continue // File was deleted between check and read
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { ToesEvent, ToesEventInput, ToesEventType } from '../shared/events'
|
||||||
import type { Subprocess } from 'bun'
|
import type { Subprocess } from 'bun'
|
||||||
import { DEFAULT_EMOJI } from '@types'
|
import { DEFAULT_EMOJI } from '@types'
|
||||||
import { buildAppUrl, toSubdomain } from '@urls'
|
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 { LOCAL_HOST } from '%config'
|
||||||
import { join, resolve } from 'path'
|
import { join, resolve } from 'path'
|
||||||
import { loadAppEnv } from '../tools/env'
|
import { loadAppEnv } from '../tools/env'
|
||||||
|
|
@ -108,7 +108,6 @@ export async function initApps() {
|
||||||
initPortPool()
|
initPortPool()
|
||||||
setupShutdownHandlers()
|
setupShutdownHandlers()
|
||||||
rotateLogs()
|
rotateLogs()
|
||||||
createAppSymlinks()
|
|
||||||
discoverApps()
|
discoverApps()
|
||||||
runApps()
|
runApps()
|
||||||
}
|
}
|
||||||
|
|
@ -339,42 +338,11 @@ export const update = () => {
|
||||||
|
|
||||||
function allAppDirs() {
|
function allAppDirs() {
|
||||||
return readdirSync(APPS_DIR, { withFileTypes: true })
|
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)
|
.map(e => e.name)
|
||||||
.sort()
|
.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() {
|
function discoverApps() {
|
||||||
for (const dir of allAppDirs()) {
|
for (const dir of allAppDirs()) {
|
||||||
const { pkg, error } = loadApp(dir)
|
const { pkg, error } = loadApp(dir)
|
||||||
|
|
@ -553,7 +521,7 @@ function markAsRunning(app: App, port: number) {
|
||||||
|
|
||||||
function loadApp(dir: string): LoadResult {
|
function loadApp(dir: string): LoadResult {
|
||||||
try {
|
try {
|
||||||
const pkgPath = join(APPS_DIR, dir, 'current', 'package.json')
|
const pkgPath = join(APPS_DIR, dir, 'package.json')
|
||||||
const file = readFileSync(pkgPath, 'utf-8')
|
const file = readFileSync(pkgPath, 'utf-8')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -637,9 +605,7 @@ async function runApp(dir: string, port: number) {
|
||||||
}
|
}
|
||||||
}, STARTUP_TIMEOUT)
|
}, STARTUP_TIMEOUT)
|
||||||
|
|
||||||
// Resolve symlink to actual timestamp directory
|
const cwd = join(APPS_DIR, dir)
|
||||||
const currentLink = join(APPS_DIR, dir, 'current')
|
|
||||||
const cwd = realpathSync(currentLink)
|
|
||||||
|
|
||||||
const needsInstall = !existsSync(join(cwd, 'node_modules'))
|
const needsInstall = !existsSync(join(cwd, 'node_modules'))
|
||||||
if (needsInstall) info(app, 'Installing dependencies...')
|
if (needsInstall) info(app, 'Installing dependencies...')
|
||||||
|
|
@ -769,7 +735,7 @@ async function runApp(dir: string, port: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveApp(dir: string, pkg: any) {
|
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')
|
writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
interface BaseEvent {
|
||||||
app: string
|
app: string
|
||||||
|
|
@ -6,9 +6,9 @@ interface BaseEvent {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ToesEvent =
|
export type ToesEvent =
|
||||||
| BaseEvent & { type: 'app:activate'; version: string }
|
|
||||||
| BaseEvent & { type: 'app:create' }
|
| BaseEvent & { type: 'app:create' }
|
||||||
| BaseEvent & { type: 'app:delete' }
|
| BaseEvent & { type: 'app:delete' }
|
||||||
|
| BaseEvent & { type: 'app:reload' }
|
||||||
| BaseEvent & { type: 'app:start' }
|
| BaseEvent & { type: 'app:start' }
|
||||||
| BaseEvent & { type: 'app:stop' }
|
| BaseEvent & { type: 'app:stop' }
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user