468 lines
12 KiB
TypeScript
468 lines
12 KiB
TypeScript
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 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',
|
|
paddingTop: 0,
|
|
maxWidth: '800px',
|
|
margin: '0 auto',
|
|
color: theme('colors-text'),
|
|
})
|
|
|
|
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 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', {
|
|
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 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',
|
|
color: theme('colors-textMuted'),
|
|
flex: 1,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
})
|
|
|
|
const ErrorBox = define('ErrorBox', {
|
|
color: theme('colors-error'),
|
|
padding: '20px',
|
|
backgroundColor: theme('colors-bgElement'),
|
|
borderRadius: theme('radius-md'),
|
|
margin: '20px 0',
|
|
})
|
|
|
|
const Form = define('Form', {
|
|
base: 'form',
|
|
display: 'flex',
|
|
gap: '10px',
|
|
marginTop: '15px',
|
|
padding: '15px',
|
|
backgroundColor: theme('colors-bgSubtle'),
|
|
borderRadius: theme('radius-md'),
|
|
})
|
|
|
|
const Hint = define('Hint', {
|
|
fontSize: '12px',
|
|
color: theme('colors-textMuted'),
|
|
marginTop: '10px',
|
|
})
|
|
|
|
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 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 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 (
|
|
<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>
|
|
{children}
|
|
</Container>
|
|
<script dangerouslySetInnerHTML={{ __html: clientScript }} />
|
|
</body>
|
|
</html>
|
|
)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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'))
|
|
|
|
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 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 globalVars = parseEnvFile(GLOBAL_ENV_PATH)
|
|
const globalKeys = new Set(globalVars.map(v => v.key))
|
|
|
|
return c.html(
|
|
<Layout title={`Env - ${appName}`}>
|
|
<TabBar>
|
|
<TabActive href={appUrl}>App</TabActive>
|
|
<Tab href={globalUrl}>Global</Tab>
|
|
</TabBar>
|
|
{appVars.length === 0 && globalKeys.size === 0 ? (
|
|
<EmptyState>No environment variables</EmptyState>
|
|
) : (
|
|
<EnvList>
|
|
{appVars.map(v => (
|
|
<EnvItem data-env-item>
|
|
<EnvKey>
|
|
{v.key}
|
|
{globalKeys.has(v.key) && <Badge>overrides global</Badge>}
|
|
</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>
|
|
))}
|
|
{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>
|
|
)}
|
|
<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}`)
|
|
})
|
|
|
|
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
|