dotenv support
This commit is contained in:
parent
f3040abc5d
commit
a58c42e0d4
12
CLAUDE.md
12
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)
|
||||
|
|
|
|||
1
apps/env/20260130-000000/.npmrc
vendored
Normal file
1
apps/env/20260130-000000/.npmrc
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
registry=https://npm.nose.space
|
||||
47
apps/env/20260130-000000/bun.lock
vendored
Normal file
47
apps/env/20260130-000000/bun.lock
vendored
Normal file
|
|
@ -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=="],
|
||||
}
|
||||
}
|
||||
332
apps/env/20260130-000000/index.tsx
vendored
Normal file
332
apps/env/20260130-000000/index.tsx
vendored
Normal file
|
|
@ -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 (
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{title}</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<script dangerouslySetInnerHTML={{ __html: initScript }} />
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>Environment Variables</Title>
|
||||
</Header>
|
||||
{children}
|
||||
</Container>
|
||||
<script dangerouslySetInnerHTML={{ __html: clientScript }} />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
<Layout title="Environment Variables">
|
||||
<ErrorBox>Please specify an app name with ?app=<name></ErrorBox>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
const appVars = parseEnvFile(appEnvPath(appName))
|
||||
|
||||
return c.html(
|
||||
<Layout title={`Env - ${appName}`}>
|
||||
{appVars.length === 0 ? (
|
||||
<EmptyState>No environment variables</EmptyState>
|
||||
) : (
|
||||
<EnvList>
|
||||
{appVars.map(v => (
|
||||
<EnvItem data-env-item>
|
||||
<EnvKey>{v.key}</EnvKey>
|
||||
<EnvValue data-value data-hidden={v.value}>{'••••••••'}</EnvValue>
|
||||
<EnvActions>
|
||||
<Button data-reveal>Reveal</Button>
|
||||
<form method="POST" action={`/delete?app=${appName}&key=${v.key}`} style="margin:0">
|
||||
<DangerButton type="submit">Delete</DangerButton>
|
||||
</form>
|
||||
</EnvActions>
|
||||
</EnvItem>
|
||||
))}
|
||||
</EnvList>
|
||||
)}
|
||||
<Form method="POST" action={`/set?app=${appName}`}>
|
||||
<Input type="text" name="key" placeholder="KEY" required />
|
||||
<Input type="text" name="value" placeholder="value" required />
|
||||
<Button type="submit">Add</Button>
|
||||
</Form>
|
||||
</Layout>
|
||||
)
|
||||
})
|
||||
|
||||
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
|
||||
26
apps/env/20260130-000000/package.json
vendored
Normal file
26
apps/env/20260130-000000/package.json
vendored
Normal file
|
|
@ -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": "*"
|
||||
}
|
||||
}
|
||||
30
apps/env/20260130-000000/tsconfig.json
vendored
Normal file
30
apps/env/20260130-000000/tsconfig.json
vendored
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
1
apps/env/current
vendored
Symbolic link
1
apps/env/current
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
20260130-000000
|
||||
41
docs/ENV.md
Normal file
41
docs/ENV.md
Normal file
|
|
@ -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.
|
||||
87
src/cli/commands/env.ts
Normal file
87
src/cli/commands/env.ts
Normal file
|
|
@ -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<EnvVar[]>(`/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'))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export { envList, envRm, envSet } from './env'
|
||||
export { logApp } from './logs'
|
||||
export {
|
||||
configShow,
|
||||
|
|
|
|||
|
|
@ -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('<key>', '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('<key>', 'variable name to remove')
|
||||
.action(envRm)
|
||||
|
||||
program
|
||||
.command('versions')
|
||||
.description('List deployed versions')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, string> {
|
||||
const envDir = join(TOES_DIR, 'env')
|
||||
const env: Record<string, string> = {}
|
||||
|
||||
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',
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user