Compare commits
3 Commits
0bf3560597
...
1e36fa0fa3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e36fa0fa3 | ||
|
|
635121a27d | ||
|
|
2fb3b2abc1 |
24
bun.lock
24
bun.lock
|
|
@ -5,9 +5,9 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "toes",
|
"name": "toes",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@_because/forge": "*",
|
||||||
|
"@_because/hype": "*",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"forge": "git+https://git.nose.space/defunkt/forge",
|
|
||||||
"hype": "git+https://git.nose.space/defunkt/hype",
|
|
||||||
"kleur": "^4.1.5",
|
"kleur": "^4.1.5",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -19,24 +19,24 @@
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
"@_because/forge": ["@_because/forge@0.1.0", "https://npm.nose.space/@_because/forge/-/forge-0.1.0.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-kut50WMLDUb088SHnCPENJKI6rcXLzswSlGcKqsl3d8F40X8uRXTX/CdZDK5Q9Z1CDpFzGZHQ9nireqme3IvPQ=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
|
"@_because/hype": ["@_because/hype@0.1.0", "https://npm.nose.space/@_because/hype/-/hype-0.1.0.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-cw3Ms2jOIv1159VeNZFAEcH/fzVKOuqul9ggcemkZQHFAX1uGJD9wEvdJd2PNbbR5GG62J5rAPpqHHNmWrb8qA=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
"@types/bun": ["@types/bun@1.3.8", "https://npm.nose.space/@types/bun/-/bun-1.3.8.tgz", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||||
|
|
||||||
"commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
|
"@types/node": ["@types/node@25.1.0", "https://npm.nose.space/@types/node/-/node-25.1.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
|
||||||
|
|
||||||
"forge": ["forge@git+https://git.nose.space/defunkt/forge#debfd73ab22c50f66ccc93cb41164c234f78a920", { "peerDependencies": { "typescript": "^5" } }, "debfd73ab22c50f66ccc93cb41164c234f78a920"],
|
"bun-types": ["bun-types@1.3.8", "https://npm.nose.space/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||||
|
|
||||||
"hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
"commander": ["commander@14.0.2", "https://npm.nose.space/commander/-/commander-14.0.2.tgz", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
|
||||||
|
|
||||||
"hype": ["hype@git+https://git.nose.space/defunkt/hype#85fa79518c15daf8d124fac1816a37321b68ffe2", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "85fa79518c15daf8d124fac1816a37321b68ffe2"],
|
"hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
||||||
|
|
||||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
package.json
10
package.json
|
|
@ -3,9 +3,13 @@
|
||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"bin": {
|
||||||
|
"toes": "src/cli/index.ts"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun run src/server/index.tsx",
|
"start": "bun run src/server/index.tsx",
|
||||||
"dev": "bun run --hot src/server/index.tsx"
|
"dev": "bun run --hot src/server/index.tsx",
|
||||||
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
|
|
@ -15,8 +19,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"forge": "git+https://git.nose.space/defunkt/forge",
|
"@_because/forge": "*",
|
||||||
"hype": "git+https://git.nose.space/defunkt/hype",
|
"@_because/hype": "*",
|
||||||
"kleur": "^4.1.5"
|
"kleur": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
339
src/cli/index.ts
Normal file → Executable file
339
src/cli/index.ts
Normal file → Executable file
|
|
@ -1,11 +1,26 @@
|
||||||
import { program } from 'commander'
|
#!/usr/bin/env bun
|
||||||
import { join } from 'path'
|
|
||||||
import type { App, LogLine } from '@types'
|
|
||||||
import color from 'kleur'
|
|
||||||
import { APPS_DIR } from '$apps'
|
import { APPS_DIR } from '$apps'
|
||||||
|
import type { App, LogLine } from '@types'
|
||||||
|
import { loadGitignore } from '@gitignore'
|
||||||
|
import { program } from 'commander'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'fs'
|
||||||
|
import color from 'kleur'
|
||||||
|
import { dirname, join, relative } from 'path'
|
||||||
|
|
||||||
const HOST = `http://localhost:${process.env.PORT ?? 3000}`
|
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 STATE_ICONS: Record<string, string> = {
|
const STATE_ICONS: Record<string, string> = {
|
||||||
running: color.green('●'),
|
running: color.green('●'),
|
||||||
starting: color.yellow('◎'),
|
starting: color.yellow('◎'),
|
||||||
|
|
@ -13,9 +28,13 @@ const STATE_ICONS: Record<string, string> = {
|
||||||
invalid: color.red('◌'),
|
invalid: color.red('◌'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeUrl(path: string): string {
|
||||||
|
return `${HOST}${path}`
|
||||||
|
}
|
||||||
|
|
||||||
async function get<T>(url: string): Promise<T | undefined> {
|
async function get<T>(url: string): Promise<T | undefined> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(join(HOST, url))
|
const res = await fetch(makeUrl(url))
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||||
return await res.json()
|
return await res.json()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -25,7 +44,7 @@ async function get<T>(url: string): Promise<T | undefined> {
|
||||||
|
|
||||||
async function post<T, B = unknown>(url: string, body?: B): Promise<T | undefined> {
|
async function post<T, B = unknown>(url: string, body?: B): Promise<T | undefined> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(join(HOST, url), {
|
const res = await fetch(makeUrl(url), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
|
@ -37,6 +56,86 @@ 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 generateLocalManifest(appPath: string, appName: string): Manifest {
|
||||||
|
const files: Record<string, FileInfo> = {}
|
||||||
|
const gitignore = loadGitignore(appPath)
|
||||||
|
|
||||||
|
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 (gitignore.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) {
|
async function infoApp(name: string) {
|
||||||
const app: App | undefined = await get(`/api/apps/${name}`)
|
const app: App | undefined = await get(`/api/apps/${name}`)
|
||||||
if (!app) {
|
if (!app) {
|
||||||
|
|
@ -110,7 +209,7 @@ async function logApp(name: string, options: { follow?: boolean }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tailLogs(name: string) {
|
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)
|
const res = await fetch(url)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(`App not found: ${name}`)
|
console.error(`App not found: ${name}`)
|
||||||
|
|
@ -154,6 +253,212 @@ async function openApp(name: string) {
|
||||||
Bun.spawn(['open', url])
|
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}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isApp(): boolean {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(join(process.cwd(), 'package.json'))
|
||||||
|
return !!pkg?.scripts?.toes
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushApp() {
|
||||||
|
if (!isApp()) {
|
||||||
|
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
if (!isApp()) {
|
||||||
|
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
program
|
||||||
.name('toes')
|
.name('toes')
|
||||||
.version('0.0.1', '-v, --version')
|
.version('0.0.1', '-v, --version')
|
||||||
|
|
@ -207,19 +512,19 @@ program
|
||||||
.action(openApp)
|
.action(openApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('new')
|
.command('get')
|
||||||
.description('Create a new app')
|
.description('Download an app from server')
|
||||||
.argument('<name>', 'app name')
|
.argument('<name>', 'app name')
|
||||||
.action(name => {
|
.action(getApp)
|
||||||
// ...
|
|
||||||
})
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('push')
|
.command('push')
|
||||||
.description('Push app to server')
|
.description('Push local changes to server')
|
||||||
.option('-f, --force', 'force overwrite')
|
.action(pushApp)
|
||||||
.action(options => {
|
|
||||||
// ...
|
program
|
||||||
})
|
.command('pull')
|
||||||
|
.description('Pull changes from server')
|
||||||
|
.action(pullApp)
|
||||||
|
|
||||||
program.parse()
|
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 SharedApp } from '@types'
|
||||||
import type { App as BackendApp } from '$apps'
|
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'
|
import { Hype } from 'hype'
|
||||||
|
|
||||||
// BackendApp -> SharedApp
|
// 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!')
|
console.log('🐾 Toes!')
|
||||||
initApps()
|
initApps()
|
||||||
|
|
||||||
|
|
|
||||||
55
src/server/sync.ts
Normal file
55
src/server/sync.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import { readdirSync, readFileSync, statSync } from 'fs'
|
||||||
|
import { join, relative } from 'path'
|
||||||
|
import { loadGitignore } from '@gitignore'
|
||||||
|
|
||||||
|
export interface FileInfo {
|
||||||
|
hash: string
|
||||||
|
mtime: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Manifest {
|
||||||
|
files: Record<string, FileInfo>
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
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> = {}
|
||||||
|
const gitignore = loadGitignore(appPath)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if (gitignore.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,
|
||||||
|
}
|
||||||
|
}
|
||||||
174
src/shared/gitignore.test.ts
Normal file
174
src/shared/gitignore.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||||
|
import { mkdirSync, rmSync, writeFileSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { loadGitignore } from './gitignore'
|
||||||
|
|
||||||
|
const TEST_DIR = '/tmp/toes-gitignore-test'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mkdirSync(TEST_DIR, { recursive: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loadGitignore', () => {
|
||||||
|
describe('with .gitignore file', () => {
|
||||||
|
test('should parse simple patterns', () => {
|
||||||
|
writeFileSync(join(TEST_DIR, '.gitignore'), 'node_modules\n*.log\ndist/')
|
||||||
|
|
||||||
|
const checker = loadGitignore(TEST_DIR)
|
||||||
|
|
||||||
|
expect(checker.shouldExclude('node_modules')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('node_modules/package.json')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('test.log')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('dist/')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('dist/main.js')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('src/index.ts')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should ignore comments and empty lines', () => {
|
||||||
|
writeFileSync(
|
||||||
|
join(TEST_DIR, '.gitignore'),
|
||||||
|
'# Comment\nnode_modules\n\n# Another comment\n*.log\n\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
const checker = loadGitignore(TEST_DIR)
|
||||||
|
|
||||||
|
expect(checker.shouldExclude('node_modules')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('test.log')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('# Comment')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle wildcard patterns', () => {
|
||||||
|
writeFileSync(join(TEST_DIR, '.gitignore'), '*.log\n*.tmp\n*.swp')
|
||||||
|
|
||||||
|
const checker = loadGitignore(TEST_DIR)
|
||||||
|
|
||||||
|
expect(checker.shouldExclude('debug.log')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('cache.tmp')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('vim.swp')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('src/file.log')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('index.ts')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle glob patterns', () => {
|
||||||
|
writeFileSync(join(TEST_DIR, '.gitignore'), '**/*.log\n**/dist\n**/.DS_Store')
|
||||||
|
|
||||||
|
const checker = loadGitignore(TEST_DIR)
|
||||||
|
|
||||||
|
expect(checker.shouldExclude('test.log')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('src/test.log')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('src/nested/test.log')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('dist')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('src/dist')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('.DS_Store')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('src/.DS_Store')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle directory patterns', () => {
|
||||||
|
writeFileSync(join(TEST_DIR, '.gitignore'), 'dist/\nbuild/\ntemp')
|
||||||
|
|
||||||
|
const checker = loadGitignore(TEST_DIR)
|
||||||
|
|
||||||
|
expect(checker.shouldExclude('dist/')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('dist/main.js')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('build/')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('build/output.js')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('temp')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('temp/file.txt')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle nested paths', () => {
|
||||||
|
writeFileSync(join(TEST_DIR, '.gitignore'), 'node_modules\n.git\n.env.local')
|
||||||
|
|
||||||
|
const checker = loadGitignore(TEST_DIR)
|
||||||
|
|
||||||
|
expect(checker.shouldExclude('src/node_modules/lib.js')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('project/node_modules')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('.git/config')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('.env.local')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('config/.env.local')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle exact filename matches', () => {
|
||||||
|
writeFileSync(join(TEST_DIR, '.gitignore'), '.DS_Store\nbun.lockb')
|
||||||
|
|
||||||
|
const checker = loadGitignore(TEST_DIR)
|
||||||
|
|
||||||
|
expect(checker.shouldExclude('.DS_Store')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('src/.DS_Store')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('bun.lockb')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('project/bun.lockb')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('without .gitignore file (default patterns)', () => {
|
||||||
|
test('should use default exclusions', () => {
|
||||||
|
const checker = loadGitignore(TEST_DIR)
|
||||||
|
|
||||||
|
expect(checker.shouldExclude('node_modules')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('node_modules/pkg/index.js')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('.DS_Store')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('src/.DS_Store')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('debug.log')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('dist')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('dist/main.js')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('build')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('.env.local')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('.git')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('.git/config')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('bun.lockb')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should not exclude normal files', () => {
|
||||||
|
const checker = loadGitignore(TEST_DIR)
|
||||||
|
|
||||||
|
expect(checker.shouldExclude('src/index.ts')).toBe(false)
|
||||||
|
expect(checker.shouldExclude('package.json')).toBe(false)
|
||||||
|
expect(checker.shouldExclude('README.md')).toBe(false)
|
||||||
|
expect(checker.shouldExclude('tsconfig.json')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
test('should handle empty .gitignore', () => {
|
||||||
|
writeFileSync(join(TEST_DIR, '.gitignore'), '')
|
||||||
|
|
||||||
|
const checker = loadGitignore(TEST_DIR)
|
||||||
|
|
||||||
|
expect(checker.shouldExclude('anything.js')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle .gitignore with only comments', () => {
|
||||||
|
writeFileSync(join(TEST_DIR, '.gitignore'), '# Comment 1\n# Comment 2')
|
||||||
|
|
||||||
|
const checker = loadGitignore(TEST_DIR)
|
||||||
|
|
||||||
|
expect(checker.shouldExclude('anything.js')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle paths with dots', () => {
|
||||||
|
writeFileSync(join(TEST_DIR, '.gitignore'), '*.env.*\n.cache')
|
||||||
|
|
||||||
|
const checker = loadGitignore(TEST_DIR)
|
||||||
|
|
||||||
|
expect(checker.shouldExclude('.env.local')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('.env.production')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('.cache')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('src/.cache')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should handle patterns with trailing slashes', () => {
|
||||||
|
writeFileSync(join(TEST_DIR, '.gitignore'), 'node_modules/\nbuild/')
|
||||||
|
|
||||||
|
const checker = loadGitignore(TEST_DIR)
|
||||||
|
|
||||||
|
expect(checker.shouldExclude('node_modules/')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('node_modules/package')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('build/')).toBe(true)
|
||||||
|
expect(checker.shouldExclude('build/output')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
77
src/shared/gitignore.ts
Normal file
77
src/shared/gitignore.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { existsSync, readFileSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
const DEFAULT_PATTERNS = [
|
||||||
|
'node_modules',
|
||||||
|
'.DS_Store',
|
||||||
|
'*.log',
|
||||||
|
'dist',
|
||||||
|
'build',
|
||||||
|
'.env.local',
|
||||||
|
'.git',
|
||||||
|
'bun.lockb',
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface GitignoreChecker {
|
||||||
|
shouldExclude: (path: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadGitignore(appPath: string): GitignoreChecker {
|
||||||
|
const gitignorePath = join(appPath, '.gitignore')
|
||||||
|
const patterns = existsSync(gitignorePath)
|
||||||
|
? parseGitignore(readFileSync(gitignorePath, 'utf-8'))
|
||||||
|
: DEFAULT_PATTERNS
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldExclude: (path: string) => matchesPattern(path, patterns),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGitignore(content: string): string[] {
|
||||||
|
return content
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(line => line && !line.startsWith('#'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesPattern(path: string, patterns: string[]): boolean {
|
||||||
|
const parts = path.split('/')
|
||||||
|
const filename = parts[parts.length - 1]!
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
// Patterns with wildcards or globs
|
||||||
|
if (pattern.includes('*') || pattern.includes('?')) {
|
||||||
|
const regex = globToRegex(pattern)
|
||||||
|
// Try matching against full path and just filename
|
||||||
|
if (regex.test(path) || regex.test(filename)) return true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory match: "node_modules" or "node_modules/"
|
||||||
|
const dirPattern = pattern.endsWith('/') ? pattern.slice(0, -1) : pattern
|
||||||
|
if (parts.includes(dirPattern)) return true
|
||||||
|
|
||||||
|
// Prefix patterns: "dist/" or "build/"
|
||||||
|
if (pattern.endsWith('/') && path.startsWith(pattern)) return true
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if (path === pattern || filename === pattern) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function globToRegex(glob: string): RegExp {
|
||||||
|
// Handle ** specially - it can match zero or more path segments
|
||||||
|
// Use placeholders to avoid conflicts with regex escaping
|
||||||
|
let pattern = glob
|
||||||
|
.replace(/\*\*\//g, '\x00') // **/ placeholder
|
||||||
|
.replace(/\*\*/g, '\x01') // ** placeholder
|
||||||
|
.replace(/\./g, '\\.') // Escape dots
|
||||||
|
.replace(/\*/g, '[^/]*') // Single * matches within path segment
|
||||||
|
.replace(/\?/g, '[^/]') // ? matches single char
|
||||||
|
.replace(/\x00/g, '(.*/)?') // **/ matches zero or more dirs
|
||||||
|
.replace(/\x01/g, '.*') // ** matches anything
|
||||||
|
|
||||||
|
return new RegExp(`^${pattern}$`)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user