Compare commits
4 Commits
fb7f94b85c
...
a47e5b8298
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a47e5b8298 | ||
|
|
1da7e77f00 | ||
|
|
bde7a2c287 | ||
|
|
28948b13b2 |
13
README.md
13
README.md
|
|
@ -17,6 +17,19 @@ Plug it in, turn it on, and forget about the cloud.
|
||||||
- https://toes.local web UI for managing your projects.
|
- https://toes.local web UI for managing your projects.
|
||||||
- Per-branch staging environments for Claude.
|
- Per-branch staging environments for Claude.
|
||||||
|
|
||||||
|
## cli configuration
|
||||||
|
|
||||||
|
by default, the CLI connects to `localhost:3000` in dev and `toes.local:80` in production.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
toes config # show current host
|
||||||
|
TOES_URL=http://192.168.1.50:3000 toes list # full URL
|
||||||
|
TOES_HOST=mypi.local toes list # hostname (port 80)
|
||||||
|
TOES_HOST=mypi.local PORT=3000 toes list # hostname + port
|
||||||
|
```
|
||||||
|
|
||||||
|
set `NODE_ENV=production` to default to `toes.local:80`.
|
||||||
|
|
||||||
## fun stuff
|
## fun stuff
|
||||||
|
|
||||||
- textOS (TODO, more?)
|
- textOS (TODO, more?)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@
|
||||||
"dev": "bun run --hot index.tsx"
|
"dev": "bun run --hot index.tsx"
|
||||||
},
|
},
|
||||||
"toes": {
|
"toes": {
|
||||||
"icon": "🖥️"
|
"tool": true,
|
||||||
|
"icon": "💻"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
|
|
|
||||||
1
apps/todo/.npmrc
Normal file
1
apps/todo/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
registry=https://npm.nose.space
|
||||||
43
apps/todo/bun.lock
Normal file
43
apps/todo/bun.lock
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "todo",
|
||||||
|
"dependencies": {
|
||||||
|
"@because/forge": "*",
|
||||||
|
"@because/howl": "*",
|
||||||
|
"@because/hype": "*",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@because/forge": ["@because/forge@0.0.1", "https://npm.nose.space/@because/forge/-/forge-0.0.1.tgz", { "peerDependencies": { "typescript": "^5" } }, "sha512-QS5CK51gcWma91i4uECWe4HPJeNHcE+Af4SQHOcfEovyzOEa7VOTAjei+jIWr2i+abGWqQCEC9wIuFgPgyr2Bg=="],
|
||||||
|
|
||||||
|
"@because/howl": ["@because/howl@0.0.2", "https://npm.nose.space/@because/howl/-/howl-0.0.2.tgz", { "dependencies": { "lucide-static": "^0.555.0" }, "peerDependencies": { "@because/forge": "*", "typescript": "^5" } }, "sha512-Z4okzEa282LKkBk9DQwEUU6FT+PeThfQ6iQAY41LIEjs8B2kfXRZnbWLs7tgpwCfYORxb0RO4Hr7KiyEqnfTvQ=="],
|
||||||
|
|
||||||
|
"@because/hype": ["@because/hype@0.0.1", "https://npm.nose.space/@because/hype/-/hype-0.0.1.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-i92DNUXJOwt3J8dN1x8sh7i86blelcTCk8XDpwD839Ic8oe710lkDSVXJ7xYZb/i8YtzGhRg+L6eXDhaRiU2Pw=="],
|
||||||
|
|
||||||
|
"@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=="],
|
||||||
|
|
||||||
|
"@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=="],
|
||||||
|
|
||||||
|
"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", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
||||||
|
|
||||||
|
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
|
"lucide-static": ["lucide-static@0.555.0", "https://npm.nose.space/lucide-static/-/lucide-static-0.555.0.tgz", {}, "sha512-FMMaYYsEYsUA6xlEzIMoKEV3oGnxIIvAN+AtLmYXvlTJptJTveJjVBQwvtA/zZLrD6KLEu89G95dQYlhivw5jQ=="],
|
||||||
|
|
||||||
|
"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", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/todo/index.tsx
Normal file
1
apps/todo/index.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from './src/server'
|
||||||
26
apps/todo/package.json
Normal file
26
apps/todo/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "todo",
|
||||||
|
"module": "index.tsx",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"toes": "bun run --watch index.tsx",
|
||||||
|
"start": "bun toes",
|
||||||
|
"dev": "bun run --hot index.tsx"
|
||||||
|
},
|
||||||
|
"toes": {
|
||||||
|
"tool": true,
|
||||||
|
"icon": "🖥️"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@because/hype": "*",
|
||||||
|
"@because/forge": "*",
|
||||||
|
"@because/howl": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
apps/todo/pub/img/bite1.png
Normal file
BIN
apps/todo/pub/img/bite1.png
Normal file
Binary file not shown.
BIN
apps/todo/pub/img/bite2.png
Normal file
BIN
apps/todo/pub/img/bite2.png
Normal file
Binary file not shown.
BIN
apps/todo/pub/img/burger.png
Normal file
BIN
apps/todo/pub/img/burger.png
Normal file
Binary file not shown.
36
apps/todo/src/client/App.tsx
Normal file
36
apps/todo/src/client/App.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { render, useState } from 'hono/jsx/dom'
|
||||||
|
import { define } from '@because/forge'
|
||||||
|
|
||||||
|
const Wrapper = define({
|
||||||
|
margin: '0 auto',
|
||||||
|
marginTop: 50,
|
||||||
|
width: '50vw',
|
||||||
|
border: '1px solid black',
|
||||||
|
padding: 24,
|
||||||
|
textAlign: 'center'
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [count, setCount] = useState(0)
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<h1>It works!</h1>
|
||||||
|
<h2>Count: {count}</h2>
|
||||||
|
<div>
|
||||||
|
<button onClick={() => setCount(c => c + 1)}>+</button>
|
||||||
|
|
||||||
|
<button onClick={() => setCount(c => c && c - 1)}>-</button>
|
||||||
|
</div>
|
||||||
|
</Wrapper>
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Render error:', error)
|
||||||
|
return <><h1>Error</h1><pre>{error instanceof Error ? error : new Error(String(error))}</pre></>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = document.getElementById('root')!
|
||||||
|
render(<App />, root)
|
||||||
40
apps/todo/src/css/main.css
Normal file
40
apps/todo/src/css/main.css
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
section {
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 200%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hype {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
background: linear-gradient(45deg,
|
||||||
|
#ff00ff 0%,
|
||||||
|
#00ffff 33%,
|
||||||
|
#ffff00 66%,
|
||||||
|
#ff00ff 100%);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
animation: gradientShift 15s ease infinite;
|
||||||
|
color: black;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradientShift {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
31
apps/todo/src/pages/index.tsx
Normal file
31
apps/todo/src/pages/index.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { $ } from 'bun'
|
||||||
|
|
||||||
|
const GIT_HASH = process.env.RENDER_GIT_COMMIT?.slice(0, 7)
|
||||||
|
|| await $`git rev-parse --short HEAD`.text().then(s => s.trim()).catch(() => 'unknown')
|
||||||
|
|
||||||
|
export default () => <>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>hype</title>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
|
||||||
|
<link href={`/css/main.css?${GIT_HASH}`} rel="stylesheet" />
|
||||||
|
<script dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
window.GIT_HASH = '${GIT_HASH}';
|
||||||
|
${(process.env.NODE_ENV !== 'production' || process.env.IS_PULL_REQUEST === 'true') ? 'window.DEBUG = true;' : ''}
|
||||||
|
`
|
||||||
|
}} />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="viewport">
|
||||||
|
<main>
|
||||||
|
<div id="root" />
|
||||||
|
<script src={`/client/app.js?${GIT_HASH}`} type="module" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
</>
|
||||||
8
apps/todo/src/server/index.tsx
Normal file
8
apps/todo/src/server/index.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
|
||||||
|
const app = new Hype({ layout: false })
|
||||||
|
|
||||||
|
// custom routes go here
|
||||||
|
// app.get("/my-custom-routes", (c) => c.text("wild, wild stuff"))
|
||||||
|
|
||||||
|
export default app.defaults
|
||||||
0
apps/todo/src/shared/types.ts
Normal file
0
apps/todo/src/shared/types.ts
Normal file
29
apps/todo/tsconfig.json
Normal file
29
apps/todo/tsconfig.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"$*": ["src/server/*"],
|
||||||
|
"#*": ["src/client/*"],
|
||||||
|
"@*": ["src/shared/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
bun.lock
8
bun.lock
|
|
@ -5,13 +5,15 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "toes",
|
"name": "toes",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@because/forge": "^0.0.1.",
|
"@because/forge": "^0.0.1",
|
||||||
"@because/hype": "^0.0.1",
|
"@because/hype": "^0.0.1",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
|
"diff": "^8.0.3",
|
||||||
"kleur": "^4.1.5",
|
"kleur": "^4.1.5",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
"@types/diff": "^8.0.0",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
|
|
@ -25,12 +27,16 @@
|
||||||
|
|
||||||
"@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=="],
|
"@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=="],
|
||||||
|
|
||||||
|
"@types/diff": ["@types/diff@8.0.0", "https://npm.nose.space/@types/diff/-/diff-8.0.0.tgz", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="],
|
||||||
|
|
||||||
"@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=="],
|
"@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=="],
|
||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
|
|
||||||
"commander": ["commander@14.0.2", "https://npm.nose.space/commander/-/commander-14.0.2.tgz", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
|
"commander": ["commander@14.0.2", "https://npm.nose.space/commander/-/commander-14.0.2.tgz", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
|
||||||
|
|
||||||
|
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||||
|
|
||||||
"hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
"hono": ["hono@4.11.7", "https://npm.nose.space/hono/-/hono-4.11.7.tgz", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
||||||
|
|
||||||
"kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "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=="],
|
||||||
|
|
|
||||||
|
|
@ -23,15 +23,17 @@
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest",
|
||||||
|
"@types/diff": "^8.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^14.0.2",
|
|
||||||
"@because/forge": "^0.0.1",
|
"@because/forge": "^0.0.1",
|
||||||
"@because/hype": "^0.0.1",
|
"@because/hype": "^0.0.1",
|
||||||
|
"commander": "^14.0.2",
|
||||||
|
"diff": "^8.0.3",
|
||||||
"kleur": "^4.1.5"
|
"kleur": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export { logApp } from './logs'
|
export { logApp } from './logs'
|
||||||
export {
|
export {
|
||||||
|
configShow,
|
||||||
infoApp,
|
infoApp,
|
||||||
listApps,
|
listApps,
|
||||||
newApp,
|
newApp,
|
||||||
|
|
@ -10,4 +11,4 @@ export {
|
||||||
startApp,
|
startApp,
|
||||||
stopApp,
|
stopApp,
|
||||||
} from './manage'
|
} from './manage'
|
||||||
export { getApp, pullApp, pushApp, syncApp } from './sync'
|
export { diffApp, getApp, pullApp, pushApp, statusApp, syncApp } from './sync'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { LogLine } from '@types'
|
import type { LogLine } from '@types'
|
||||||
import { get, makeUrl } from '../http'
|
import { get, handleError, makeUrl } from '../http'
|
||||||
import { resolveAppName } from '../name'
|
import { resolveAppName } from '../name'
|
||||||
|
|
||||||
export const printLog = (line: LogLine) =>
|
export const printLog = (line: LogLine) =>
|
||||||
|
|
@ -29,6 +29,7 @@ export async function logApp(arg: string | undefined, options: { follow?: boolea
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function tailLogs(name: string) {
|
export async function tailLogs(name: string) {
|
||||||
|
try {
|
||||||
const url = makeUrl(`/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) {
|
||||||
|
|
@ -56,4 +57,7 @@ export async function tailLogs(name: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { generateTemplates, type TemplateType } from '%templates'
|
||||||
import color from 'kleur'
|
import color from 'kleur'
|
||||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||||
import { basename, join } from 'path'
|
import { basename, join } from 'path'
|
||||||
import { del, get, getManifest, 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'
|
import { pushApp } from './sync'
|
||||||
|
|
@ -15,6 +15,33 @@ export const STATE_ICONS: Record<string, string> = {
|
||||||
invalid: color.red('◌'),
|
invalid: color.red('◌'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function configShow() {
|
||||||
|
console.log(`Host: ${color.bold(HOST)}`)
|
||||||
|
|
||||||
|
const source = process.env.TOES_URL
|
||||||
|
? 'TOES_URL'
|
||||||
|
: process.env.TOES_HOST
|
||||||
|
? 'TOES_HOST' + (process.env.PORT ? ' + PORT' : '')
|
||||||
|
: process.env.NODE_ENV === 'production'
|
||||||
|
? 'default (production)'
|
||||||
|
: 'default (development)'
|
||||||
|
|
||||||
|
console.log(`Source: ${color.gray(source)}`)
|
||||||
|
|
||||||
|
if (process.env.TOES_URL) {
|
||||||
|
console.log(` TOES_URL=${process.env.TOES_URL}`)
|
||||||
|
}
|
||||||
|
if (process.env.TOES_HOST) {
|
||||||
|
console.log(` TOES_HOST=${process.env.TOES_HOST}`)
|
||||||
|
}
|
||||||
|
if (process.env.PORT) {
|
||||||
|
console.log(` PORT=${process.env.PORT}`)
|
||||||
|
}
|
||||||
|
if (process.env.NODE_ENV) {
|
||||||
|
console.log(` NODE_ENV=${process.env.NODE_ENV}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,21 @@ import type { Manifest } from '@types'
|
||||||
import { loadGitignore } from '@gitignore'
|
import { loadGitignore } from '@gitignore'
|
||||||
import { computeHash, generateManifest } from '%sync'
|
import { computeHash, generateManifest } from '%sync'
|
||||||
import color from 'kleur'
|
import color from 'kleur'
|
||||||
|
import { diffLines } from 'diff'
|
||||||
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, watch, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, watch, writeFileSync } from 'fs'
|
||||||
import { dirname, join } from 'path'
|
import { dirname, join } from 'path'
|
||||||
import { del, download, get, getManifest, handleError, makeUrl, post, put } from '../http'
|
import { del, download, get, getManifest, handleError, makeUrl, post, put } from '../http'
|
||||||
import { confirm } from '../prompts'
|
import { confirm } from '../prompts'
|
||||||
import { getAppName, isApp } from '../name'
|
import { getAppName, isApp } from '../name'
|
||||||
|
|
||||||
|
interface ManifestDiff {
|
||||||
|
changed: string[]
|
||||||
|
localOnly: string[]
|
||||||
|
remoteOnly: string[]
|
||||||
|
localManifest: Manifest
|
||||||
|
remoteManifest: Manifest | null
|
||||||
|
}
|
||||||
|
|
||||||
export async function getApp(name: string) {
|
export async function getApp(name: string) {
|
||||||
console.log(`Fetching ${color.bold(name)} from server...`)
|
console.log(`Fetching ${color.bold(name)} from server...`)
|
||||||
|
|
||||||
|
|
@ -136,42 +145,40 @@ export async function pushApp() {
|
||||||
console.log(color.green(`✓ Deployed and activated version ${version}`))
|
console.log(color.green(`✓ Deployed and activated version ${version}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pullApp() {
|
export async function pullApp(options: { force?: boolean } = {}) {
|
||||||
if (!isApp()) {
|
if (!isApp()) {
|
||||||
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const appName = getAppName()
|
const appName = getAppName()
|
||||||
|
const diff = await getManifestDiff(appName)
|
||||||
|
|
||||||
|
if (diff === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { changed, localOnly, remoteOnly, remoteManifest } = diff
|
||||||
|
|
||||||
const remoteManifest: Manifest | undefined = await get(`/api/sync/apps/${appName}/manifest`)
|
|
||||||
if (!remoteManifest) {
|
if (!remoteManifest) {
|
||||||
console.error('App not found on server')
|
console.error('App not found on server')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const localManifest = generateManifest(process.cwd(), appName)
|
// Check for local changes that would be overwritten
|
||||||
|
const wouldOverwrite = changed.length > 0 || localOnly.length > 0
|
||||||
const localFiles = new Set(Object.keys(localManifest.files))
|
if (wouldOverwrite && !options.force) {
|
||||||
const remoteFiles = new Set(Object.keys(remoteManifest.files))
|
console.error('Cannot pull: you have local changes that would be overwritten')
|
||||||
|
console.error(' Use `toes status` and `toes diff` to see differences')
|
||||||
// Files to download (new or changed)
|
console.error(' Use `toes pull --force` to overwrite local changes')
|
||||||
const toDownload: string[] = []
|
return
|
||||||
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)
|
// Files to download: changed + remoteOnly
|
||||||
const toDelete: string[] = []
|
const toDownload = [...changed, ...remoteOnly]
|
||||||
for (const file of localFiles) {
|
|
||||||
if (!remoteFiles.has(file)) {
|
// Files to delete: localOnly
|
||||||
toDelete.push(file)
|
const toDelete = localOnly
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toDownload.length === 0 && toDelete.length === 0) {
|
if (toDownload.length === 0 && toDelete.length === 0) {
|
||||||
console.log('Already up to date')
|
console.log('Already up to date')
|
||||||
|
|
@ -213,13 +220,173 @@ export async function pullApp() {
|
||||||
console.log(color.green('✓ Pull complete'))
|
console.log(color.green('✓ Pull complete'))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncApp() {
|
export async function diffApp() {
|
||||||
if (!isApp()) {
|
if (!isApp()) {
|
||||||
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const appName = getAppName()
|
const appName = getAppName()
|
||||||
|
const diff = await getManifestDiff(appName)
|
||||||
|
|
||||||
|
if (diff === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { changed, localOnly, remoteOnly } = diff
|
||||||
|
|
||||||
|
if (changed.length === 0 && localOnly.length === 0 && remoteOnly.length === 0) {
|
||||||
|
console.log(color.green('✓ No differences'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all changed files in parallel
|
||||||
|
const remoteContents = await Promise.all(
|
||||||
|
changed.map(file => download(`/api/sync/apps/${appName}/files/${file}`))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Show diffs for changed files
|
||||||
|
for (let i = 0; i < changed.length; i++) {
|
||||||
|
const file = changed[i]!
|
||||||
|
const remoteContent = remoteContents[i]
|
||||||
|
const localContent = readFileSync(join(process.cwd(), file), 'utf-8')
|
||||||
|
|
||||||
|
if (!remoteContent) {
|
||||||
|
console.log(color.red(`Failed to fetch remote version of ${file}`))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteText = new TextDecoder().decode(remoteContent)
|
||||||
|
|
||||||
|
console.log(color.bold(`\n${file}`))
|
||||||
|
console.log(color.gray('─'.repeat(60)))
|
||||||
|
showDiff(remoteText, localContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show local-only files
|
||||||
|
for (const file of localOnly) {
|
||||||
|
console.log(color.green('\nNew file (local only)'))
|
||||||
|
console.log(color.bold(`${file}`))
|
||||||
|
console.log(color.gray('─'.repeat(60)))
|
||||||
|
const content = readFileSync(join(process.cwd(), file), 'utf-8')
|
||||||
|
const lines = content.split('\n')
|
||||||
|
for (let i = 0; i < Math.min(lines.length, 10); i++) {
|
||||||
|
console.log(color.green(`+ ${lines[i]}`))
|
||||||
|
}
|
||||||
|
if (lines.length > 10) {
|
||||||
|
console.log(color.gray(`... ${lines.length - 10} more lines`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all remote-only files in parallel
|
||||||
|
const remoteOnlyContents = await Promise.all(
|
||||||
|
remoteOnly.map(file => download(`/api/sync/apps/${appName}/files/${file}`))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Show remote-only files
|
||||||
|
for (let i = 0; i < remoteOnly.length; i++) {
|
||||||
|
const file = remoteOnly[i]!
|
||||||
|
const content = remoteOnlyContents[i]
|
||||||
|
|
||||||
|
console.log(color.bold(`\n${file}`))
|
||||||
|
console.log(color.gray('─'.repeat(60)))
|
||||||
|
console.log(color.red('Remote only (would be deleted on push)'))
|
||||||
|
if (content) {
|
||||||
|
const text = new TextDecoder().decode(content)
|
||||||
|
const lines = text.split('\n')
|
||||||
|
for (let i = 0; i < Math.min(lines.length, 10); i++) {
|
||||||
|
console.log(color.red(`- ${lines[i]}`))
|
||||||
|
}
|
||||||
|
if (lines.length > 10) {
|
||||||
|
console.log(color.gray(`... ${lines.length - 10} more lines`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function statusApp() {
|
||||||
|
if (!isApp()) {
|
||||||
|
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const appName = getAppName()
|
||||||
|
const diff = await getManifestDiff(appName)
|
||||||
|
|
||||||
|
if (diff === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { changed, localOnly, remoteOnly, localManifest, remoteManifest } = diff
|
||||||
|
|
||||||
|
// toPush = changed + localOnly (new or modified locally)
|
||||||
|
const toPush = [...changed, ...localOnly]
|
||||||
|
|
||||||
|
// Local changes block pull
|
||||||
|
const hasLocalChanges = toPush.length > 0
|
||||||
|
|
||||||
|
// Display status
|
||||||
|
console.log(`Status for ${color.bold(appName)}:\n`)
|
||||||
|
|
||||||
|
if (!remoteManifest) {
|
||||||
|
console.log(color.yellow('App does not exist on server'))
|
||||||
|
const localFileCount = Object.keys(localManifest.files).length
|
||||||
|
console.log(`\nWould create new app with ${localFileCount} files on push\n`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push status
|
||||||
|
if (toPush.length > 0 || remoteOnly.length > 0) {
|
||||||
|
console.log(color.bold('Changes to push:'))
|
||||||
|
for (const file of toPush) {
|
||||||
|
console.log(` ${color.green('↑')} ${file}`)
|
||||||
|
}
|
||||||
|
for (const file of remoteOnly) {
|
||||||
|
console.log(` ${color.red('✗')} ${file}`)
|
||||||
|
}
|
||||||
|
console.log()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull status (only show if no local changes blocking)
|
||||||
|
if (!hasLocalChanges && remoteOnly.length > 0) {
|
||||||
|
console.log(color.bold('Changes to pull:'))
|
||||||
|
for (const file of remoteOnly) {
|
||||||
|
console.log(` ${color.green('↓')} ${file}`)
|
||||||
|
}
|
||||||
|
console.log()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
if (toPush.length === 0 && remoteOnly.length === 0) {
|
||||||
|
console.log(color.green('✓ In sync with server'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncApp(options?: { rollback?: boolean }) {
|
||||||
|
if (!isApp()) {
|
||||||
|
console.error('Not a toes app. Use `toes get <app>` to grab one.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const appName = getAppName()
|
||||||
|
|
||||||
|
// Handle rollback
|
||||||
|
if (options?.rollback) {
|
||||||
|
console.log(`Rolling back ${color.bold(appName)} to sync checkpoint...`)
|
||||||
|
type RollbackResponse = { ok: boolean, version?: string, error?: string }
|
||||||
|
const result = await post<RollbackResponse>(`/api/sync/apps/${appName}/sync/rollback`)
|
||||||
|
|
||||||
|
if (!result?.ok) {
|
||||||
|
console.error(result?.error || 'Failed to rollback')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(color.green(`✓ Rolled back to checkpoint (version ${result.version})`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const gitignore = loadGitignore(process.cwd())
|
const gitignore = loadGitignore(process.cwd())
|
||||||
const localHashes = new Map<string, string>()
|
const localHashes = new Map<string, string>()
|
||||||
|
|
||||||
|
|
@ -230,6 +397,7 @@ export async function syncApp() {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Syncing ${color.bold(appName)}...`)
|
console.log(`Syncing ${color.bold(appName)}...`)
|
||||||
|
console.log(color.gray(`Checkpoint created - run 'toes sync --rollback' to undo changes`))
|
||||||
|
|
||||||
// Watch local files
|
// Watch local files
|
||||||
const watcher = watch(process.cwd(), { recursive: true }, async (_event, filename) => {
|
const watcher = watch(process.cwd(), { recursive: true }, async (_event, filename) => {
|
||||||
|
|
@ -318,3 +486,132 @@ export async function syncApp() {
|
||||||
watcher.close()
|
watcher.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getManifestDiff(appName: string): Promise<ManifestDiff | null> {
|
||||||
|
const localManifest = generateManifest(process.cwd(), appName)
|
||||||
|
const result = await getManifest(appName)
|
||||||
|
|
||||||
|
if (result === null) {
|
||||||
|
// Connection error - already printed
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const localFiles = new Set(Object.keys(localManifest.files))
|
||||||
|
const remoteFiles = new Set(Object.keys(result.manifest?.files ?? {}))
|
||||||
|
|
||||||
|
// Files that differ
|
||||||
|
const changed: string[] = []
|
||||||
|
for (const file of localFiles) {
|
||||||
|
if (remoteFiles.has(file)) {
|
||||||
|
const local = localManifest.files[file]!
|
||||||
|
const remote = result.manifest!.files[file]!
|
||||||
|
if (local.hash !== remote.hash) {
|
||||||
|
changed.push(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files only in local
|
||||||
|
const localOnly: string[] = []
|
||||||
|
for (const file of localFiles) {
|
||||||
|
if (!remoteFiles.has(file)) {
|
||||||
|
localOnly.push(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files only in remote
|
||||||
|
const remoteOnly: string[] = []
|
||||||
|
for (const file of remoteFiles) {
|
||||||
|
if (!localFiles.has(file)) {
|
||||||
|
remoteOnly.push(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
changed,
|
||||||
|
localOnly,
|
||||||
|
remoteOnly,
|
||||||
|
localManifest,
|
||||||
|
remoteManifest: result.manifest ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDiff(remote: string, local: string) {
|
||||||
|
const changes = diffLines(remote, local)
|
||||||
|
let lineCount = 0
|
||||||
|
const maxLines = 50
|
||||||
|
const contextLines = 3
|
||||||
|
|
||||||
|
for (let i = 0; i < changes.length; i++) {
|
||||||
|
const part = changes[i]!
|
||||||
|
const lines = part.value.replace(/\n$/, '').split('\n')
|
||||||
|
|
||||||
|
if (part.added) {
|
||||||
|
for (const line of lines) {
|
||||||
|
if (lineCount >= maxLines) {
|
||||||
|
console.log(color.gray('... diff truncated'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(color.green(`+ ${line}`))
|
||||||
|
lineCount++
|
||||||
|
}
|
||||||
|
} else if (part.removed) {
|
||||||
|
for (const line of lines) {
|
||||||
|
if (lineCount >= maxLines) {
|
||||||
|
console.log(color.gray('... diff truncated'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(color.red(`- ${line}`))
|
||||||
|
lineCount++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Context: show lines near changes
|
||||||
|
const prevHasChange = i > 0 && (changes[i - 1]!.added || changes[i - 1]!.removed)
|
||||||
|
const nextHasChange = i < changes.length - 1 && (changes[i + 1]!.added || changes[i + 1]!.removed)
|
||||||
|
|
||||||
|
if (prevHasChange && nextHasChange && lines.length <= contextLines * 2) {
|
||||||
|
// Small gap between changes - show all
|
||||||
|
for (const line of lines) {
|
||||||
|
if (lineCount >= maxLines) {
|
||||||
|
console.log(color.gray('... diff truncated'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(color.gray(` ${line}`))
|
||||||
|
lineCount++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show context before next change
|
||||||
|
if (nextHasChange) {
|
||||||
|
const start = Math.max(0, lines.length - contextLines)
|
||||||
|
if (start > 0) {
|
||||||
|
console.log(color.gray(' ...'))
|
||||||
|
lineCount++
|
||||||
|
}
|
||||||
|
for (let j = start; j < lines.length; j++) {
|
||||||
|
if (lineCount >= maxLines) {
|
||||||
|
console.log(color.gray('... diff truncated'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(color.gray(` ${lines[j]}`))
|
||||||
|
lineCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Show context after previous change
|
||||||
|
if (prevHasChange) {
|
||||||
|
const end = Math.min(lines.length, contextLines)
|
||||||
|
for (let j = 0; j < end; j++) {
|
||||||
|
if (lineCount >= maxLines) {
|
||||||
|
console.log(color.gray('... diff truncated'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(color.gray(` ${lines[j]}`))
|
||||||
|
lineCount++
|
||||||
|
}
|
||||||
|
if (end < lines.length && !nextHasChange) {
|
||||||
|
console.log(color.gray(' ...'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
import type { Manifest } from '@types'
|
import type { Manifest } from '@types'
|
||||||
|
|
||||||
export const HOST = `http://localhost:${process.env.PORT ?? 3000}`
|
function getDefaultHost(): string {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
return `http://toes.local:${process.env.PORT ?? 80}`
|
||||||
|
}
|
||||||
|
return `http://localhost:${process.env.PORT ?? 3000}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultPort = process.env.NODE_ENV === 'production' ? 80 : 3000
|
||||||
|
|
||||||
|
export const HOST = process.env.TOES_URL
|
||||||
|
?? (process.env.TOES_HOST ? `http://${process.env.TOES_HOST}:${process.env.PORT ?? defaultPort}` : undefined)
|
||||||
|
?? getDefaultHost()
|
||||||
|
|
||||||
export function makeUrl(path: string): string {
|
export function makeUrl(path: string): string {
|
||||||
return `${HOST}${path}`
|
return `${HOST}${path}`
|
||||||
|
|
@ -9,6 +20,7 @@ export function makeUrl(path: string): string {
|
||||||
export function handleError(error: unknown): void {
|
export function handleError(error: unknown): void {
|
||||||
if (error instanceof Error && 'code' in error && error.code === 'ConnectionRefused') {
|
if (error instanceof Error && 'code' in error && error.code === 'ConnectionRefused') {
|
||||||
console.error(`🐾 Can't connect to toes server at ${HOST}`)
|
console.error(`🐾 Can't connect to toes server at ${HOST}`)
|
||||||
|
console.error(` Set TOES_URL or TOES_HOST to connect to a different host`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { readFileSync } from 'fs'
|
||||||
|
|
||||||
import color from 'kleur'
|
import color from 'kleur'
|
||||||
import {
|
import {
|
||||||
|
configShow,
|
||||||
|
diffApp,
|
||||||
getApp,
|
getApp,
|
||||||
infoApp,
|
infoApp,
|
||||||
listApps,
|
listApps,
|
||||||
|
|
@ -15,6 +17,7 @@ import {
|
||||||
restartApp,
|
restartApp,
|
||||||
rmApp,
|
rmApp,
|
||||||
startApp,
|
startApp,
|
||||||
|
statusApp,
|
||||||
stopApp,
|
stopApp,
|
||||||
syncApp,
|
syncApp,
|
||||||
} from './commands'
|
} from './commands'
|
||||||
|
|
@ -43,6 +46,11 @@ program
|
||||||
.command('version', { hidden: true })
|
.command('version', { hidden: true })
|
||||||
.action(() => console.log(program.version()))
|
.action(() => console.log(program.version()))
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('config')
|
||||||
|
.description('Show current host configuration')
|
||||||
|
.action(configShow)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('info')
|
.command('info')
|
||||||
.description('Show info for an app')
|
.description('Show info for an app')
|
||||||
|
|
@ -114,11 +122,23 @@ program
|
||||||
program
|
program
|
||||||
.command('pull')
|
.command('pull')
|
||||||
.description('Pull changes from server')
|
.description('Pull changes from server')
|
||||||
|
.option('-f, --force', 'overwrite local changes')
|
||||||
.action(pullApp)
|
.action(pullApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('status')
|
||||||
|
.description('Show what would be pushed/pulled')
|
||||||
|
.action(statusApp)
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('diff')
|
||||||
|
.description('Show diff of changed files')
|
||||||
|
.action(diffApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('sync')
|
.command('sync')
|
||||||
.description('Watch and sync changes bidirectionally')
|
.description('Watch and sync changes bidirectionally')
|
||||||
|
.option('-r, --rollback', 'rollback to checkpoint before sync started')
|
||||||
.action(syncApp)
|
.action(syncApp)
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { define } from '@because/forge'
|
||||||
import type { App } from '../../shared/types'
|
import type { App } from '../../shared/types'
|
||||||
import { restartApp, startApp, stopApp } from '../api'
|
import { restartApp, startApp, stopApp } from '../api'
|
||||||
import { openDeleteAppModal, openRenameAppModal } from '../modals'
|
import { openDeleteAppModal, openRenameAppModal } from '../modals'
|
||||||
import { selectedTab } from '../state'
|
import { apps, selectedTab } from '../state'
|
||||||
import {
|
import {
|
||||||
ActionBar,
|
ActionBar,
|
||||||
Button,
|
Button,
|
||||||
|
|
@ -44,6 +44,9 @@ const OpenEmojiPicker = define('OpenEmojiPicker', {
|
||||||
})
|
})
|
||||||
|
|
||||||
export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
||||||
|
// Find all tools
|
||||||
|
const tools = apps.filter(a => a.tool)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Main>
|
<Main>
|
||||||
<MainHeader>
|
<MainHeader>
|
||||||
|
|
@ -57,7 +60,7 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
||||||
</HeaderActions>
|
</HeaderActions>
|
||||||
</MainHeader>
|
</MainHeader>
|
||||||
<MainContent>
|
<MainContent>
|
||||||
<Nav render={render} />
|
<Nav app={app} render={render} />
|
||||||
|
|
||||||
<TabContent active={selectedTab === 'overview'}>
|
<TabContent active={selectedTab === 'overview'}>
|
||||||
<Section>
|
<Section>
|
||||||
|
|
@ -142,9 +145,29 @@ export function AppDetail({ app, render }: { app: App, render: () => void }) {
|
||||||
</ActionBar>
|
</ActionBar>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
|
|
||||||
<TabContent active={selectedTab === 'todo'}>
|
{tools.map(tool => {
|
||||||
<h1>hardy har har</h1>
|
const toolName = typeof tool.tool === 'string' ? tool.tool : tool.name
|
||||||
|
const isSelected = selectedTab === tool.name
|
||||||
|
return (
|
||||||
|
<TabContent key={tool.name} active={isSelected}>
|
||||||
|
<Section>
|
||||||
|
<SectionTitle>{toolName}</SectionTitle>
|
||||||
|
{tool.state !== 'running' && (
|
||||||
|
<p style={{ color: theme('colors-textFaint') }}>
|
||||||
|
Tool is {stateLabels[tool.state].toLowerCase()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{/* Target for iframe overlay positioning */}
|
||||||
|
{tool.state === 'running' && (
|
||||||
|
<div
|
||||||
|
data-tool-target={isSelected ? tool.name : undefined}
|
||||||
|
style={{ width: '100%', height: '600px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</MainContent>
|
</MainContent>
|
||||||
</Main>
|
</Main>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,33 @@
|
||||||
import { selectedTab, setSelectedTab } from '../state'
|
import type { App } from '../../shared/types'
|
||||||
|
import { apps, selectedTab, setSelectedTab } from '../state'
|
||||||
import { Tab, TabBar } from '../styles'
|
import { Tab, TabBar } from '../styles'
|
||||||
|
|
||||||
export function Nav({ render }: { render: () => void }) {
|
export function Nav({ app, render }: { app: App; render: () => void }) {
|
||||||
const handleTabClick = (tab: 'overview' | 'todo') => {
|
const handleTabClick = (tab: string) => {
|
||||||
setSelectedTab(tab)
|
setSelectedTab(tab)
|
||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find all tools
|
||||||
|
const tools = apps.filter(a => a.tool)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabBar>
|
<TabBar>
|
||||||
<Tab active={selectedTab === 'overview' ? true : undefined} onClick={() => handleTabClick('overview')}>Overview</Tab>
|
<Tab active={selectedTab === 'overview' ? true : undefined} onClick={() => handleTabClick('overview')}>
|
||||||
<Tab active={selectedTab === 'todo' ? true : undefined} onClick={() => handleTabClick('todo')}>TODO</Tab>
|
Overview
|
||||||
|
</Tab>
|
||||||
|
{tools.map(tool => {
|
||||||
|
const toolName = typeof tool.tool === 'string' ? tool.tool : tool.name
|
||||||
|
return (
|
||||||
|
<Tab
|
||||||
|
key={tool.name}
|
||||||
|
active={selectedTab === tool.name ? true : undefined}
|
||||||
|
onClick={() => handleTabClick(tool.name)}
|
||||||
|
>
|
||||||
|
{toolName}
|
||||||
|
</Tab>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</TabBar>
|
</TabBar>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import { openNewAppModal } from '../modals'
|
import { openNewAppModal } from '../modals'
|
||||||
import { apps, selectedApp, setSelectedApp, setSidebarCollapsed, sidebarCollapsed } from '../state'
|
import {
|
||||||
|
apps,
|
||||||
|
selectedApp,
|
||||||
|
setSelectedApp,
|
||||||
|
setSidebarCollapsed,
|
||||||
|
setSidebarSection,
|
||||||
|
sidebarCollapsed,
|
||||||
|
sidebarSection,
|
||||||
|
} from '../state'
|
||||||
import {
|
import {
|
||||||
AppItem,
|
AppItem,
|
||||||
AppList,
|
AppList,
|
||||||
|
|
@ -7,7 +15,8 @@ import {
|
||||||
HamburgerLine,
|
HamburgerLine,
|
||||||
Logo,
|
Logo,
|
||||||
NewAppButton,
|
NewAppButton,
|
||||||
SectionLabel,
|
SectionSwitcher,
|
||||||
|
SectionTab,
|
||||||
Sidebar as SidebarContainer,
|
Sidebar as SidebarContainer,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
StatusDot,
|
StatusDot,
|
||||||
|
|
@ -24,6 +33,15 @@ export function Sidebar({ render }: { render: () => void }) {
|
||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const switchSection = (section: 'apps' | 'tools') => {
|
||||||
|
setSidebarSection(section)
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
const regularApps = apps.filter(app => !app.tool)
|
||||||
|
const toolApps = apps.filter(app => app.tool)
|
||||||
|
const activeApps = sidebarSection === 'apps' ? regularApps : toolApps
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarContainer style={sidebarCollapsed ? { width: 'auto' } : undefined}>
|
<SidebarContainer style={sidebarCollapsed ? { width: 'auto' } : undefined}>
|
||||||
<Logo>
|
<Logo>
|
||||||
|
|
@ -34,9 +52,18 @@ export function Sidebar({ render }: { render: () => void }) {
|
||||||
<HamburgerLine />
|
<HamburgerLine />
|
||||||
</HamburgerButton>
|
</HamburgerButton>
|
||||||
</Logo>
|
</Logo>
|
||||||
{!sidebarCollapsed && <SectionLabel>Apps</SectionLabel>}
|
{!sidebarCollapsed && toolApps.length > 0 && (
|
||||||
|
<SectionSwitcher>
|
||||||
|
<SectionTab active={sidebarSection === 'apps' ? true : undefined} onClick={() => switchSection('apps')}>
|
||||||
|
Apps
|
||||||
|
</SectionTab>
|
||||||
|
<SectionTab active={sidebarSection === 'tools' ? true : undefined} onClick={() => switchSection('tools')}>
|
||||||
|
Tools
|
||||||
|
</SectionTab>
|
||||||
|
</SectionSwitcher>
|
||||||
|
)}
|
||||||
<AppList>
|
<AppList>
|
||||||
{apps.map(app => (
|
{activeApps.map(app => (
|
||||||
<AppItem
|
<AppItem
|
||||||
key={app.name}
|
key={app.name}
|
||||||
onClick={() => selectApp(app.name)}
|
onClick={() => selectApp(app.name)}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,23 @@
|
||||||
import { render as renderApp } from 'hono/jsx/dom'
|
import { render as renderApp } from 'hono/jsx/dom'
|
||||||
import { Dashboard } from './components'
|
import { Dashboard } from './components'
|
||||||
import { apps, selectedApp, setApps, setSelectedApp } from './state'
|
import { apps, selectedApp, selectedTab, setApps, setSelectedApp } from './state'
|
||||||
import { initModal } from './components/modal'
|
import { initModal } from './components/modal'
|
||||||
|
import { initToolIframes, updateToolIframes } from './tool-iframes'
|
||||||
import { initUpdate } from './update'
|
import { initUpdate } from './update'
|
||||||
|
|
||||||
const render = () => {
|
const render = () => {
|
||||||
renderApp(<Dashboard render={render} />, document.getElementById('app')!)
|
renderApp(<Dashboard render={render} />, document.getElementById('app')!)
|
||||||
|
// Update tool iframes after DOM settles
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const tools = apps.filter(a => a.tool)
|
||||||
|
updateToolIframes(selectedTab, tools)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize render functions
|
// Initialize render functions
|
||||||
initModal(render)
|
initModal(render)
|
||||||
initUpdate(render)
|
initUpdate(render)
|
||||||
|
initToolIframes()
|
||||||
|
|
||||||
// Set theme based on system preference
|
// Set theme based on system preference
|
||||||
const setTheme = () => {
|
const setTheme = () => {
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@ import type { App } from '../shared/types'
|
||||||
// UI state (survives re-renders)
|
// UI state (survives re-renders)
|
||||||
export let selectedApp: string | null = localStorage.getItem('selectedApp')
|
export let selectedApp: string | null = localStorage.getItem('selectedApp')
|
||||||
export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
|
export let sidebarCollapsed: boolean = localStorage.getItem('sidebarCollapsed') === 'true'
|
||||||
|
export let sidebarSection: 'apps' | 'tools' = (localStorage.getItem('sidebarSection') as 'apps' | 'tools') || 'apps'
|
||||||
|
|
||||||
// Server state (from SSE)
|
// Server state (from SSE)
|
||||||
export let apps: App[] = []
|
export let apps: App[] = []
|
||||||
|
|
||||||
// Tab state
|
// Tab state
|
||||||
export let selectedTab: 'overview' | 'todo' = 'overview'
|
export let selectedTab: string = 'overview'
|
||||||
|
|
||||||
// State setters
|
// State setters
|
||||||
export function setSelectedApp(name: string | null) {
|
export function setSelectedApp(name: string | null) {
|
||||||
|
|
@ -25,10 +26,15 @@ export function setSidebarCollapsed(collapsed: boolean) {
|
||||||
localStorage.setItem('sidebarCollapsed', String(collapsed))
|
localStorage.setItem('sidebarCollapsed', String(collapsed))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setSidebarSection(section: 'apps' | 'tools') {
|
||||||
|
sidebarSection = section
|
||||||
|
localStorage.setItem('sidebarSection', section)
|
||||||
|
}
|
||||||
|
|
||||||
export function setApps(newApps: App[]) {
|
export function setApps(newApps: App[]) {
|
||||||
apps = newApps
|
apps = newApps
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setSelectedTab(tab: 'overview' | 'todo') {
|
export function setSelectedTab(tab: string) {
|
||||||
selectedTab = tab
|
selectedTab = tab
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ export {
|
||||||
MainHeader,
|
MainHeader,
|
||||||
MainTitle,
|
MainTitle,
|
||||||
SectionLabel,
|
SectionLabel,
|
||||||
|
SectionSwitcher,
|
||||||
|
SectionTab,
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
} from './layout'
|
} from './layout'
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,40 @@ export const SectionLabel = define('SectionLabel', {
|
||||||
letterSpacing: '0.05em',
|
letterSpacing: '0.05em',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const SectionSwitcher = define('SectionSwitcher', {
|
||||||
|
display: 'flex',
|
||||||
|
gap: 0,
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SectionTab = define('SectionTab', {
|
||||||
|
base: 'button',
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 12px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
borderRadius: theme('radius-sm'),
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
background: theme('colors-bgHover'),
|
||||||
|
color: theme('colors-text'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
active: {
|
||||||
|
background: theme('colors-bgSelected'),
|
||||||
|
color: theme('colors-text'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const AppList = define('AppList', {
|
export const AppList = define('AppList', {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
|
|
|
||||||
130
src/client/tool-iframes.ts
Normal file
130
src/client/tool-iframes.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { theme } from './themes'
|
||||||
|
|
||||||
|
// Iframe cache - these never get recreated once loaded
|
||||||
|
// Use a global to survive hot reloads
|
||||||
|
const iframes: Map<string, { iframe: HTMLIFrameElement; port: number }> =
|
||||||
|
(window as any).__toolIframes ??= new Map()
|
||||||
|
|
||||||
|
// Track current state to avoid unnecessary DOM updates
|
||||||
|
// Also stored on window to survive hot reloads
|
||||||
|
let currentTool: string | null = (window as any).__currentTool ?? null
|
||||||
|
|
||||||
|
// Get the stable container (outside Hono-managed DOM)
|
||||||
|
const getContainer = () => document.getElementById('tool-iframes')
|
||||||
|
|
||||||
|
// Initialize the container styles
|
||||||
|
export function initToolIframes() {
|
||||||
|
const container = getContainer()
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
// Restore iframe cache from DOM if module was hot-reloaded
|
||||||
|
if (iframes.size === 0) {
|
||||||
|
const existingIframes = container.querySelectorAll('iframe')
|
||||||
|
existingIframes.forEach(iframe => {
|
||||||
|
const match = iframe.src.match(/localhost:(\d+)/)
|
||||||
|
if (match && match[1]) {
|
||||||
|
const port = parseInt(match[1], 10)
|
||||||
|
const name = iframe.dataset.toolName
|
||||||
|
if (name) {
|
||||||
|
iframes.set(name, { iframe, port })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
container.style.cssText = `
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
background: ${theme('colors-bg')};
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update which iframe is visible based on selected tab and tool state
|
||||||
|
export function updateToolIframes(
|
||||||
|
selectedTab: string,
|
||||||
|
tools: Array<{ name: string; port?: number; state: string }>
|
||||||
|
) {
|
||||||
|
const container = getContainer()
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
// Find the selected tool
|
||||||
|
const selectedTool = tools.find(t => t.name === selectedTab)
|
||||||
|
const showIframe = selectedTool?.state === 'running' && selectedTool?.port
|
||||||
|
|
||||||
|
if (!showIframe) {
|
||||||
|
// Only update if state changed
|
||||||
|
if (currentTool !== null) {
|
||||||
|
container.style.display = 'none'
|
||||||
|
currentTool = null
|
||||||
|
;(window as any).__currentTool = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tool = selectedTool!
|
||||||
|
|
||||||
|
// Skip if nothing changed
|
||||||
|
if (currentTool === tool.name) {
|
||||||
|
// Just update position in case of scroll/resize
|
||||||
|
const tabContent = document.querySelector('[data-tool-target]')
|
||||||
|
if (tabContent) {
|
||||||
|
const rect = tabContent.getBoundingClientRect()
|
||||||
|
container.style.top = `${rect.top}px`
|
||||||
|
container.style.left = `${rect.left}px`
|
||||||
|
container.style.width = `${rect.width}px`
|
||||||
|
container.style.height = `${rect.height}px`
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position container over the tab content area
|
||||||
|
const tabContent = document.querySelector('[data-tool-target]')
|
||||||
|
if (!tabContent) {
|
||||||
|
container.style.display = 'none'
|
||||||
|
currentTool = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = tabContent.getBoundingClientRect()
|
||||||
|
container.style.cssText = `
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
top: ${rect.top}px;
|
||||||
|
left: ${rect.left}px;
|
||||||
|
width: ${rect.width}px;
|
||||||
|
height: ${rect.height}px;
|
||||||
|
background: ${theme('colors-bg')};
|
||||||
|
z-index: 100;
|
||||||
|
`
|
||||||
|
|
||||||
|
// Get or create the iframe for this tool
|
||||||
|
let cached = iframes.get(tool.name)
|
||||||
|
|
||||||
|
if (!cached || cached.port !== tool.port) {
|
||||||
|
// Create new iframe (first time or port changed)
|
||||||
|
const iframe = document.createElement('iframe')
|
||||||
|
iframe.src = `http://localhost:${tool.port}`
|
||||||
|
iframe.dataset.toolName = tool.name // For hot reload recovery
|
||||||
|
iframe.style.cssText = `
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
`
|
||||||
|
cached = { iframe, port: tool.port! }
|
||||||
|
iframes.set(tool.name, cached)
|
||||||
|
// Add to container
|
||||||
|
container.appendChild(iframe)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show only the selected iframe, hide others
|
||||||
|
for (const [name, { iframe }] of iframes) {
|
||||||
|
const shouldShow = name === tool.name
|
||||||
|
if (shouldShow && iframe.parentElement !== container) {
|
||||||
|
container.appendChild(iframe)
|
||||||
|
}
|
||||||
|
iframe.style.display = shouldShow ? 'block' : 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTool = tool.name
|
||||||
|
;(window as any).__currentTool = tool.name
|
||||||
|
}
|
||||||
|
|
@ -19,14 +19,10 @@ function convert(app: BackendApp): SharedApp {
|
||||||
// SSE endpoint for real-time app state updates
|
// SSE endpoint for real-time app state updates
|
||||||
router.sse('/stream', (send) => {
|
router.sse('/stream', (send) => {
|
||||||
const broadcast = () => {
|
const broadcast = () => {
|
||||||
const apps: SharedApp[] = allApps().map(({ name, state, icon, error, port, started, logs }) => ({
|
const apps: SharedApp[] = allApps().map(({
|
||||||
name,
|
name, state, icon, error, port, started, logs, tool
|
||||||
state,
|
}) => ({
|
||||||
icon,
|
name, state, icon, error, port, started, logs, tool,
|
||||||
error,
|
|
||||||
port,
|
|
||||||
started,
|
|
||||||
logs,
|
|
||||||
}))
|
}))
|
||||||
send(apps)
|
send(apps)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,13 @@ router.post('/apps/:app/activate', async c => {
|
||||||
rmSync(dirPath, { recursive: true, force: true })
|
rmSync(dirPath, { recursive: true, force: true })
|
||||||
console.log(`Cleaned up old version: ${dir}`)
|
console.log(`Cleaned up old version: ${dir}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove sync checkpoint - new deployment is now source of truth
|
||||||
|
const checkpointPath = join(appDir, '.sync-checkpoint')
|
||||||
|
if (existsSync(checkpointPath)) {
|
||||||
|
rmSync(checkpointPath, { recursive: true, force: true })
|
||||||
|
console.log(`Removed sync checkpoint after successful deployment`)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Log but don't fail activation if cleanup fails
|
// Log but don't fail activation if cleanup fails
|
||||||
console.error(`Failed to clean up old versions: ${e}`)
|
console.error(`Failed to clean up old versions: ${e}`)
|
||||||
|
|
@ -216,6 +223,65 @@ router.post('/apps/:app/activate', async c => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.post('/apps/:app/sync/rollback', async c => {
|
||||||
|
const appName = c.req.param('app')
|
||||||
|
if (!appName) return c.json({ error: 'App name required' }, 400)
|
||||||
|
|
||||||
|
const appDir = join(APPS_DIR, appName)
|
||||||
|
const checkpointPath = join(appDir, '.sync-checkpoint')
|
||||||
|
const currentLink = join(appDir, 'current')
|
||||||
|
|
||||||
|
if (!existsSync(checkpointPath)) {
|
||||||
|
return c.json({ error: 'No sync checkpoint found' }, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get current version name for cleanup
|
||||||
|
const currentReal = existsSync(currentLink) ? realpathSync(currentLink) : null
|
||||||
|
const currentVersion = currentReal ? currentReal.split('/').pop() : null
|
||||||
|
|
||||||
|
// Generate timestamp for rollback version
|
||||||
|
const now = new Date()
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
||||||
|
|
||||||
|
// Copy checkpoint to new timestamped version
|
||||||
|
const newVersion = join(appDir, timestamp)
|
||||||
|
cpSync(checkpointPath, newVersion, { recursive: true })
|
||||||
|
|
||||||
|
// Atomic symlink update
|
||||||
|
const tempLink = join(appDir, '.current.tmp')
|
||||||
|
if (existsSync(tempLink)) {
|
||||||
|
unlinkSync(tempLink)
|
||||||
|
}
|
||||||
|
symlinkSync(timestamp, tempLink, 'dir')
|
||||||
|
renameSync(tempLink, currentLink)
|
||||||
|
|
||||||
|
// Clean up the broken version if it's a timestamp dir (not named 'current')
|
||||||
|
if (currentVersion && /^\d{8}-\d{6}$/.test(currentVersion)) {
|
||||||
|
const brokenVersion = join(appDir, currentVersion)
|
||||||
|
if (existsSync(brokenVersion)) {
|
||||||
|
rmSync(brokenVersion, { recursive: true, force: true })
|
||||||
|
console.log(`Removed broken version: ${currentVersion}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart app with rolled-back version
|
||||||
|
const app = allApps().find(a => a.name === appName)
|
||||||
|
if (app?.state === 'running') {
|
||||||
|
try {
|
||||||
|
await restartApp(appName)
|
||||||
|
} catch (e) {
|
||||||
|
return c.json({ error: `Rolled back but failed to restart: ${e instanceof Error ? e.message : String(e)}` }, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ok: true, version: timestamp })
|
||||||
|
} catch (e) {
|
||||||
|
return c.json({ error: `Failed to rollback: ${e instanceof Error ? e.message : String(e)}` }, 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
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')
|
||||||
|
|
||||||
|
|
@ -224,17 +290,32 @@ router.sse('/apps/:app/watch', (send, c) => {
|
||||||
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 appDir = join(APPS_DIR, appName)
|
||||||
const canonicalPath = realpathSync(appPath)
|
const checkpointPath = join(appDir, '.sync-checkpoint')
|
||||||
|
const currentReal = realpathSync(appPath)
|
||||||
|
|
||||||
const gitignore = loadGitignore(canonicalPath)
|
// Create checkpoint snapshot for rollback
|
||||||
|
try {
|
||||||
|
// Remove old checkpoint if exists
|
||||||
|
if (existsSync(checkpointPath)) {
|
||||||
|
rmSync(checkpointPath, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
// Copy current version to checkpoint
|
||||||
|
cpSync(currentReal, checkpointPath, { recursive: true })
|
||||||
|
console.log(`Created sync checkpoint for ${appName}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to create sync checkpoint: ${e}`)
|
||||||
|
// Continue anyway - checkpoint is optional safety feature
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitignore = loadGitignore(currentReal)
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -244,7 +325,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
|
||||||
|
|
|
||||||
|
|
@ -243,7 +243,8 @@ function discoverApps() {
|
||||||
const { pkg, error } = loadApp(dir)
|
const { pkg, error } = loadApp(dir)
|
||||||
const state: AppState = error ? 'invalid' : 'stopped'
|
const state: AppState = error ? 'invalid' : 'stopped'
|
||||||
const icon = pkg.toes?.icon ?? DEFAULT_EMOJI
|
const icon = pkg.toes?.icon ?? DEFAULT_EMOJI
|
||||||
_apps.set(dir, { name: dir, state, icon, error })
|
const tool = pkg.toes?.tool
|
||||||
|
_apps.set(dir, { name: dir, state, icon, error, tool })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -613,7 +614,8 @@ function watchAppsDir() {
|
||||||
const { pkg, error } = loadApp(dir)
|
const { pkg, error } = loadApp(dir)
|
||||||
const state: AppState = error ? 'invalid' : 'stopped'
|
const state: AppState = error ? 'invalid' : 'stopped'
|
||||||
const icon = pkg.toes?.icon
|
const icon = pkg.toes?.icon
|
||||||
_apps.set(dir, { name: dir, state, icon, error })
|
const tool = pkg.toes?.tool
|
||||||
|
_apps.set(dir, { name: dir, state, icon, error, tool })
|
||||||
update()
|
update()
|
||||||
if (!error) {
|
if (!error) {
|
||||||
runApp(dir, getPort(dir))
|
runApp(dir, getPort(dir))
|
||||||
|
|
@ -637,8 +639,9 @@ function watchAppsDir() {
|
||||||
|
|
||||||
const { pkg, error } = loadApp(dir)
|
const { pkg, error } = loadApp(dir)
|
||||||
|
|
||||||
// Update icon and error from package.json
|
// Update icon, tool, and error from package.json
|
||||||
app.icon = pkg.toes?.icon
|
app.icon = pkg.toes?.icon
|
||||||
|
app.tool = pkg.toes?.tool
|
||||||
app.error = error
|
app.error = error
|
||||||
|
|
||||||
// App became valid - start it if stopped
|
// App became valid - start it if stopped
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export const Shell = () => (
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
<div id="tool-iframes"></div>
|
||||||
<script type="module" src="/client/index.js"></script>
|
<script type="module" src="/client/index.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -26,4 +26,5 @@ export type App = {
|
||||||
port?: number
|
port?: number
|
||||||
started?: number
|
started?: number
|
||||||
logs?: LogLine[]
|
logs?: LogLine[]
|
||||||
|
tool?: boolean | string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user