diff --git a/apps/env/20260130-000000/index.tsx b/apps/env/20260130-000000/index.tsx index 1d1347a..36fd28b 100644 --- a/apps/env/20260130-000000/index.tsx +++ b/apps/env/20260130-000000/index.tsx @@ -7,9 +7,38 @@ 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 GLOBAL_ENV_PATH = join(ENV_DIR, '_global.env') const app = new Hype({ prettyHTML: false }) +const Badge = define('Badge', { + base: 'span', + fontSize: '11px', + padding: '2px 6px', + borderRadius: '3px', + backgroundColor: theme('colors-bgSubtle'), + color: theme('colors-textMuted'), + fontFamily: theme('fonts-sans'), + fontWeight: 'normal', + marginLeft: '8px', +}) + +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 Container = define('Container', { fontFamily: theme('fonts-sans'), padding: '20px', @@ -19,13 +48,34 @@ const Container = define('Container', { color: theme('colors-text'), }) -const EnvList = define('EnvList', { - listStyle: 'none', - padding: 0, - margin: 0, - border: `1px solid ${theme('colors-border')}`, +const DangerButton = define('DangerButton', { + base: 'button', + padding: '6px 12px', + fontSize: '13px', borderRadius: theme('radius-md'), - overflow: 'hidden', + border: 'none', + backgroundColor: theme('colors-error'), + color: 'white', + cursor: 'pointer', + states: { + ':hover': { + opacity: 0.9, + }, + }, +}) + +const EmptyState = define('EmptyState', { + padding: '30px', + textAlign: 'center', + color: theme('colors-textMuted'), + backgroundColor: theme('colors-bgSubtle'), + borderRadius: theme('radius-md'), +}) + +const EnvActions = define('EnvActions', { + display: 'flex', + gap: '8px', + flexShrink: 0, }) const EnvItem = define('EnvItem', { @@ -49,6 +99,15 @@ const EnvKey = define('EnvKey', { color: theme('colors-text'), }) +const EnvList = define('EnvList', { + listStyle: 'none', + padding: 0, + margin: 0, + border: `1px solid ${theme('colors-border')}`, + borderRadius: theme('radius-md'), + overflow: 'hidden', +}) + const EnvValue = define('EnvValue', { fontFamily: theme('fonts-mono'), fontSize: '14px', @@ -59,42 +118,12 @@ const EnvValue = define('EnvValue', { 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')}`, +const ErrorBox = define('ErrorBox', { + color: theme('colors-error'), + padding: '20px', 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, - }, - }, + margin: '20px 0', }) const Form = define('Form', { @@ -107,6 +136,12 @@ const Form = define('Form', { borderRadius: theme('radius-md'), }) +const Hint = define('Hint', { + fontSize: '12px', + color: theme('colors-textMuted'), + marginTop: '10px', +}) + const Input = define('Input', { base: 'input', flex: 1, @@ -125,27 +160,54 @@ const Input = define('Input', { }, }) -const ErrorBox = define('ErrorBox', { - color: theme('colors-error'), - padding: '20px', - backgroundColor: theme('colors-bgElement'), - borderRadius: theme('radius-md'), - margin: '20px 0', +const Tab = define('Tab', { + base: 'a', + padding: '8px 16px', + fontSize: '13px', + fontFamily: theme('fonts-sans'), + color: theme('colors-textMuted'), + textDecoration: 'none', + borderBottom: '2px solid transparent', + cursor: 'pointer', + states: { + ':hover': { + color: theme('colors-text'), + }, + }, }) -const EmptyState = define('EmptyState', { - padding: '30px', - textAlign: 'center', - color: theme('colors-textMuted'), - backgroundColor: theme('colors-bgSubtle'), - borderRadius: theme('radius-md'), +const TabActive = define('TabActive', { + base: 'a', + padding: '8px 16px', + fontSize: '13px', + fontFamily: theme('fonts-sans'), + color: theme('colors-text'), + textDecoration: 'none', + borderBottom: `2px solid ${theme('colors-primary')}`, + fontWeight: 'bold', + cursor: 'default', }) +const TabBar = define('TabBar', { + display: 'flex', + gap: '4px', + borderBottom: `1px solid ${theme('colors-border')}`, + marginBottom: '15px', +}) + +interface EnvVar { + key: string + value: string +} + interface LayoutProps { title: string children: Child } +const appEnvPath = (appName: string) => + join(ENV_DIR, `${appName}.env`) + function Layout({ title, children }: LayoutProps) { return ( @@ -166,29 +228,6 @@ function Layout({ title, children }: LayoutProps) { ) } -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 }) @@ -229,9 +268,23 @@ function writeEnvFile(path: string, vars: EnvVar[]) { writeFileSync(path, content) } -function appEnvPath(appName: string): string { - return join(ENV_DIR, `${appName}.env`) -} +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'; + } + }); +}); +` app.get('/ok', c => c.text('ok')) @@ -250,17 +303,67 @@ app.get('/', async c => { ) } + const tab = c.req.query('tab') === 'global' ? 'global' : 'app' + const appUrl = `/?app=${appName}` + const globalUrl = `/?app=${appName}&tab=global` + + if (tab === 'global') { + const globalVars = parseEnvFile(GLOBAL_ENV_PATH) + + return c.html( + + + App + Global + + {globalVars.length === 0 ? ( + No global environment variables + ) : ( + + {globalVars.map(v => ( + + {v.key} + {'••••••••'} + + +
+ Delete +
+
+
+ ))} +
+ )} +
+ + + +
+ Global vars are available to all apps. Changes take effect on next app restart. +
+ ) + } + const appVars = parseEnvFile(appEnvPath(appName)) + const globalVars = parseEnvFile(GLOBAL_ENV_PATH) + const globalKeys = new Set(globalVars.map(v => v.key)) return c.html( - {appVars.length === 0 ? ( + + App + Global + + {appVars.length === 0 && globalKeys.size === 0 ? ( No environment variables ) : ( {appVars.map(v => ( - {v.key} + + {v.key} + {globalKeys.has(v.key) && overrides global} + {'••••••••'} @@ -270,6 +373,18 @@ app.get('/', async c => { ))} + {globalVars.filter(v => !appVars.some(a => a.key === v.key)).map(v => ( + + + {v.key} + global + + {'••••••••'} + + + + + ))} )}
@@ -316,4 +431,37 @@ app.post('/delete', async c => { return c.redirect(`/?app=${appName}`) }) +app.post('/set-global', 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 vars = parseEnvFile(GLOBAL_ENV_PATH) + const existing = vars.findIndex(v => v.key === key) + + if (existing >= 0) { + vars[existing]!.value = value + } else { + vars.push({ key, value }) + } + + writeEnvFile(GLOBAL_ENV_PATH, vars) + return c.redirect(`/?app=${appName}&tab=global`) +}) + +app.post('/delete-global', 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 vars = parseEnvFile(GLOBAL_ENV_PATH).filter(v => v.key !== key) + writeEnvFile(GLOBAL_ENV_PATH, vars) + return c.redirect(`/?app=${appName}&tab=global`) +}) + export default app.defaults diff --git a/src/server/api/apps.ts b/src/server/api/apps.ts index 30059af..a53ce6a 100644 --- a/src/server/api/apps.ts +++ b/src/server/api/apps.ts @@ -221,8 +221,9 @@ interface EnvVar { value: string } -const envDir = () => join(TOES_DIR, 'env') const appEnvPath = (appName: string) => join(envDir(), `${appName}.env`) +const envDir = () => join(TOES_DIR, 'env') +const globalEnvPath = () => join(envDir(), '_global.env') function parseEnvFile(path: string): EnvVar[] { if (!existsSync(path)) return [] @@ -251,7 +252,49 @@ function writeEnvFile(path: string, vars: EnvVar[]) { writeFileSync(path, content) } -// Get env vars for an app +// Global env vars +router.get('/env', c => { + return c.json(parseEnvFile(globalEnvPath())) +}) + +router.post('/env', async c => { + 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 = globalEnvPath() + 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.json({ ok: true }) +}) + +router.delete('/env/:key', c => { + const key = c.req.param('key') + if (!key) return c.json({ error: 'Key required' }, 400) + + const path = globalEnvPath() + const vars = parseEnvFile(path).filter(v => v.key !== key.toUpperCase()) + writeEnvFile(path, vars) + return c.json({ ok: true }) +}) + +// App env vars router.get('/:app/env', c => { const appName = c.req.param('app') if (!appName) return c.json({ error: 'App not found' }, 404) diff --git a/src/server/apps.ts b/src/server/apps.ts index 3910355..0badfc8 100644 --- a/src/server/apps.ts +++ b/src/server/apps.ts @@ -512,6 +512,7 @@ function loadAppEnv(appName: string): Record { } } + parseEnvFile(join(envDir, '_global.env')) parseEnvFile(join(envDir, `${appName}.env`)) return env