diff --git a/CLAUDE.md b/CLAUDE.md
index 88e252a..e1fc55f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -126,6 +126,18 @@ export default app.defaults
- `DATA_DIR` env controls where apps are discovered
- Path aliases: `$` → server, `@` → shared, `%` → lib
+## Environment Variables
+
+Env vars are stored per-app in `TOES_DIR/env/`:
+
+```
+${DATA_DIR}/toes/env/
+ clock.env # env vars for clock app
+ todo.env # env vars for todo app
+```
+
+`TOES_DIR` defaults to `${DATA_DIR}/toes`. Apps cannot access this directory directly.
+
## Current State
### Infrastructure (Complete)
diff --git a/apps/env/20260130-000000/.npmrc b/apps/env/20260130-000000/.npmrc
new file mode 100644
index 0000000..6c57d5c
--- /dev/null
+++ b/apps/env/20260130-000000/.npmrc
@@ -0,0 +1 @@
+registry=https://npm.nose.space
diff --git a/apps/env/20260130-000000/bun.lock b/apps/env/20260130-000000/bun.lock
new file mode 100644
index 0000000..57e3f6f
--- /dev/null
+++ b/apps/env/20260130-000000/bun.lock
@@ -0,0 +1,47 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "env",
+ "dependencies": {
+ "@because/forge": "*",
+ "@because/hype": "*",
+ "@because/toes": "*",
+ },
+ "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/hype": ["@because/hype@0.0.2", "https://npm.nose.space/@because/hype/-/hype-0.0.2.tgz", { "dependencies": { "hono": "^4.10.4", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5" } }, "sha512-fdKeII6USGC1loVVj+tPz086cKz+Bm+XozNee3NOnK4VP+q4yNPP2Fq1Yujw5xeDYE+ZvJn40gKwlngRvmX2hA=="],
+
+ "@because/toes": ["@because/toes@0.0.4", "https://npm.nose.space/@because/toes/-/toes-0.0.4.tgz", { "dependencies": { "@because/forge": "^0.0.1", "@because/hype": "^0.0.1", "commander": "^14.0.2", "diff": "^8.0.3", "kleur": "^4.1.5" }, "peerDependencies": { "typescript": "^5.9.2" }, "bin": { "toes": "src/cli/index.ts" } }, "sha512-/eZB84VoARYzSBtwJe00dV7Ilgqq7DRFj3vJlWhCHg87Jx5Yr2nTqPnzclLmiZ55XvWNogXqGTzyW8hApzXnJw=="],
+
+ "@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.2.0", "https://npm.nose.space/@types/node/-/node-25.2.0.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
+
+ "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.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
+
+ "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=="],
+
+ "kleur": ["kleur@4.1.5", "https://npm.nose.space/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
+
+ "typescript": ["typescript@5.9.3", "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=="],
+
+ "@because/toes/@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=="],
+ }
+}
diff --git a/apps/env/20260130-000000/index.tsx b/apps/env/20260130-000000/index.tsx
new file mode 100644
index 0000000..75da411
--- /dev/null
+++ b/apps/env/20260130-000000/index.tsx
@@ -0,0 +1,332 @@
+import { Hype } from '@because/hype'
+import { define, stylesToCSS } from '@because/forge'
+import { baseStyles, initScript, theme } from '@because/toes/tools'
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
+import { join } from 'path'
+import type { Child } from 'hono/jsx'
+
+const TOES_DIR = process.env.TOES_DIR ?? join(process.env.HOME!, '.toes')
+const ENV_DIR = join(TOES_DIR, 'env')
+
+const app = new Hype({ prettyHTML: false })
+
+const Container = define('Container', {
+ fontFamily: theme('fonts-sans'),
+ padding: '20px',
+ paddingTop: 0,
+ maxWidth: '800px',
+ margin: '0 auto',
+ color: theme('colors-text'),
+})
+
+const Header = define('Header', {
+ marginBottom: '20px',
+ paddingBottom: '10px',
+ borderBottom: `2px solid ${theme('colors-border')}`,
+})
+
+const Title = define('Title', {
+ margin: 0,
+ fontSize: '24px',
+ fontWeight: 'bold',
+})
+
+const EnvList = define('EnvList', {
+ listStyle: 'none',
+ padding: 0,
+ margin: 0,
+ border: `1px solid ${theme('colors-border')}`,
+ borderRadius: theme('radius-md'),
+ overflow: 'hidden',
+})
+
+const EnvItem = define('EnvItem', {
+ padding: '12px 15px',
+ borderBottom: `1px solid ${theme('colors-border')}`,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: '10px',
+ states: {
+ ':last-child': {
+ borderBottom: 'none',
+ },
+ },
+})
+
+const EnvKey = define('EnvKey', {
+ fontFamily: theme('fonts-mono'),
+ fontSize: '14px',
+ fontWeight: 'bold',
+ color: theme('colors-text'),
+})
+
+const EnvValue = define('EnvValue', {
+ fontFamily: theme('fonts-mono'),
+ fontSize: '14px',
+ color: theme('colors-textMuted'),
+ flex: 1,
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+})
+
+const EnvActions = define('EnvActions', {
+ display: 'flex',
+ gap: '8px',
+ flexShrink: 0,
+})
+
+const Button = define('Button', {
+ base: 'button',
+ padding: '6px 12px',
+ fontSize: '13px',
+ borderRadius: theme('radius-md'),
+ border: `1px solid ${theme('colors-border')}`,
+ backgroundColor: theme('colors-bgElement'),
+ color: theme('colors-text'),
+ cursor: 'pointer',
+ states: {
+ ':hover': {
+ backgroundColor: theme('colors-bgHover'),
+ },
+ },
+})
+
+const DangerButton = define('DangerButton', {
+ base: 'button',
+ padding: '6px 12px',
+ fontSize: '13px',
+ borderRadius: theme('radius-md'),
+ border: 'none',
+ backgroundColor: theme('colors-error'),
+ color: 'white',
+ cursor: 'pointer',
+ states: {
+ ':hover': {
+ opacity: 0.9,
+ },
+ },
+})
+
+const Form = define('Form', {
+ base: 'form',
+ display: 'flex',
+ gap: '10px',
+ marginTop: '15px',
+ padding: '15px',
+ backgroundColor: theme('colors-bgSubtle'),
+ borderRadius: theme('radius-md'),
+})
+
+const Input = define('Input', {
+ base: 'input',
+ flex: 1,
+ padding: '8px 12px',
+ fontSize: '14px',
+ fontFamily: theme('fonts-mono'),
+ borderRadius: theme('radius-md'),
+ border: `1px solid ${theme('colors-border')}`,
+ backgroundColor: theme('colors-bg'),
+ color: theme('colors-text'),
+ states: {
+ ':focus': {
+ outline: 'none',
+ borderColor: theme('colors-primary'),
+ },
+ },
+})
+
+const ErrorBox = define('ErrorBox', {
+ color: theme('colors-error'),
+ padding: '20px',
+ backgroundColor: theme('colors-bgElement'),
+ borderRadius: theme('radius-md'),
+ margin: '20px 0',
+})
+
+const EmptyState = define('EmptyState', {
+ padding: '30px',
+ textAlign: 'center',
+ color: theme('colors-textMuted'),
+ backgroundColor: theme('colors-bgSubtle'),
+ borderRadius: theme('radius-md'),
+})
+
+interface LayoutProps {
+ title: string
+ children: Child
+}
+
+function Layout({ title, children }: LayoutProps) {
+ return (
+
+
+
+
+ {title}
+
+
+
+
+
+
+ Environment Variables
+
+ {children}
+
+
+
+
+ )
+}
+
+const clientScript = `
+document.querySelectorAll('[data-reveal]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const valueEl = btn.closest('[data-env-item]').querySelector('[data-value]');
+ const hidden = valueEl.dataset.hidden;
+ if (hidden) {
+ valueEl.textContent = hidden;
+ valueEl.dataset.hidden = '';
+ btn.textContent = 'Hide';
+ } else {
+ valueEl.dataset.hidden = valueEl.textContent;
+ valueEl.textContent = '••••••••';
+ btn.textContent = 'Reveal';
+ }
+ });
+});
+`
+
+interface EnvVar {
+ key: string
+ value: string
+}
+
+function ensureEnvDir() {
+ if (!existsSync(ENV_DIR)) {
+ mkdirSync(ENV_DIR, { recursive: true })
+ }
+}
+
+function parseEnvFile(path: string): EnvVar[] {
+ if (!existsSync(path)) return []
+
+ const content = readFileSync(path, 'utf-8')
+ const vars: EnvVar[] = []
+
+ for (const line of content.split('\n')) {
+ const trimmed = line.trim()
+ if (!trimmed || trimmed.startsWith('#')) continue
+
+ const eqIndex = trimmed.indexOf('=')
+ if (eqIndex === -1) continue
+
+ const key = trimmed.slice(0, eqIndex).trim()
+ let value = trimmed.slice(eqIndex + 1).trim()
+
+ // Remove surrounding quotes if present
+ if ((value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'"))) {
+ value = value.slice(1, -1)
+ }
+
+ if (key) vars.push({ key, value })
+ }
+
+ return vars
+}
+
+function writeEnvFile(path: string, vars: EnvVar[]) {
+ ensureEnvDir()
+ const content = vars.map(v => `${v.key}=${v.value}`).join('\n') + (vars.length ? '\n' : '')
+ writeFileSync(path, content)
+}
+
+function appEnvPath(appName: string): string {
+ return join(ENV_DIR, `${appName}.env`)
+}
+
+app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
+ 'Content-Type': 'text/css; charset=utf-8',
+}))
+
+app.get('/', async c => {
+ const appName = c.req.query('app')
+
+ if (!appName) {
+ return c.html(
+
+ Please specify an app name with ?app=<name>
+
+ )
+ }
+
+ const appVars = parseEnvFile(appEnvPath(appName))
+
+ return c.html(
+
+ {appVars.length === 0 ? (
+ No environment variables
+ ) : (
+
+ {appVars.map(v => (
+
+ {v.key}
+ {'••••••••'}
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ )
+})
+
+app.post('/set', async c => {
+ const appName = c.req.query('app')
+ if (!appName) return c.text('Missing app', 400)
+
+ const body = await c.req.parseBody()
+ const key = String(body.key).trim().toUpperCase()
+ const value = String(body.value)
+
+ if (!key) return c.text('Missing key', 400)
+
+ const path = appEnvPath(appName)
+ const vars = parseEnvFile(path)
+ const existing = vars.findIndex(v => v.key === key)
+
+ if (existing >= 0) {
+ vars[existing]!.value = value
+ } else {
+ vars.push({ key, value })
+ }
+
+ writeEnvFile(path, vars)
+ return c.redirect(`/?app=${appName}`)
+})
+
+app.post('/delete', async c => {
+ const appName = c.req.query('app')
+ const key = c.req.query('key')
+ if (!appName || !key) return c.text('Missing app or key', 400)
+
+ const path = appEnvPath(appName)
+ const vars = parseEnvFile(path).filter(v => v.key !== key)
+ writeEnvFile(path, vars)
+ return c.redirect(`/?app=${appName}`)
+})
+
+export default app.defaults
diff --git a/apps/env/20260130-000000/package.json b/apps/env/20260130-000000/package.json
new file mode 100644
index 0000000..6d5864e
--- /dev/null
+++ b/apps/env/20260130-000000/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "env",
+ "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": ".env",
+ "icon": "🔑"
+ },
+ "devDependencies": {
+ "@types/bun": "latest"
+ },
+ "peerDependencies": {
+ "typescript": "^5.9.2"
+ },
+ "dependencies": {
+ "@because/forge": "*",
+ "@because/hype": "*",
+ "@because/toes": "*"
+ }
+}
diff --git a/apps/env/20260130-000000/tsconfig.json b/apps/env/20260130-000000/tsconfig.json
new file mode 100644
index 0000000..545396c
--- /dev/null
+++ b/apps/env/20260130-000000/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "compilerOptions": {
+ // Environment setup & latest features
+ "lib": ["ESNext"],
+ "target": "ESNext",
+ "module": "Preserve",
+ "moduleDetection": "force",
+ "jsx": "react-jsx",
+ "jsxImportSource": "hono/jsx",
+ "allowJs": true,
+
+ // Bundler mode
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+
+ // Best practices
+ "strict": true,
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+
+ // Some stricter flags (disabled by default)
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noPropertyAccessFromIndexSignature": false
+ }
+}
diff --git a/apps/env/current b/apps/env/current
new file mode 120000
index 0000000..1a45961
--- /dev/null
+++ b/apps/env/current
@@ -0,0 +1 @@
+20260130-000000
\ No newline at end of file
diff --git a/docs/ENV.md b/docs/ENV.md
new file mode 100644
index 0000000..a53f4ab
--- /dev/null
+++ b/docs/ENV.md
@@ -0,0 +1,41 @@
+# Environment Variables
+
+Store API keys and secrets outside your app code.
+
+## Using env vars
+
+Access them via `process.env`:
+
+```tsx
+const apiKey = process.env.OPENAI_API_KEY
+if (!apiKey) throw new Error('Missing OPENAI_API_KEY')
+```
+
+Env vars are injected when your app starts. Changing them restarts the app automatically.
+
+## Managing env vars
+
+### CLI
+
+```bash
+toes env my-app # list env vars
+toes env my-app set KEY value # set a var
+toes env my-app set KEY=value # also works
+toes env my-app rm KEY # remove a var
+```
+
+### Dashboard
+
+The `.env` tool in the tab bar lets you view and edit vars for the selected app. Values are masked until you click Reveal.
+
+## Format
+
+Standard `.env` syntax:
+
+```
+OPENAI_API_KEY=sk-...
+DATABASE_URL=postgres://localhost/mydb
+DEBUG=true
+```
+
+Keys are uppercased automatically. Quotes around values are stripped.
diff --git a/src/cli/commands/env.ts b/src/cli/commands/env.ts
new file mode 100644
index 0000000..d9d1608
--- /dev/null
+++ b/src/cli/commands/env.ts
@@ -0,0 +1,87 @@
+import color from 'kleur'
+import { del, get, handleError, post } from '../http'
+import { resolveAppName } from '../name'
+
+interface EnvVar {
+ key: string
+ value: string
+}
+
+export async function envList(name: string | undefined) {
+ const appName = resolveAppName(name)
+ if (!appName) return
+
+ const vars = await get(`/api/apps/${appName}/env`)
+ if (!vars) {
+ console.error(`App not found: ${appName}`)
+ return
+ }
+
+ console.log(color.bold().cyan(`Environment Variables for ${appName}`))
+ console.log()
+
+ if (vars.length === 0) {
+ console.log(color.gray(' No environment variables set'))
+ return
+ }
+
+ for (const v of vars) {
+ console.log(` ${color.bold(v.key)}=${color.gray(v.value)}`)
+ }
+}
+
+export async function envSet(name: string | undefined, keyOrKeyValue: string, valueArg?: string) {
+ let key: string
+ let value: string
+
+ if (valueArg !== undefined) {
+ // KEY value format
+ key = keyOrKeyValue.trim()
+ value = valueArg
+ } else {
+ // KEY=value format
+ const eqIndex = keyOrKeyValue.indexOf('=')
+ if (eqIndex === -1) {
+ console.error('Invalid format. Use: KEY value or KEY=value')
+ return
+ }
+ key = keyOrKeyValue.slice(0, eqIndex).trim()
+ value = keyOrKeyValue.slice(eqIndex + 1)
+ }
+
+ if (!key) {
+ console.error('Key cannot be empty')
+ return
+ }
+
+ const appName = resolveAppName(name)
+ if (!appName) return
+
+ try {
+ const result = await post<{ ok: boolean, error?: string }>(`/api/apps/${appName}/env`, { key, value })
+ if (result?.ok) {
+ console.log(color.green(`Set ${color.bold(key.toUpperCase())} for ${appName}`))
+ console.log(color.gray('App restarted to apply changes'))
+ } else {
+ console.error(result?.error ?? 'Failed to set variable')
+ }
+ } catch (error) {
+ handleError(error)
+ }
+}
+
+export async function envRm(name: string | undefined, key: string) {
+ if (!key) {
+ console.error('Key is required')
+ return
+ }
+
+ const appName = resolveAppName(name)
+ if (!appName) return
+
+ const ok = await del(`/api/apps/${appName}/env/${key.toUpperCase()}`)
+ if (ok) {
+ console.log(color.green(`Removed ${color.bold(key.toUpperCase())} from ${appName}`))
+ console.log(color.gray('App restarted to apply changes'))
+ }
+}
diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts
index a79022f..e802e72 100644
--- a/src/cli/commands/index.ts
+++ b/src/cli/commands/index.ts
@@ -1,3 +1,4 @@
+export { envList, envRm, envSet } from './env'
export { logApp } from './logs'
export {
configShow,
diff --git a/src/cli/setup.ts b/src/cli/setup.ts
index 6eaaf6e..7f60414 100644
--- a/src/cli/setup.ts
+++ b/src/cli/setup.ts
@@ -5,6 +5,9 @@ import {
cleanApp,
configShow,
diffApp,
+ envList,
+ envRm,
+ envSet,
getApp,
infoApp,
listApps,
@@ -175,6 +178,27 @@ stash
.description('List all stashes')
.action(stashListApp)
+const env = program
+ .command('env')
+ .description('Manage environment variables')
+ .argument('[name]', 'app name (uses current directory if omitted)')
+ .action(envList)
+
+env
+ .command('set')
+ .description('Set an environment variable')
+ .argument('[name]', 'app name (uses current directory if omitted)')
+ .argument('', 'variable name')
+ .argument('[value]', 'variable value (or use KEY=value format)')
+ .action(envSet)
+
+env
+ .command('rm')
+ .description('Remove an environment variable')
+ .argument('[name]', 'app name (uses current directory if omitted)')
+ .argument('', 'variable name to remove')
+ .action(envRm)
+
program
.command('versions')
.description('List deployed versions')
diff --git a/src/server/api/apps.ts b/src/server/api/apps.ts
index 1d6d5bd..5c44124 100644
--- a/src/server/api/apps.ts
+++ b/src/server/api/apps.ts
@@ -1,9 +1,9 @@
-import { APPS_DIR, allApps, getLogDates, onChange, readLogs, registerApp, renameApp, startApp, stopApp, updateAppIcon } from '$apps'
+import { APPS_DIR, TOES_DIR, allApps, getLogDates, onChange, readLogs, registerApp, renameApp, restartApp, startApp, stopApp, updateAppIcon } from '$apps'
import type { App as BackendApp } from '$apps'
import type { App as SharedApp } from '@types'
import { generateTemplates, type TemplateType } from '%templates'
import { Hype } from '@because/hype'
-import { existsSync, mkdirSync, symlinkSync, writeFileSync } from 'fs'
+import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'fs'
import { dirname, join } from 'path'
const timestamp = () => {
@@ -216,4 +216,109 @@ router.post('/:app/rename', async c => {
return c.json({ ok: true, name: newName })
})
+// --- Environment Variables ---
+
+interface EnvVar {
+ key: string
+ value: string
+}
+
+const envDir = () => join(TOES_DIR, 'env')
+const appEnvPath = (appName: string) => join(envDir(), `${appName}.env`)
+
+function parseEnvFile(path: string): EnvVar[] {
+ if (!existsSync(path)) return []
+ const content = readFileSync(path, 'utf-8')
+ const vars: EnvVar[] = []
+ for (const line of content.split('\n')) {
+ const trimmed = line.trim()
+ if (!trimmed || trimmed.startsWith('#')) continue
+ const eqIndex = trimmed.indexOf('=')
+ if (eqIndex === -1) continue
+ const key = trimmed.slice(0, eqIndex).trim()
+ let value = trimmed.slice(eqIndex + 1).trim()
+ if ((value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'"))) {
+ value = value.slice(1, -1)
+ }
+ if (key) vars.push({ key, value })
+ }
+ return vars
+}
+
+function writeEnvFile(path: string, vars: EnvVar[]) {
+ const dir = dirname(path)
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
+ const content = vars.map(v => `${v.key}=${v.value}`).join('\n') + (vars.length ? '\n' : '')
+ writeFileSync(path, content)
+}
+
+// Get env vars for an app
+router.get('/:app/env', c => {
+ const appName = c.req.param('app')
+ if (!appName) return c.json({ error: 'App not found' }, 404)
+
+ const app = allApps().find(a => a.name === appName)
+ if (!app) return c.json({ error: 'App not found' }, 404)
+
+ return c.json(parseEnvFile(appEnvPath(appName)))
+})
+
+// Set env var for an app
+router.post('/:app/env', async c => {
+ const appName = c.req.param('app')
+ if (!appName) return c.json({ error: 'App not found' }, 404)
+
+ const app = allApps().find(a => a.name === appName)
+ if (!app) return c.json({ error: 'App not found' }, 404)
+
+ let body: { key?: string, value?: string }
+ try {
+ body = await c.req.json()
+ } catch {
+ return c.json({ ok: false, error: 'Invalid JSON body' }, 400)
+ }
+
+ const key = body.key?.trim().toUpperCase()
+ const value = body.value ?? ''
+
+ if (!key) return c.json({ ok: false, error: 'Key is required' }, 400)
+
+ const path = appEnvPath(appName)
+ const vars = parseEnvFile(path)
+ const existing = vars.findIndex(v => v.key === key)
+
+ if (existing >= 0) {
+ vars[existing]!.value = value
+ } else {
+ vars.push({ key, value })
+ }
+
+ writeEnvFile(path, vars)
+
+ // Restart app to pick up new env
+ await restartApp(appName)
+
+ return c.json({ ok: true })
+})
+
+// Delete env var for an app
+router.delete('/:app/env/:key', async c => {
+ const appName = c.req.param('app')
+ const key = c.req.param('key')
+ if (!appName || !key) return c.json({ error: 'App and key required' }, 400)
+
+ const app = allApps().find(a => a.name === appName)
+ if (!app) return c.json({ error: 'App not found' }, 404)
+
+ const path = appEnvPath(appName)
+ const vars = parseEnvFile(path).filter(v => v.key !== key.toUpperCase())
+ writeEnvFile(path, vars)
+
+ // Restart app to pick up removed env
+ await restartApp(appName)
+
+ return c.json({ ok: true })
+})
+
export default router
diff --git a/src/server/apps.ts b/src/server/apps.ts
index a540561..e857449 100644
--- a/src/server/apps.ts
+++ b/src/server/apps.ts
@@ -8,6 +8,7 @@ import { appLog, hostLog, setApps } from './tui'
export type { AppState } from '@types'
export const APPS_DIR = process.env.APPS_DIR ?? resolve(join(process.env.DATA_DIR ?? '.', 'apps'))
+export const TOES_DIR = process.env.TOES_DIR ?? join(process.env.DATA_DIR ?? '.', 'toes')
export const TOES_URL = process.env.TOES_URL ?? `http://localhost:${process.env.PORT || 3000}`
const HEALTH_CHECK_FAILURES_BEFORE_RESTART = 3
@@ -437,6 +438,34 @@ function loadApp(dir: string): LoadResult {
}
}
+function loadAppEnv(appName: string): Record {
+ const envDir = join(TOES_DIR, 'env')
+ const env: Record = {}
+
+ const parseEnvFile = (path: string) => {
+ if (!existsSync(path)) return
+ const content = readFileSync(path, 'utf-8')
+ for (const line of content.split('\n')) {
+ const trimmed = line.trim()
+ if (!trimmed || trimmed.startsWith('#')) continue
+ const eqIndex = trimmed.indexOf('=')
+ if (eqIndex === -1) continue
+ const key = trimmed.slice(0, eqIndex).trim()
+ let value = trimmed.slice(eqIndex + 1).trim()
+ // Remove surrounding quotes
+ if ((value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'"))) {
+ value = value.slice(1, -1)
+ }
+ if (key) env[key] = value
+ }
+ }
+
+ parseEnvFile(join(envDir, `${appName}.env`))
+
+ return env
+}
+
function maybeResetBackoff(app: App) {
if (app.started && Date.now() - app.started >= STABLE_RUN_TIME) {
app.restartAttempts = 0
@@ -511,9 +540,12 @@ async function runApp(dir: string, port: number) {
info(app, `Starting on port ${port}...`)
+ // Load env vars from TOES_DIR/env/
+ const appEnv = loadAppEnv(dir)
+
const proc = Bun.spawn(['bun', 'run', 'toes'], {
cwd,
- env: { ...process.env, PORT: String(port), NO_AUTOPORT: 'true', APPS_DIR, TOES_URL },
+ env: { ...process.env, ...appEnv, PORT: String(port), NO_AUTOPORT: 'true', APPS_DIR, TOES_DIR, TOES_URL },
stdout: 'pipe',
stderr: 'pipe',
})