Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"permissions": {
"allow": ["Bash(git:*)", "Bash(wc:*)", "Bash(find:*)", "Bash(npm test:*)", "Bash(npm run:*)"]
}
}
19 changes: 19 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ dist/
*.log
*.tsbuildinfo
coverage/
.credentials.json
.DS_Store
Thumbs.db
42 changes: 42 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
196 changes: 157 additions & 39 deletions src/utils/keychain.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("keytar") | null> {
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<string, string> {
try {
if (fs.existsSync(FALLBACK_FILE)) {
return JSON.parse(fs.readFileSync(FALLBACK_FILE, "utf-8")) as Record<string, string>;
}
} catch {
// Corrupt file – start fresh
}
return {};
}

function writeFallbackStore(store: Record<string, string>): 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<void> {
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<string | null> {
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<boolean> {
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<void> {
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<string | null> {
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<boolean> {
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<boolean> {
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<void> {
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);
}
}