feat: config & onboarding — each teammate connects their own Gitea (Phase A)
The foundation for a shareable team build. Replaces the .env.local-only dev config
with a real, per-teammate connection flow.
main:
- config-store.ts: token encrypted at rest via Electron safeStorage (OS keychain),
config JSON in userData. Token lives only in main; renderer gets everything but.
- resolveConfig: saved config > .env.local (dev) > null; ignored under COMMITEA_E2E.
pm-state repo defaults to `${repo}-pm-state`. resetClients() re-reads on change so
saving config takes effect without a restart. gitea:status gains `demo` (e2e).
- IPC: config:get (no token), config:test (authed read validates token+repo),
config:set (encrypt+save+reset), config:clear. Model bridge reads config.modelUrl
and probes reachability — chat is "configured" only if a model actually answers;
localhost default is dev-only (app.isPackaged gate).
renderer:
- ConnectScreen: real onboarding form (URL/owner/repo/PAT/optional model) → test →
save. AppShell gates on it: demo → shell (fixtures/e2e); configured → shell (real);
else → connect. Settings Connection card is real (repo/url/model/sidecar) with
Reconfigure + Disconnect. Chat cleanly disables with a "no model" state instead of
the scripted canned reply.
Verified: main + desktop typecheck clean, 14 fixture e2e green (demo mode unchanged),
live onboarding e2e: fresh app → connect form → validated PAT → real board (24 done /
10 open). COMMITEA_NO_ENV_LOCAL + COMMITEA_USERDATA are test hooks for the onboarding path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
101
apps/desktop/src/main/config-store.ts
Normal file
101
apps/desktop/src/main/config-store.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Persistent app config for a shared/team build. The gitea PAT is encrypted at
|
||||
* rest with Electron's safeStorage (OS keychain-backed) and only ever lives in
|
||||
* the main process — the renderer receives everything *except* the token. Config
|
||||
* lives in userData so each teammate has their own; a `.env.local` remains a dev
|
||||
* fallback (see resolveConfig). Nothing here runs under COMMITEA_E2E.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { app, safeStorage } from 'electron'
|
||||
|
||||
export interface AppConfig {
|
||||
baseUrl: string
|
||||
owner: string
|
||||
repo: string
|
||||
token: string
|
||||
/** Defaults to `${repo}-pm-state` when omitted. */
|
||||
pmStateRepo?: string
|
||||
/** OpenAI-compatible base for Reginald; chat stays off when absent. */
|
||||
modelUrl?: string
|
||||
}
|
||||
|
||||
/** The renderer-safe view — everything but the token, plus whether one is set. */
|
||||
export type PublicConfig = Omit<AppConfig, 'token'> & { hasToken: boolean }
|
||||
|
||||
interface StoredConfig {
|
||||
baseUrl: string
|
||||
owner: string
|
||||
repo: string
|
||||
tokenEnc: string | null // base64 of safeStorage-encrypted token
|
||||
pmStateRepo?: string
|
||||
modelUrl?: string
|
||||
}
|
||||
|
||||
function configPath(): string {
|
||||
return join(app.getPath('userData'), 'commitea-config.json')
|
||||
}
|
||||
|
||||
function readStored(): StoredConfig | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(configPath(), 'utf8')) as StoredConfig
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Full config incl. the decrypted token, or null when unset. Main-process only. */
|
||||
export function loadConfig(): AppConfig | null {
|
||||
const s = readStored()
|
||||
if (!s || !s.baseUrl || !s.owner || !s.repo) return null
|
||||
let token = ''
|
||||
if (s.tokenEnc) {
|
||||
try {
|
||||
token = safeStorage.decryptString(Buffer.from(s.tokenEnc, 'base64'))
|
||||
} catch {
|
||||
token = '' // key rotated / different machine — treat as no token
|
||||
}
|
||||
}
|
||||
if (!token) return null
|
||||
return { baseUrl: s.baseUrl, owner: s.owner, repo: s.repo, token, pmStateRepo: s.pmStateRepo, modelUrl: s.modelUrl }
|
||||
}
|
||||
|
||||
/** The renderer-safe view of the saved config (never includes the token). */
|
||||
export function publicConfig(): PublicConfig | null {
|
||||
const s = readStored()
|
||||
if (!s) return null
|
||||
return {
|
||||
baseUrl: s.baseUrl,
|
||||
owner: s.owner,
|
||||
repo: s.repo,
|
||||
pmStateRepo: s.pmStateRepo,
|
||||
modelUrl: s.modelUrl,
|
||||
hasToken: !!s.tokenEnc,
|
||||
}
|
||||
}
|
||||
|
||||
/** Encrypt the token + persist. Best-effort; throws only on a genuine write failure. */
|
||||
export function saveConfig(cfg: AppConfig): void {
|
||||
const tokenEnc = cfg.token
|
||||
? Buffer.from(safeStorage.encryptString(cfg.token)).toString('base64')
|
||||
: (readStored()?.tokenEnc ?? null) // keep the existing token if none supplied
|
||||
const stored: StoredConfig = {
|
||||
baseUrl: cfg.baseUrl.trim().replace(/\/+$/, ''),
|
||||
owner: cfg.owner.trim(),
|
||||
repo: cfg.repo.trim(),
|
||||
tokenEnc,
|
||||
pmStateRepo: cfg.pmStateRepo?.trim() || undefined,
|
||||
modelUrl: cfg.modelUrl?.trim() || undefined,
|
||||
}
|
||||
writeFileSync(configPath(), JSON.stringify(stored, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
export function clearConfig(): void {
|
||||
try {
|
||||
writeFileSync(configPath(), JSON.stringify({ baseUrl: '', owner: '', repo: '', tokenEnc: null }), 'utf8')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user