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>
102 lines
3.1 KiB
TypeScript
102 lines
3.1 KiB
TypeScript
/**
|
|
* 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
|
|
}
|
|
}
|