Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
105 changes: 103 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,108 @@ jobs:
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}

- name: Verify macOS artifacts
if: ${{ inputs.dry_run == false }}
run: |
set -euo pipefail

while IFS= read -r app_path; do
echo "Verifying signature for $app_path"
codesign --verify --deep --strict --verbose=2 "$app_path"
done < <(find dist-electron -maxdepth 2 -path '*/Deus.app' -type d | sort)

while IFS= read -r dmg_path; do
echo "Validating notarization for $dmg_path"
xcrun stapler validate "$dmg_path"
spctl --assess --type open --verbose=4 "$dmg_path"
done < <(find dist-electron -maxdepth 1 -name '*.dmg' -type f | sort)

- name: Smoke test packaged runtime from DMG copy
if: ${{ inputs.dry_run == false }}
run: |
set -euo pipefail

dmg_path="$(find dist-electron -maxdepth 1 -name '*arm64.dmg' -type f | head -n 1)"
if [[ -z "$dmg_path" ]]; then
dmg_path="$(find dist-electron -maxdepth 1 -name '*.dmg' -type f | head -n 1)"
fi

mount_dir="$(mktemp -d "${RUNNER_TEMP}/deus-dmg.XXXXXX")"
copied_root="$(mktemp -d "${RUNNER_TEMP}/deus-app.XXXXXX")"
copied_app="$copied_root/Deus.app"
smoke_log="$(mktemp "${RUNNER_TEMP}/deus-smoke.XXXXXX.log")"
agent_log="$(mktemp "${RUNNER_TEMP}/deus-agent.XXXXXX.log")"
smoke_db="$(mktemp "${RUNNER_TEMP}/deus-smoke.XXXXXX.db")"
attached=0
backend_pid=""
agent_pid=""

cleanup() {
if [[ -n "$backend_pid" ]]; then
kill "$backend_pid" 2>/dev/null || true
wait "$backend_pid" 2>/dev/null || true
fi
if [[ -n "$agent_pid" ]]; then
kill "$agent_pid" 2>/dev/null || true
wait "$agent_pid" 2>/dev/null || true
fi
if [[ "$attached" -eq 1 ]]; then
hdiutil detach "$mount_dir" -quiet || true
fi
}
trap cleanup EXIT

hdiutil attach "$dmg_path" -mountpoint "$mount_dir" -nobrowse
attached=1
ditto "$mount_dir/Deus.app" "$copied_app"
hdiutil detach "$mount_dir" -quiet
attached=0

app_bin="$copied_app/Contents/MacOS/Deus"
resources_dir="$copied_app/Contents/Resources"

ELECTRON_RUN_AS_NODE=1 \
DATABASE_PATH="$smoke_db" \
NODE_PATH="$resources_dir/app.asar/node_modules" \
"$app_bin" "$resources_dir/bin/index.bundled.cjs" >"$agent_log" 2>&1 &
agent_pid=$!

agent_url=""
for _ in {1..30}; do
if grep -q '^LISTEN_URL=' "$agent_log"; then
agent_url="$(grep '^LISTEN_URL=' "$agent_log" | head -n 1 | sed 's/^LISTEN_URL=//')"
break
fi
if ! kill -0 "$agent_pid" 2>/dev/null; then
break
fi
sleep 1
done

[[ -n "$agent_url" ]]

ELECTRON_RUN_AS_NODE=1 \
DATABASE_PATH="$smoke_db" \
AGENT_SERVER_URL="$agent_url" \
AUTH_TOKEN=smoke \
PORT=0 \
CDP_PORT=19222 \
NODE_PATH="$resources_dir/app.asar/node_modules" \
"$app_bin" "$resources_dir/backend/server.bundled.cjs" >"$smoke_log" 2>&1 &
backend_pid=$!

for _ in {1..30}; do
if grep -q '^\[BACKEND_PORT\]' "$smoke_log"; then
break
fi
if ! kill -0 "$backend_pid" 2>/dev/null; then
break
fi
sleep 1
done

grep -q '^\[BACKEND_PORT\]' "$smoke_log"

- uses: actions/upload-artifact@v4
with:
name: macos
Expand Down Expand Up @@ -288,8 +390,7 @@ jobs:

- name: Build CLI
run: |
bun run build:agent-server
bun run build:backend
bun run build:runtime
cd apps/cli && bun run build

- name: Publish to npm
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ Deus runs in three modes depending on your setup:

### Desktop app

