global env variables

This commit is contained in:
Chris Wanstrath 2026-02-09 10:50:17 -08:00
parent 3ef7eba0d9
commit d6ae39ac15
3 changed files with 274 additions and 82 deletions

View File

@ -7,9 +7,38 @@ import type { Child } from 'hono/jsx'
const TOES_DIR = process.env.TOES_DIR ?? join(process.env.HOME!, '.toes') const TOES_DIR = process.env.TOES_DIR ?? join(process.env.HOME!, '.toes')
const ENV_DIR = join(TOES_DIR, 'env') const ENV_DIR = join(TOES_DIR, 'env')
const GLOBAL_ENV_PATH = join(ENV_DIR, '_global.env')
const app = new Hype({ prettyHTML: false }) 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', { const Container = define('Container', {
fontFamily: theme('fonts-sans'), fontFamily: theme('fonts-sans'),
padding: '20px', padding: '20px',
@ -19,13 +48,34 @@ const Container = define('Container', {
color: theme('colors-text'), color: theme('colors-text'),
}) })
const EnvList = define('EnvList', { const DangerButton = define('DangerButton', {
listStyle: 'none', base: 'button',
padding: 0, padding: '6px 12px',
margin: 0, fontSize: '13px',
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'), 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', { const EnvItem = define('EnvItem', {
@ -49,6 +99,15 @@ const EnvKey = define('EnvKey', {
color: theme('colors-text'), 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', { const EnvValue = define('EnvValue', {
fontFamily: theme('fonts-mono'), fontFamily: theme('fonts-mono'),
fontSize: '14px', fontSize: '14px',
@ -59,42 +118,12 @@ const EnvValue = define('EnvValue', {
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}) })
const EnvActions = define('EnvActions', { const ErrorBox = define('ErrorBox', {
display: 'flex', color: theme('colors-error'),
gap: '8px', padding: '20px',
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'), 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'), borderRadius: theme('radius-md'),
border: 'none', margin: '20px 0',
backgroundColor: theme('colors-error'),
color: 'white',
cursor: 'pointer',
states: {
':hover': {
opacity: 0.9,
},
},
}) })
const Form = define('Form', { const Form = define('Form', {
@ -107,6 +136,12 @@ const Form = define('Form', {
borderRadius: theme('radius-md'), borderRadius: theme('radius-md'),
}) })
const Hint = define('Hint', {
fontSize: '12px',
color: theme('colors-textMuted'),
marginTop: '10px',
})
const Input = define('Input', { const Input = define('Input', {
base: 'input', base: 'input',
flex: 1, flex: 1,
@ -125,27 +160,54 @@ const Input = define('Input', {
}, },
}) })
const ErrorBox = define('ErrorBox', { const Tab = define('Tab', {
color: theme('colors-error'), base: 'a',
padding: '20px', padding: '8px 16px',
backgroundColor: theme('colors-bgElement'), fontSize: '13px',
borderRadius: theme('radius-md'), fontFamily: theme('fonts-sans'),
margin: '20px 0', color: theme('colors-textMuted'),
textDecoration: 'none',
borderBottom: '2px solid transparent',
cursor: 'pointer',
states: {
':hover': {
color: theme('colors-text'),
},
},
}) })
const EmptyState = define('EmptyState', { const TabActive = define('TabActive', {
padding: '30px', base: 'a',
textAlign: 'center', padding: '8px 16px',
color: theme('colors-textMuted'), fontSize: '13px',
backgroundColor: theme('colors-bgSubtle'), fontFamily: theme('fonts-sans'),
borderRadius: theme('radius-md'), 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 { interface LayoutProps {
title: string title: string
children: Child children: Child
} }
const appEnvPath = (appName: string) =>
join(ENV_DIR, `${appName}.env`)
function Layout({ title, children }: LayoutProps) { function Layout({ title, children }: LayoutProps) {
return ( return (
<html> <html>
@ -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() { function ensureEnvDir() {
if (!existsSync(ENV_DIR)) { if (!existsSync(ENV_DIR)) {
mkdirSync(ENV_DIR, { recursive: true }) mkdirSync(ENV_DIR, { recursive: true })
@ -229,9 +268,23 @@ function writeEnvFile(path: string, vars: EnvVar[]) {
writeFileSync(path, content) writeFileSync(path, content)
} }
function appEnvPath(appName: string): string { const clientScript = `
return join(ENV_DIR, `${appName}.env`) 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')) 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(
<Layout title={`Env - Global`}>
<TabBar>
<Tab href={appUrl}>App</Tab>
<TabActive href={globalUrl}>Global</TabActive>
</TabBar>
{globalVars.length === 0 ? (
<EmptyState>No global environment variables</EmptyState>
) : (
<EnvList>
{globalVars.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-global?app=${appName}&key=${v.key}`} style="margin:0">
<DangerButton type="submit">Delete</DangerButton>
</form>
</EnvActions>
</EnvItem>
))}
</EnvList>
)}
<Form method="POST" action={`/set-global?app=${appName}`}>
<Input type="text" name="key" placeholder="KEY" required />
<Input type="text" name="value" placeholder="value" required />
<Button type="submit">Add</Button>
</Form>
<Hint>Global vars are available to all apps. Changes take effect on next app restart.</Hint>
</Layout>
)
}
const appVars = parseEnvFile(appEnvPath(appName)) const appVars = parseEnvFile(appEnvPath(appName))
const globalVars = parseEnvFile(GLOBAL_ENV_PATH)
const globalKeys = new Set(globalVars.map(v => v.key))
return c.html( return c.html(
<Layout title={`Env - ${appName}`}> <Layout title={`Env - ${appName}`}>
{appVars.length === 0 ? ( <TabBar>
<TabActive href={appUrl}>App</TabActive>
<Tab href={globalUrl}>Global</Tab>
</TabBar>
{appVars.length === 0 && globalKeys.size === 0 ? (
<EmptyState>No environment variables</EmptyState> <EmptyState>No environment variables</EmptyState>
) : ( ) : (
<EnvList> <EnvList>
{appVars.map(v => ( {appVars.map(v => (
<EnvItem data-env-item> <EnvItem data-env-item>
<EnvKey>{v.key}</EnvKey> <EnvKey>
{v.key}
{globalKeys.has(v.key) && <Badge>overrides global</Badge>}
</EnvKey>
<EnvValue data-value data-hidden={v.value}>{'••••••••'}</EnvValue> <EnvValue data-value data-hidden={v.value}>{'••••••••'}</EnvValue>
<EnvActions> <EnvActions>
<Button data-reveal>Reveal</Button> <Button data-reveal>Reveal</Button>
@ -270,6 +373,18 @@ app.get('/', async c => {
</EnvActions> </EnvActions>
</EnvItem> </EnvItem>
))} ))}
{globalVars.filter(v => !appVars.some(a => a.key === v.key)).map(v => (
<EnvItem data-env-item>
<EnvKey>
{v.key}
<Badge>global</Badge>
</EnvKey>
<EnvValue data-value data-hidden={v.value}>{'••••••••'}</EnvValue>
<EnvActions>
<Button data-reveal>Reveal</Button>
</EnvActions>
</EnvItem>
))}
</EnvList> </EnvList>
)} )}
<Form method="POST" action={`/set?app=${appName}`}> <Form method="POST" action={`/set?app=${appName}`}>
@ -316,4 +431,37 @@ app.post('/delete', async c => {
return c.redirect(`/?app=${appName}`) 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 export default app.defaults

View File

@ -221,8 +221,9 @@ interface EnvVar {
value: string value: string
} }
const envDir = () => join(TOES_DIR, 'env')
const appEnvPath = (appName: string) => join(envDir(), `${appName}.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[] { function parseEnvFile(path: string): EnvVar[] {
if (!existsSync(path)) return [] if (!existsSync(path)) return []
@ -251,7 +252,49 @@ function writeEnvFile(path: string, vars: EnvVar[]) {
writeFileSync(path, content) 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 => { router.get('/:app/env', c => {
const appName = c.req.param('app') const appName = c.req.param('app')
if (!appName) return c.json({ error: 'App not found' }, 404) if (!appName) return c.json({ error: 'App not found' }, 404)

View File

@ -512,6 +512,7 @@ function loadAppEnv(appName: string): Record<string, string> {
} }
} }
parseEnvFile(join(envDir, '_global.env'))
parseEnvFile(join(envDir, `${appName}.env`)) parseEnvFile(join(envDir, `${appName}.env`))
return env return env