feat: add CI/CD pipeline with cross-compilation support

This commit is contained in:
2026-03-04 09:00:36 +01:00
parent d82738e7ea
commit e8c8ddd48d
7 changed files with 352 additions and 74 deletions

View File

@@ -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.

124
.gitea/workflows/build.yaml Normal file
View File

@@ -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

View File

@@ -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<string, string> = {};
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-<platform>-<arch>-*).
// npm install on Linux only pulls the Linux variant. Force-install the target's.
const platformNativePackages: Record<string, Record<string, string>> = {
'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<string, string> = {
'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']),

18
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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<string | null> {
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<void> {
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<boolean> {
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;
}

View File

@@ -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