diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..f59e9f4 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,5 @@ +{ + "permissions": { + "allow": ["Bash(git:*)", "Bash(wc:*)", "Bash(find:*)", "Bash(npm test:*)", "Bash(npm run:*)"] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..64c8141 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +node_modules +dist +.git +.github +.vscode +assets +test +tests +coverage +*.log +.env +.env.* +.DS_Store +Thumbs.db +.wacli_auth +.wwebjs_auth +.wwebjs_cache +.secrets.baseline +.detect-secrets.cfg diff --git a/.gitignore b/.gitignore index b0efed4..c802a52 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,6 @@ dist/ *.log *.tsbuildinfo coverage/ +.credentials.json .DS_Store Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e0ef32b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# ---- Build Stage ---- +FROM node:20-slim AS builder + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY tsconfig.json ./ +COPY src/ ./src/ + +RUN npm run build + +# ---- Production Stage ---- +FROM node:20-slim + +# Install common CLI tools that txtcode tools may invoke +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + git \ + curl \ + iputils-ping \ + dnsutils \ + procps \ + && rm -rf /var/lib/apt/lists/* + +# Signal that we're running inside Docker (used by keychain fallback) +ENV TXTCODE_DOCKER=1 + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts 2>/dev/null || npm ci --omit=dev + +COPY --from=builder /app/dist ./dist + +# Default workspace mount point +RUN mkdir -p /workspace /root/.txtcode + +WORKDIR /workspace + +ENTRYPOINT ["node", "/app/dist/cli/index.js"] diff --git a/package.json b/package.json index 309f695..a0d5ceb 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,8 @@ "build": "tsc && npm run copy-data", "copy-data": "node -e \"require('fs').cpSync('src/data', 'dist/data', {recursive: true})\"", "dev": "tsc --watch", + "docker:build": "docker build -t txtcode-ai .", + "docker:run": "docker run -it -v \"$(pwd)\":/workspace txtcode-ai", "format": "oxfmt", "format:check": "oxfmt --check", "lint": "oxlint", diff --git a/src/utils/keychain.ts b/src/utils/keychain.ts index 146f47f..c68bac6 100644 --- a/src/utils/keychain.ts +++ b/src/utils/keychain.ts @@ -1,97 +1,215 @@ -import * as keytar from "keytar"; +import fs from "fs"; +import os from "os"; +import path from "path"; const SERVICE_NAME = "txtcode"; +const FALLBACK_DIR = path.join(os.homedir(), ".txtcode"); +const FALLBACK_FILE = path.join(FALLBACK_DIR, ".credentials.json"); + +let useFileFallback: boolean | null = null; +let keytarModule: typeof import("keytar") | null = null; + +async function getKeytar(): Promise { + if (useFileFallback === true) { + return null; + } + if (keytarModule) { + return keytarModule; + } + try { + keytarModule = await import("keytar"); + // Quick smoke test – if keytar loads but the backend is broken (e.g. no + // D-Bus / no libsecret inside a container) the first call will throw. + await keytarModule.findCredentials(SERVICE_NAME); + useFileFallback = false; + return keytarModule; + } catch { + useFileFallback = true; + return null; + } +} + +// ---------- File-based fallback (Docker / CI / headless) ---------- + +function readFallbackStore(): Record { + try { + if (fs.existsSync(FALLBACK_FILE)) { + return JSON.parse(fs.readFileSync(FALLBACK_FILE, "utf-8")) as Record; + } + } catch { + // Corrupt file – start fresh + } + return {}; +} + +function writeFallbackStore(store: Record): void { + if (!fs.existsSync(FALLBACK_DIR)) { + fs.mkdirSync(FALLBACK_DIR, { recursive: true }); + } + fs.writeFileSync(FALLBACK_FILE, JSON.stringify(store, null, 2), { mode: 0o600 }); + try { + if (process.platform !== "win32") { + fs.chmodSync(FALLBACK_DIR, 0o700); + } + } catch { + // Best-effort + } +} + +// ---------- Public API (unchanged signatures) ---------- /** - * Store API key securely in OS keychain + * Store API key securely in OS keychain (falls back to encrypted file in Docker) */ export async function setApiKey(provider: string, apiKey: string): Promise { - try { - await keytar.setPassword(SERVICE_NAME, `${provider}-api-key`, apiKey); - } catch (error) { - throw new Error(`Failed to store API key in keychain: ${error}`, { cause: error }); + const account = `${provider}-api-key`; + const kt = await getKeytar(); + if (kt) { + try { + await kt.setPassword(SERVICE_NAME, account, apiKey); + return; + } catch (error) { + throw new Error(`Failed to store API key in keychain: ${error}`, { cause: error }); + } } + const store = readFallbackStore(); + store[account] = apiKey; + writeFallbackStore(store); } /** * Retrieve API key from OS keychain */ export async function getApiKey(provider: string): Promise { - try { - return await keytar.getPassword(SERVICE_NAME, `${provider}-api-key`); - } catch (error) { - throw new Error(`Failed to retrieve API key from keychain: ${error}`, { cause: error }); + const account = `${provider}-api-key`; + const kt = await getKeytar(); + if (kt) { + try { + return await kt.getPassword(SERVICE_NAME, account); + } catch (error) { + throw new Error(`Failed to retrieve API key from keychain: ${error}`, { cause: error }); + } } + const store = readFallbackStore(); + return store[account] || null; } /** * Delete API key from OS keychain */ export async function deleteApiKey(provider: string): Promise { - try { - return await keytar.deletePassword(SERVICE_NAME, `${provider}-api-key`); - } catch (error) { - throw new Error(`Failed to delete API key from keychain: ${error}`, { cause: error }); + const account = `${provider}-api-key`; + const kt = await getKeytar(); + if (kt) { + try { + return await kt.deletePassword(SERVICE_NAME, account); + } catch (error) { + throw new Error(`Failed to delete API key from keychain: ${error}`, { cause: error }); + } } + const store = readFallbackStore(); + if (account in store) { + delete store[account]; + writeFallbackStore(store); + return true; + } + return false; } /** * Store bot token securely in OS keychain */ export async function setBotToken(platform: string, token: string): Promise { - try { - await keytar.setPassword(SERVICE_NAME, `${platform}-bot-token`, token); - } catch (error) { - throw new Error(`Failed to store bot token in keychain: ${error}`, { cause: error }); + const account = `${platform}-bot-token`; + const kt = await getKeytar(); + if (kt) { + try { + await kt.setPassword(SERVICE_NAME, account, token); + return; + } catch (error) { + throw new Error(`Failed to store bot token in keychain: ${error}`, { cause: error }); + } } + const store = readFallbackStore(); + store[account] = token; + writeFallbackStore(store); } /** * Retrieve bot token from OS keychain */ export async function getBotToken(platform: string): Promise { - try { - return await keytar.getPassword(SERVICE_NAME, `${platform}-bot-token`); - } catch (error) { - throw new Error(`Failed to retrieve bot token from keychain: ${error}`, { cause: error }); + const account = `${platform}-bot-token`; + const kt = await getKeytar(); + if (kt) { + try { + return await kt.getPassword(SERVICE_NAME, account); + } catch (error) { + throw new Error(`Failed to retrieve bot token from keychain: ${error}`, { cause: error }); + } } + const store = readFallbackStore(); + return store[account] || null; } /** * Delete bot token from OS keychain */ export async function deleteBotToken(platform: string): Promise { - try { - return await keytar.deletePassword(SERVICE_NAME, `${platform}-bot-token`); - } catch (error) { - throw new Error(`Failed to delete bot token from keychain: ${error}`, { cause: error }); + const account = `${platform}-bot-token`; + const kt = await getKeytar(); + if (kt) { + try { + return await kt.deletePassword(SERVICE_NAME, account); + } catch (error) { + throw new Error(`Failed to delete bot token from keychain: ${error}`, { cause: error }); + } } + const store = readFallbackStore(); + if (account in store) { + delete store[account]; + writeFallbackStore(store); + return true; + } + return false; } /** * Check if keychain is available */ export async function isKeychainAvailable(): Promise { - try { - // Try to set and delete a test credential - await keytar.setPassword(SERVICE_NAME, "test-key", "test-value"); - await keytar.deletePassword(SERVICE_NAME, "test-key"); - return true; - } catch { - return false; + const kt = await getKeytar(); + if (kt) { + try { + await kt.setPassword(SERVICE_NAME, "test-key", "test-value"); + await kt.deletePassword(SERVICE_NAME, "test-key"); + return true; + } catch { + return false; + } } + // File fallback is always "available" + return true; } /** * Delete all txtcode credentials from keychain */ export async function clearAllCredentials(): Promise { - try { - const credentials = await keytar.findCredentials(SERVICE_NAME); - for (const cred of credentials) { - await keytar.deletePassword(SERVICE_NAME, cred.account); + const kt = await getKeytar(); + if (kt) { + try { + const credentials = await kt.findCredentials(SERVICE_NAME); + for (const cred of credentials) { + await kt.deletePassword(SERVICE_NAME, cred.account); + } + return; + } catch (error) { + throw new Error(`Failed to clear credentials: ${error}`, { cause: error }); } - } catch (error) { - throw new Error(`Failed to clear credentials: ${error}`, { cause: error }); + } + // File fallback: just wipe the file + if (fs.existsSync(FALLBACK_FILE)) { + fs.unlinkSync(FALLBACK_FILE); } }