diff --git a/src/app/api/github/route.ts b/src/app/api/github/route.ts index c73eb0a..1a87fdc 100644 --- a/src/app/api/github/route.ts +++ b/src/app/api/github/route.ts @@ -41,7 +41,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'repo query parameter required (owner/repo format)' }, { status: 400 }) } - const token = getGitHubToken() + const token = await getGitHubToken() if (!token) { return NextResponse.json({ error: 'GITHUB_TOKEN not configured' }, { status: 400 }) } @@ -109,7 +109,7 @@ async function handleSync( return NextResponse.json({ error: 'repo is required' }, { status: 400 }) } - const token = getGitHubToken() + const token = await getGitHubToken() if (!token) { return NextResponse.json({ error: 'GITHUB_TOKEN not configured' }, { status: 400 }) } @@ -344,7 +344,7 @@ function handleStatus(workspaceId: number) { // ── Stats: GitHub user profile + repo overview ────────────────── async function handleGitHubStats() { - const token = getGitHubToken() + const token = await getGitHubToken() if (!token) { return NextResponse.json({ error: 'GITHUB_TOKEN not configured' }, { status: 400 }) } diff --git a/src/lib/__tests__/runtime-env.test.ts b/src/lib/__tests__/runtime-env.test.ts new file mode 100644 index 0000000..1f15291 --- /dev/null +++ b/src/lib/__tests__/runtime-env.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { getEffectiveEnvValue } from '../runtime-env' + +describe('getEffectiveEnvValue', () => { + afterEach(() => { + delete process.env.TEST_RUNTIME_ENV + }) + + it('reads values from the OpenClaw env file before process.env', async () => { + const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'mc-runtime-env-')) + try { + const envFilePath = path.join(tmpDir, '.env') + await writeFile(envFilePath, 'TEST_RUNTIME_ENV=from-file\n', 'utf-8') + process.env.TEST_RUNTIME_ENV = 'from-process' + + await expect(getEffectiveEnvValue('TEST_RUNTIME_ENV', { envFilePath })).resolves.toBe('from-file') + } finally { + await rm(tmpDir, { recursive: true, force: true }) + } + }) + + it('falls back to process.env when the env file does not define the key', async () => { + const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'mc-runtime-env-')) + try { + const envFilePath = path.join(tmpDir, '.env') + await writeFile(envFilePath, 'OTHER_KEY=value\n', 'utf-8') + process.env.TEST_RUNTIME_ENV = 'from-process' + + await expect(getEffectiveEnvValue('TEST_RUNTIME_ENV', { envFilePath })).resolves.toBe('from-process') + } finally { + await rm(tmpDir, { recursive: true, force: true }) + } + }) + + it('returns an empty string when the key is missing everywhere', async () => { + const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'mc-runtime-env-')) + try { + const envFilePath = path.join(tmpDir, '.env') + await writeFile(envFilePath, '', 'utf-8') + + await expect(getEffectiveEnvValue('TEST_RUNTIME_ENV', { envFilePath })).resolves.toBe('') + } finally { + await rm(tmpDir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/lib/github.ts b/src/lib/github.ts index 5542b3d..4bab583 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -1,7 +1,9 @@ /** * GitHub API client for Mission Control issue sync. - * Uses GITHUB_TOKEN from env (integration key, not core config). + * Resolves GITHUB_TOKEN from the OpenClaw integration env file first, + * then falls back to process.env for deployments that export it directly. */ +import { getEffectiveEnvValue } from '@/lib/runtime-env' export interface GitHubLabel { name: string @@ -25,8 +27,8 @@ export interface GitHubIssue { updated_at: string } -export function getGitHubToken(): string | null { - return process.env.GITHUB_TOKEN || null +export async function getGitHubToken(): Promise { + return await getEffectiveEnvValue('GITHUB_TOKEN') || null } /** @@ -36,7 +38,7 @@ export async function githubFetch( path: string, options: RequestInit = {} ): Promise { - const token = getGitHubToken() + const token = await getGitHubToken() if (!token) { throw new Error('GITHUB_TOKEN not configured') } diff --git a/src/lib/runtime-env.ts b/src/lib/runtime-env.ts new file mode 100644 index 0000000..6231718 --- /dev/null +++ b/src/lib/runtime-env.ts @@ -0,0 +1,47 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { config } from '@/lib/config' + +function parseEnvLine(line: string): { key: string; value: string } | null { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) return null + + const eqIdx = line.indexOf('=') + if (eqIdx <= 0) return null + + const key = line.slice(0, eqIdx).trim() + const value = line.slice(eqIdx + 1).trim() + if (!key) return null + return { key, value } +} + +async function readOpenClawEnvFile(envFilePath: string): Promise> { + try { + const raw = await readFile(envFilePath, 'utf-8') + const envMap = new Map() + for (const line of raw.split('\n')) { + const parsed = parseEnvLine(line) + if (parsed) envMap.set(parsed.key, parsed.value) + } + return envMap + } catch (error: any) { + if (error?.code === 'ENOENT') return new Map() + throw error + } +} + +export async function getEffectiveEnvValue( + key: string, + options?: { envFilePath?: string } +): Promise { + const envFilePath = options?.envFilePath || join(config.openclawStateDir, '.env') + const envMap = await readOpenClawEnvFile(envFilePath) + const fromFile = envMap.get(key) + if (typeof fromFile === 'string' && fromFile.length > 0) return fromFile + + const fromProcess = process.env[key] + if (typeof fromProcess === 'string' && fromProcess.length > 0) return fromProcess + + return '' +}