Download the macOS app from [GitHub Releases](https://github.com/zvadaadam/deus-machine/releases). Open it, point it at a repo, and start spinning up workspaces.
Download the macOS app from [GitHub Releases](https://github.com/zvadaadam/deus-machine/releases). Open the DMG, drag `Deus.app` into `Applications`, then launch it from `Applications`.

If Deus detects that it is running from a disk image, Downloads, or another transient location, it will ask to move itself into `Applications` before continuing.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

### Headless server (CLI)

Expand Down
6 changes: 2 additions & 4 deletions apps/backend/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,14 @@ build({
format: "cjs",
outfile: path.join(backendDir, "dist/server.bundled.cjs"),
external: [
// Native modules — must be resolved at runtime
// Native modules — must be resolved at runtime (compiled against Electron's ABI)
"better-sqlite3",
"node-pty",
// WebSocket library with optional native extensions
"ws",
// Sentry — optional, loaded at runtime if DSN is configured
// Sentry — uses native crash-reporter hooks, must match runtime
"@sentry/node",
],
// Mark all Node.js built-ins as external
packages: "external",
minify: false,
sourcemap: false,
logLevel: "info",
Expand Down
11 changes: 7 additions & 4 deletions apps/backend/src/lib/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import os from "os";
import { resolveDefaultDatabasePath } from "../../../../shared/runtime";
import { SCHEMA_SQL, MIGRATIONS } from "@shared/schema";

const DEFAULT_DB_PATH = path.join(
process.env.HOME || os.homedir(),
"Library/Application Support/com.deus.app/deus.db"
);
const DEFAULT_DB_PATH = resolveDefaultDatabasePath({
platform: process.platform,
homeDir: process.env.HOME || os.homedir(),
appData: process.env.APPDATA,
xdgDataHome: process.env.XDG_DATA_HOME,
});

const DB_PATH = process.env.DATABASE_PATH || DEFAULT_DB_PATH;

Expand Down
95 changes: 2 additions & 93 deletions apps/backend/src/routes/onboarding.ts
Original file line number Diff line number Diff line change
@@ -1,101 +1,10 @@
import { Hono } from "hono";
import Database from "better-sqlite3";
import { existsSync, readdirSync } from "fs";
import { homedir } from "os";
import { join, basename } from "path";
import type { RecentProject } from "@shared/types/onboarding";
import { listRecentProjects } from "../services/recent-projects.service";

const app = new Hono();

app.get("/onboarding/recent-projects", (c) => {
const home = homedir();
const projects: RecentProject[] = [];
const seenPaths = new Set<string>();

// Read from Cursor state.vscdb
const cursorDbPath = join(
home,
"Library/Application Support/Cursor/User/globalStorage/state.vscdb"
);
readVscdbProjects(cursorDbPath, "cursor", projects, seenPaths);

// Read from VSCode state.vscdb
const vscodeDbPath = join(
home,
"Library/Application Support/Code/User/globalStorage/state.vscdb"
);
readVscdbProjects(vscodeDbPath, "vscode", projects, seenPaths);

// Read from Claude projects directory
readClaudeProjects(join(home, ".claude/projects"), projects, seenPaths);

return c.json({ projects: projects.slice(0, 30) });
return c.json({ projects: listRecentProjects() });
});

// Worktree directories created by AI coding tools — filter these from recent projects
// so only root repos are shown, not individual worktree checkouts.
const WORKTREE_SEGMENTS = [
"/.deus/", // Deus worktrees
"/.conductor/", // OpenDevs worktrees
"/.claude/worktrees/", // Claude Code worktrees
"/.cursor/worktrees/", // Cursor parallel agent worktrees
"/copilot-worktree/", // GitHub Copilot CLI worktrees
];

function isWorktreePath(fsPath: string): boolean {
return WORKTREE_SEGMENTS.some((seg) => fsPath.includes(seg));
}

function readVscdbProjects(
dbPath: string,
source: "cursor" | "vscode",
projects: RecentProject[],
seen: Set<string>
) {
if (!existsSync(dbPath)) return;
let db: InstanceType<typeof Database> | undefined;
try {
db = new Database(dbPath, { readonly: true });
const row = db
.prepare("SELECT value FROM ItemTable WHERE key = 'history.recentlyOpenedPathsList'")
.get() as { value: string } | undefined;

if (!row?.value) return;
const data = JSON.parse(row.value);
const entries = data.entries || [];

for (const entry of entries) {
const uri = entry.folderUri;
if (!uri || !uri.startsWith("file://")) continue;
const fsPath = decodeURIComponent(uri.replace("file://", ""));
if (seen.has(fsPath) || isWorktreePath(fsPath) || !existsSync(fsPath)) continue;
seen.add(fsPath);
projects.push({ path: fsPath, name: basename(fsPath), source });
}
} catch {
// Silently skip if DB is locked or malformed
} finally {
db?.close();
}
}

function readClaudeProjects(dir: string, projects: RecentProject[], seen: Set<string>) {
if (!existsSync(dir)) return;
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
// Claude encodes paths: leading dash + dashes as path separators
// e.g., "-Users-zvada-Developer-myproject" -> "/Users/zvada/Developer/myproject"
const decoded = entry.name.replace(/-/g, "/");
if (!decoded.startsWith("/")) continue;
if (seen.has(decoded) || isWorktreePath(decoded) || !existsSync(decoded)) continue;
seen.add(decoded);
projects.push({ path: decoded, name: basename(decoded), source: "claude" });
}
} catch {
// Silently skip
}
}

export default app;
7 changes: 6 additions & 1 deletion apps/backend/src/routes/repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,12 @@ app.post("/repos/clone", async (c) => {

// Check target doesn't already exist
if (fs.existsSync(resolvedPath)) {
throw new ConflictError("Target directory already exists");
const gitMetadataPath = path.join(resolvedPath, ".git");
if (fs.existsSync(gitMetadataPath)) {
throw new ConflictError("Target already contains a git repository");
}

throw new ConflictError("Target directory already exists and is not a git repository");
}

// Run git clone with progress, forward raw stderr lines to frontend
Expand Down
80 changes: 6 additions & 74 deletions apps/backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,82 +124,16 @@ if (remoteEnabled === true) {

// Connect to agent-server.
//
// Two bootstrap paths:
// 1. AGENT_SERVER_URL is set (dev.sh): agent-server already running, connect directly.
// 2. AGENT_SERVER_BUNDLE_PATH is set (Electron): spawn agent-server as child process,
// parse LISTEN_URL from its stdout, then connect.
// Launchers (Electron main, CLI, dev.sh) own the runtime topology and always
// provide AGENT_SERVER_URL. The backend only connects.
const agentServerUrl = process.env.AGENT_SERVER_URL;
const agentServerBundlePath = process.env.AGENT_SERVER_BUNDLE_PATH;

if (agentServerUrl) {
// Path 1: Direct connection (dev.sh spawned the agent-server externally)
agentService.init(agentServerUrl);
} else if (agentServerBundlePath) {
// Path 2: Spawn agent-server and connect (Electron desktop mode)
void spawnAgentServerAndConnect(agentServerBundlePath);
}

// Track agent-server child process for cleanup on shutdown
let agentServerChild: import("child_process").ChildProcess | null = null;

function killAgentServer(): void {
if (agentServerChild && !agentServerChild.killed) {
agentServerChild.kill("SIGTERM");
agentServerChild = null;
}
}

/** Spawn the agent-server bundle as a child process and connect to its WebSocket. */
async function spawnAgentServerAndConnect(bundlePath: string): Promise<void> {
const { spawn } = await import("child_process");
const fs = await import("fs");

if (!fs.existsSync(bundlePath)) {
console.error(`[server] Agent-server bundle not found: ${bundlePath}`);
return;
}

agentServerChild = spawn(process.execPath, [bundlePath], {
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
// Forward database path so agent-server can pass it to agents
DATABASE_PATH: process.env.DATABASE_PATH,
},
});

const agentServer = agentServerChild!;
let stdoutBuffer = "";

agentServer.stdout?.on("data", (data: Buffer) => {
stdoutBuffer += data.toString();
const lines = stdoutBuffer.split("\n");
stdoutBuffer = lines.pop() ?? "";

for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
console.log("[agent-server]", trimmed);

// Capture the LISTEN_URL and connect
if (trimmed.startsWith("LISTEN_URL=")) {
const url = trimmed.slice("LISTEN_URL=".length);
console.log(`[server] Agent-server spawned and listening at ${url}`);
agentService.init(url);
}
}
});

agentServer.stderr?.on("data", (data: Buffer) => {
for (const line of data.toString().split("\n")) {
if (line.trim()) console.error("[agent-server:stderr]", line.trim());
}
});

agentServer.on("exit", (code, signal) => {
console.log(`[agent-server] Exited with code=${code} signal=${signal}`);
agentServerChild = null;
});
} else {
console.warn(
"[server] AGENT_SERVER_URL is not set; agent features will remain disconnected until a launcher provides one"
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

// Global error handlers
Expand All @@ -209,7 +143,6 @@ process.on("uncaughtException", (error, origin) => {
Sentry.close(2000).finally(() => {
destroyAllPtySessions();
destroyAllWatchers();
killAgentServer();
try {
closeDatabase();
} catch {}
Expand All @@ -229,7 +162,6 @@ function shutdown() {
agentService.shutdown();
destroyAllPtySessions();
destroyAllWatchers();
killAgentServer();
disconnectFromRelay();
closeAllWsConnections();
closeDatabase();
Expand Down
Loading
Loading