diff --git a/package.json b/package.json index 78282b9..2dd3752 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "module": "src/index.ts", "type": "module", "private": true, + "bin": { + "toes": "src/cli/index.ts" + }, "scripts": { "start": "bun run src/server/index.tsx", "dev": "bun run --hot src/server/index.tsx" diff --git a/src/cli/index.ts b/src/cli/index.ts old mode 100644 new mode 100755 index 90bca56..3e94f2f --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,11 +1,36 @@ +#!/usr/bin/env bun import { program } from 'commander' -import { join } from 'path' +import { createHash } from 'crypto' +import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'fs' +import { dirname, join, relative } from 'path' import type { App, LogLine } from '@types' import color from 'kleur' import { APPS_DIR } from '$apps' const HOST = `http://localhost:${process.env.PORT ?? 3000}` +interface FileInfo { + hash: string + mtime: string + size: number +} + +interface Manifest { + files: Record + name: string +} + +const EXCLUDE_PATTERNS = [ + 'node_modules', + '.DS_Store', + '*.log', + 'dist', + 'build', + '.env.local', + '.git', + 'bun.lockb', +] + const STATE_ICONS: Record = { running: color.green('●'), starting: color.yellow('◎'), @@ -13,9 +38,13 @@ const STATE_ICONS: Record = { invalid: color.red('◌'), } +function makeUrl(path: string): string { + return `${HOST}${path}` +} + async function get(url: string): Promise { try { - const res = await fetch(join(HOST, url)) + const res = await fetch(makeUrl(url)) if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) return await res.json() } catch (error) { @@ -25,7 +54,7 @@ async function get(url: string): Promise { async function post(url: string, body?: B): Promise { try { - const res = await fetch(join(HOST, url), { + const res = await fetch(makeUrl(url), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -37,6 +66,99 @@ async function post(url: string, body?: B): Promise { + try { + const res = await fetch(makeUrl(url), { + method: 'PUT', + body: body as BodyInit, + }) + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) + return true + } catch (error) { + console.error(error) + return false + } +} + +async function download(url: string): Promise { + try { + const fullUrl = makeUrl(url) + const res = await fetch(fullUrl) + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) + return Buffer.from(await res.arrayBuffer()) + } catch (error) { + console.error(error) + } +} + +async function del(url: string): Promise { + try { + const res = await fetch(makeUrl(url), { + method: 'DELETE', + }) + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) + return true + } catch (error) { + console.error(error) + return false + } +} + +function computeHash(content: Buffer | string): string { + return createHash('sha256').update(content).digest('hex') +} + +function shouldExclude(path: string): boolean { + const parts = path.split('/') + + for (const pattern of EXCLUDE_PATTERNS) { + if (parts.includes(pattern)) return true + if (pattern.startsWith('*')) { + const ext = pattern.slice(1) + if (path.endsWith(ext)) return true + } + } + + return false +} + +function generateLocalManifest(appPath: string, appName: string): Manifest { + const files: Record = {} + + function walkDir(dir: string) { + if (!existsSync(dir)) return + + const entries = readdirSync(dir, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = join(dir, entry.name) + const relativePath = relative(appPath, fullPath) + + if (shouldExclude(relativePath)) continue + + if (entry.isDirectory()) { + walkDir(fullPath) + } else if (entry.isFile()) { + const content = readFileSync(fullPath) + const stats = statSync(fullPath) + + files[relativePath] = { + hash: computeHash(content), + mtime: stats.mtime.toISOString(), + size: stats.size, + } + } + } + } + + walkDir(appPath) + + return { + name: appName, + files, + } +} + async function infoApp(name: string) { const app: App | undefined = await get(`/api/apps/${name}`) if (!app) { @@ -110,7 +232,7 @@ async function logApp(name: string, options: { follow?: boolean }) { } async function tailLogs(name: string) { - const url = join(HOST, `/api/apps/${name}/logs/stream`) + const url = makeUrl(`/api/apps/${name}/logs/stream`) const res = await fetch(url) if (!res.ok) { console.error(`App not found: ${name}`) @@ -154,6 +276,193 @@ async function openApp(name: string) { Bun.spawn(['open', url]) } +async function getApp(name: string) { + console.log(`Fetching ${color.bold(name)} from server...`) + + const manifest: Manifest | undefined = await get(`/api/sync/apps/${name}/manifest`) + if (!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(manifest.files) + console.log(`Downloading ${files.length} files...`) + + 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) + } + + console.log(color.green(`✓ Downloaded ${name}`)) +} + +async function pushApp() { + const appName = process.cwd().split('/').pop() + if (!appName) { + console.error('Could not determine app name from current directory') + return + } + + console.log(`Pushing ${color.bold(appName)} to server...`) + + const localManifest = generateLocalManifest(process.cwd(), appName) + const remoteManifest: Manifest | undefined = await get(`/api/sync/apps/${appName}/manifest`) + + if (!remoteManifest) { + console.error('App not found on server') + return + } + + const localFiles = new Set(Object.keys(localManifest.files)) + const remoteFiles = new Set(Object.keys(remoteManifest.files)) + + // Files to upload (new or changed) + const toUpload: string[] = [] + for (const file of localFiles) { + const local = localManifest.files[file]! + const remote = remoteManifest.files[file] + if (!remote || local.hash !== remote.hash) { + toUpload.push(file) + } + } + + // Files to delete (in remote but not local) + const toDelete: string[] = [] + for (const file of remoteFiles) { + if (!localFiles.has(file)) { + toDelete.push(file) + } + } + + if (toUpload.length === 0 && toDelete.length === 0) { + console.log('Already up to date') + return + } + + if (toUpload.length > 0) { + console.log(`Uploading ${toUpload.length} files...`) + for (const file of toUpload) { + const content = readFileSync(join(process.cwd(), file)) + const success = await put(`/api/sync/apps/${appName}/files/${file}`, content) + if (success) { + console.log(` ${color.green('↑')} ${file}`) + } else { + console.log(` ${color.red('✗')} ${file}`) + } + } + } + + if (toDelete.length > 0) { + console.log(`Deleting ${toDelete.length} files on server...`) + for (const file of toDelete) { + const success = await del(`/api/sync/apps/${appName}/files/${file}`) + if (success) { + console.log(` ${color.red('✗')} ${file}`) + } else { + console.log(` ${color.red('Failed to delete')} ${file}`) + } + } + } + + console.log(color.green('✓ Push complete')) +} + +async function pullApp() { + const appName = process.cwd().split('/').pop() + if (!appName) { + console.error('Could not determine app name from current directory') + return + } + + console.log(`Pulling ${color.bold(appName)} from server...`) + + const remoteManifest: Manifest | undefined = await get(`/api/sync/apps/${appName}/manifest`) + if (!remoteManifest) { + console.error('App not found on server') + return + } + + const localManifest = generateLocalManifest(process.cwd(), appName) + + const localFiles = new Set(Object.keys(localManifest.files)) + const remoteFiles = new Set(Object.keys(remoteManifest.files)) + + // Files to download (new or changed) + const toDownload: string[] = [] + for (const file of remoteFiles) { + const remote = remoteManifest.files[file]! + const local = localManifest.files[file] + if (!local || remote.hash !== local.hash) { + toDownload.push(file) + } + } + + // Files to delete (in local but not remote) + const toDelete: string[] = [] + for (const file of localFiles) { + if (!remoteFiles.has(file)) { + toDelete.push(file) + } + } + + if (toDownload.length === 0 && toDelete.length === 0) { + console.log('Already up to date') + return + } + + if (toDownload.length > 0) { + console.log(`Downloading ${toDownload.length} files...`) + 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 files...`) + for (const file of toDelete) { + const fullPath = join(process.cwd(), file) + unlinkSync(fullPath) + console.log(` ${color.red('✗')} ${file}`) + } + } + + console.log(color.green('✓ Pull complete')) +} + program .name('toes') .version('0.0.1', '-v, --version') @@ -207,19 +516,19 @@ program .action(openApp) program - .command('new') - .description('Create a new app') + .command('get') + .description('Download an app from server') .argument('', 'app name') - .action(name => { - // ... - }) + .action(getApp) program .command('push') - .description('Push app to server') - .option('-f, --force', 'force overwrite') - .action(options => { - // ... - }) + .description('Push local changes to server') + .action(pushApp) + +program + .command('pull') + .description('Pull changes from server') + .action(pullApp) program.parse() \ No newline at end of file diff --git a/src/server/index.tsx b/src/server/index.tsx index 5177ee8..a357ff0 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -1,6 +1,9 @@ -import { allApps, initApps, onChange, startApp, stopApp, updateAppIcon } from '$apps' +import { APPS_DIR, allApps, initApps, onChange, startApp, stopApp, updateAppIcon } from '$apps' import type { App as SharedApp } from '@types' import type { App as BackendApp } from '$apps' +import { generateManifest } from './sync' +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs' +import { dirname, join } from 'path' import { Hype } from 'hype' // BackendApp -> SharedApp @@ -143,6 +146,68 @@ app.post('/api/apps/:app/icon', c => { } }) +// Sync API +app.get('/api/sync/apps', c => + c.json(allApps().map(a => a.name)) +) + +app.get('/api/sync/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) + if (!existsSync(appPath)) return c.json({ error: 'App not found' }, 404) + + const manifest = generateManifest(appPath, appName) + return c.json(manifest) +}) + +app.get('/api/sync/apps/:app/files/:path{.+}', c => { + const appName = c.req.param('app') + const filePath = c.req.param('path') + + if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400) + + const fullPath = join(APPS_DIR, appName, filePath) + if (!existsSync(fullPath)) return c.json({ error: 'File not found' }, 404) + + const content = readFileSync(fullPath) + return new Response(content) +}) + +app.put('/api/sync/apps/:app/files/:path{.+}', async c => { + const appName = c.req.param('app') + const filePath = c.req.param('path') + + if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400) + + const fullPath = join(APPS_DIR, appName, filePath) + const dir = dirname(fullPath) + + // Ensure directory exists + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + + const body = await c.req.arrayBuffer() + writeFileSync(fullPath, new Uint8Array(body)) + + return c.json({ ok: true }) +}) + +app.delete('/api/sync/apps/:app/files/:path{.+}', c => { + const appName = c.req.param('app') + const filePath = c.req.param('path') + + if (!appName || !filePath) return c.json({ error: 'Invalid path' }, 400) + + const fullPath = join(APPS_DIR, appName, filePath) + if (!existsSync(fullPath)) return c.json({ error: 'File not found' }, 404) + + unlinkSync(fullPath) + return c.json({ ok: true }) +}) + console.log('🐾 Toes!') initApps() diff --git a/src/server/sync.ts b/src/server/sync.ts new file mode 100644 index 0000000..763c18c --- /dev/null +++ b/src/server/sync.ts @@ -0,0 +1,82 @@ +import { readdirSync, readFileSync, statSync } from 'fs' +import { createHash } from 'crypto' +import { join, relative } from 'path' + +export interface FileInfo { + hash: string + mtime: string + size: number +} + +export interface Manifest { + files: Record + name: string +} + +const EXCLUDE_PATTERNS = [ + 'node_modules', + '.DS_Store', + '*.log', + 'dist', + 'build', + '.env.local', + '.git', + 'bun.lockb', +] + +export function computeHash(content: Buffer | string): string { + return createHash('sha256').update(content).digest('hex') +} + +export function generateManifest(appPath: string, appName: string): Manifest { + const files: Record = {} + + function walkDir(dir: string) { + const entries = readdirSync(dir, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = join(dir, entry.name) + const relativePath = relative(appPath, fullPath) + + // Check exclusions + if (shouldExclude(relativePath)) continue + + if (entry.isDirectory()) { + walkDir(fullPath) + } else if (entry.isFile()) { + const content = readFileSync(fullPath) + const stats = statSync(fullPath) + + files[relativePath] = { + hash: computeHash(content), + mtime: stats.mtime.toISOString(), + size: stats.size, + } + } + } + } + + walkDir(appPath) + + return { + name: appName, + files, + } +} + +function shouldExclude(path: string): boolean { + const parts = path.split('/') + + for (const pattern of EXCLUDE_PATTERNS) { + // Exact match or starts with (for directories) + if (parts.includes(pattern)) return true + + // Wildcard patterns + if (pattern.startsWith('*')) { + const ext = pattern.slice(1) + if (path.endsWith(ext)) return true + } + } + + return false +}