This commit is contained in:
Chris Wanstrath 2026-01-30 19:48:07 -08:00
parent f1b78197c7
commit cd1a1cdbe5
17 changed files with 130 additions and 467 deletions

194
ISSUES.md
View File

@ -1,194 +0,0 @@
# Issues - Versioned Deployment Implementation
## Critical Issues
### 1. Watch Filter Breaks File Change Detection
**Location**: `src/server/apps.ts:589-593`
**Problem**: The watch logic ignores all changes deeper than 2 levels:
```ts
// Ignore changes deeper than 2 levels (inside timestamp dirs)
if (parts.length > 2) return
// For versioned apps, only care about changes to "current" directory
if (parts.length === 2 && parts[1] !== 'current' && parts[1] !== 'package.json') return
```
Files inside `current/` are 3 levels deep: `appname/current/somefile.ts`
This **ignores all file changes** inside the current directory, breaking hot reload and auto-restart.
**Fix**:
```ts
// Ignore changes inside old timestamp dirs (but allow current/)
if (parts.length > 2 && parts[1] !== 'current') return
```
---
### 2. App Restart Race Condition
**Location**: `src/server/api/sync.ts:145-148`
**Problem**: Activation uses arbitrary 1 second delay without confirming stop completed:
```ts
// Restart app to use new version
const app = allApps().find(a => a.name === appName)
if (app?.state === 'running') {
stopApp(appName)
setTimeout(() => startApp(appName), 1000) // ⚠️ Arbitrary 1s delay
}
```
**Issues**:
- 1 second may not be enough for app to fully stop
- No confirmation that stop completed before start
- If app crashes during startup, activation still returns success
**Fix**: Make activation async and wait for stop to complete, or move restart logic to after symlink succeeds and poll for stop completion.
---
## Major Issues
### 3. No Version Cleanup
**Problem**: Old timestamp directories accumulate forever with no cleanup mechanism.
**Impact**: Disk space grows indefinitely as deployments pile up.
**Recommendation**: Add cleanup logic:
- Keep last N versions (e.g., 5-10)
- Delete versions older than X days
- Expose as `toes clean <app>` command or automatic post-activation
---
### 4. safePath Behavior Change
**Location**: `src/server/api/sync.ts:14-21`
**Problem**: Security model changed by resolving symlinks in base path:
```ts
const canonicalBase = existsSync(base) ? realpathSync(base) : base
```
Previously paths were checked against literal base, now against resolved base. This changes behavior if someone creates a symlink attack.
**Recommendation**: Document this intentional change, or keep original check and add separate symlink resolution only where needed.
---
### 5. Non-Atomic Deploy Copy
**Location**: `src/server/api/sync.ts:133-141`
**Problem**: Race condition possible during deploy:
```ts
if (existsSync(currentLink)) {
const currentReal = realpathSync(currentLink)
cpSync(currentReal, newVersion, { recursive: true })
}
```
If `current` changes between `existsSync` and `cpSync`, stale code might be copied.
**Impact**: Low probability for single-user system, but possible during concurrent deploys.
**Fix**: Read symlink once and reuse: `const currentReal = existsSync(currentLink) ? realpathSync(currentLink) : null`
---
### 6. Upload Error Handling Inconsistency
**Location**: `src/cli/commands/sync.ts:113-115`
**Problem**: Upload continues if a file fails but doesn't track failures:
```ts
if (success) {
console.log(` ${color.green('↑')} ${file}`)
} else {
console.log(` ${color.red('✗')} ${file}`)
// Continue even if one file fails
}
```
If any file fails to upload, deployment still activates with incomplete files.
**Fix**: Either:
- Abort entire deployment on first failure
- Track failures and warn/abort before activating
---
## Minor Issues
### 7. POST Body Check Too Loose
**Location**: `src/cli/http.ts:42-44`
**Problem**: Falsy values like `0`, `false`, or `""` would incorrectly skip body:
```ts
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
```
**Fix**:
```ts
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
```
---
### 8. Unconventional Timestamp Format
**Location**: `src/server/api/sync.ts:115-123`
**Problem**: Unusual array construction with separator as element:
```ts
const timestamp = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
'-', // Unusual to put separator as array element
String(now.getHours()).padStart(2, '0'),
String(now.getMinutes()).padStart(2, '0'),
String(now.getSeconds()).padStart(2, '0'),
].join('')
```
**Recommendation**: Use template literal:
```ts
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())}`
```
Or ISO format: `now.toISOString().replace(/[:.]/g, '-').slice(0, -5)`
---
## Positive Points
✓ Atomic symlink swapping with temp file pattern is correct
✓ Clear 3-step deployment protocol (deploy, upload, activate)
✓ Proper use of `realpathSync` to resolve canonical paths for running apps
✓ Good separation of concerns in API routes
---
## Priority
1. **BLOCKER**: Fix watch filter (#1) - breaks hot reload
2. **HIGH**: Fix restart race condition (#2) - affects deployment reliability
3. **HIGH**: Add version cleanup (#3) - disk space concern
4. **MEDIUM**: Fix upload error handling (#6) - data integrity
5. **LOW**: Everything else

View File

@ -5,9 +5,9 @@
"": { "": {
"name": "code", "name": "code",
"dependencies": { "dependencies": {
"@because/forge": "*",
"@because/howl": "*", "@because/howl": "*",
"@because/hype": "*", "@because/hype": "*",
"@because/toes": "link:../../..",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
@ -24,6 +24,8 @@
"@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=="], "@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=="],
"@because/toes": ["@because/toes@link:../../..", { "bin": { "toes": "src/cli/index.ts" } }],
"@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/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=="],

View File

@ -20,7 +20,7 @@
}, },
"dependencies": { "dependencies": {
"@because/hype": "*", "@because/hype": "*",
"@because/forge": "*", "@because/toes": "link:../../..",
"@because/howl": "*" "@because/howl": "*"
} }
} }

View File

@ -1,5 +1,5 @@
import { Hype } from '@because/hype' import { Hype } from '@because/hype'
import { createThemes, define, stylesToCSS } from '@because/forge' import { theme, define, stylesToCSS, initScript, baseStyles } from '@because/toes/tools'
import { readdir, stat } from 'fs/promises' import { readdir, stat } from 'fs/promises'
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
import { join, extname, basename } from 'path' import { join, extname, basename } from 'path'
@ -9,53 +9,18 @@ const APPS_DIR = process.env.APPS_DIR!
const app = new Hype({ prettyHTML: false }) const app = new Hype({ prettyHTML: false })
// Theme
const theme = createThemes({
light: {
bg: '#ffffff',
text: '#1a1a1a',
textMuted: '#666666',
border: '#dddddd',
borderSubtle: '#eeeeee',
borderStrong: '#333333',
hover: '#f5f5f5',
surface: '#f5f5f5',
link: '#0066cc',
icon: '#666666',
codeBg: '#fafafa',
error: '#d32f2f',
errorBg: '#ffebee',
},
dark: {
bg: '#1a1a1a',
text: '#e5e5e5',
textMuted: '#999999',
border: '#404040',
borderSubtle: '#333333',
borderStrong: '#555555',
hover: '#2a2a2a',
surface: '#252525',
link: '#5c9eff',
icon: '#888888',
codeBg: '#1e1e1e',
error: '#ff6b6b',
errorBg: '#3d1f1f',
},
})
// Styles
const Container = define('Container', { const Container = define('Container', {
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', fontFamily: theme('fonts-sans'),
padding: '20px', padding: '20px',
maxWidth: '1200px', maxWidth: '1200px',
margin: '0 auto', margin: '0 auto',
color: theme('text'), color: theme('colors-text'),
}) })
const Header = define('Header', { const Header = define('Header', {
marginBottom: '20px', marginBottom: '20px',
paddingBottom: '10px', paddingBottom: '10px',
borderBottom: `2px solid ${theme('borderStrong')}`, borderBottom: `2px solid ${theme('colors-border')}`,
}) })
const Title = define('Title', { const Title = define('Title', {
@ -65,7 +30,7 @@ const Title = define('Title', {
}) })
const Subtitle = define('Subtitle', { const Subtitle = define('Subtitle', {
color: theme('textMuted'), color: theme('colors-textMuted'),
fontSize: '18px', fontSize: '18px',
marginTop: '5px', marginTop: '5px',
}) })
@ -74,20 +39,20 @@ const FileList = define('FileList', {
listStyle: 'none', listStyle: 'none',
padding: 0, padding: 0,
margin: '20px 0', margin: '20px 0',
border: `1px solid ${theme('border')}`, border: `1px solid ${theme('colors-border')}`,
borderRadius: '4px', borderRadius: theme('radius-md'),
overflow: 'hidden', overflow: 'hidden',
}) })
const FileItem = define('FileItem', { const FileItem = define('FileItem', {
padding: '10px 15px', padding: '10px 15px',
borderBottom: `1px solid ${theme('borderSubtle')}`, borderBottom: `1px solid ${theme('colors-border')}`,
states: { states: {
':last-child': { ':last-child': {
borderBottom: 'none', borderBottom: 'none',
}, },
':hover': { ':hover': {
backgroundColor: theme('hover'), backgroundColor: theme('colors-bgHover'),
}, },
} }
}) })
@ -95,7 +60,7 @@ const FileItem = define('FileItem', {
const FileLink = define('FileLink', { const FileLink = define('FileLink', {
base: 'a', base: 'a',
textDecoration: 'none', textDecoration: 'none',
color: theme('link'), color: theme('colors-link'),
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '8px', gap: '8px',
@ -111,48 +76,48 @@ const FileIcon = define('FileIcon', {
width: '18px', width: '18px',
height: '18px', height: '18px',
flexShrink: 0, flexShrink: 0,
fill: theme('icon'), fill: theme('colors-textMuted'),
}) })
const CodeBlock = define('CodeBlock', { const CodeBlock = define('CodeBlock', {
margin: '20px 0', margin: '20px 0',
border: `1px solid ${theme('border')}`, border: `1px solid ${theme('colors-border')}`,
borderRadius: '4px', borderRadius: theme('radius-md'),
overflowX: 'auto', overflowX: 'auto',
selectors: { selectors: {
'& pre': { '& pre': {
margin: 0, margin: 0,
padding: '15px', padding: '15px',
whiteSpace: 'pre', whiteSpace: 'pre',
backgroundColor: theme('codeBg'), backgroundColor: theme('colors-bgSubtle'),
}, },
'& pre code': { '& pre code': {
whiteSpace: 'pre', whiteSpace: 'pre',
fontFamily: 'monospace', fontFamily: theme('fonts-mono'),
}, },
}, },
}) })
const CodeHeader = define('CodeHeader', { const CodeHeader = define('CodeHeader', {
padding: '10px 15px', padding: '10px 15px',
backgroundColor: theme('surface'), backgroundColor: theme('colors-bgElement'),
borderBottom: `1px solid ${theme('border')}`, borderBottom: `1px solid ${theme('colors-border')}`,
fontWeight: 'bold', fontWeight: 'bold',
fontSize: '14px', fontSize: '14px',
}) })
const ErrorBox = define('ErrorBox', { const ErrorBox = define('ErrorBox', {
color: theme('error'), color: theme('colors-error'),
padding: '20px', padding: '20px',
backgroundColor: theme('errorBg'), backgroundColor: theme('colors-bgElement'),
borderRadius: '4px', borderRadius: theme('radius-md'),
margin: '20px 0', margin: '20px 0',
}) })
const BackLink = define('BackLink', { const BackLink = define('BackLink', {
base: 'a', base: 'a',
textDecoration: 'none', textDecoration: 'none',
color: theme('link'), color: theme('colors-link'),
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
gap: '5px', gap: '5px',
@ -176,37 +141,6 @@ const FileIconSvg = () => (
</FileIcon> </FileIcon>
) )
const initScript = `
(function() {
var theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.body.setAttribute('data-theme', theme);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
document.body.setAttribute('data-theme', e.matches ? 'dark' : 'light');
});
function sendHeight() {
// Measure the actual content container (skip script tags)
var container = document.querySelector('.Container');
if (!container) return;
var rect = container.getBoundingClientRect();
var height = rect.bottom + 20; // Add some padding
window.parent.postMessage({ type: 'resize-iframe', height: height }, '*');
}
sendHeight();
setTimeout(sendHeight, 50);
setTimeout(sendHeight, 200);
setTimeout(sendHeight, 500);
new ResizeObserver(sendHeight).observe(document.body);
window.addEventListener('load', sendHeight);
})();
`
const baseStyles = `
body {
background: ${theme('bg')};
margin: 0;
}
`
interface LayoutProps { interface LayoutProps {
title: string title: string
subtitle?: string subtitle?: string
@ -233,10 +167,6 @@ function Layout({ title, subtitle, children, highlight }: LayoutProps) {
<body> <body>
<script dangerouslySetInnerHTML={{ __html: initScript }} /> <script dangerouslySetInnerHTML={{ __html: initScript }} />
<Container> <Container>
<Header>
<Title>Code Browser</Title>
{subtitle && <Subtitle>{subtitle}</Subtitle>}
</Header>
{children} {children}
</Container> </Container>
{highlight && <script dangerouslySetInnerHTML={{ __html: 'hljs.highlightAll();' }} />} {highlight && <script dangerouslySetInnerHTML={{ __html: 'hljs.highlightAll();' }} />}

View File

@ -5,9 +5,8 @@
"": { "": {
"name": "todo", "name": "todo",
"dependencies": { "dependencies": {
"@because/forge": "*",
"@because/howl": "*",
"@because/hype": "*", "@because/hype": "*",
"@because/toes": "link:../../..",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
@ -18,12 +17,10 @@
}, },
}, },
"packages": { "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=="], "@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=="],
"@because/toes": ["@because/toes@link:../../..", { "bin": { "toes": "src/cli/index.ts" } }],
"@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/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=="],
@ -34,8 +31,6 @@
"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=="],
"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=="], "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=="], "undici-types": ["undici-types@7.16.0", "https://npm.nose.space/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],

View File

@ -20,7 +20,6 @@
}, },
"dependencies": { "dependencies": {
"@because/hype": "*", "@because/hype": "*",
"@because/forge": "*", "@because/toes": "link:../../.."
"@because/howl": "*"
} }
} }

View File

@ -1,5 +1,5 @@
import { Hype } from '@because/hype' import { Hype } from '@because/hype'
import { createThemes, define, stylesToCSS } from '@because/forge' import { theme, define, stylesToCSS, initScript, baseStyles } from '@because/toes/tools'
import { readFileSync, writeFileSync, existsSync } from 'fs' import { readFileSync, writeFileSync, existsSync } from 'fs'
import { join } from 'path' import { join } from 'path'
@ -7,45 +7,12 @@ const APPS_DIR = process.env.APPS_DIR!
const app = new Hype({ prettyHTML: false }) const app = new Hype({ prettyHTML: false })
// Theme
const theme = createThemes({
light: {
bg: '#ffffff',
text: '#1a1a1a',
textMuted: '#666666',
border: '#dddddd',
hover: '#f5f5f5',
surface: '#f8f8f8',
accent: '#0066cc',
done: '#888888',
error: '#d32f2f',
errorBg: '#ffebee',
success: '#2e7d32',
successBg: '#e8f5e9',
},
dark: {
bg: '#1a1a1a',
text: '#e5e5e5',
textMuted: '#999999',
border: '#404040',
hover: '#2a2a2a',
surface: '#252525',
accent: '#5c9eff',
done: '#666666',
error: '#ff6b6b',
errorBg: '#3d1f1f',
success: '#81c784',
successBg: '#1b3d1e',
},
})
// Styles
const Container = define('TodoContainer', { const Container = define('TodoContainer', {
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', fontFamily: theme('fonts-sans'),
padding: '20px', padding: '20px',
maxWidth: '800px', maxWidth: '800px',
margin: '0 auto', margin: '0 auto',
color: theme('text'), color: theme('colors-text'),
}) })
const Header = define('Header', { const Header = define('Header', {
@ -62,7 +29,7 @@ const Title = define('Title', {
}) })
const AppName = define('AppName', { const AppName = define('AppName', {
color: theme('textMuted'), color: theme('colors-textMuted'),
fontSize: '14px', fontSize: '14px',
}) })
@ -79,10 +46,10 @@ const TodoSection = define('TodoSection', {
const SectionTitle = define('SectionTitle', { const SectionTitle = define('SectionTitle', {
fontSize: '16px', fontSize: '16px',
fontWeight: 600, fontWeight: 600,
color: theme('textMuted'), color: theme('colors-textMuted'),
marginBottom: '12px', marginBottom: '12px',
paddingBottom: '8px', paddingBottom: '8px',
borderBottom: `1px solid ${theme('border')}`, borderBottom: `1px solid ${theme('colors-border')}`,
}) })
const TodoItemStyle = define('TodoItem', { const TodoItemStyle = define('TodoItem', {
@ -108,10 +75,10 @@ const TodoItemStyle = define('TodoItem', {
const doneClass = 'todo-done' const doneClass = 'todo-done'
const Error = define('Error', { const Error = define('Error', {
color: theme('error'), color: theme('colors-error'),
padding: '20px', padding: '20px',
backgroundColor: theme('errorBg'), backgroundColor: theme('colors-bgElement'),
borderRadius: '4px', borderRadius: theme('radius-md'),
margin: '20px 0', margin: '20px 0',
}) })
@ -120,11 +87,11 @@ const errorClass = 'msg-error'
const SaveButton = define('SaveButton', { const SaveButton = define('SaveButton', {
base: 'button', base: 'button',
backgroundColor: theme('accent'), backgroundColor: theme('colors-primary'),
color: '#ffffff', color: theme('colors-primaryText'),
border: 'none', border: 'none',
padding: '8px 16px', padding: '8px 16px',
borderRadius: '4px', borderRadius: theme('radius-md'),
cursor: 'pointer', cursor: 'pointer',
fontSize: '14px', fontSize: '14px',
fontWeight: 500, fontWeight: 500,
@ -149,63 +116,37 @@ const AddInput = define('AddInput', {
base: 'input', base: 'input',
flex: 1, flex: 1,
padding: '8px 12px', padding: '8px 12px',
border: `1px solid ${theme('border')}`, border: `1px solid ${theme('colors-border')}`,
borderRadius: '4px', borderRadius: theme('radius-md'),
fontSize: '14px', fontSize: '14px',
backgroundColor: theme('bg'), backgroundColor: theme('colors-bg'),
color: theme('text'), color: theme('colors-text'),
states: { states: {
':focus': { ':focus': {
outline: 'none', outline: 'none',
borderColor: theme('accent'), borderColor: theme('colors-primary'),
}, },
}, },
}) })
const themeScript = ` const todoStyles = `
(function() {
var theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.body.setAttribute('data-theme', theme);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
document.body.setAttribute('data-theme', e.matches ? 'dark' : 'light');
});
})();
`
const resizeScript = `
(function() {
function sendHeight() {
var height = document.documentElement.scrollHeight;
window.parent.postMessage({ type: 'resize-iframe', height: height }, '*');
}
sendHeight();
new ResizeObserver(sendHeight).observe(document.body);
window.addEventListener('load', sendHeight);
})();
`
const baseStyles = `
body {
background: ${theme('bg')};
margin: 0;
}
.${doneClass} { .${doneClass} {
color: ${theme('done')}; color: ${theme('colors-done')};
text-decoration: line-through; text-decoration: line-through;
} }
.${successClass} { .${successClass} {
padding: 12px 16px; padding: 12px 16px;
border-radius: 4px; border-radius: ${theme('radius-md')};
margin-bottom: 16px; margin-bottom: 16px;
background-color: ${theme('successBg')}; background-color: ${theme('colors-successBg')};
color: ${theme('success')}; color: ${theme('colors-success')};
} }
.${errorClass} { .${errorClass} {
padding: 12px 16px; padding: 12px 16px;
border-radius: 4px; border-radius: ${theme('radius-md')};
margin-bottom: 16px; margin-bottom: 16px;
background-color: ${theme('errorBg')}; background-color: ${theme('colors-bgElement')};
color: ${theme('error')}; color: ${theme('colors-error')};
} }
` `
@ -248,7 +189,7 @@ function serializeTodo(todo: ParsedTodo): string {
return lines.join('\n') + '\n' return lines.join('\n') + '\n'
} }
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, { app.get('/styles.css', c => c.text(baseStyles + todoStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8', 'Content-Type': 'text/css; charset=utf-8',
})) }))
@ -267,7 +208,7 @@ app.get('/', async c => {
<link rel="stylesheet" href="/styles.css" /> <link rel="stylesheet" href="/styles.css" />
</head> </head>
<body> <body>
<script dangerouslySetInnerHTML={{ __html: themeScript + resizeScript }} /> <script dangerouslySetInnerHTML={{ __html: initScript }} />
<Container> <Container>
<Header> <Header>
<Title>TODO</Title> <Title>TODO</Title>
@ -290,7 +231,6 @@ app.get('/', async c => {
} }
const pendingItems = todo.items.filter(i => !i.done) const pendingItems = todo.items.filter(i => !i.done)
const doneItems = todo.items.filter(i => i.done)
return c.html( return c.html(
<html> <html>
@ -301,7 +241,7 @@ app.get('/', async c => {
<link rel="stylesheet" href="/styles.css" /> <link rel="stylesheet" href="/styles.css" />
</head> </head>
<body> <body>
<script dangerouslySetInnerHTML={{ __html: themeScript + resizeScript }} /> <script dangerouslySetInnerHTML={{ __html: initScript }} />
<Container> <Container>
<Header> <Header>
<div> <div>
@ -354,7 +294,7 @@ app.get('/', async c => {
{todo.items.length === 0 && ( {todo.items.length === 0 && (
<TodoSection> <TodoSection>
<SectionTitle>No todos yet</SectionTitle> <SectionTitle>No todos yet</SectionTitle>
<p style={{ color: theme('textMuted') }}>Add your first todo above!</p> <p style={{ color: theme('colors-textMuted') }}>Add your first todo above!</p>
</TodoSection> </TodoSection>
)} )}
</Container> </Container>

View File

@ -5,8 +5,8 @@
"": { "": {
"name": "versions", "name": "versions",
"dependencies": { "dependencies": {
"@because/forge": "*",
"@because/hype": "*", "@because/hype": "*",
"@because/toes": "link:../../..",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
@ -17,10 +17,10 @@
}, },
}, },
"packages": { "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/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=="], "@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=="],
"@because/toes": ["@because/toes@link:../../..", { "bin": { "toes": "src/cli/index.ts" } }],
"@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/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=="],

View File

@ -1,5 +1,5 @@
import { Hype } from '@because/hype' import { Hype } from '@because/hype'
import { createThemes, define, stylesToCSS } from '@because/forge' import { theme, define, stylesToCSS, initScript, baseStyles } from '@because/toes/tools'
import { readdir, readlink, stat } from 'fs/promises' import { readdir, readlink, stat } from 'fs/promises'
import { join } from 'path' import { join } from 'path'
import type { Child } from 'hono/jsx' import type { Child } from 'hono/jsx'
@ -8,49 +8,18 @@ const APPS_DIR = process.env.APPS_DIR!
const app = new Hype({ prettyHTML: false }) const app = new Hype({ prettyHTML: false })
const theme = createThemes({
light: {
bg: '#ffffff',
text: '#1a1a1a',
textMuted: '#666666',
border: '#dddddd',
borderSubtle: '#eeeeee',
borderStrong: '#333333',
hover: '#f5f5f5',
link: '#0066cc',
error: '#d32f2f',
errorBg: '#ffebee',
accent: '#2e7d32',
accentBg: '#e8f5e9',
},
dark: {
bg: '#1a1a1a',
text: '#e5e5e5',
textMuted: '#999999',
border: '#404040',
borderSubtle: '#333333',
borderStrong: '#555555',
hover: '#2a2a2a',
link: '#5c9eff',
error: '#ff6b6b',
errorBg: '#3d1f1f',
accent: '#81c784',
accentBg: '#1b3d1f',
},
})
const Container = define('Container', { const Container = define('Container', {
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', fontFamily: theme('fonts-sans'),
padding: '20px', padding: '20px',
maxWidth: '800px', maxWidth: '800px',
margin: '0 auto', margin: '0 auto',
color: theme('text'), color: theme('colors-text'),
}) })
const Header = define('Header', { const Header = define('Header', {
marginBottom: '20px', marginBottom: '20px',
paddingBottom: '10px', paddingBottom: '10px',
borderBottom: `2px solid ${theme('borderStrong')}`, borderBottom: `2px solid ${theme('colors-border')}`,
}) })
const Title = define('Title', { const Title = define('Title', {
@ -60,7 +29,7 @@ const Title = define('Title', {
}) })
const Subtitle = define('Subtitle', { const Subtitle = define('Subtitle', {
color: theme('textMuted'), color: theme('colors-textMuted'),
fontSize: '18px', fontSize: '18px',
marginTop: '5px', marginTop: '5px',
}) })
@ -69,14 +38,14 @@ const VersionList = define('VersionList', {
listStyle: 'none', listStyle: 'none',
padding: 0, padding: 0,
margin: '20px 0', margin: '20px 0',
border: `1px solid ${theme('border')}`, border: `1px solid ${theme('colors-border')}`,
borderRadius: '4px', borderRadius: theme('radius-md'),
overflow: 'hidden', overflow: 'hidden',
}) })
const VersionItem = define('VersionItem', { const VersionItem = define('VersionItem', {
padding: '12px 15px', padding: '12px 15px',
borderBottom: `1px solid ${theme('borderSubtle')}`, borderBottom: `1px solid ${theme('colors-border')}`,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
@ -85,7 +54,7 @@ const VersionItem = define('VersionItem', {
borderBottom: 'none', borderBottom: 'none',
}, },
':hover': { ':hover': {
backgroundColor: theme('hover'), backgroundColor: theme('colors-bgHover'),
}, },
}, },
}) })
@ -93,8 +62,8 @@ const VersionItem = define('VersionItem', {
const VersionLink = define('VersionLink', { const VersionLink = define('VersionLink', {
base: 'a', base: 'a',
textDecoration: 'none', textDecoration: 'none',
color: theme('link'), color: theme('colors-link'),
fontFamily: 'monospace', fontFamily: theme('fonts-mono'),
fontSize: '15px', fontSize: '15px',
cursor: 'pointer', cursor: 'pointer',
states: { states: {
@ -107,46 +76,20 @@ const VersionLink = define('VersionLink', {
const Badge = define('Badge', { const Badge = define('Badge', {
fontSize: '12px', fontSize: '12px',
padding: '2px 8px', padding: '2px 8px',
borderRadius: '4px', borderRadius: theme('radius-md'),
backgroundColor: theme('accentBg'), backgroundColor: theme('colors-bgElement'),
color: theme('accent'), color: theme('colors-statusRunning'),
fontWeight: 'bold', fontWeight: 'bold',
}) })
const ErrorBox = define('ErrorBox', { const ErrorBox = define('ErrorBox', {
color: theme('error'), color: theme('colors-error'),
padding: '20px', padding: '20px',
backgroundColor: theme('errorBg'), backgroundColor: theme('colors-bgElement'),
borderRadius: '4px', borderRadius: theme('radius-md'),
margin: '20px 0', margin: '20px 0',
}) })
const initScript = `
(function() {
var theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.body.setAttribute('data-theme', theme);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
document.body.setAttribute('data-theme', e.matches ? 'dark' : 'light');
});
function sendHeight() {
var container = document.querySelector('.Container');
if (!container) return;
var rect = container.getBoundingClientRect();
window.parent.postMessage({ type: 'resize-iframe', height: rect.bottom + 20 }, '*');
}
sendHeight();
setTimeout(sendHeight, 50);
new ResizeObserver(sendHeight).observe(document.body);
})();
`
const baseStyles = `
body {
background: ${theme('bg')};
margin: 0;
}
`
interface LayoutProps { interface LayoutProps {
title: string title: string
subtitle?: string subtitle?: string

View File

@ -20,6 +20,6 @@
}, },
"dependencies": { "dependencies": {
"@because/hype": "*", "@because/hype": "*",
"@because/forge": "*" "@because/toes": "link:../../.."
} }
} }

View File

@ -1,9 +1,14 @@
{ {
"name": "@because/toes", "name": "@because/toes",
"version": "0.0.3", "version": "0.0.4",
"description": "personal web appliance - turn it on and forget about the cloud", "description": "personal web appliance - turn it on and forget about the cloud",
"module": "src/index.ts", "module": "src/index.ts",
"type": "module", "type": "module",
"files": ["src"],
"exports": {
".": "./src/index.ts",
"./tools": "./src/tools/index.ts"
},
"bin": { "bin": {
"toes": "src/cli/index.ts" "toes": "src/cli/index.ts"
}, },

View File

@ -23,7 +23,7 @@ import {
program program
.name('toes') .name('toes')
.version('v0.0.3', '-v, --version') .version('v0.0.4', '-v, --version')
.addHelpText('beforeAll', (ctx) => { .addHelpText('beforeAll', (ctx) => {
if (ctx.command === program) { if (ctx.command === program) {
return color.bold().cyan('\n🐾 Toes') + color.gray(' - personal web appliance\n') return color.bold().cyan('\n🐾 Toes') + color.gray(' - personal web appliance\n')

View File

@ -25,6 +25,10 @@ export default {
'colors-statusStarting': '#eab308', 'colors-statusStarting': '#eab308',
'colors-statusInvalid': '#ef4444', 'colors-statusInvalid': '#ef4444',
'colors-success': '#81c784',
'colors-successBg': '#1b3d1e',
'colors-done': '#666',
'fonts-sans': 'system-ui, -apple-system, sans-serif', 'fonts-sans': 'system-ui, -apple-system, sans-serif',
'fonts-mono': 'ui-monospace, monospace', 'fonts-mono': 'ui-monospace, monospace',

View File

@ -25,6 +25,10 @@ export default {
'colors-statusStarting': '#ca8a04', 'colors-statusStarting': '#ca8a04',
'colors-statusInvalid': '#dc2626', 'colors-statusInvalid': '#dc2626',
'colors-success': '#2e7d32',
'colors-successBg': '#e8f5e9',
'colors-done': '#888',
'fonts-sans': 'system-ui, -apple-system, sans-serif', 'fonts-sans': 'system-ui, -apple-system, sans-serif',
'fonts-mono': 'ui-monospace, monospace', 'fonts-mono': 'ui-monospace, monospace',

1
src/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './server'

3
src/tools/index.ts Normal file
View File

@ -0,0 +1,3 @@
export { theme } from '../client/themes'
export { baseStyles, initScript } from './scripts'
export { define, stylesToCSS } from '@because/forge'

31
src/tools/scripts.ts Normal file
View File

@ -0,0 +1,31 @@
import { theme } from '../client/themes'
export const initScript = `
(function() {
var theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.body.setAttribute('data-theme', theme);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
document.body.setAttribute('data-theme', e.matches ? 'dark' : 'light');
});
function sendHeight() {
var container = document.querySelector('.Container');
if (!container) return;
var rect = container.getBoundingClientRect();
var height = rect.bottom + 20;
window.parent.postMessage({ type: 'resize-iframe', height: height }, '*');
}
sendHeight();
setTimeout(sendHeight, 50);
setTimeout(sendHeight, 200);
setTimeout(sendHeight, 500);
new ResizeObserver(sendHeight).observe(document.body);
window.addEventListener('load', sendHeight);
})();
`
export const baseStyles = `
body {
background: ${theme('colors-bg')};
margin: 0;
}
`