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
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { dirname, join } from 'node:path'
|
||||
import {
|
||||
appendDirective,
|
||||
createGiteaClient,
|
||||
GiteaApiError,
|
||||
type DirectiveEntry,
|
||||
type GiteaClient,
|
||||
type GiteaConfig,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
} from '@commitea/core'
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
import { type AppConfig, clearConfig, loadConfig, publicConfig, saveConfig } from './config-store.js'
|
||||
import { loadSnapshot, saveSnapshot } from './snapshot-store.js'
|
||||
|
||||
/** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */
|
||||
@@ -48,9 +50,15 @@ function loadEnvLocalToken(): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function resolveConfig(): GiteaConfig | null {
|
||||
/** Saved config wins (team build); a `.env.local` is the dev fallback; e2e uses fixtures. */
|
||||
export function resolveConfig(): GiteaConfig | null {
|
||||
// E2E runs against fixtures — never hit the network from the test harness.
|
||||
if (process.env.COMMITEA_E2E === '1') return null
|
||||
const saved = loadConfig()
|
||||
if (saved) return { baseUrl: saved.baseUrl, token: saved.token, owner: saved.owner, repo: saved.repo }
|
||||
// COMMITEA_NO_ENV_LOCAL forces the onboarding path (no .env.local fallback) for testing.
|
||||
if (process.env.COMMITEA_NO_ENV_LOCAL === '1') return null
|
||||
// dev fallback — .env.local / env vars
|
||||
const token = process.env.GITEA_TOKEN ?? loadEnvLocalToken()
|
||||
if (!token) return null
|
||||
return {
|
||||
@@ -61,8 +69,23 @@ function resolveConfig(): GiteaConfig | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Memoized client so both the gitea and model bridges share one instance.
|
||||
/** The pm-state repo name: configured, else `${repo}-pm-state`, else the env override. */
|
||||
function pmStateRepoName(config: GiteaConfig): string {
|
||||
return loadConfig()?.pmStateRepo ?? process.env.COMMITEA_PMSTATE_REPO ?? `${config.repo}-pm-state`
|
||||
}
|
||||
|
||||
// Memoized clients; reset via resetClients() when the config changes.
|
||||
let sharedClient: GiteaClient | null | undefined
|
||||
let pmStateClient: GiteaClient | null | undefined
|
||||
|
||||
/** Drop the cached clients + snapshot so the next call re-reads the new config. */
|
||||
export function resetClients(): void {
|
||||
sharedClient = undefined
|
||||
pmStateClient = undefined
|
||||
labelCache = null
|
||||
invalidateSnapshot()
|
||||
}
|
||||
|
||||
export function getGiteaClient(): GiteaClient | null {
|
||||
if (sharedClient === undefined) {
|
||||
const config = resolveConfig()
|
||||
@@ -73,13 +96,10 @@ export function getGiteaClient(): GiteaClient | null {
|
||||
|
||||
// The pm-state repo holds machine-derived state (the directive ledger). Same
|
||||
// token/host as the work repo, a different repo (the purity split, decisions D4).
|
||||
let pmStateClient: GiteaClient | null | undefined
|
||||
export function getPmStateClient(): GiteaClient | null {
|
||||
if (pmStateClient === undefined) {
|
||||
const config = resolveConfig()
|
||||
pmStateClient = config
|
||||
? createGiteaClient({ ...config, repo: process.env.COMMITEA_PMSTATE_REPO ?? 'commitea-pm-state' }, fetch)
|
||||
: null
|
||||
pmStateClient = config ? createGiteaClient({ ...config, repo: pmStateRepoName(config) }, fetch) : null
|
||||
}
|
||||
return pmStateClient
|
||||
}
|
||||
@@ -192,20 +212,38 @@ export function bootSnapshot(): (Snapshot & { savedAt: string }) | null {
|
||||
/** Agent tool calls tolerate a slightly stale snapshot (seconds) to stay responsive. */
|
||||
export const AGENT_SNAPSHOT_TTL_MS = 30_000
|
||||
|
||||
export function registerGiteaIpc(): void {
|
||||
const client = getGiteaClient()
|
||||
const repo = client ? `${process.env.GITEA_OWNER ?? 'christian'}/${process.env.GITEA_REPO ?? 'commitea'}` : null
|
||||
// Label cache is reset with the clients (module-level so resetClients can clear it).
|
||||
let labelCache: GiteaLabel[] | null = null
|
||||
async function resolveLabelIds(client: GiteaClient, names: string[]): Promise<number[]> {
|
||||
const lookup = () => new Map(labelCache!.map((l) => [l.name, l.id]))
|
||||
if (!labelCache) labelCache = await client.listLabels()
|
||||
let byName = lookup()
|
||||
if (names.some((n) => !byName.has(n))) {
|
||||
labelCache = await client.listLabels() // a name we don't know — refetch once
|
||||
byName = lookup()
|
||||
}
|
||||
return names.map((n) => byName.get(n)).filter((id): id is number => id != null)
|
||||
}
|
||||
|
||||
ipcMain.handle('gitea:status', () => ({ configured: !!client, repo }))
|
||||
export function registerGiteaIpc(): void {
|
||||
ipcMain.handle('gitea:status', () => {
|
||||
const cfg = resolveConfig()
|
||||
return {
|
||||
configured: !!cfg,
|
||||
repo: cfg ? `${cfg.owner}/${cfg.repo}` : null,
|
||||
demo: process.env.COMMITEA_E2E === '1',
|
||||
}
|
||||
})
|
||||
|
||||
// Instant boot: the last persisted snapshot, shown before the fresh reconcile lands.
|
||||
ipcMain.handle('gitea:boot', () => {
|
||||
if (!client) return { configured: false }
|
||||
if (!getGiteaClient()) return { configured: false }
|
||||
const persisted = bootSnapshot()
|
||||
return persisted ? { configured: true, cached: true, ...persisted } : { configured: true, cached: false }
|
||||
})
|
||||
|
||||
ipcMain.handle('gitea:reconcile', async () => {
|
||||
const client = getGiteaClient()
|
||||
if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} }
|
||||
try {
|
||||
// explicit UI sync — force fresh, and warm the cache for agent tool calls
|
||||
@@ -220,32 +258,20 @@ export function registerGiteaIpc(): void {
|
||||
})
|
||||
|
||||
ipcMain.handle('gitea:getIssue', async (_event, index: number) => {
|
||||
const client = getGiteaClient()
|
||||
if (!client) return null
|
||||
return client.getIssue(index)
|
||||
})
|
||||
|
||||
// Cached label list for name→id resolution; refreshed on demand if a name misses.
|
||||
let labelCache: GiteaLabel[] | null = null
|
||||
async function resolveLabelIds(names: string[]): Promise<number[]> {
|
||||
if (!client) return []
|
||||
const lookup = () => new Map(labelCache!.map((l) => [l.name, l.id]))
|
||||
if (!labelCache) labelCache = await client.listLabels()
|
||||
let byName = lookup()
|
||||
if (names.some((n) => !byName.has(n))) {
|
||||
labelCache = await client.listLabels() // a name we don't know — refetch once
|
||||
byName = lookup()
|
||||
}
|
||||
return names.map((n) => byName.get(n)).filter((id): id is number => id != null)
|
||||
}
|
||||
|
||||
// The write path (apply_changes). Additive label swaps, applied only after the
|
||||
// renderer's propose-approve. Returns the plan + the freshly-read issue.
|
||||
ipcMain.handle('gitea:applyChange', async (_event, change: IssueChange) => {
|
||||
const client = getGiteaClient()
|
||||
if (!client) return { ok: false as const, reason: 'unconfigured' as const }
|
||||
const current = await client.getIssue(change.issue)
|
||||
const plan = planIssueChange(current.labels, change)
|
||||
if (plan.noop) return { ok: true as const, plan, issue: current }
|
||||
const ids = await resolveLabelIds(plan.labels)
|
||||
const ids = await resolveLabelIds(client, plan.labels)
|
||||
await client.setIssueLabels(change.issue, ids)
|
||||
const issue = await client.getIssue(change.issue)
|
||||
invalidateSnapshot() // the board + forecast must reflect the label change
|
||||
@@ -257,11 +283,12 @@ export function registerGiteaIpc(): void {
|
||||
ipcMain.handle(
|
||||
'gitea:createIssues',
|
||||
async (_event, issues: { title: string; body?: string; estimate?: string; priority?: string }[]) => {
|
||||
const client = getGiteaClient()
|
||||
if (!client) return { ok: false as const, reason: 'unconfigured' as const }
|
||||
const created: { number: number; title: string }[] = []
|
||||
for (const it of issues) {
|
||||
const names = [it.estimate, it.priority].filter((n): n is string => !!n)
|
||||
const labelIds = await resolveLabelIds(names)
|
||||
const labelIds = await resolveLabelIds(client, names)
|
||||
const issue = await client.createIssue({ title: it.title, body: it.body, labelIds })
|
||||
created.push({ number: issue.number, title: issue.title })
|
||||
}
|
||||
@@ -291,4 +318,33 @@ export function registerGiteaIpc(): void {
|
||||
return { ok: true as const, members: [] }
|
||||
}
|
||||
})
|
||||
|
||||
// ---- config (team onboarding) ----
|
||||
ipcMain.handle('config:get', () => publicConfig())
|
||||
|
||||
ipcMain.handle('config:set', (_event, cfg: AppConfig) => {
|
||||
saveConfig(cfg)
|
||||
resetClients() // new config takes effect without a restart
|
||||
return { ok: true as const }
|
||||
})
|
||||
|
||||
ipcMain.handle('config:clear', () => {
|
||||
clearConfig()
|
||||
resetClients()
|
||||
return { ok: true as const }
|
||||
})
|
||||
|
||||
// Validate a token + repo before saving: any authed read on the repo proves access.
|
||||
ipcMain.handle('config:test', async (_event, cfg: AppConfig) => {
|
||||
try {
|
||||
const c = createGiteaClient(
|
||||
{ baseUrl: cfg.baseUrl.replace(/\/+$/, ''), owner: cfg.owner.trim(), repo: cfg.repo.trim(), token: cfg.token },
|
||||
fetch,
|
||||
)
|
||||
await c.listLabels()
|
||||
return { ok: true as const }
|
||||
} catch (e) {
|
||||
return { ok: false as const, error: e instanceof GiteaApiError ? `${e.status}` : e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import { join } from 'node:path'
|
||||
|
||||
import { BrowserWindow, app, shell } from 'electron'
|
||||
|
||||
// Test hook: isolate config/cache to a throwaway dir (must run before any getPath).
|
||||
if (process.env.COMMITEA_USERDATA) app.setPath('userData', process.env.COMMITEA_USERDATA)
|
||||
|
||||
import { registerGiteaIpc } from './gitea.js'
|
||||
import { registerModelIpc } from './model.js'
|
||||
|
||||
|
||||
@@ -24,8 +24,9 @@ import {
|
||||
runAgentTurn,
|
||||
toDirectiveInput,
|
||||
} from '@commitea/core'
|
||||
import { ipcMain } from 'electron'
|
||||
import { app, ipcMain } from 'electron'
|
||||
|
||||
import { loadConfig } from './config-store.js'
|
||||
import {
|
||||
AGENT_SNAPSHOT_TTL_MS,
|
||||
appendDirectiveEntry,
|
||||
@@ -37,7 +38,11 @@ import {
|
||||
/** Small local model for prose + the read tool; big model reserved for later decomposition. */
|
||||
function resolveModelRouter(): ModelRouter | null {
|
||||
if (process.env.COMMITEA_E2E === '1') return null // e2e uses the scripted fixture Reginald
|
||||
const baseUrl = process.env.COMMITEA_MODEL_URL ?? 'http://localhost:1234/v1'
|
||||
// Config wins (team build); env is the dev override; localhost is a dev convenience only.
|
||||
const baseUrl =
|
||||
loadConfig()?.modelUrl ??
|
||||
process.env.COMMITEA_MODEL_URL ??
|
||||
(app.isPackaged ? undefined : 'http://localhost:1234/v1')
|
||||
if (!baseUrl) return null
|
||||
return {
|
||||
small: { baseUrl, model: process.env.COMMITEA_MODEL_SMALL ?? '' },
|
||||
@@ -45,6 +50,19 @@ function resolveModelRouter(): ModelRouter | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Probe the endpoint for a usable model; null when unreachable (chat then stays off). */
|
||||
async function probeModel(baseUrl: string): Promise<string | null> {
|
||||
try {
|
||||
const model = await resolveLoadedModel(baseUrl, process.env.COMMITEA_MODEL_SMALL ?? '')
|
||||
// resolveLoadedModel only returns a real id when the server answered; the default
|
||||
// fallback means unreachable, so confirm with a lightweight models call.
|
||||
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/models`)
|
||||
return res.ok ? model : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which model to actually ask for. An explicit env override wins;
|
||||
* otherwise ask the server which model is *loaded* (LM Studio's native
|
||||
@@ -78,15 +96,15 @@ async function resolveLoadedModel(baseUrl: string, override: string): Promise<st
|
||||
}
|
||||
|
||||
export function registerModelIpc(): void {
|
||||
const router = resolveModelRouter()
|
||||
|
||||
ipcMain.handle('model:status', async () => {
|
||||
if (!router) return { configured: false, model: null }
|
||||
const model = await resolveLoadedModel(router.small.baseUrl, router.small.model)
|
||||
return { configured: true, model }
|
||||
const r = resolveModelRouter() // resolve fresh so a saved modelUrl takes effect
|
||||
if (!r) return { configured: false, model: null }
|
||||
const model = await probeModel(r.small.baseUrl) // only "configured" if a model actually answers
|
||||
return { configured: !!model, model }
|
||||
})
|
||||
|
||||
ipcMain.handle('model:chat', async (event, messages: ChatMessage[]) => {
|
||||
const router = resolveModelRouter()
|
||||
if (!router) return { ok: false as const, reason: 'unconfigured' as const }
|
||||
const client = getGiteaClient()
|
||||
const model = await resolveLoadedModel(router.small.baseUrl, router.small.model)
|
||||
@@ -149,6 +167,7 @@ export function registerModelIpc(): void {
|
||||
// decomposition (with one loaded local model, that's the loaded one). Returns
|
||||
// a proposal; nothing is filed until the Capture tray approves it.
|
||||
ipcMain.handle('model:capture', async (_event, braindump: string) => {
|
||||
const router = resolveModelRouter()
|
||||
if (!router) return { ok: false as const, reason: 'unconfigured' as const }
|
||||
const model = await resolveLoadedModel(router.big.baseUrl, router.big.model)
|
||||
const chat = createChatClient({ ...router.big, model }, fetch)
|
||||
|
||||
Reference in New Issue
Block a user