diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index aea9e9d..8874721 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -82,12 +82,9 @@ Tool-calling strategy differs by provider: OpenAI/Anthropic use LangChain `bindT **Provider factory** (`llm.ts`): `gpt-4o-mini` (OpenAI), `claude-sonnet-4-20250514` (Anthropic), or ChatCopilot wrapper — all with `temperature: 0.3` and streaming enabled. -**Token storage** (`token.ts`) — three-tier fallback: -1. keytar (OS keychain) — preferred, encrypted per-user -2. electron-store + `safeStorage` — encrypted at rest -3. Plain electron-store — WSL fallback - -Keytar service name is `'adiuva'`. Once keytar fails, `keytarFailed` flag skips it for the session. +**Token storage** (`token.ts`) — two-tier fallback: +1. electron-store + `safeStorage` — encrypted at rest (preferred) +2. Plain electron-store — last resort (e.g. WSL with no keyring) **AI approval pattern**: Tasks and checkpoints have `isAiSuggested` (bool) and `isApproved` (bool) columns. AI-suggested items appear in the UI pending user approval before being treated as real records. diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..90a1cfb --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,124 @@ +name: Release Electron App +run-name: Releasing ${{ gitea.ref_name }} +on: + push: + tags: + - 'v*' + +jobs: + release-desktop: + runs-on: ubuntu-latest + container: + image: electronuserland/builder:wine + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Install System Dependencies for Linux Makers + run: | + apt-get update + apt-get install -y fakeroot dpkg mono-complete + + - name: Install Dependencies + run: npm install + + - name: Set version from tag + run: npm version "${GITHUB_REF_NAME#v}" --no-git-tag-version --allow-same-version + + - name: Make App (Linux) + run: npm run make -- --platform=linux --arch=x64 + + - name: Initialize Wine + run: | + export WINEDEBUG=-all + export DISPLAY= + wineboot --init + env: + WINEDEBUG: '-all' + + - name: Make App (Windows) + run: npm run make -- --platform=win32 --arch=x64 + env: + WINEDEBUG: '-all' + + - name: Create Gitea Release + run: | + GITEA_URL="http://10.0.0.119:3000" + TAG="${GITHUB_REF_NAME}" + REPO="${GITHUB_REPOSITORY}" + TOKEN="${{ gitea.token }}" + + # Check if release exists, create if not + RELEASE_ID=$(curl -sf \ + -H "Authorization: token ${TOKEN}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}" \ + | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) + + if [ -z "$RELEASE_ID" ]; then + RELEASE_ID=$(curl -sf \ + -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\"}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases" \ + | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) + fi + + echo "Release ID: ${RELEASE_ID}" + echo "RELEASE_ID=${RELEASE_ID}" >> "$GITHUB_ENV" + + - name: Upload Release Assets + shell: bash + run: | + GITEA_URL="http://10.0.0.119:3000" + REPO="${GITHUB_REPOSITORY}" + TOKEN="${{ gitea.token }}" + MAX_RETRIES=3 + + upload_file() { + local file="$1" + local name + name=$(basename "$file") + local encoded_name + encoded_name=$(printf '%s' "$name" | sed 's/ /%20/g') + local attempt=1 + + while [ $attempt -le $MAX_RETRIES ]; do + local filesize + filesize=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null || echo "unknown") + echo "Uploading ${name} (${filesize} bytes, attempt ${attempt}/${MAX_RETRIES})..." + RESPONSE=$(curl -s -w "\n%{http_code}" \ + --max-time 300 \ + -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Expect:" \ + -F "attachment=@${file}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${encoded_name}") + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | head -n -1) + + if [ "$HTTP_CODE" -ge 200 ] 2>/dev/null && [ "$HTTP_CODE" -lt 300 ] 2>/dev/null; then + echo "✅ Uploaded ${name}" + return 0 + fi + + echo "⚠️ Upload failed (HTTP ${HTTP_CODE}), body: ${BODY}" + echo "Retrying in 5s..." + sleep 5 + attempt=$((attempt + 1)) + done + + echo "❌ Failed to upload ${name} after ${MAX_RETRIES} attempts" + return 1 + } + + FAILED=0 + while IFS= read -r -d '' file; do + upload_file "$file" || FAILED=1 + done < <(find out/make -type f \( -name "*.exe" -o -name "*.zip" -o -name "*.deb" -o -name "*.rpm" \) -print0) + + if [ $FAILED -eq 1 ]; then + echo "Some uploads failed" + exit 1 + fi \ No newline at end of file diff --git a/forge.config.ts b/forge.config.ts index 42f3e1d..abbd50d 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -7,12 +7,232 @@ import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-nati import { VitePlugin } from '@electron-forge/plugin-vite'; import { FusesPlugin } from '@electron-forge/plugin-fuses'; import { FuseV1Options, FuseVersion } from '@electron/fuses'; +import path from 'node:path'; +import fs from 'node:fs'; +import { execSync } from 'node:child_process'; + +// Packages externalized in vite.main.config.mts that must be installed at runtime. +// Keep this list in sync with the Vite external array. +const externalPackages = [ + 'better-sqlite3', + '@github/copilot-sdk', + '@langchain/core', + '@langchain/langgraph', + '@langchain/openai', + '@langchain/anthropic', + 'vectordb', + 'electron-squirrel-startup', + 'electron-store', +]; const config: ForgeConfig = { packagerConfig: { - asar: true, + asar: { + unpack: '**/{*.node,*.dll,*.so,*.dylib}', + }, + name: 'adiuva', }, rebuildConfig: {}, + hooks: { + packageAfterCopy: async (_forgeConfig, buildPath, _electronVersion, platform, arch) => { + // The VitePlugin's ignore filter only copies .vite/ into the build. + // Externalized packages need to be installed into node_modules here. + // At this point, only .vite/ exists. The VitePlugin writes package.json + // in its own afterCopy hook (which may run after ours). Read from source. + const srcPjPath = path.resolve(__dirname, 'package.json'); + const pjPath = path.resolve(buildPath, 'package.json'); + const pj = JSON.parse(fs.readFileSync(srcPjPath, 'utf-8')); + + // Keep only externalized packages in dependencies + const filtered: Record = {}; + for (const pkg of externalPackages) { + if (pj.dependencies?.[pkg]) { + filtered[pkg] = pj.dependencies[pkg]; + } + } + pj.dependencies = filtered; + delete pj.devDependencies; + fs.writeFileSync(pjPath, JSON.stringify(pj, null, 2)); + + // Copy lockfile for reproducible installs + const lockSrc = path.resolve(buildPath, '..', '..', 'package-lock.json'); + if (fs.existsSync(lockSrc)) { + fs.copyFileSync(lockSrc, path.resolve(buildPath, 'package-lock.json')); + } + + // Install only the externalized runtime deps + console.log('[forge] Installing externalized dependencies...'); + execSync('npm install --omit=dev', { + cwd: buildPath, + stdio: 'inherit', + env: { ...process.env, npm_config_nodedir: '' }, + }); + + const targetKey = `${platform}-${arch}`; + + // vectordb uses platform-specific optional deps (@lancedb/vectordb---*). + // npm install on Linux only pulls the Linux variant. Force-install the target's. + const platformNativePackages: Record> = { + 'win32-x64': { + '@lancedb/vectordb-win32-x64-msvc': '', + }, + 'linux-x64': { + '@lancedb/vectordb-linux-x64-gnu': '', + }, + 'darwin-x64': { + '@lancedb/vectordb-darwin-x64': '', + }, + 'darwin-arm64': { + '@lancedb/vectordb-darwin-arm64': '', + }, + }; + const nativePkgs = platformNativePackages[targetKey]; + if (nativePkgs) { + // Remove wrong-platform lancedb native packages + const nmPath = path.join(buildPath, 'node_modules', '@lancedb'); + if (fs.existsSync(nmPath)) { + for (const entry of fs.readdirSync(nmPath)) { + if (entry.startsWith('vectordb-') && !Object.keys(nativePkgs).includes(`@lancedb/${entry}`)) { + fs.rmSync(path.join(nmPath, entry), { recursive: true, force: true }); + console.log(`[forge] Removed non-target native package: @lancedb/${entry}`); + } + } + } + // Install correct platform packages + const pkgsToInstall = Object.keys(nativePkgs).join(' '); + console.log(`[forge] Installing platform-specific packages for ${targetKey}: ${pkgsToInstall}`); + execSync(`npm install ${pkgsToInstall} --omit=dev --no-save --force`, { + cwd: buildPath, + stdio: 'inherit', + }); + } + + // Remove cross-platform prebuilt binaries that don't match the target. + // Packages like @github/copilot ship prebuilds for all platforms; + // keeping foreign-arch .node files breaks rpmbuild's strip step. + const nodeModulesPath = path.join(buildPath, 'node_modules'); + const findPrebuilds = (dir: string): string[] => { + const results: string[] = []; + if (!fs.existsSync(dir)) return results; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'prebuilds') { + results.push(full); + } else { + results.push(...findPrebuilds(full)); + } + } + } + return results; + }; + + for (const prebuildsDir of findPrebuilds(nodeModulesPath)) { + for (const entry of fs.readdirSync(prebuildsDir)) { + if (entry !== targetKey) { + const fullPath = path.join(prebuildsDir, entry); + fs.rmSync(fullPath, { recursive: true, force: true }); + console.log(`[forge] Removed non-target prebuild: ${entry}`); + } + } + } + + // @github/copilot ships @teddyzhu/clipboard-* platform packages outside + // of prebuilds/. Remove non-target variants to avoid bundling wrong binaries. + const clipboardDir = path.join(buildPath, 'node_modules', '@github', 'copilot', 'clipboard', 'node_modules', '@teddyzhu'); + if (fs.existsSync(clipboardDir)) { + const targetClipboardMap: Record = { + 'win32-x64': 'clipboard-win32-x64-msvc', + 'win32-arm64': 'clipboard-win32-arm64-msvc', + 'linux-x64': 'clipboard-linux-x64-gnu', + 'linux-arm64': 'clipboard-linux-arm64-gnu', + 'darwin-x64': 'clipboard-darwin-x64', + 'darwin-arm64': 'clipboard-darwin-arm64', + }; + const wantedPkg = targetClipboardMap[targetKey]; + for (const entry of fs.readdirSync(clipboardDir)) { + if (entry.startsWith('clipboard-') && entry !== wantedPkg) { + fs.rmSync(path.join(clipboardDir, entry), { recursive: true, force: true }); + console.log(`[forge] Removed non-target clipboard package: @teddyzhu/${entry}`); + } + } + } + }, + + // ── Post-rebuild: fix native binaries for cross-compilation ────── + // Forge runs @electron/rebuild AFTER packageAfterCopy, which + // recompiles native addons for the BUILD platform (Linux). + // packageAfterPrune runs AFTER rebuild+prune, so we can safely + // replace the Linux .node files with the correct target prebuilts. + packageAfterPrune: async (_forgeConfig, buildPath, _electronVersion, platform, arch) => { + const targetKey = `${platform}-${arch}`; + const buildKey = `${process.platform}-${process.arch}`; + if (targetKey === buildKey) return; // native build — nothing to fix + + console.log(`[forge:afterPrune] Cross-compile fixup: ${buildKey} → ${targetKey}`); + const electronVersion = JSON.parse( + fs.readFileSync(path.resolve(__dirname, 'node_modules', 'electron', 'package.json'), 'utf-8'), + ).version; + + // Replace native addons that @electron/rebuild compiled for the host. + const nativeModules = ['better-sqlite3']; + for (const mod of nativeModules) { + const modDir = path.join(buildPath, 'node_modules', mod); + if (!fs.existsSync(modDir)) continue; + + // Remove the host-platform binary left by @electron/rebuild + const buildRelease = path.join(modDir, 'build', 'Release'); + if (fs.existsSync(buildRelease)) { + fs.rmSync(buildRelease, { recursive: true, force: true }); + console.log(`[forge:afterPrune] Cleaned host-platform build/Release for ${mod}`); + } + + // Download the correct prebuilt for the TARGET platform + console.log(`[forge:afterPrune] Downloading ${mod} prebuilt for ${targetKey} (Electron ${electronVersion})...`); + execSync( + `npx --yes prebuild-install -r electron -t ${electronVersion} ` + + `--platform ${platform} --arch ${arch} --tag-prefix v --verbose`, + { cwd: modDir, stdio: 'inherit' }, + ); + + // Verify the binary exists and is for the correct platform. + const releaseDir = path.join(modDir, 'build', 'Release'); + if (!fs.existsSync(releaseDir)) { + throw new Error( + `[forge] FATAL: build/Release/ not found for ${mod} after prebuild-install. ` + + `The native binary was not downloaded.`, + ); + } + const nodeFiles = fs.readdirSync(releaseDir).filter((f) => f.endsWith('.node')); + if (nodeFiles.length === 0) { + throw new Error( + `[forge] FATAL: No .node files in build/Release/ for ${mod} after prebuild-install.`, + ); + } + for (const f of nodeFiles) { + const buf = Buffer.alloc(4); + const fd = fs.openSync(path.join(releaseDir, f), 'r'); + fs.readSync(fd, buf, 0, 4, 0); + fs.closeSync(fd); + // ELF magic: 0x7f 'E' 'L' 'F' + if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) { + throw new Error( + `[forge] FATAL: ${mod} build/Release/${f} is an ELF binary! ` + + `Cross-compilation failed — refusing to package a Linux .node for Windows.`, + ); + } + // PE magic: 'M' 'Z' (0x4d 0x5a) — expected for win32 + if (platform === 'win32' && !(buf[0] === 0x4d && buf[1] === 0x5a)) { + throw new Error( + `[forge] FATAL: ${mod} build/Release/${f} is not a PE (Windows) binary! ` + + `Magic bytes: ${buf.toString('hex')}. Refusing to package.`, + ); + } + console.log(`[forge:afterPrune] Verified ${mod}/${f} — correct platform ✓`); + } + } + }, + }, makers: [ new MakerSquirrel({}), new MakerZIP({}, ['darwin']), diff --git a/package-lock.json b/package-lock.json index 966c182..5e421c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,6 @@ "electron-squirrel-startup": "^1.0.1", "electron-store": "^8.2.0", "framer-motion": "^12.34.2", - "keytar": "^7.9.0", "lucide-react": "^0.575.0", "radix-ui": "^1.4.3", "react": "^19.2.4", @@ -16007,17 +16006,6 @@ "node": ">= 12" } }, - "node_modules/keytar": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", - "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-addon-api": "^4.3.0", - "prebuild-install": "^7.0.1" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -18209,12 +18197,6 @@ "node": ">=10" } }, - "node_modules/node-addon-api": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", - "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", - "license": "MIT" - }, "node_modules/node-api-version": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", diff --git a/package.json b/package.json index 504bcdc..214267f 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "knip": "knip" }, "keywords": [], - "author": "rmusso", + "author": "roberto", "license": "MIT", "devDependencies": { "@electron-forge/cli": "^7.11.1", @@ -71,7 +71,6 @@ "electron-squirrel-startup": "^1.0.1", "electron-store": "^8.2.0", "framer-motion": "^12.34.2", - "keytar": "^7.9.0", "lucide-react": "^0.575.0", "radix-ui": "^1.4.3", "react": "^19.2.4", diff --git a/src/main/ai/token.ts b/src/main/ai/token.ts index d26e8b9..19ab01c 100644 --- a/src/main/ai/token.ts +++ b/src/main/ai/token.ts @@ -2,27 +2,11 @@ import { safeStorage } from 'electron'; import { getStore } from '../store'; /** - * Token storage with three-tier fallback: - * 1. OS keychain via keytar (best — encrypted, per-user) - * 2. Electron safeStorage + electron-store (encrypted at rest) - * 3. Plain electron-store (last resort — e.g. WSL with no keyring) + * Token storage with two-tier fallback: + * 1. Electron safeStorage + electron-store (encrypted at rest) + * 2. Plain electron-store (last resort — e.g. WSL with no keyring) */ -let keytar: typeof import('keytar') | null = null; -let keytarFailed = false; - -try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - keytar = require('keytar') as typeof import('keytar'); -} catch { - keytarFailed = true; - console.log('[Token] keytar native module unavailable'); -} - -function useKeytar(): boolean { - return keytar !== null && !keytarFailed; -} - function canUseSafeStorage(): boolean { try { return safeStorage.isEncryptionAvailable(); @@ -31,8 +15,6 @@ function canUseSafeStorage(): boolean { } } -const SERVICE_NAME = 'adiuva'; - // --- electron-store helpers (with optional safeStorage encryption) --- function readFromStore(providerName: string): string | null { @@ -74,41 +56,16 @@ function removeFromStore(providerName: string): void { /** Read a stored token for the given provider. */ export async function getToken(providerName: string): Promise { - if (useKeytar()) { - try { - return await keytar!.getPassword(SERVICE_NAME, providerName); - } catch (err) { - console.log('[Token] keytar runtime error, falling back:', (err as Error).message); - keytarFailed = true; - } - } return readFromStore(providerName); } /** Store a token for the given provider. */ export async function setToken(providerName: string, token: string): Promise { - if (useKeytar()) { - try { - await keytar!.setPassword(SERVICE_NAME, providerName, token); - return; - } catch (err) { - console.log('[Token] keytar runtime error, falling back:', (err as Error).message); - keytarFailed = true; - } - } writeToStore(providerName, token); } /** Delete a stored token for the given provider. */ async function deleteToken(providerName: string): Promise { - if (useKeytar()) { - try { - return await keytar!.deletePassword(SERVICE_NAME, providerName); - } catch (err) { - console.log('[Token] keytar runtime error, falling back:', (err as Error).message); - keytarFailed = true; - } - } removeFromStore(providerName); return true; } diff --git a/vite.main.config.mts b/vite.main.config.mts index c8ffe97..56a4813 100644 --- a/vite.main.config.mts +++ b/vite.main.config.mts @@ -7,7 +7,6 @@ export default defineConfig({ // Externalize native Node modules — they're rebuilt by electron-forge external: [ 'better-sqlite3', - 'keytar', '@github/copilot-sdk', '@github/copilot', // LangChain — externalize to avoid bundling Node.js-specific code