Move domain config from .env to a private SQLite store
Forges (multi-host) + tokens, project directories, and git identity now live in a private SQLite config store (internal/store, modernc.org/sqlite) on a /data named volume that is not bind-mounted or exposed, so credentials aren't reachable outside the container. New Settings page (/settings) + <settings-panel> with /api/config CRUD. Scanner reads roots fresh from the store each cycle; service resolves forges per-repo from the store and reapplies per-forge git auth on change. First run seeds the store from .env. Overturns the old no-datastore/.env-config laws (AGENT.md updated). Verified live end-to-end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
// <settings-panel> — manage the config store (AGENT.md §1.3): forge hosts +
|
||||
// tokens, project directories to scan, and the git commit identity. Replaces
|
||||
// editing .env for domain config.
|
||||
//
|
||||
// A self-contained control (§1.1): shadow DOM, fetches its own data, posts to the
|
||||
// /api/config/* endpoints, and reports results via `toast` events. Tokens are
|
||||
// write-only from here — the server never returns them (only whether one is set).
|
||||
|
||||
class SettingsPanel extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.#renderShell();
|
||||
this.#loadForges();
|
||||
this.#loadDirs();
|
||||
this.#loadIdentity();
|
||||
}
|
||||
|
||||
// --- forges ---------------------------------------------------------------
|
||||
|
||||
async #loadForges() {
|
||||
const box = this.shadowRoot.getElementById('forges');
|
||||
try {
|
||||
const forges = await this.#json('GET', '/api/config/forges');
|
||||
if (!forges || forges.length === 0) {
|
||||
box.innerHTML = `<p class="muted">No forges configured.</p>`;
|
||||
return;
|
||||
}
|
||||
box.replaceChildren(...forges.map((f) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row';
|
||||
row.innerHTML = `
|
||||
<div>
|
||||
<span class="k">${this.#esc(f.name || f.kind)}</span>
|
||||
<span class="url">${this.#esc(f.baseUrl)}</span>
|
||||
<span class="badge ${f.hasToken ? 'ok' : 'warn'}">${f.hasToken ? 'token set' : 'no token'}</span>
|
||||
</div>`;
|
||||
const del = document.createElement('button');
|
||||
del.className = 'danger';
|
||||
del.textContent = 'Remove';
|
||||
del.onclick = () => this.#delete(`/api/config/forges/${f.id}`, 'Forge removed', () => this.#loadForges());
|
||||
row.appendChild(del);
|
||||
return row;
|
||||
}));
|
||||
} catch (err) {
|
||||
box.innerHTML = `<p class="error">${this.#esc(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async #addForge() {
|
||||
const name = this.shadowRoot.getElementById('f-name').value.trim();
|
||||
const baseUrl = this.shadowRoot.getElementById('f-url').value.trim();
|
||||
const token = this.shadowRoot.getElementById('f-token').value;
|
||||
if (!baseUrl) { this.#toast('A base URL is required', 'error'); return; }
|
||||
try {
|
||||
await this.#json('POST', '/api/config/forges', { name, kind: 'gitea', baseUrl, token });
|
||||
this.shadowRoot.getElementById('f-name').value = '';
|
||||
this.shadowRoot.getElementById('f-url').value = '';
|
||||
this.shadowRoot.getElementById('f-token').value = '';
|
||||
this.#toast('Forge added', 'success');
|
||||
this.#loadForges();
|
||||
} catch (err) {
|
||||
this.#toast(`Add forge failed: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// --- project directories --------------------------------------------------
|
||||
|
||||
async #loadDirs() {
|
||||
const box = this.shadowRoot.getElementById('dirs');
|
||||
try {
|
||||
const dirs = await this.#json('GET', '/api/config/project-dirs');
|
||||
if (!dirs || dirs.length === 0) {
|
||||
box.innerHTML = `<p class="muted">No project directories configured.</p>`;
|
||||
return;
|
||||
}
|
||||
box.replaceChildren(...dirs.map((d) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row';
|
||||
row.innerHTML = `
|
||||
<label class="dir">
|
||||
<input type="checkbox" ${d.enabled ? 'checked' : ''}>
|
||||
<code>${this.#esc(d.path)}</code>
|
||||
</label>`;
|
||||
row.querySelector('input').onchange = (e) =>
|
||||
this.#put(`/api/config/project-dirs/${d.id}`, { enabled: e.target.checked }, e.target.checked ? 'Directory enabled' : 'Directory disabled');
|
||||
const del = document.createElement('button');
|
||||
del.className = 'danger';
|
||||
del.textContent = 'Remove';
|
||||
del.onclick = () => this.#delete(`/api/config/project-dirs/${d.id}`, 'Directory removed', () => this.#loadDirs());
|
||||
row.appendChild(del);
|
||||
return row;
|
||||
}));
|
||||
} catch (err) {
|
||||
box.innerHTML = `<p class="error">${this.#esc(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async #addDir() {
|
||||
const path = this.shadowRoot.getElementById('d-path').value.trim();
|
||||
if (!path) { this.#toast('A path is required', 'error'); return; }
|
||||
try {
|
||||
await this.#json('POST', '/api/config/project-dirs', { path });
|
||||
this.shadowRoot.getElementById('d-path').value = '';
|
||||
this.#toast('Directory added', 'success');
|
||||
this.#loadDirs();
|
||||
} catch (err) {
|
||||
this.#toast(`Add directory failed: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// --- git identity ---------------------------------------------------------
|
||||
|
||||
async #loadIdentity() {
|
||||
try {
|
||||
const id = await this.#json('GET', '/api/config/identity');
|
||||
this.shadowRoot.getElementById('i-name').value = id.name || '';
|
||||
this.shadowRoot.getElementById('i-email').value = id.email || '';
|
||||
} catch { /* leave blank */ }
|
||||
}
|
||||
|
||||
async #saveIdentity() {
|
||||
const name = this.shadowRoot.getElementById('i-name').value.trim();
|
||||
const email = this.shadowRoot.getElementById('i-email').value.trim();
|
||||
try {
|
||||
await this.#put('/api/config/identity', { name, email }, 'Identity saved');
|
||||
} catch (err) {
|
||||
this.#toast(`Save failed: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers --------------------------------------------------------------
|
||||
|
||||
async #json(method, url, body) {
|
||||
const opts = { method };
|
||||
if (body !== undefined) {
|
||||
opts.headers = { 'Content-Type': 'application/json' };
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(url, opts);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async #put(url, body, okMsg) {
|
||||
await this.#json('PUT', url, body);
|
||||
if (okMsg) this.#toast(okMsg, 'success');
|
||||
}
|
||||
|
||||
async #delete(url, okMsg, after) {
|
||||
if (!window.confirm('Remove this entry?')) return;
|
||||
try {
|
||||
await this.#json('DELETE', url);
|
||||
this.#toast(okMsg, 'success');
|
||||
after?.();
|
||||
} catch (err) {
|
||||
this.#toast(`Remove failed: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
#toast(message, kind) {
|
||||
document.dispatchEvent(new CustomEvent('toast', { detail: { message, kind } }));
|
||||
}
|
||||
|
||||
#renderShell() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; }
|
||||
section { background: var(--surface-1); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 16px 18px; margin-bottom: 16px; }
|
||||
h2 { font-size: 15px; margin: 0 0 4px; }
|
||||
.hint { color: var(--color-fg-muted); font-size: 13px; margin: 0 0 12px; }
|
||||
.row { display: flex; align-items: center; gap: 10px; padding: 6px 0;
|
||||
border-top: 1px solid var(--border); }
|
||||
.row:first-of-type { border-top: none; }
|
||||
.row > div:first-child, .dir { display: flex; align-items: center; gap: 10px; flex: 1; flex-wrap: wrap; }
|
||||
.k { font-weight: 600; }
|
||||
.url { color: var(--color-fg-muted); }
|
||||
code { background: var(--surface-2); padding: 1px 6px; border-radius: var(--radius-sm); }
|
||||
.badge { font-size: 11px; padding: 1px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-strong); }
|
||||
.badge.ok { color: var(--color-success); border-color: var(--color-success); }
|
||||
.badge.warn { color: var(--color-warning); border-color: var(--color-warning); }
|
||||
.form { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
||||
input[type=text], input[type=password] {
|
||||
font: inherit; background: var(--surface-2); color: var(--color-fg);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 9px; }
|
||||
input.grow { flex: 1; min-width: 160px; }
|
||||
button { font: inherit; cursor: pointer; padding: 6px 12px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--fill-accent); color: var(--fill-accent); background: transparent; }
|
||||
button:hover { background: var(--surface-2); }
|
||||
button.danger { border-color: var(--color-danger); color: var(--color-danger); margin-left: auto; }
|
||||
.muted { color: var(--color-fg-muted); }
|
||||
.error { color: var(--color-danger); }
|
||||
</style>
|
||||
|
||||
<section>
|
||||
<h2>Forges</h2>
|
||||
<p class="hint">Hosting servers (Gitea/Forgejo) and their access tokens.
|
||||
Tokens are stored in the private config database and never shown again.</p>
|
||||
<div id="forges"><p class="muted">Loading…</p></div>
|
||||
<div class="form">
|
||||
<input id="f-name" type="text" placeholder="Name (e.g. Gitea)">
|
||||
<input id="f-url" type="text" class="grow" placeholder="https://git.example.com">
|
||||
<input id="f-token" type="password" placeholder="Access token">
|
||||
<button id="f-add">Add forge</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Project directories</h2>
|
||||
<p class="hint">Directories to scan for repositories. Paths are inside the
|
||||
container — they must be under a mounted directory (e.g. under <code>/repos</code>).</p>
|
||||
<div id="dirs"><p class="muted">Loading…</p></div>
|
||||
<div class="form">
|
||||
<input id="d-path" type="text" class="grow" placeholder="/repos/some-folder">
|
||||
<button id="d-add">Add directory</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Git identity</h2>
|
||||
<p class="hint">Author used for commits the app makes.</p>
|
||||
<div class="form">
|
||||
<input id="i-name" type="text" placeholder="Your Name">
|
||||
<input id="i-email" type="text" class="grow" placeholder="you@example.com">
|
||||
<button id="i-save">Save</button>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
this.shadowRoot.getElementById('f-add').onclick = () => this.#addForge();
|
||||
this.shadowRoot.getElementById('d-add').onclick = () => this.#addDir();
|
||||
this.shadowRoot.getElementById('i-save').onclick = () => this.#saveIdentity();
|
||||
}
|
||||
|
||||
#esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('settings-panel', SettingsPanel);
|
||||
Reference in New Issue
Block a user