toes push/pull
This commit is contained in:
parent
2fb3b2abc1
commit
635121a27d
|
|
@ -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"
|
||||
|
|
|
|||
337
src/cli/index.ts
Normal file → Executable file
337
src/cli/index.ts
Normal file → Executable file
|
|
@ -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<string, FileInfo>
|
||||
name: string
|
||||
}
|
||||
|
||||
const EXCLUDE_PATTERNS = [
|
||||
'node_modules',
|
||||
'.DS_Store',
|
||||
'*.log',
|
||||
'dist',
|
||||
'build',
|
||||
'.env.local',
|
||||
'.git',
|
||||
'bun.lockb',
|
||||
]
|
||||
|
||||
const STATE_ICONS: Record<string, string> = {
|
||||
running: color.green('●'),
|
||||
starting: color.yellow('◎'),
|
||||
|
|
@ -13,9 +38,13 @@ const STATE_ICONS: Record<string, string> = {
|
|||
invalid: color.red('◌'),
|
||||
}
|
||||
|
||||
function makeUrl(path: string): string {
|
||||
return `${HOST}${path}`
|
||||
}
|
||||
|
||||
async function get<T>(url: string): Promise<T | undefined> {
|
||||
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<T>(url: string): Promise<T | undefined> {
|
|||
|
||||
async function post<T, B = unknown>(url: string, body?: B): Promise<T | undefined> {
|
||||
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<T, B = unknown>(url: string, body?: B): Promise<T | undefine
|
|||
}
|
||||
}
|
||||
|
||||
async function put(url: string, body: Buffer | Uint8Array): Promise<boolean> {
|
||||
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<Buffer | undefined> {
|
||||
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<boolean> {
|
||||
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<string, FileInfo> = {}
|
||||
|
||||
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('<name>', '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()
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
82
src/server/sync.ts
Normal file
82
src/server/sync.ts
Normal file
|
|
@ -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<string, FileInfo>
|
||||
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<string, FileInfo> = {}
|
||||
|
||||
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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user