diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab06fc0..68fb0a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,10 @@ jobs: node-version: 24 cache: npm + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: npm install --ignore-scripts --no-audit --no-fund - run: npm run lint diff --git a/.gitignore b/.gitignore index ecd2396..b646ff7 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,4 @@ docs/brain/ # Beads / Dolt files (added by bd init) .dolt/ .beads-credential-key +golem-agent-*.tgz diff --git a/README.md b/README.md index 9c2000b..e16c8c5 100644 --- a/README.md +++ b/README.md @@ -5,218 +5,74 @@

๐Ÿค– Multi-agent  โ€ข  ๐Ÿ’ฌ Telegram-native  โ€ข  ๐Ÿง  Working memory  โ€ข  ๐Ÿ”ง Skills & MCP  โ€ข  โฐ Schedules & webhooks

- Built on Mastra - LLMs - MIT License + Deploy on Vultr + Built on Mastra + OpenRouter + MIT License

![Welcome](screenshots/welcome.png) -## From zero to your first agent in 2 minutes +## Quick start -```bash -git clone https://github.com/AvivK5498/Golem.git -cd Golem && npm install -cp .env.example .env # add your OpenRouter API key -npm start # opens http://localhost:3015 -``` - -The onboarding wizard takes it from there โ€” providers, model tiers, your Telegram bot, your first agent's persona. Done. - -## Philosophy - -- **Agents act, they don't chat.** Every agent has tools, schedules, webhooks, and the agency to use them. Conversation is one input among many. -- **One bot per job.** Specialized agents beat one mega-prompt. Spin up a research agent, a code agent, a personal assistant โ€” each with its own bot, its own boundaries. -- **Telegram-native, not Telegram-bolted-on.** Your agents live where you already are. Voice notes in, voice replies out. Group chats, media, buttons, identity. -- **You own the stack.** Your machine, your SQLite, your API keys, your bot tokens. Portable. Forkable. No cloud account required. -- **Configuration is data.** No YAML to edit by hand. The web UI writes SQLite; everything is hot-reloadable. - -## What your agent can do out of the box - -| Capability | What it actually means | -|---|---| -| **Multi-agent platform** | Run N agents in one process, each with its own Telegram bot, persona, model tier, and toolset. | -| **AI-generated personas** | Describe the job in a sentence; Golem writes the persona โ€” identity, boundaries, domain expertise. | -| **Working memory** | A persistent scratchpad per agent. Tell it your coffee order on Monday, it remembers on Friday. | -| **Skills & MCP** | Drop a `SKILL.md` into `skills/` or wire up an MCP server. Your agent learns a new trick. | -| **Filesystem mounts** | Mount an Obsidian vault or any directory at `/mnt/`. Read-only or read-write. Sub-agents inherit. | -| **Schedules & webhooks** | Cron-driven check-ins, webhook handlers (GitHub, Strava, CI) with LLM-based scenario routing. | -| **Proactive check-ins** | Agents initiate. Configurable cadence, probability gates, active-hours windows. | -| **Voice in, voice out** | Whisper transcription via Groq, optional ElevenLabs TTS replies โ€” per-agent modes. | -| **Group chats, handled** | LLM classifier decides when to chime in. Identity tagging keeps multi-bot rooms sane. | -| **Sub-agent delegation** | A parent agent hands a job to a specialist child. Results compacted before they return. | -| **Code agent** | Delegate coding tasks to Claude Code with live progress. Effort-based model selection. | -| **Tool approval** | Destructive operations ping you on Telegram with Approve/Deny buttons. 15-minute expiry. | -| **Command security** | Allowlist-based binary execution for `run_command`. You decide what shell commands an agent may run. | -| **Conversation tempo** | Agents see elapsed time between messages and adapt โ€” greetings, stale references, context freshness. | -| **Phoenix observability** | OpenTelemetry traces for every turn. Full prompt/response history in the UI for debugging. | - -![Dashboard](screenshots/dashboard.png) - -## How it works - -1. **`npm start`** boots the platform daemon and the Next.js control plane. -2. **The onboarding wizard** asks for an OpenRouter key (or a Codex login), three model tiers (low/med/high), and a Telegram bot token from [@BotFather](https://t.me/BotFather). -3. **You describe your first agent** in a sentence. Golem generates a persona, picks tools, and seeds working memory. -4. **You message the bot on Telegram.** That's it. The agent is live. Configure tools, skills, schedules, and webhooks from the web UI as you go. - -## Skills - -Skills are markdown files that teach agents how to use tools for specific tasks. Each skill lives in `skills//SKILL.md`: +**On a VPS:** -```yaml ---- -name: my-skill -description: "What this skill does" -requires: - env: [API_KEY] - bins: [some-cli] ---- - -# My Skill - -Instructions for the agent on how to use this skill... +```bash +npm install -g golem-agent +golem install-daemon ``` -![Skills](screenshots/skills.png) - ---- - -## Under the hood - -For the engineers. Golem is a **single Node.js process**, multi-tenant by convention. No microservices, no external DB, no Kubernetes. A laptop under `launchd` is the production target. - -### Architecture - -- **Backend** (port 3847) โ€” HTTP API, Telegram transports, agent runners, job scheduler. -- **Web UI** (port 3015) โ€” Next.js 16 control plane, proxied to backend. +That's it. The daemon is running under systemd (Linux) or launchd (macOS), survives reboots and SSH logouts. To configure your first agent, open an SSH tunnel from your laptop and visit the wizard: -All state lives in SQLite under the data directory: - -| Database | Purpose | -|---|---| -| `agents.db` | Agent definitions (config, persona, memory template) | -| `settings.db` | Runtime settings (model tiers, behavior, integrations) | -| `platform-memory.db` | Conversation history and working memory | -| `crons.db` | Scheduled tasks | -| `jobs.db` | Async job queue (coding, HTTP polling, workflows) | -| `feed.db` | Activity audit log | - -### Agent message flow - -``` -Telegram โ†’ Transport โ†’ Dedup โ†’ Chat classification - โ†’ Media processing (vision / voice transcription) - โ†’ AgentRunner โ†’ agent.generate() with memory + tools - โ†’ Response โ†’ Telegram +```bash +ssh -L 3015:localhost:3015 you@your-vps +# then open http://localhost:3015 in your browser ``` -### Processors pipeline - -Input processors run before each LLM step, in order: - -- **PromptCache** โ€” marks the Anthropic prompt-cache boundary -- **ImageStripper** โ€” strips base64 image data from history -- **AsyncJobGuard** โ€” stops the agent loop after an async job dispatch -- **ToolCallFilter** โ€” strips tool calls from recalled history -- **SubAgentResultCompactor** โ€” compacts verbose sub-agent results -- **ToolResultSanitizer** โ€” caps oversized tool results -- **MessageTimestamp** โ€” prepends `[Apr 18 09:35]` markers for tempo awareness -- **OwnerStepBudget** โ€” enforces a per-step tool-call budget (8 primary, 4 sub-agent) -- **TokenLimiter** โ€” 170K-token-per-turn ceiling -- **ToolErrorGate** โ€” disables tools after repeated failures - -Output processors run after each turn, before memory persistence: +**Even faster โ€” one click:** the "Deploy on Vultr" badge above provisions a Vultr instance with Golem pre-installed via a first-boot startup script. SSH in, open the tunnel, walk the wizard. -- **SubAgentResultCompactor**, **ToolResultSanitizer**, **ImageStripper**, **ReasoningStripper**, **GroupIdentity**. - -### LLM tiers, not model IDs - -Three global tiers (low / med / high) map to specific OpenRouter model IDs. Each agent stores a *tier*, not a model. Change the tier's model and every agent on it updates. Override per-agent when you need to. - -### Behavior dropdowns - -Each agent's response style is governed by five dropdowns: - -| Setting | Options | -|---|---| -| Response Length | Brief / Balanced / Detailed | -| Agency | Execute first / Ask before acting / Consultative | -| Tone | Casual / Balanced / Professional | -| Format | Texting / Conversational / Structured | -| Language | English / Hebrew / Auto-detect | - -## Requirements - -- macOS or Linux (Windows not supported yet) -- Node.js 20+ -- An [OpenRouter](https://openrouter.ai/keys) API key, or a ChatGPT Plus/Pro subscription for Codex models -- A Telegram bot token from [@BotFather](https://t.me/BotFather) - -### Environment variables - -| Variable | Required | Description | -|---|---|---| -| `OPENROUTER_API_KEY` | Yes | OpenRouter API key for LLM access | -| `GROQ_API_KEY` | No | Voice transcription (Whisper, free tier) | -| `ELEVENLABS_API_KEY` | No | Voice replies (TTS) | -| `GOLEM_DATA_DIR` | No | Custom data directory (default: `./data`) | -| `GOLEM_SKILLS_DIR` | No | Custom skills directory (default: `./skills`) | - -### Optional: code agent - -To let your agents delegate coding tasks to [Claude Code](https://claude.ai/code): +**Local development:** ```bash -npm install -g @anthropic-ai/claude-code -claude login # one-time OAuth in browser +git clone https://github.com/AvivK5498/Golem.git +cd Golem && npm install +cp .env.example .env +npm start # http://localhost:3015 ``` -Then enable the `code_agent` tool on any agent. It can write code, refactor, run tests, and install dependencies by spawning Claude Code sessions. - -## Development - -### Test harness - -A standalone CLI that exercises the full agent pipeline without Telegram. Real LLM calls, Phoenix traces, no transport mocks. +For full install options, see **[docs/INSTALL.md](./docs/INSTALL.md)**. For the CLI reference, **[docs/CLI.md](./docs/CLI.md)**. -```bash -npm run test:agent "Hello, what can you do?" -npm run test:agent -- --verbose "Run ls /tmp" -npm run test:agent -- --image /path/to/image.jpg "What do you see?" -npm run test:agent -- --mount vault:/path/to/dir:rw "Read and write files under /mnt/vault" -``` +## What your agent can do -### Unit tests +![Dashboard](screenshots/dashboard.png) -```bash -bun test -``` +Each agent runs in its own Telegram bot with a custom persona, working memory, schedules, and a toolset you pick. Out of the box: -## Tech stack +- **AI-generated personas** โ€” describe the job in a sentence, Golem writes the prompt. +- **Working memory** โ€” agents remember things between conversations (your coffee order on Monday, used on Friday). +- **Skills & MCP** โ€” drop a `SKILL.md` or wire an MCP server; the agent learns a new trick. +- **Filesystem mounts** โ€” mount an Obsidian vault at `/mnt/`, agents read and write. +- **Schedules & webhooks** โ€” cron-driven check-ins, GitHub/Strava/CI webhook handlers. +- **Voice in, voice out** โ€” Whisper transcription, ElevenLabs TTS replies. +- **Group chats, handled** โ€” LLM classifier decides when to chime in; identity tagging keeps multi-bot rooms sane. +- **Sub-agent delegation** โ€” parent agents hand specialised jobs to specialist children. +- **Code agent** โ€” delegate coding tasks to Claude Code with live progress. +- **Tool approval** โ€” destructive operations ping you on Telegram with Approve/Deny buttons. +- **Phoenix observability** โ€” OpenTelemetry traces for every turn. -- **Runtime**: Node.js 20+ / TypeScript (ES modules) -- **Agent framework**: [Mastra](https://mastra.ai) (`@mastra/core`) -- **LLM providers**: [OpenRouter](https://openrouter.ai) (300+ models) + [OpenAI Codex](https://openai.com/index/introducing-codex/) (ChatGPT subscription, fair-use quota) -- **Messaging**: Telegram ([grammY](https://grammy.dev)) -- **Memory**: LibSQL (conversation history + working memory) -- **Storage**: SQLite (better-sqlite3) -- **UI**: Next.js 16 + shadcn/ui + Tailwind CSS v4 -- **Observability**: Phoenix (OpenTelemetry) -- **Testing**: Bun test runner +![Skills](screenshots/skills.png) ---- +## Philosophy -## Ready to ship your AI agent? +- **Agents act, they don't chat.** Every agent has tools, schedules, webhooks, and the agency to use them. Conversation is one input among many. +- **One bot per job.** Specialized agents beat one mega-prompt. Spin up a research agent, a code agent, a personal assistant โ€” each with its own bot. +- **Telegram-native, not Telegram-bolted-on.** Your agents live where you already are. Voice notes in, voice replies out, group chats, media, buttons. +- **You own the stack.** Your machine, your SQLite, your API keys, your bot tokens. Portable. Forkable. No cloud account required. +- **Configuration is data.** No YAML to edit by hand. The web UI writes SQLite; everything is hot-reloadable. -```bash -git clone https://github.com/AvivK5498/Golem.git -cd Golem && npm install -cp .env.example .env -npm start -``` +## Tech stack -Open **http://localhost:3015**, walk the wizard, message your bot. Welcome to Golem. +Node.js 20+ ยท TypeScript ยท [Mastra](https://mastra.ai) ยท [OpenRouter](https://openrouter.ai) ยท Telegram ([grammY](https://grammy.dev)) ยท LibSQL + SQLite ยท Next.js 16 + shadcn/ui ยท Phoenix (OpenTelemetry) ยท Bun test ## License diff --git a/bin/golem.js b/bin/golem.js index fb73c6e..1f628e0 100755 --- a/bin/golem.js +++ b/bin/golem.js @@ -1,29 +1,44 @@ #!/usr/bin/env node -// Golem โ€” start the platform with auto-restart support -// Exit code 75 = restart requested (e.g. after onboarding writes config) +// Golem CLI wrapper. +// +// - Forwards subcommands and args to src/cli.ts (or dist/cli.js when installed). +// - For `start` (default) only: re-spawns the child on exit code 75 +// (onboarding-requested restart). Other subcommands exit naturally. +// - Does NOT chdir; src/utils/paths.ts resolves the data dir from +// $GOLEM_DATA_DIR > ./data/ > OS-native default, so cwd matters. + import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = resolve(__dirname, ".."); -process.chdir(root); +const builtEntry = resolve(root, "dist", "cli.js"); +const sourceEntry = resolve(root, "src", "cli.ts"); -function start() { - const child = spawn("npx", ["tsx", "src/cli.ts"], { - cwd: root, - stdio: "inherit", - shell: true, - }); +const argv = process.argv.slice(2); +const subcommand = argv[0] ?? "start"; +const isStart = subcommand === "start" || subcommand.startsWith("-") || !["stop", "status", "logs", "version", "update", "doctor", "install-daemon", "uninstall-daemon", "help", "-h", "--help"].includes(subcommand); + +function spawnChild() { + // Prefer compiled dist/ if present (npm-installed), else use tsx on src/. + if (existsSync(builtEntry)) { + return spawn(process.execPath, [builtEntry, ...argv], { stdio: "inherit" }); + } + return spawn("npx", ["tsx", sourceEntry, ...argv], { stdio: "inherit", shell: true }); +} +function run() { + const child = spawnChild(); child.on("exit", (code) => { - if (code === 75) { + if (isStart && code === 75) { console.log("[golem] restarting platform..."); - setTimeout(start, 1000); + setTimeout(run, 1000); } else { process.exit(code ?? 1); } }); } -start(); +run(); diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000..719e37e --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,112 @@ +# Golem CLI reference + +The `golem` command is a thin ops wrapper. All content management โ€” agents, schedules, settings โ€” lives in the web UI or Telegram. The CLI exists to run, stop, inspect, and diagnose the daemon. + +``` +golem [command] [args] +``` + +Running `golem` with no arguments is equivalent to `golem start`. + +## Commands + +### `golem start` + +Start the platform in the foreground. Refuses to launch a second instance if a live PID file is present. + +Used by systemd / launchd via `install-daemon`. You normally don't call it directly โ€” `systemctl --user start golem` or the launchd plist runs it under the hood. + +When run interactively (e.g. SSH'd in, foreground for debugging), it prints the first-run banner including a copy-paste SSH tunnel command derived from `$SSH_CONNECTION`. + +### `golem stop` + +Send `SIGTERM` to the running daemon, wait up to 10 seconds for graceful shutdown, then `SIGKILL` if it didn't comply. + +Does the right thing whether the daemon was started by `golem start` or by systemd/launchd. For systemd you can also use `systemctl --user stop golem`. + +### `golem status` + +Report whether the daemon is running and where its data lives. + +``` +data dir: /home/you/.local/share/golem (default) +status: running (pid 12345, up 2h 17m) +``` + +Resolution source (`env` / `cwd` / `default`) shows which branch of the data-dir lookup won: +- `env` โ€” `GOLEM_DATA_DIR` was set +- `cwd` โ€” there's a `./data/` directory in your current working dir (dev workflow) +- `default` โ€” OS-native default (`~/.local/share/golem` on Linux, `~/Library/Application Support/golem` on macOS) + +Exit code `0` if running, `3` if not (LSB convention). + +### `golem logs [-n N] [-f]` + +Tail the daemon's logs. Detection order: + +1. Linux with a systemd user unit named `golem` โ†’ `journalctl --user -u golem -f` +2. macOS with `com.golem.agent.plist` installed โ†’ `tail -F ~/Library/Logs/com.golem.agent.log` +3. Fallback โ†’ `tail -F` on the most recent `*.log` under the data dir's `logs/` folder + +Flags: +- `-f`, `--follow` โ€” follow (default) +- `--no-follow` โ€” print recent lines and exit +- `-n N`, `--lines N` โ€” show N most recent lines (default 100) + +### `golem doctor` + +Run health checks. Each check is OK / WARN / FAIL. + +- **Node version** โ€” must be โ‰ฅ 20 +- **Data dir writable** โ€” does the data dir exist and is it writable? +- **Disk space** โ€” warn under 1 GiB free, fail under 100 MiB +- **OpenRouter key** โ€” `OPENROUTER_API_KEY` set, and accepted by `/api/v1/key` +- **Telegram tokens** โ€” for each agent in `agents.db`, validates the bot token via Telegram `getMe` (resolves `${VAR}` refs from the data-dir `.env`) +- **Daemon running** โ€” PID file present, process alive +- **Logs dir** โ€” exists and readable + +Exit code `0` if everything is OK or WARN; `1` if any FAIL. + +Notably absent: ffmpeg is **not** checked. Voice transcription sends OGG/Opus directly to Whisper โ€” no transcoding needed. + +### `golem version` + +Print the installed package version. + +### `golem update` + +(Stub.) Will eventually run `npm install -g golem-agent@latest && systemctl --user restart golem`. For now, run those commands manually. + +### `golem install-daemon [--force] [--dry-run]` + +Write a user-level systemd unit (Linux) or launchd plist (macOS) and start the daemon. + +- Linux: `~/.config/systemd/user/golem.service`, then `systemctl --user daemon-reload && enable --now golem.service`. Reminds you to `loginctl enable-linger $USER` if it's not on. +- macOS: `~/Library/LaunchAgents/com.golem.agent.plist`, then `launchctl bootstrap` + `kickstart`. Refuses to overwrite an existing repo-local plist (the dev install pattern) without `--force` and prints the exact migration steps. + +Flags: +- `--dry-run` (`-n`) โ€” print what would be written, don't touch anything +- `--force` (`-f`) โ€” overwrite an existing unit/plist even if it differs + +### `golem uninstall-daemon [--dry-run]` + +Stop the daemon, remove the systemd unit or launchd plist, reload the supervisor. Leaves your data directory alone โ€” to remove that, do it manually. + +## Environment + +| Variable | Default | What it does | +|---|---|---| +| `GOLEM_DATA_DIR` | OS-native (`~/.local/share/golem` on Linux, `~/Library/Application Support/golem` on macOS) | Override where the daemon stores everything. `golem status` shows the resolved path. | +| `GOLEM_SKILLS_DIR` | `./skills` (relative to cwd) | Override where skills are loaded from. | + +The CLI loads `.env` from **both** cwd and the data directory, in that order โ€” so the wizard-written `.env` (in the data dir) is picked up by `golem doctor` when you run it from your shell, and your repo-root `.env` works for `npm start` in a dev clone. + +## Exit codes + +| Code | Meaning | +|---|---| +| 0 | Success | +| 1 | Generic failure | +| 3 | `golem status` only โ€” daemon not running (LSB convention) | +| 64 | Unknown subcommand (`EX_USAGE`) | +| 75 | (Internal) `golem start` requesting restart after wizard finishes | diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..a2c6704 --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,179 @@ +# Installing Golem + +Three paths. Pick the one that fits your situation. + +| | When to use | Time | +|---|---|---| +| [Deploy on Vultr](#deploy-on-vultr-one-click) | You want a VPS and don't have one yet | 3 min | +| [Manual VPS install](#manual-vps-install) | You have a server already (any provider) | 5 min | +| [Local development](#local-development) | You want to hack on Golem itself | 5 min | + +--- + +## Deploy on Vultr (one-click) + +The [Deploy on Vultr badge in the README](../README.md) links to a pre-filled Vultr deploy page. The first-boot startup script (registered in the Vultr account that owns the badge) installs Node, runs `npm install -g golem-agent`, and runs `golem install-daemon` automatically. By the time you can SSH in, the daemon is already running. + +After the instance boots (~2 minutes): + +```bash +# 1. SSH to confirm the bootstrap finished +ssh root@ +# (the MOTD will tell you Golem is running and what to do next) + +# 2. From your laptop in a separate terminal, open the tunnel +ssh -L 3015:localhost:3015 root@ + +# 3. Open http://localhost:3015 in your browser and walk the wizard +``` + +The startup script source lives in [`scripts/vultr-startup.sh`](../scripts/vultr-startup.sh) โ€” feel free to inspect it or fork it. + +--- + +## Manual VPS install + +Works on any Linux VPS provider (Hetzner, DigitalOcean, AWS, Hetzner, etc.) running Ubuntu 22.04+, Debian 12+, or Fedora 40+. + +### 1. Provision a box + +Specs: **1GB RAM minimum** (2GB comfortable), 20GB disk, Ubuntu 24.04 LTS. Any x86_64 or arm64 will do. + +### 2. SSH in and install Node 20+ + +```bash +# Ubuntu/Debian โ€” install Node 24 via NodeSource +curl -fsSL https://deb.nodesource.com/setup_24.x | bash - +apt-get install -y nodejs + +# Verify +node -v # should print v24.x.x +npm -v +``` + +### 3. Install Golem + +```bash +npm install -g golem-agent +golem install-daemon +``` + +`install-daemon` writes a user-level systemd unit at `~/.config/systemd/user/golem.service` and starts it. The daemon serves the platform API on `127.0.0.1:3847` and the web UI on `127.0.0.1:3015` โ€” both loopback only, never exposed. + +### 4. Enable lingering (so the daemon survives logout) + +```bash +loginctl enable-linger $USER +``` + +Without this, systemd kills your user services when you SSH out. `install-daemon` reminds you about this if it's missing โ€” but the one-liner above gets it done. + +### 5. Configure via SSH tunnel + +From your **laptop**, in a separate terminal: + +```bash +ssh -L 3015:localhost:3015 you@your-vps +# (port 3015 already taken on your laptop? use any free port: +# ssh -L 3030:localhost:3015 you@your-vps โ†’ open http://localhost:3030) +``` + +Then open in your browser. The 5-step onboarding wizard collects: + +1. **OpenRouter API key** ([get one](https://openrouter.ai/keys)) +2. **Model tiers** โ€” pick three OpenRouter model IDs for low/medium/high tiers +3. **Telegram bot token** โ€” get one from [@BotFather](https://t.me/BotFather); the wizard validates it via `getMe` before advancing +4. **Owner Telegram ID** โ€” leave blank, it'll be auto-detected when you DM the bot +5. **First agent persona** + +After the wizard, the daemon restarts and you can message your bot. + +### 6. Verify + +```bash +golem status # should show pid + uptime +golem doctor # should be all green +``` + +--- + +## Local development + +```bash +git clone https://github.com/AvivK5498/Golem.git +cd Golem && npm install +cp .env.example .env # add your OPENROUTER_API_KEY +npm start # runs platform + Next.js dev UI on :3015 +``` + +The dev `npm start` uses `concurrently` to run both the platform daemon and the Next.js dev server. Use this when you want hot reload on the UI side. + +```bash +bun test # unit tests +npm run typecheck # tsc +npm run test:agent "your prompt" # end-to-end agent test (real LLM, no Telegram) +``` + +To build the publishable bundle: + +```bash +npm run build # tsc + Next.js standalone build +npm pack # creates golem-agent-x.y.z.tgz (no publish) +npm publish # actually publish (prepublishOnly runs typecheck + tests + build) +``` + +--- + +## Updating + +```bash +npm install -g golem-agent@latest +systemctl --user restart golem # Linux +# or +launchctl kickstart -k gui/$(id -u)/com.golem.agent # macOS +``` + +`golem update` will eventually do both steps automatically โ€” currently a stub. + +## Uninstalling + +```bash +golem uninstall-daemon # stops the daemon, removes the systemd unit +npm uninstall -g golem-agent # removes the package +# Your data directory stays put. Remove manually if you want a clean slate: +rm -rf ~/.local/share/golem # Linux +rm -rf "~/Library/Application Support/golem" # macOS +``` + +## Backing up + +The data directory **is** the install. Tar it. + +```bash +golem stop +tar czf golem-backup-$(date +%F).tgz -C ~/.local/share golem +golem start # or: systemctl --user start golem +``` + +Move that tarball to another machine, install Golem there, untar over the data directory, and you've moved your entire install โ€” agents, conversation history, working memory, scheduled tasks, all of it. + +## Requirements + +- **OS:** Linux x86_64 or arm64 (Ubuntu 22.04+, Debian 12+, Fedora 40+), or macOS. Windows isn't supported. +- **Node:** 20 or newer. +- **An OpenRouter API key** ([get one](https://openrouter.ai/keys)). +- **A Telegram bot token** ([@BotFather](https://t.me/BotFather)). + +Optional: Groq API key (free Whisper tier), ElevenLabs (TTS), a Claude Code login (delegated coding). + +## Environment variables + +| Variable | Description | +|---|---| +| `OPENROUTER_API_KEY` | LLM access (set by the wizard or in `.env`) | +| `GOLEM_DATA_DIR` | Override the data directory | +| `GOLEM_SKILLS_DIR` | Override the skills directory | +| `GROQ_API_KEY` | Voice transcription | +| `ELEVENLABS_API_KEY` | Voice replies | + +The wizard writes secrets to `/.env`. `golem start` and `golem doctor` both load that file at startup. diff --git a/package-lock.json b/package-lock.json index 8abf87e..3931696 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "golem-agent", - "version": "0.1.5", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "golem-agent", - "version": "0.1.5", + "version": "0.2.0", "license": "MIT", "workspaces": [ "ui" diff --git a/package.json b/package.json index aa81bbd..9309e45 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,21 @@ { "name": "golem-agent", - "version": "0.1.5", + "version": "0.2.0", "description": "Workflow-first personal AI agent. Multi-platform messaging. Semantic memory. Autonomous operation.", "type": "module", "bin": { "golem": "./bin/golem.js" }, "files": [ + "dist/", + "bin/", + "skills/", + "ui/.next/standalone/", "src/", + "!src/**/*.test.ts", "!src/__tests__/", "!src/**/__tests__/", "!src/test-harness.ts", - "bin/", "README.md", "LICENSE" ], @@ -42,6 +46,8 @@ "test": "bun test", "lint": "eslint src/", "typecheck": "tsc --noEmit", + "build": "rm -rf dist ui/.next && tsc -p tsconfig.build.json && npm run build --workspace=ui && cp -r ui/public ui/.next/standalone/ui/ && cp -r ui/.next/static ui/.next/standalone/ui/.next/", + "prepublishOnly": "npm run typecheck && npm run test && npm run build", "prepare": "husky" }, "dependencies": { diff --git a/src/cli.ts b/src/cli.ts index 79e0018..b1eccec 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,21 +1,108 @@ /** - * Golem entry point โ€” starts the platform. + * Golem CLI entry point โ€” dispatches subcommands. + * + * Subcommand surface (v1): + * start Start the platform in the foreground (default) + * stop Stop the running daemon + * status Report whether the daemon is running + * logs [-n N] [-f] Tail daemon logs (journald / launchd log / data/logs/*) + * version Print the package version + * update (Stub) Pull the latest published version โ€” see bd vx9 + * doctor Health checks โ€” extended by bd 49x + * install-daemon Install systemd unit / launchd plist โ€” bd 3mm/8if (TBD) + * uninstall-daemon Reverse install-daemon โ€” bd 3mm/8if (TBD) + * + * Running with no subcommand is equivalent to `start`. */ -import "dotenv/config"; +// .env loading: support both layouts. +// - Dev clone: .env lives in repo root (cwd when running `npm start`). +// - Installed: .env lives in the data dir, written by the onboarding wizard +// (the daemon's WorkingDirectory is the data dir, but `golem +// doctor` from an interactive shell has cwd=$HOME and won't +// find it). Loading both โ€” cwd first so dev wins on conflict. +import dotenv from "dotenv"; +import path from "node:path"; +import { getDataDir } from "./utils/paths.js"; +dotenv.config(); // cwd-relative .env +dotenv.config({ path: path.join(getDataDir(), ".env"), override: false }); -if (!process.env.OPENROUTER_API_KEY) { - console.log("\n No OPENROUTER_API_KEY found โ€” starting in onboarding mode."); - console.log(" Open http://localhost:3015 to configure your platform.\n"); -} +type Subcommand = (args: string[]) => Promise; + +const COMMANDS: Record Promise<{ run: Subcommand }>> = { + start: () => import("./cli/start.js"), + stop: () => import("./cli/stop.js"), + status: () => import("./cli/status.js"), + logs: () => import("./cli/logs.js"), + version: () => import("./cli/version.js"), + update: () => import("./cli/update.js"), + doctor: () => import("./cli/doctor.js"), + "install-daemon": () => + import("./cli/install-daemon/index.js").then((m) => ({ run: m.runInstall })), + "uninstall-daemon": () => + import("./cli/install-daemon/index.js").then((m) => ({ run: m.runUninstall })), +}; + +const ALIASES: Record = { + "-v": "version", + "--version": "version", +}; + +const HELP = `golem โ€” multi-agent platform + +Usage: + golem [command] [args] + +Commands: + start Start the platform (default if no command given) + stop Stop the running daemon + status Report whether the daemon is running + logs [-n N] [-f] Tail the daemon logs + install-daemon [--force] Install user systemd unit / launchd plist + uninstall-daemon Remove the unit/plist and stop the daemon + version Print the version + update Pull the latest published version (not yet wired) + doctor Run health checks + help, -h, --help Show this message -const { startPlatform } = await import("./platform/platform.js"); +Environment: + GOLEM_DATA_DIR Override the data directory (default: OS-native). + See \`golem status\` for the resolved path. +`; + +async function main(): Promise { + const argv = process.argv.slice(2); + let cmd = argv[0] ?? "start"; + let args = argv.slice(1); + + if (cmd in ALIASES) cmd = ALIASES[cmd]; + if (cmd === "help" || cmd === "-h" || cmd === "--help") { + console.log(HELP); + return 0; + } + // If the first token is a flag and isn't a known alias, treat everything as + // arguments to `start`. + if (cmd.startsWith("-") && !(cmd in COMMANDS)) { + args = argv; + cmd = "start"; + } + + const loader = COMMANDS[cmd]; + if (!loader) { + console.error(`Unknown command: ${cmd}\n`); + console.error(HELP); + return 64; // EX_USAGE + } + + const mod = await loader(); + return mod.run(args); +} -startPlatform() - .then(() => { - // Keep the process alive โ€” transports and scheduler run on intervals/callbacks - return new Promise(() => {}); - }) - .catch((err: unknown) => { +main().then( + (code) => { + process.exit(code); + }, + (err) => { console.error(err); process.exit(1); - }); + }, +); diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts new file mode 100644 index 0000000..931f427 --- /dev/null +++ b/src/cli/doctor.ts @@ -0,0 +1,202 @@ +/** + * `golem doctor` โ€” diagnostic health checks. + * + * Highest-leverage subcommand for hesitating-user trust: the single command + * that answers "is this thing actually wired up correctly on this machine?" + * + * Checks (each independent, run concurrently where possible): + * - Node version (>=20) + * - Data dir writable + * - Disk space (warn <1GB, fail <100MB) + * - OpenRouter key set and accepted by /api/v1/key + * - Each agent's Telegram bot token via getMe (reads agents.db readonly) + * - Daemon running (via PID file) + * - Logs directory present and readable + * + * Exit codes: 0 = all green or warnings only; 1 = at least one fail. + * + * ffmpeg is NOT checked โ€” Whisper API accepts Telegram OGG directly. + */ +import { createRequire } from "node:module"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; + +import { dataPath, describeDataDirResolution } from "../utils/paths.js"; + +// ESM has no global require; synthesize one bound to this module so we can +// lazy-load the optional better-sqlite3 dependency without forcing a top-level +// import (and without making doctor depend on AgentStore's full lifecycle). +const require = createRequire(import.meta.url); +import { validateOpenRouterKey } from "../utils/openrouter-validate.js"; +import { validateTelegramToken } from "../utils/telegram-validate.js"; +import { getRunningDaemon } from "./pid.js"; + +interface Check { + name: string; + status: "ok" | "warn" | "fail"; + detail: string; +} + +function check(name: string, status: Check["status"], detail: string): Check { + return { name, status, detail }; +} + +function checkNodeVersion(): Check { + const major = Number(process.versions.node.split(".")[0]); + if (major >= 20) return check("Node version", "ok", process.versions.node); + return check("Node version", "fail", `${process.versions.node} (need >= 20)`); +} + +function checkDataDir(): Check { + const r = describeDataDirResolution(); + try { + fs.accessSync(r.path, fs.constants.W_OK); + return check("Data dir writable", "ok", `${r.path} (${r.source})`); + } catch { + return check("Data dir writable", "fail", `${r.path} โ€” not writable`); + } +} + +async function checkDiskSpace(): Promise { + try { + const stats = await fsp.statfs(describeDataDirResolution().path); + const freeBytes = stats.bavail * stats.bsize; + const freeGiB = freeBytes / 1024 ** 3; + const human = freeGiB >= 1 ? `${freeGiB.toFixed(1)} GiB free` : `${(freeBytes / 1024 ** 2).toFixed(0)} MiB free`; + if (freeBytes < 100 * 1024 ** 2) return check("Disk space", "fail", `${human} โ€” under 100 MiB threshold`); + if (freeBytes < 1024 ** 3) return check("Disk space", "warn", `${human} โ€” under 1 GiB`); + return check("Disk space", "ok", human); + } catch (err) { + return check("Disk space", "warn", `couldn't statfs data dir: ${(err as Error).message}`); + } +} + +async function checkOpenRouterKey(): Promise { + const key = process.env.OPENROUTER_API_KEY; + if (!key) return check("OpenRouter key", "fail", "OPENROUTER_API_KEY not set โ€” run setup first"); + const r = await validateOpenRouterKey(key); + if (r.ok) { + // Some accounts have no limit set, in which case OpenRouter returns null + // for limit_remaining. Only format when we actually have a number. + const detail = + typeof r.limitRemaining === "number" ? `valid (credit ${r.limitRemaining.toFixed(2)})` : "valid"; + return check("OpenRouter key", "ok", detail); + } + return check("OpenRouter key", "fail", r.error); +} + +function expandEnvVarsLocal(value: string): string { + return value.replace(/\$\{([A-Z0-9_]+)\}/g, (_, name) => process.env[name] ?? ""); +} + +interface TelegramAgentSummary { + id: string; + tokenRef: string; + resolved: string; +} + +function readAgentTelegramTokens(): TelegramAgentSummary[] | null { + const dbPath = dataPath("agents.db"); + if (!fs.existsSync(dbPath)) return null; + + // Open readonly so a running daemon's writes are unaffected. Using better-sqlite3 + // via createRequire here keeps doctor decoupled from AgentStore's lifecycle + // (no migration runs, no schema upgrades). + const Database = require("better-sqlite3") as new ( + p: string, + o?: { readonly?: boolean; fileMustExist?: boolean }, + ) => { prepare(sql: string): { all(): unknown[] }; close(): void }; + const db = new Database(dbPath, { readonly: true, fileMustExist: true }); + try { + type Row = { id: string; config_json: string }; + const rows = db.prepare("SELECT id, config_json FROM agents").all() as Row[]; + const out: TelegramAgentSummary[] = []; + for (const row of rows) { + try { + const cfg = JSON.parse(row.config_json) as { + transport?: { platform?: string; botToken?: string }; + }; + if (cfg.transport?.platform !== "telegram" || !cfg.transport.botToken) continue; + out.push({ + id: row.id, + tokenRef: cfg.transport.botToken, + resolved: expandEnvVarsLocal(cfg.transport.botToken), + }); + } catch { + // skip malformed configs; doctor reports failures elsewhere + } + } + return out; + } finally { + db.close(); + } +} + +async function checkTelegramTokens(): Promise { + const agents = readAgentTelegramTokens(); + if (agents === null) return [check("Telegram tokens", "warn", "no agents.db yet (run setup)")]; + if (agents.length === 0) return [check("Telegram tokens", "warn", "no Telegram agents configured")]; + + const results = await Promise.all( + agents.map(async (a) => { + if (!a.resolved) return check(`Telegram: ${a.id}`, "fail", `env var ${a.tokenRef} not resolved`); + const r = await validateTelegramToken(a.resolved); + return r.ok + ? check(`Telegram: ${a.id}`, "ok", `@${r.botUsername}`) + : check(`Telegram: ${a.id}`, "fail", r.error); + }), + ); + return results; +} + +function checkDaemon(): Check { + const record = getRunningDaemon(); + if (record) return check("Daemon running", "ok", `pid ${record.pid}`); + return check("Daemon running", "warn", "not running (use `golem start`)"); +} + +function checkLogsDir(): Check { + const dir = dataPath("logs"); + try { + const entries = fs.readdirSync(dir); + return check("Logs dir", "ok", `${dir} (${entries.length} files)`); + } catch { + return check("Logs dir", "warn", `${dir} โ€” not present yet (created on first run)`); + } +} + +function renderCheck(c: Check): string { + const icon = c.status === "ok" ? "โœ“" : c.status === "warn" ? "!" : "โœ—"; + return ` ${icon} ${c.name.padEnd(24)} ${c.detail}`; +} + +export async function run(_args: string[]): Promise { + console.log("Golem doctor โ€” running health checks"); + console.log(""); + + // Run async checks concurrently; sync ones inline. + const [diskSpace, openrouter, telegram] = await Promise.all([ + checkDiskSpace(), + checkOpenRouterKey(), + checkTelegramTokens(), + ]); + + const checks: Check[] = [ + checkNodeVersion(), + checkDataDir(), + diskSpace, + openrouter, + ...telegram, + checkDaemon(), + checkLogsDir(), + ]; + + let exit = 0; + for (const c of checks) { + console.log(renderCheck(c)); + if (c.status === "fail") exit = 1; + } + console.log(""); + console.log(exit === 0 ? "All checks passed." : "Some checks failed โ€” see details above."); + return exit; +} diff --git a/src/cli/first-run-banner.test.ts b/src/cli/first-run-banner.test.ts new file mode 100644 index 0000000..6abe7be --- /dev/null +++ b/src/cli/first-run-banner.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { _resetPathCacheForTests } from "../utils/paths.js"; +import { detectSshSession, renderBanner } from "./first-run-banner.js"; + +let savedEnv: Record; +const KEYS = ["SSH_CONNECTION", "USER", "LOGNAME", "GOLEM_DATA_DIR"]; + +beforeEach(() => { + savedEnv = Object.fromEntries(KEYS.map((k) => [k, process.env[k]])); + for (const k of KEYS) delete process.env[k]; + process.env.GOLEM_DATA_DIR = "/tmp/golem-banner-test"; + _resetPathCacheForTests(); +}); + +afterEach(() => { + for (const k of KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + _resetPathCacheForTests(); +}); + +describe("detectSshSession", () => { + test("returns null when SSH_CONNECTION is unset", () => { + expect(detectSshSession({})).toBeNull(); + }); + + test("parses standard IPv4 SSH_CONNECTION", () => { + expect( + detectSshSession({ SSH_CONNECTION: "10.0.0.5 41234 192.168.1.10 22", USER: "aviv" }), + ).toEqual({ user: "aviv", host: "192.168.1.10" }); + }); + + test("wraps IPv6 server addresses in brackets", () => { + expect( + detectSshSession({ + SSH_CONNECTION: "fe80::1 41234 2001:db8::1 22", + USER: "aviv", + }), + ).toEqual({ user: "aviv", host: "[2001:db8::1]" }); + }); + + test("falls back to LOGNAME when USER is unset", () => { + expect( + detectSshSession({ SSH_CONNECTION: "10.0.0.5 41234 1.2.3.4 22", LOGNAME: "root" }), + ).toEqual({ user: "root", host: "1.2.3.4" }); + }); + + test("returns null on malformed SSH_CONNECTION", () => { + expect(detectSshSession({ SSH_CONNECTION: "incomplete", USER: "aviv" })).toBeNull(); + }); + + test("returns null when no user can be determined", () => { + expect(detectSshSession({ SSH_CONNECTION: "1 2 3 4" })).toBeNull(); + }); +}); + +describe("renderBanner", () => { + test("onboarding + no SSH points at localhost", () => { + const out = renderBanner({ hasApiKey: false, ssh: null }); + expect(out).toContain("Not yet configured"); + expect(out).toContain("http://localhost:3015"); + expect(out).not.toContain("ssh -L"); + }); + + test("onboarding + SSH renders a copy-paste tunnel command", () => { + const out = renderBanner({ + hasApiKey: false, + ssh: { user: "aviv", host: "203.0.113.7" }, + }); + expect(out).toContain("ssh -L 3015:localhost:3015 aviv@203.0.113.7"); + expect(out).toContain("http://localhost:3015"); + }); + + test("configured + SSH still shows the tunnel command for re-access", () => { + const out = renderBanner({ + hasApiKey: true, + ssh: { user: "root", host: "[2001:db8::1]" }, + }); + expect(out).toContain("ssh -L 3015:localhost:3015 root@[2001:db8::1]"); + expect(out).not.toContain("Not yet configured"); + }); + + test("configured + no SSH is a one-liner", () => { + const out = renderBanner({ hasApiKey: true, ssh: null }); + expect(out).toContain("Running."); + expect(out).toContain("http://localhost:3015"); + expect(out).not.toContain("ssh -L"); + }); + + test("always shows the resolved data dir", () => { + const out = renderBanner({ hasApiKey: true, ssh: null }); + expect(out).toContain("/tmp/golem-banner-test"); + expect(out).toContain("GOLEM_DATA_DIR env var"); + }); +}); diff --git a/src/cli/first-run-banner.ts b/src/cli/first-run-banner.ts new file mode 100644 index 0000000..d205c49 --- /dev/null +++ b/src/cli/first-run-banner.ts @@ -0,0 +1,94 @@ +/** + * First-run / startup banner. + * + * On every start prints the resolved data directory. When the platform is not + * yet onboarded (no OPENROUTER_API_KEY), prints the path to localhost:3015 and, + * if the user is in an SSH session, the copy-paste-ready ssh tunnel command. + * + * This is the highest-leverage UX copy in the project โ€” the moment between + * "I ran the install" and "I can see the UI". Keep it terse, scannable, and + * accurate; treat changes here like changes to a public API. + */ +import { describeDataDirResolution } from "../utils/paths.js"; + +interface SshSession { + user: string; + host: string; +} + +const UI_PORT = 3015; + +/** + * Parse $SSH_CONNECTION = " " + * into the address the user should target in `ssh user@host`. IPv6 addresses + * are wrapped in brackets for shell-safety. Returns null when not in an SSH + * session or the env var is malformed. + */ +export function detectSshSession(env: NodeJS.ProcessEnv = process.env): SshSession | null { + const conn = env.SSH_CONNECTION?.trim(); + if (!conn) return null; + const parts = conn.split(/\s+/); + if (parts.length < 4) return null; + const serverIp = parts[2]; + if (!serverIp) return null; + const user = env.USER || env.LOGNAME; + if (!user) return null; + const host = serverIp.includes(":") ? `[${serverIp}]` : serverIp; + return { user, host }; +} + +interface BannerOptions { + hasApiKey: boolean; + ssh: SshSession | null; +} + +export function renderBanner(opts: BannerOptions): string { + const dataDir = describeDataDirResolution(); + const sourceLabel = { + env: "GOLEM_DATA_DIR env var", + cwd: "./data/ in current directory", + default: "OS default", + }[dataDir.source]; + + const lines: string[] = []; + lines.push(""); + lines.push(" โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—"); + lines.push(" โ•‘ Golem โ•‘"); + lines.push(" โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•"); + lines.push(""); + lines.push(` Data: ${dataDir.path}`); + lines.push(` (${sourceLabel})`); + lines.push(""); + + if (!opts.hasApiKey) { + lines.push(" Not yet configured โ€” let's get you set up."); + lines.push(""); + if (opts.ssh) { + lines.push(" You're SSH'd in. To configure Golem, open an SSH tunnel from"); + lines.push(" your laptop in a separate terminal:"); + lines.push(""); + lines.push(` ssh -L ${UI_PORT}:localhost:${UI_PORT} ${opts.ssh.user}@${opts.ssh.host}`); + lines.push(""); + lines.push(` Then open http://localhost:${UI_PORT} in your laptop's browser.`); + } else { + lines.push(` Open http://localhost:${UI_PORT} to configure your platform.`); + } + lines.push(""); + } else { + if (opts.ssh) { + lines.push(" Running. To access the UI from your laptop:"); + lines.push(""); + lines.push(` ssh -L ${UI_PORT}:localhost:${UI_PORT} ${opts.ssh.user}@${opts.ssh.host}`); + lines.push(` open http://localhost:${UI_PORT}`); + } else { + lines.push(` Running. UI at http://localhost:${UI_PORT}`); + } + lines.push(""); + } + + return lines.join("\n"); +} + +export function printFirstRunBanner(opts: BannerOptions): void { + process.stdout.write(renderBanner(opts) + "\n"); +} diff --git a/src/cli/install-daemon/index.ts b/src/cli/install-daemon/index.ts new file mode 100644 index 0000000..0ac65c1 --- /dev/null +++ b/src/cli/install-daemon/index.ts @@ -0,0 +1,67 @@ +/** + * `golem install-daemon` / `golem uninstall-daemon` โ€” top-level dispatch. + * + * Routes to the platform-specific implementation. Resolves the absolute path + * of the `golem` binary that the unit/plist should launch by inspecting + * `process.argv[1]` (the entry the user invoked). + */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import * as linux from "./linux.js"; +import * as macos from "./macos.js"; + +interface ParsedArgs { + dryRun: boolean; + force: boolean; +} + +function parseArgs(args: string[]): ParsedArgs { + return { + dryRun: args.includes("--dry-run") || args.includes("-n"), + force: args.includes("--force") || args.includes("-f"), + }; +} + +/** + * Resolve the absolute path of the golem CLI entry that the unit/plist should + * launch. Only accepts argv[1] if it's the bin shim โ€” running via tsx in dev + * gives argv[1]=src/cli.ts, which we definitely don't want in a unit file. + */ +function resolveGolemBinary(): string { + const argv1 = process.argv[1]; + if (argv1 && path.isAbsolute(argv1) && /[\\/]bin[\\/]golem(\.js)?$/.test(argv1)) { + return argv1; + } + // Dev / unusual layouts: derive from this file's location. Both src/cli/install-daemon/ + // and dist/cli/install-daemon/ sit two levels under the package root. + const here = path.dirname(fileURLToPath(import.meta.url)); + // src/cli/install-daemon/ -> src/cli/ -> src/ -> -> /bin/golem.js + return path.resolve(here, "..", "..", "..", "bin", "golem.js"); +} + +export async function runInstall(args: string[]): Promise { + const opts = parseArgs(args); + const golemBinary = resolveGolemBinary(); + if (process.platform === "linux") { + return linux.install({ ...opts, golemBinary }); + } + if (process.platform === "darwin") { + return macos.install({ ...opts, golemBinary }); + } + console.error(`install-daemon is not supported on ${process.platform}. Linux and macOS only.`); + return 1; +} + +export async function runUninstall(args: string[]): Promise { + const opts = parseArgs(args); + if (process.platform === "linux") return linux.uninstall(opts); + if (process.platform === "darwin") return macos.uninstall(opts); + console.error(`uninstall-daemon is not supported on ${process.platform}. Linux and macOS only.`); + return 1; +} + +// Subcommand entry points conforming to the CLI dispatcher contract. +export async function run(args: string[]): Promise { + return runInstall(args); +} diff --git a/src/cli/install-daemon/linux.ts b/src/cli/install-daemon/linux.ts new file mode 100644 index 0000000..ad2c7d7 --- /dev/null +++ b/src/cli/install-daemon/linux.ts @@ -0,0 +1,127 @@ +/** + * `golem install-daemon` / `uninstall-daemon` on Linux โ€” manages a user-level + * systemd unit at ~/.config/systemd/user/golem.service. + * + * User-level only (no system-wide install). This keeps the personal-tool + * model intact and avoids sudo. On VPS providers the user typically needs to + * `loginctl enable-linger` so the unit keeps running after logout โ€” we + * detect and remind, but don't enable it ourselves (requires sudo). + */ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { getDataDir } from "../../utils/paths.js"; +import { renderSystemdUnit } from "./unit-renderers.js"; + +const UNIT_NAME = "golem.service"; + +function unitPath(): string { + return path.join(os.homedir(), ".config", "systemd", "user", UNIT_NAME); +} + +function runSystemctl(...args: string[]): { ok: boolean; output: string } { + const r = spawnSync("systemctl", ["--user", ...args], { encoding: "utf-8" }); + return { ok: r.status === 0, output: (r.stdout || "") + (r.stderr || "") }; +} + +function lingerEnabled(): boolean { + const user = os.userInfo().username; + const r = spawnSync("loginctl", ["show-user", user, "-p", "Linger"], { encoding: "utf-8" }); + return /Linger=yes/i.test(r.stdout || ""); +} + +interface InstallOptions { + dryRun: boolean; + force: boolean; + golemBinary: string; +} + +export async function install(opts: InstallOptions): Promise { + const target = unitPath(); + const dataDir = getDataDir(); + const unit = renderSystemdUnit({ + golemBinary: opts.golemBinary, + homeDir: os.homedir(), + dataDir, + }); + + if (fs.existsSync(target) && !opts.force) { + const existing = fs.readFileSync(target, "utf-8"); + if (existing === unit) { + console.log(`Unit already installed and up to date at ${target}.`); + } else { + console.error(`A different golem.service exists at ${target}.`); + console.error("Re-run with --force to overwrite, or remove it manually first."); + return 1; + } + } else { + if (opts.dryRun) { + console.log("--- would write to", target, "---"); + console.log(unit); + return 0; + } + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, unit); + console.log(`Wrote ${target}`); + } + + if (opts.dryRun) { + console.log("--- would run: systemctl --user daemon-reload && enable --now golem.service ---"); + return 0; + } + + console.log("Reloading systemd..."); + let r = runSystemctl("daemon-reload"); + if (!r.ok) { + console.error(r.output); + return 1; + } + r = runSystemctl("enable", "--now", UNIT_NAME); + if (!r.ok) { + console.error(r.output); + return 1; + } + + console.log(""); + console.log("Golem is installed and running."); + console.log(" Status: systemctl --user status golem"); + console.log(" Logs: journalctl --user -u golem -f"); + console.log(""); + + if (!lingerEnabled()) { + console.log("Heads up: linger is NOT enabled for your user."); + console.log("On most VPSes, systemd kills user services when you log out."); + console.log("To keep golem running across logouts, run (once, requires sudo):"); + console.log(""); + console.log(" sudo loginctl enable-linger $USER"); + console.log(""); + } + + return 0; +} + +interface UninstallOptions { + dryRun: boolean; +} + +export async function uninstall(opts: UninstallOptions): Promise { + const target = unitPath(); + if (!fs.existsSync(target)) { + console.log(`No unit at ${target} โ€” nothing to uninstall.`); + return 0; + } + + if (opts.dryRun) { + console.log("--- would run: systemctl --user disable --now golem.service && rm", target, "---"); + return 0; + } + + console.log("Stopping and disabling golem.service..."); + runSystemctl("disable", "--now", UNIT_NAME); // tolerate failure: unit may already be down + fs.unlinkSync(target); + runSystemctl("daemon-reload"); + console.log(`Removed ${target}.`); + return 0; +} diff --git a/src/cli/install-daemon/macos.ts b/src/cli/install-daemon/macos.ts new file mode 100644 index 0000000..96f2a1b --- /dev/null +++ b/src/cli/install-daemon/macos.ts @@ -0,0 +1,128 @@ +/** + * `golem install-daemon` / `uninstall-daemon` on macOS โ€” manages a launchd + * agent at ~/Library/LaunchAgents/com.golem.agent.plist. + * + * Migration: existing local-checkout users have a plist pointing at the + * repo's bin/golem.js. We detect that, refuse to overwrite without --force, + * and tell the user exactly what'll change. + */ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { getDataDir } from "../../utils/paths.js"; +import { inferPlistOrigin, renderLaunchdPlist } from "./unit-renderers.js"; + +const LABEL = "com.golem.agent"; + +function plistPath(): string { + return path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`); +} + +function gui(): string { + return `gui/${os.userInfo().uid}`; +} + +function launchctl(...args: string[]): { ok: boolean; output: string } { + const r = spawnSync("launchctl", args, { encoding: "utf-8" }); + return { ok: r.status === 0, output: (r.stdout || "") + (r.stderr || "") }; +} + +interface InstallOptions { + dryRun: boolean; + force: boolean; + golemBinary: string; +} + +export async function install(opts: InstallOptions): Promise { + const target = plistPath(); + const dataDir = getDataDir(); + const plist = renderLaunchdPlist({ + golemBinary: opts.golemBinary, + homeDir: os.homedir(), + dataDir, + }); + + if (fs.existsSync(target) && !opts.force) { + const existing = fs.readFileSync(target, "utf-8"); + const origin = inferPlistOrigin(existing, opts.golemBinary); + if (origin === "matches" && existing === plist) { + console.log(`Plist already installed and up to date at ${target}.`); + } else if (origin === "repo-local") { + console.error(`Existing ${target} points at a local repo checkout.`); + console.error("Migrating an existing dev install is destructive โ€” review and confirm:"); + console.error(""); + console.error(` 1. Stop the old daemon: launchctl bootout ${gui()}/${LABEL}`); + console.error(" 2. Move your data: mv data/* " + dataDir + "/ (run from your repo)"); + console.error(" 3. Re-run with --force: golem install-daemon --force"); + console.error(""); + console.error("Your data directory will be: " + dataDir); + return 1; + } else { + console.error(`A different ${LABEL}.plist exists at ${target}.`); + console.error("Re-run with --force to overwrite, or remove it manually first."); + return 1; + } + } else { + if (opts.dryRun) { + console.log("--- would write to", target, "---"); + console.log(plist); + return 0; + } + fs.mkdirSync(path.dirname(target), { recursive: true }); + // If --force and the agent is running, bootout first so the rewrite is clean. + if (opts.force && fs.existsSync(target)) { + launchctl("bootout", `${gui()}/${LABEL}`); // tolerate failure + } + fs.writeFileSync(target, plist); + console.log(`Wrote ${target}`); + } + + if (opts.dryRun) { + console.log(`--- would run: launchctl bootstrap ${gui()} ${target} && kickstart -k ${gui()}/${LABEL} ---`); + return 0; + } + + // bootstrap can fail if it's already loaded โ€” that's fine; kickstart -k handles restart. + const bootstrap = launchctl("bootstrap", gui(), target); + if (!bootstrap.ok && !/already loaded|service exists/i.test(bootstrap.output)) { + console.error(bootstrap.output); + return 1; + } + const kick = launchctl("kickstart", "-k", `${gui()}/${LABEL}`); + if (!kick.ok) { + console.error(kick.output); + return 1; + } + + console.log(""); + console.log("Golem is installed and running."); + console.log(` Status: launchctl print ${gui()}/${LABEL} | head`); + console.log(` Logs: tail -F ~/Library/Logs/${LABEL}.log`); + console.log(""); + return 0; +} + +interface UninstallOptions { + dryRun: boolean; +} + +export async function uninstall(opts: UninstallOptions): Promise { + const target = plistPath(); + if (!fs.existsSync(target)) { + console.log(`No plist at ${target} โ€” nothing to uninstall.`); + return 0; + } + + if (opts.dryRun) { + console.log(`--- would run: launchctl bootout ${gui()}/${LABEL} && rm ${target} ---`); + return 0; + } + + console.log("Stopping and unloading com.golem.agent..."); + launchctl("bootout", `${gui()}/${LABEL}`); // tolerate failure: may already be down + fs.unlinkSync(target); + console.log(`Removed ${target}.`); + return 0; +} diff --git a/src/cli/install-daemon/unit-renderers.test.ts b/src/cli/install-daemon/unit-renderers.test.ts new file mode 100644 index 0000000..8e6a2f3 --- /dev/null +++ b/src/cli/install-daemon/unit-renderers.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; + +import { inferPlistOrigin, renderLaunchdPlist, renderSystemdUnit } from "./unit-renderers.js"; + +const CTX = { + golemBinary: "/Users/aviv/.bun/install/global/node_modules/golem-agent/bin/golem.js", + homeDir: "/Users/aviv", + dataDir: "/Users/aviv/.local/share/golem", +}; + +describe("renderSystemdUnit", () => { + test("references the supplied binary and data dir", () => { + const out = renderSystemdUnit(CTX); + expect(out).toContain(`ExecStart=${CTX.golemBinary} start`); + expect(out).toContain(`WorkingDirectory=${CTX.dataDir}`); + expect(out).toContain(`Environment=GOLEM_DATA_DIR=${CTX.dataDir}`); + }); + test("uses Restart=on-failure (not always) so `golem stop` works", () => { + expect(renderSystemdUnit(CTX)).toContain("Restart=on-failure"); + }); + test("installs as a default.target dep so it starts at user login", () => { + expect(renderSystemdUnit(CTX)).toContain("WantedBy=default.target"); + }); +}); + +describe("renderLaunchdPlist", () => { + test("uses absolute paths everywhere โ€” launchd doesn't expand ~", () => { + const out = renderLaunchdPlist(CTX); + expect(out).toContain(`${CTX.golemBinary}`); + expect(out).toContain(`${CTX.dataDir}`); + expect(out).not.toContain("~/Library"); + }); + test("includes KeepAlive and RunAtLoad", () => { + const out = renderLaunchdPlist(CTX); + expect(out).toContain("KeepAlive\n "); + expect(out).toContain("RunAtLoad\n "); + }); + test("logs go to ~/Library/Logs", () => { + const out = renderLaunchdPlist(CTX); + expect(out).toContain(`${CTX.homeDir}/Library/Logs/com.golem.agent.log`); + }); +}); + +describe("inferPlistOrigin", () => { + test("recognizes a plist that matches the new binary path", () => { + expect(inferPlistOrigin(renderLaunchdPlist(CTX), CTX.golemBinary)).toBe("matches"); + }); + test("recognizes the old repo-local plist (bin/golem.js)", () => { + const oldPlist = ` + ProgramArguments + + /usr/bin/env + node + /Users/aviv/src/agents/Personal_Agent/bin/golem.js + + `; + expect(inferPlistOrigin(oldPlist, CTX.golemBinary)).toBe("repo-local"); + }); + test("returns unknown for arbitrary other plists", () => { + expect(inferPlistOrigin("", CTX.golemBinary)).toBe("unknown"); + }); +}); diff --git a/src/cli/install-daemon/unit-renderers.ts b/src/cli/install-daemon/unit-renderers.ts new file mode 100644 index 0000000..c62bff5 --- /dev/null +++ b/src/cli/install-daemon/unit-renderers.ts @@ -0,0 +1,106 @@ +/** + * Pure renderers for systemd unit and launchd plist contents. + * + * Split out so the rendering is unit-testable without invoking systemctl / + * launchctl. The write + activate logic lives in linux.ts / macos.ts. + */ + +export interface UnitContext { + /** Absolute path to the `golem` executable that the unit should launch. */ + golemBinary: string; + /** Absolute path to the user's home directory (paths inside the unit must be absolute). */ + homeDir: string; + /** Absolute path to the data directory the daemon should treat as its working dir. */ + dataDir: string; +} + +/** + * systemd user unit. Lives at ~/.config/systemd/user/golem.service. + * + * Type=simple because we want systemd to track our PID directly (the start + * subcommand stays in the foreground). Restart=on-failure (not 'always') so + * an intentional `golem stop` doesn't loop forever. WantedBy=default.target + * means it auto-starts at user login (and after lingering is enabled, at boot). + */ +export function renderSystemdUnit(ctx: UnitContext): string { + return [ + "[Unit]", + "Description=Golem multi-agent platform", + "After=network-online.target", + "Wants=network-online.target", + "", + "[Service]", + "Type=simple", + `ExecStart=${ctx.golemBinary} start`, + `WorkingDirectory=${ctx.dataDir}`, + `Environment=GOLEM_DATA_DIR=${ctx.dataDir}`, + "Environment=NODE_ENV=production", + "Restart=on-failure", + "RestartSec=5", + "StandardOutput=journal", + "StandardError=journal", + "", + "[Install]", + "WantedBy=default.target", + "", + ].join("\n"); +} + +/** + * launchd plist. Lives at ~/Library/LaunchAgents/com.golem.agent.plist. + * + * KeepAlive=true matches the existing repo's convention so the daemon is + * always running while the user is logged in. WorkingDirectory must be an + * absolute path โ€” launchd doesn't expand ~. + */ +export function renderLaunchdPlist(ctx: UnitContext): string { + const logDir = `${ctx.homeDir}/Library/Logs`; + return ` + + + + Label + com.golem.agent + ProgramArguments + + ${ctx.golemBinary} + start + + KeepAlive + + RunAtLoad + + WorkingDirectory + ${ctx.dataDir} + EnvironmentVariables + + GOLEM_DATA_DIR + ${ctx.dataDir} + NODE_ENV + production + + StandardOutPath + ${logDir}/com.golem.agent.log + StandardErrorPath + ${logDir}/com.golem.agent.error.log + + +`; +} + +/** + * Inspect an existing plist's to detect whether it points + * at the new installed binary or at the old repo-local `bin/golem.js`. The + * regex is intentionally loose โ€” we just want to recognize "uses an absolute + * path under a checkout that isn't the global install." + */ +export function inferPlistOrigin( + plistContent: string, + expectedBinary: string, +): "matches" | "repo-local" | "unknown" { + const args = plistContent.match(/ProgramArguments<\/key>\s*([\s\S]*?)<\/array>/); + if (!args) return "unknown"; + if (args[1].includes(expectedBinary)) return "matches"; + if (/[^<]*\/bin\/golem\.js<\/string>/.test(args[1])) return "repo-local"; + return "unknown"; +} diff --git a/src/cli/logs.ts b/src/cli/logs.ts new file mode 100644 index 0000000..dc12da9 --- /dev/null +++ b/src/cli/logs.ts @@ -0,0 +1,111 @@ +/** + * `golem logs` โ€” tail the daemon's logs. + * + * Detection order: + * 1. Linux with a `--user` systemd unit named `golem` โ†’ journalctl -f + * 2. macOS with the launchd plist `com.golem.agent` โ†’ tail the configured log + * 3. Fallback: tail the most recent file under /logs/ + * + * Supports `-f`/`--follow` (default on) and `-n ` for line count. + */ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { dataPath } from "../utils/paths.js"; + +interface LogOptions { + follow: boolean; + lines: number; +} + +function parseArgs(args: string[]): LogOptions { + let follow = true; + let lines = 100; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "--no-follow") follow = false; + else if (a === "-f" || a === "--follow") follow = true; + else if (a === "-n" || a === "--lines") { + const n = Number(args[++i]); + if (Number.isFinite(n) && n > 0) lines = n; + } + } + return { follow, lines }; +} + +function hasSystemdUnit(): boolean { + if (process.platform !== "linux") return false; + const unitPath = path.join(os.homedir(), ".config", "systemd", "user", "golem.service"); + return fs.existsSync(unitPath); +} + +function hasLaunchdPlist(): string | null { + if (process.platform !== "darwin") return null; + const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", "com.golem.agent.plist"); + return fs.existsSync(plistPath) ? plistPath : null; +} + +function findMostRecentLogFile(): string | null { + const logsDir = dataPath("logs"); + try { + const entries = fs + .readdirSync(logsDir, { withFileTypes: true }) + .filter((e) => e.isFile() && e.name.endsWith(".log")) + .map((e) => path.join(logsDir, e.name)) + .map((p) => ({ p, mtime: fs.statSync(p).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime); + return entries[0]?.p ?? null; + } catch { + return null; + } +} + +function tailJournald(opts: LogOptions): number { + const args = ["--user", "-u", "golem", "-n", String(opts.lines)]; + if (opts.follow) args.push("-f"); + const child = spawn("journalctl", args, { stdio: "inherit" }); + child.on("exit", (code) => process.exit(code ?? 0)); + return -1; // never returns to caller +} + +function tailFile(file: string, opts: LogOptions): number { + const args = ["-n", String(opts.lines)]; + if (opts.follow) args.push("-F"); // -F follows by name across rotations + args.push(file); + const child = spawn("tail", args, { stdio: "inherit" }); + child.on("exit", (code) => process.exit(code ?? 0)); + return -1; +} + +export async function run(args: string[]): Promise { + const opts = parseArgs(args); + + if (hasSystemdUnit()) { + console.error("# tailing systemd user journal for golem.service"); + return tailJournald(opts); + } + + const plist = hasLaunchdPlist(); + if (plist) { + // The plist points at ~/Library/Logs/com.golem.agent.log per CLAUDE.md; + // not parsed dynamically โ€” fall back to the conventional location. + const macLog = path.join(os.homedir(), "Library", "Logs", "com.golem.agent.log"); + if (fs.existsSync(macLog)) { + console.error(`# tailing ${macLog}`); + return tailFile(macLog, opts); + } + } + + const recent = findMostRecentLogFile(); + if (recent) { + console.error(`# tailing ${recent}`); + return tailFile(recent, opts); + } + + console.error("No logs found."); + console.error(`Checked: systemd user unit, macOS launchd plist, ${dataPath("logs")}/*.log`); + console.error("If the daemon is running, redirect stdout/stderr or install the daemon with `golem install-daemon`."); + return 1; +} diff --git a/src/cli/pid.test.ts b/src/cli/pid.test.ts new file mode 100644 index 0000000..ce08204 --- /dev/null +++ b/src/cli/pid.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { _resetPathCacheForTests } from "../utils/paths.js"; +import { + getRunningDaemon, + isProcessAlive, + pidFilePath, + readPidFile, + removePidFile, + writePidFile, +} from "./pid.js"; + +let tmp: string; +let savedEnv: string | undefined; + +beforeEach(() => { + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "golem-pid-"))); + savedEnv = process.env.GOLEM_DATA_DIR; + process.env.GOLEM_DATA_DIR = tmp; + _resetPathCacheForTests(); +}); + +afterEach(() => { + if (savedEnv === undefined) delete process.env.GOLEM_DATA_DIR; + else process.env.GOLEM_DATA_DIR = savedEnv; + _resetPathCacheForTests(); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe("pid file round-trip", () => { + test("write then read returns the same record", () => { + writePidFile({ pid: 12345, startedAt: 1700000000000 }); + const r = readPidFile(); + expect(r).toEqual({ pid: 12345, startedAt: 1700000000000 }); + }); + + test("read returns null when file does not exist", () => { + expect(readPidFile()).toBeNull(); + }); + + test("read returns null on corrupted contents", () => { + fs.writeFileSync(pidFilePath(), "not-a-pid\n"); + expect(readPidFile()).toBeNull(); + }); + + test("remove is idempotent", () => { + expect(() => removePidFile()).not.toThrow(); + writePidFile({ pid: 1, startedAt: 0 }); + removePidFile(); + expect(readPidFile()).toBeNull(); + expect(() => removePidFile()).not.toThrow(); + }); + + test("pidFilePath lives under the data dir", () => { + expect(pidFilePath()).toBe(path.join(tmp, "golem.pid")); + }); +}); + +describe("isProcessAlive", () => { + test("returns true for our own pid", () => { + expect(isProcessAlive(process.pid)).toBe(true); + }); + + test("returns false for a clearly-dead pid", () => { + // pid 1 is init/launchd; instead use a very large pid that can't exist. + expect(isProcessAlive(2 ** 22)).toBe(false); + }); +}); + +describe("getRunningDaemon", () => { + test("returns the record when the process is alive", () => { + writePidFile({ pid: process.pid, startedAt: Date.now() }); + const r = getRunningDaemon(); + expect(r?.pid).toBe(process.pid); + }); + + test("removes a stale pid file and returns null", () => { + writePidFile({ pid: 2 ** 22, startedAt: 0 }); + expect(getRunningDaemon()).toBeNull(); + expect(fs.existsSync(pidFilePath())).toBe(false); + }); + + test("returns null when no pid file exists", () => { + expect(getRunningDaemon()).toBeNull(); + }); +}); diff --git a/src/cli/pid.ts b/src/cli/pid.ts new file mode 100644 index 0000000..2636f8b --- /dev/null +++ b/src/cli/pid.ts @@ -0,0 +1,73 @@ +/** + * PID file management for the Golem daemon. + * + * Stored at /golem.pid. Written on platform start, removed on + * graceful shutdown. status/stop subcommands read it to find the running + * daemon. + * + * The file holds two whitespace-separated fields: . + * That lets `status` report uptime without an extra syscall. + */ +import fs from "node:fs"; + +import { dataPath } from "../utils/paths.js"; + +export interface PidRecord { + pid: number; + startedAt: number; +} + +export function pidFilePath(): string { + return dataPath("golem.pid"); +} + +export function writePidFile(record: PidRecord = { pid: process.pid, startedAt: Date.now() }): void { + fs.writeFileSync(pidFilePath(), `${record.pid} ${record.startedAt}\n`); +} + +export function removePidFile(): void { + try { + fs.unlinkSync(pidFilePath()); + } catch { + // already gone โ€” fine + } +} + +export function readPidFile(): PidRecord | null { + try { + const raw = fs.readFileSync(pidFilePath(), "utf-8").trim(); + const [pidStr, startedAtStr] = raw.split(/\s+/); + const pid = Number(pidStr); + const startedAt = Number(startedAtStr); + if (!Number.isFinite(pid) || pid <= 0) return null; + return { pid, startedAt: Number.isFinite(startedAt) ? startedAt : 0 }; + } catch { + return null; + } +} + +/** Returns true if a process with the given pid is currently alive on this system. */ +export function isProcessAlive(pid: number): boolean { + try { + // signal 0 = existence check; throws ESRCH if dead, EPERM if alive but unowned. + process.kill(pid, 0); + return true; + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + return code === "EPERM"; // EPERM means it exists; we just can't signal it + } +} + +/** + * Read the pid file and return the record only if the recorded process is + * still alive. Removes stale pid files as a side effect. + */ +export function getRunningDaemon(): PidRecord | null { + const record = readPidFile(); + if (!record) return null; + if (!isProcessAlive(record.pid)) { + removePidFile(); + return null; + } + return record; +} diff --git a/src/cli/start.ts b/src/cli/start.ts new file mode 100644 index 0000000..9490dbc --- /dev/null +++ b/src/cli/start.ts @@ -0,0 +1,65 @@ +/** + * `golem start` โ€” start the platform in the foreground. + * + * Refuses to start a second instance if a live PID file is present. The + * platform writes its PID on first successful start, removes it on graceful + * shutdown, and runs until SIGINT/SIGTERM. + */ +import { detectSshSession, printFirstRunBanner } from "./first-run-banner.js"; +import { getRunningDaemon, removePidFile, writePidFile } from "./pid.js"; +import { startUi, type UiHandle } from "./ui-server.js"; + +export async function run(_args: string[]): Promise { + const running = getRunningDaemon(); + if (running) { + console.error( + `golem is already running (pid ${running.pid}, started ${new Date(running.startedAt).toISOString()}).`, + ); + console.error("Stop it first with `golem stop`, or remove the pid file if you're sure it's dead."); + return 1; + } + + printFirstRunBanner({ + hasApiKey: Boolean(process.env.OPENROUTER_API_KEY), + ssh: detectSshSession(), + }); + + const { startPlatform } = await import("../platform/platform.js"); + + let shuttingDown = false; + let uiHandle: UiHandle | null = null; + const shutdown = (signal: NodeJS.Signals): void => { + if (shuttingDown) return; + shuttingDown = true; + console.log(`[golem] received ${signal}, shutting down...`); + uiHandle?.stop(); + removePidFile(); + // The platform registers its own graceful-shutdown handlers; we just need to + // make sure the pid file is gone. Re-raise so Node exits naturally. + process.kill(process.pid, signal); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + process.on("exit", () => { + uiHandle?.stop(); + removePidFile(); + }); + + try { + await startPlatform(); + } catch (err) { + console.error(err); + removePidFile(); + return 1; + } + + // Spawn the Next.js UI after the platform is up so its rewrite proxy (/api/* -> :3847) + // has something to proxy to. If the standalone bundle isn't present (dev clone + // without a build) startUi warns and returns null โ€” the platform still runs. + uiHandle = startUi(); + + writePidFile(); + // Keep the process alive โ€” transports and scheduler run on intervals/callbacks. + await new Promise(() => {}); + return 0; +} diff --git a/src/cli/status.ts b/src/cli/status.ts new file mode 100644 index 0000000..4b53d41 --- /dev/null +++ b/src/cli/status.ts @@ -0,0 +1,28 @@ +import { describeDataDirResolution } from "../utils/paths.js"; +import { getRunningDaemon } from "./pid.js"; + +function formatUptime(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ${s % 60}s`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ${m % 60}m`; + const d = Math.floor(h / 24); + return `${d}d ${h % 24}h`; +} + +export async function run(_args: string[]): Promise { + const dataDir = describeDataDirResolution(); + console.log(`data dir: ${dataDir.path} (${dataDir.source})`); + + const record = getRunningDaemon(); + if (!record) { + console.log("status: not running"); + return 3; // LSB convention: 3 = program not running + } + const uptime = record.startedAt > 0 ? formatUptime(Date.now() - record.startedAt) : "unknown"; + console.log(`status: running (pid ${record.pid}, up ${uptime})`); + return 0; +} diff --git a/src/cli/stop.ts b/src/cli/stop.ts new file mode 100644 index 0000000..e30ce4a --- /dev/null +++ b/src/cli/stop.ts @@ -0,0 +1,48 @@ +/** + * `golem stop` โ€” signal the running daemon to shut down gracefully. + * + * Sends SIGTERM, polls the pid for up to ~10s, escalates to SIGKILL on timeout. + */ +import { getRunningDaemon, isProcessAlive, removePidFile } from "./pid.js"; + +const GRACE_MS = 10_000; +const POLL_MS = 200; + +async function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +export async function run(_args: string[]): Promise { + const record = getRunningDaemon(); + if (!record) { + console.log("golem is not running."); + return 0; + } + + console.log(`Stopping golem (pid ${record.pid})...`); + try { + process.kill(record.pid, "SIGTERM"); + } catch (err) { + console.error(`Failed to signal pid ${record.pid}:`, (err as Error).message); + return 1; + } + + const deadline = Date.now() + GRACE_MS; + while (Date.now() < deadline) { + if (!isProcessAlive(record.pid)) { + removePidFile(); + console.log("Stopped."); + return 0; + } + await sleep(POLL_MS); + } + + console.warn(`pid ${record.pid} did not exit within ${GRACE_MS / 1000}s โ€” sending SIGKILL.`); + try { + process.kill(record.pid, "SIGKILL"); + } catch { + // race: process exited between checks + } + removePidFile(); + return 0; +} diff --git a/src/cli/ui-server.ts b/src/cli/ui-server.ts new file mode 100644 index 0000000..0742a6d --- /dev/null +++ b/src/cli/ui-server.ts @@ -0,0 +1,73 @@ +/** + * Spawn the bundled Next.js UI as a child process during `golem start`. + * + * The UI is built with `output: "standalone"` so the bundle is self-contained + * (its own node_modules, no workspace resolution). The server.js entrypoint + * lives at /ui/.next/standalone/ui/server.js โ€” the extra `/ui/` + * segment is preserved by outputFileTracingRoot pointing at the workspace. + * + * In a dev checkout the standalone bundle doesn't exist (unless someone ran + * `npm run build` from the root). We detect that and skip the spawn with a + * clear warning rather than crashing. + */ +import { spawn, type ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const UI_PORT = 3015; + +/** + * Locate the standalone server.js. Walks up from this module to find the + * package root, then checks the canonical standalone location. Returns null + * when the bundle isn't present (e.g. dev clone without a UI build). + */ +function findStandaloneServer(): string | null { + const here = path.dirname(fileURLToPath(import.meta.url)); + // src/cli/ or dist/cli/ -> ../.. -> package root + const packageRoot = path.resolve(here, "..", ".."); + const candidate = path.join(packageRoot, "ui", ".next", "standalone", "ui", "server.js"); + return fs.existsSync(candidate) ? candidate : null; +} + +export interface UiHandle { + child: ChildProcess; + stop(): void; +} + +export function startUi(): UiHandle | null { + const serverPath = findStandaloneServer(); + if (!serverPath) { + console.warn("[ui] standalone bundle not found โ€” UI will not be served."); + console.warn("[ui] If you cloned the repo, run `npm install && npm run build`."); + console.warn("[ui] If you installed via npm, this is a packaging bug โ€” please report."); + return null; + } + + console.log(`[ui] starting Next.js on 127.0.0.1:${UI_PORT}`); + const child = spawn(process.execPath, [serverPath], { + stdio: ["ignore", "inherit", "inherit"], + env: { + ...process.env, + // Loopback-only โ€” never expose the UI publicly. SSH tunnel / Tailscale is the auth. + HOSTNAME: "127.0.0.1", + PORT: String(UI_PORT), + }, + }); + + child.on("exit", (code, signal) => { + // Only complain if we didn't intend to stop it. shuttingDown sets killed=true. + if (!child.killed) { + console.error(`[ui] exited unexpectedly (code=${code} signal=${signal})`); + } + }); + + return { + child, + stop() { + if (!child.killed && child.pid) { + child.kill("SIGTERM"); + } + }, + }; +} diff --git a/src/cli/update.ts b/src/cli/update.ts new file mode 100644 index 0000000..2bf9e39 --- /dev/null +++ b/src/cli/update.ts @@ -0,0 +1,18 @@ +/** + * `golem update` โ€” pull the latest published version. + * + * Stub: full implementation depends on the npm publishing pipeline (bd vx9). + * Until the package is published as `golem-agent` on npm, there's nothing to + * update to. + */ +export async function run(_args: string[]): Promise { + console.log("`golem update` is not yet implemented."); + console.log(""); + console.log("Once Golem is published to npm, this will run:"); + console.log(" npm install -g golem-agent@latest"); + console.log(" systemctl --user restart golem # (Linux)"); + console.log(" launchctl kickstart -k gui/$(id -u)/com.golem.agent # (macOS)"); + console.log(""); + console.log("For now, run those commands manually after `git pull` in your dev checkout."); + return 0; +} diff --git a/src/cli/version.test.ts b/src/cli/version.test.ts new file mode 100644 index 0000000..eeac7f3 --- /dev/null +++ b/src/cli/version.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "bun:test"; + +import { readPackageVersion } from "./version.js"; + +describe("readPackageVersion", () => { + test("returns a non-empty version string", () => { + const v = readPackageVersion(); + expect(v).toMatch(/^\d+\.\d+\.\d+/); + }); +}); diff --git a/src/cli/version.ts b/src/cli/version.ts new file mode 100644 index 0000000..d5e3d17 --- /dev/null +++ b/src/cli/version.ts @@ -0,0 +1,27 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export function readPackageVersion(): string { + // Walk up from src/cli/ to find package.json. Works in both dev (src/cli/version.ts) + // and installed (dist/cli/version.js inside node_modules/golem-agent/). + let dir = __dirname; + for (let i = 0; i < 5; i++) { + const candidate = path.join(dir, "package.json"); + if (fs.existsSync(candidate)) { + const pkg = JSON.parse(fs.readFileSync(candidate, "utf-8")) as { name?: string; version?: string }; + if (pkg.name && (pkg.name === "golem-agent" || pkg.name.endsWith("/golem-agent"))) { + return pkg.version ?? "0.0.0"; + } + } + dir = path.dirname(dir); + } + return "unknown"; +} + +export async function run(_args: string[]): Promise { + console.log(readPackageVersion()); + return 0; +} diff --git a/src/platform/platform.ts b/src/platform/platform.ts index e5963ea..d18ae87 100644 --- a/src/platform/platform.ts +++ b/src/platform/platform.ts @@ -25,7 +25,7 @@ import type { Memory } from "@mastra/memory"; import { LibSQLStore } from "@mastra/libsql"; import { CronExpressionParser } from "cron-parser"; -import { dataPath } from "../utils/paths.js"; +import { dataPath, describeDataDirResolution } from "../utils/paths.js"; import { initMCPClient, getMCPTools, disconnectMCP } from "../agent/mcp-client.js"; import { getModelForId } from "../agent/model.js"; import { allTools } from "../agent/tools/index.js"; @@ -969,7 +969,8 @@ function registerAgentTransport( export async function startPlatform(): Promise { console.log("[platform] starting multi-agent platform..."); - logger.info("platform starting"); + const dataDir = describeDataDirResolution(); + logger.info("platform starting", { dataDir: dataDir.path, dataDirSource: dataDir.source }); const startedAt = Date.now(); // 1. Shared memory storage diff --git a/src/server.ts b/src/server.ts index aad8688..e0278d9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -209,6 +209,18 @@ export function startServer(deps: ServerDeps) { return json(res, { status: "ok", uptime: Math.floor((Date.now() - startedAt) / 1000) }); } + // โ”€โ”€ Telegram token validation (wizard + tooling) โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (req.method === "POST" && pathname === "/api/telegram/verify-token") { + try { + const body = JSON.parse(await readBody(req)) as { token?: string }; + const { validateTelegramToken } = await import("./utils/telegram-validate.js"); + const result = await validateTelegramToken(body.token ?? ""); + return json(res, result, result.ok ? 200 : 400); + } catch (err) { + return json(res, { ok: false, error: (err as Error).message }, 500); + } + } + // โ”€โ”€ Setup / Onboarding โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if (req.method === "GET" && pathname === "/api/setup/status") { const hasApiKey = !!process.env.OPENROUTER_API_KEY; diff --git a/src/utils/openrouter-validate.test.ts b/src/utils/openrouter-validate.test.ts new file mode 100644 index 0000000..ba6683c --- /dev/null +++ b/src/utils/openrouter-validate.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; + +import { validateOpenRouterKey } from "./openrouter-validate.js"; + +function mockFetch(response: Partial & { json?: () => Promise }): typeof fetch { + return ((async () => ({ + ok: response.ok ?? true, + status: response.status ?? 200, + json: response.json ?? (async () => ({})), + } as Response))) as unknown as typeof fetch; +} + +describe("validateOpenRouterKey", () => { + test("empty key rejected without a network call", async () => { + const r = await validateOpenRouterKey(""); + expect(r.ok).toBe(false); + }); + + test("happy path returns ok with optional limit_remaining", async () => { + const fetchImpl = mockFetch({ + ok: true, + status: 200, + json: async () => ({ data: { limit_remaining: 9.95, usage: 0.05 } }), + }); + const r = await validateOpenRouterKey("sk-or-v1-xxx", { fetchImpl }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.limitRemaining).toBeCloseTo(9.95); + }); + + test("401 maps to clear error", async () => { + const fetchImpl = mockFetch({ ok: false, status: 401 }); + const r = await validateOpenRouterKey("sk-or-bad", { fetchImpl }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toContain("401"); + }); + + test("500 returns generic HTTP error", async () => { + const fetchImpl = mockFetch({ ok: false, status: 503 }); + const r = await validateOpenRouterKey("sk-or-x", { fetchImpl }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toContain("503"); + }); +}); diff --git a/src/utils/openrouter-validate.ts b/src/utils/openrouter-validate.ts new file mode 100644 index 0000000..cb991c9 --- /dev/null +++ b/src/utils/openrouter-validate.ts @@ -0,0 +1,60 @@ +/** + * Validate an OpenRouter API key by calling /api/v1/key. + * + * Returns 200 with usage/limit info when valid, 401 when not. Consumed by + * `golem doctor` and (potentially) the onboarding wizard. + */ + +export interface OpenRouterValidationOk { + ok: true; + /** Optional remaining credit, when OpenRouter returns it. */ + limitRemaining?: number; +} + +export interface OpenRouterValidationErr { + ok: false; + error: string; +} + +export type OpenRouterValidationResult = OpenRouterValidationOk | OpenRouterValidationErr; + +interface Options { + timeoutMs?: number; + fetchImpl?: typeof fetch; +} + +interface KeyResponse { + data?: { limit_remaining?: number; usage?: number }; +} + +const DEFAULT_TIMEOUT_MS = 5_000; + +export async function validateOpenRouterKey( + key: string, + opts: Options = {}, +): Promise { + const trimmed = key?.trim(); + if (!trimmed) return { ok: false, error: "key is empty" }; + + const fetchImpl = opts.fetchImpl ?? fetch; + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); + + try { + const resp = await fetchImpl("https://openrouter.ai/api/v1/key", { + method: "GET", + headers: { Authorization: `Bearer ${trimmed}` }, + signal: ctrl.signal, + }); + if (resp.status === 401) return { ok: false, error: "key rejected by OpenRouter (401)" }; + if (!resp.ok) return { ok: false, error: `OpenRouter returned HTTP ${resp.status}` }; + + const body = (await resp.json()) as KeyResponse; + return { ok: true, limitRemaining: body.data?.limit_remaining }; + } catch (err: unknown) { + if ((err as Error).name === "AbortError") return { ok: false, error: "request timed out" }; + return { ok: false, error: (err as Error).message }; + } finally { + clearTimeout(t); + } +} diff --git a/src/utils/paths.test.ts b/src/utils/paths.test.ts new file mode 100644 index 0000000..22098af --- /dev/null +++ b/src/utils/paths.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + _resetPathCacheForTests, + dataPath, + describeDataDirResolution, + getDataDir, + getSkillsDir, +} from "./paths.js"; + +// Each test gets a fresh tmpdir and a clean env snapshot. paths.ts memoizes +// getDataDir() per-process, so we also reset its cache. +const ENV_KEYS = ["GOLEM_DATA_DIR", "XDG_DATA_HOME", "HOME", "APPDATA"]; + +let savedEnv: Record; +let tmpRoot: string; +let originalCwd: string; + +beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + // realpath: on macOS, mkdtemp returns /var/... but path.resolve canonicalizes + // to /private/var/..., which breaks toBe(...) comparisons. + tmpRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "golem-paths-"))); + originalCwd = process.cwd(); + process.chdir(tmpRoot); + for (const k of ENV_KEYS) delete process.env[k]; + _resetPathCacheForTests(); +}); + +afterEach(() => { + process.chdir(originalCwd); + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + _resetPathCacheForTests(); + fs.rmSync(tmpRoot, { recursive: true, force: true }); +}); + +describe("getDataDir resolution", () => { + test("GOLEM_DATA_DIR overrides everything", () => { + const explicit = path.join(tmpRoot, "explicit"); + process.env.GOLEM_DATA_DIR = explicit; + // even if ./data/ exists in cwd, env var wins + fs.mkdirSync(path.join(tmpRoot, "data")); + + expect(getDataDir()).toBe(explicit); + expect(describeDataDirResolution().source).toBe("env"); + expect(fs.existsSync(explicit)).toBe(true); + }); + + test("falls back to ./data/ in cwd when it exists (dev workflow preserved)", () => { + const cwdData = path.join(tmpRoot, "data"); + fs.mkdirSync(cwdData); + + expect(getDataDir()).toBe(cwdData); + expect(describeDataDirResolution().source).toBe("cwd"); + }); + + test("falls back to OS-default when neither env nor ./data/ is present", () => { + process.env.HOME = tmpRoot; + delete process.env.XDG_DATA_HOME; + + const dir = getDataDir(); + expect(describeDataDirResolution().source).toBe("default"); + if (process.platform === "darwin") { + expect(dir).toBe(path.join(tmpRoot, "Library", "Application Support", "golem")); + } else if (process.platform === "win32") { + // win32 uses APPDATA; without it, falls back under HOME + expect(dir.endsWith(path.join("golem"))).toBe(true); + } else { + expect(dir).toBe(path.join(tmpRoot, ".local", "share", "golem")); + } + expect(fs.existsSync(dir)).toBe(true); + }); + + test("Linux honors XDG_DATA_HOME when set", () => { + if (process.platform === "darwin" || process.platform === "win32") return; + process.env.HOME = tmpRoot; + const xdg = path.join(tmpRoot, "custom-xdg"); + process.env.XDG_DATA_HOME = xdg; + + expect(getDataDir()).toBe(path.join(xdg, "golem")); + }); + + test("creates the data directory if missing", () => { + const target = path.join(tmpRoot, "new", "nested", "dir"); + process.env.GOLEM_DATA_DIR = target; + + getDataDir(); + + expect(fs.statSync(target).isDirectory()).toBe(true); + }); + + test("chmod 0700 on the data dir (best effort)", () => { + if (process.platform === "win32") return; // chmod is meaningless on Windows + const target = path.join(tmpRoot, "secured"); + process.env.GOLEM_DATA_DIR = target; + + getDataDir(); + + const mode = fs.statSync(target).mode & 0o777; + expect(mode).toBe(0o700); + }); + + test("memoizes โ€” repeated calls return the same path", () => { + process.env.GOLEM_DATA_DIR = path.join(tmpRoot, "first"); + const a = getDataDir(); + process.env.GOLEM_DATA_DIR = path.join(tmpRoot, "second"); // changing env after first call has no effect + const b = getDataDir(); + + expect(a).toBe(b); + }); + + test("ignores a ./data/ file that isn't a directory", () => { + fs.writeFileSync(path.join(tmpRoot, "data"), "not a dir"); + process.env.HOME = tmpRoot; + + expect(describeDataDirResolution().source).toBe("default"); + }); +}); + +describe("dataPath", () => { + test("joins relative to data dir", () => { + const root = path.join(tmpRoot, "d"); + process.env.GOLEM_DATA_DIR = root; + + expect(dataPath("agents.db")).toBe(path.join(root, "agents.db")); + expect(dataPath("logs/agent.log")).toBe(path.join(root, "logs", "agent.log")); + }); +}); + +describe("getSkillsDir", () => { + test("defaults to cwd/skills, honors GOLEM_SKILLS_DIR override", () => { + expect(getSkillsDir()).toBe(path.resolve("skills")); + + _resetPathCacheForTests(); + process.env.GOLEM_SKILLS_DIR = path.join(tmpRoot, "my-skills"); + expect(getSkillsDir()).toBe(path.join(tmpRoot, "my-skills")); + }); +}); diff --git a/src/utils/paths.ts b/src/utils/paths.ts index 67ef454..5c4deec 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -1,23 +1,93 @@ /** * Centralized path resolution for runtime data and skills. * - * Uses lazy resolution so environment variables from .env are available - * (dotenv loads after module imports in ESM). + * Resolution order for the data directory: + * 1. GOLEM_DATA_DIR env var (explicit override โ€” anything goes) + * 2. ./data/ in cwd if it exists as a directory (preserves dev workflow: + * cloning the repo and running `npm start` keeps writing to repo-local data/) + * 3. OS-native default: + * macOS: ~/Library/Application Support/golem + * Linux: $XDG_DATA_HOME/golem (default ~/.local/share/golem) + * Windows: %APPDATA%/golem + * + * The directory is created on first access and chmod'd to 0700 (best-effort; + * silently ignored on filesystems that don't support unix modes). + * + * Lazy resolution so dotenv (loaded after module imports in ESM) wins. */ import fs from "node:fs"; import path from "node:path"; import os from "node:os"; -let _dataDir: string | null = null; +export type DataDirSource = "env" | "cwd" | "default"; + +interface ResolvedDataDir { + path: string; + source: DataDirSource; +} + +let _resolved: ResolvedDataDir | null = null; let _skillsDir: string | null = null; +function homeDir(): string { + // Prefer $HOME so tests and users can override portably. Node's homeDir() + // honors $HOME on POSIX, but Bun's doesn't in all cases. + return process.env.HOME || (process.platform === "win32" ? process.env.USERPROFILE : null) || os.homedir(); +} + +function osDefaultDataDir(): string { + const home = homeDir(); + if (process.platform === "darwin") { + return path.join(home, "Library", "Application Support", "golem"); + } + if (process.platform === "win32") { + const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming"); + return path.join(appData, "golem"); + } + // Linux / other *nix: XDG Base Directory spec + const xdgDataHome = process.env.XDG_DATA_HOME || path.join(home, ".local", "share"); + return path.join(xdgDataHome, "golem"); +} + +function resolveDataDir(): ResolvedDataDir { + if (process.env.GOLEM_DATA_DIR) { + return { path: path.resolve(process.env.GOLEM_DATA_DIR), source: "env" }; + } + const cwdData = path.resolve("data"); + try { + if (fs.statSync(cwdData).isDirectory()) { + return { path: cwdData, source: "cwd" }; + } + } catch { + // not present โ€” fall through + } + return { path: osDefaultDataDir(), source: "default" }; +} + +function ensureDataDir(): ResolvedDataDir { + if (!_resolved) { + _resolved = resolveDataDir(); + fs.mkdirSync(_resolved.path, { recursive: true }); + try { + fs.chmodSync(_resolved.path, 0o700); + } catch { + // best-effort: ignore on filesystems without unix mode support + } + } + return _resolved; +} + /** Root directory for all runtime data (SQLite DBs, logs, handoffs, approvals) */ export function getDataDir(): string { - if (!_dataDir) { - _dataDir = path.resolve(process.env.GOLEM_DATA_DIR || "data"); - fs.mkdirSync(_dataDir, { recursive: true }); - } - return _dataDir; + return ensureDataDir().path; +} + +/** + * Describe how the data directory was resolved. Useful for the first-run banner + * and `golem doctor`. Calling this triggers resolution if it hasn't happened yet. + */ +export function describeDataDirResolution(): ResolvedDataDir { + return { ...ensureDataDir() }; } /** Root directory for skill definitions */ @@ -40,7 +110,16 @@ export function dataPath(filename: string): string { /** Expand a leading ~ or ~/ in a path to the user's home directory. */ export function expandTilde(p: string): string { - if (p === "~") return os.homedir(); - if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2)); + if (p === "~") return homeDir(); + if (p.startsWith("~/")) return path.join(homeDir(), p.slice(2)); return p; } + +/** + * Test-only: clear the memoized data/skills paths so the next call re-resolves + * from env + cwd. Do not call from production code. + */ +export function _resetPathCacheForTests(): void { + _resolved = null; + _skillsDir = null; +} diff --git a/src/utils/telegram-validate.test.ts b/src/utils/telegram-validate.test.ts new file mode 100644 index 0000000..87c7315 --- /dev/null +++ b/src/utils/telegram-validate.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; + +import { looksLikeTelegramToken, validateTelegramToken } from "./telegram-validate.js"; + +const VALID_SHAPE = "1234567890:AAExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; + +function mockFetch(responses: Array & { json?: () => Promise }>): typeof fetch { + let i = 0; + return ((async () => { + const r = responses[i++]; + return { + ok: r.ok ?? true, + status: r.status ?? 200, + json: r.json ?? (async () => ({})), + } as Response; + }) as unknown) as typeof fetch; +} + +describe("looksLikeTelegramToken", () => { + test("accepts well-formed tokens", () => { + expect(looksLikeTelegramToken(VALID_SHAPE)).toBe(true); + }); + test("rejects obviously malformed input", () => { + expect(looksLikeTelegramToken("nope")).toBe(false); + expect(looksLikeTelegramToken("12345:short")).toBe(false); + expect(looksLikeTelegramToken("")).toBe(false); + }); +}); + +describe("validateTelegramToken", () => { + test("empty token rejected without a network call", async () => { + const fetchImpl = mockFetch([]); + const r = await validateTelegramToken("", { fetchImpl }); + expect(r.ok).toBe(false); + }); + + test("malformed token rejected without a network call", async () => { + const r = await validateTelegramToken("not-a-token", { fetchImpl: mockFetch([]) }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/format/); + }); + + test("happy path returns username + id", async () => { + const fetchImpl = mockFetch([ + { + ok: true, + status: 200, + json: async () => ({ ok: true, result: { id: 42, is_bot: true, username: "test_bot" } }), + }, + ]); + const r = await validateTelegramToken(VALID_SHAPE, { fetchImpl }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.botUsername).toBe("test_bot"); + expect(r.botId).toBe(42); + } + }); + + test("401 maps to clear error", async () => { + const fetchImpl = mockFetch([{ ok: false, status: 401 }]); + const r = await validateTelegramToken(VALID_SHAPE, { fetchImpl }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toContain("401"); + }); + + test("non-bot account rejected", async () => { + const fetchImpl = mockFetch([ + { + ok: true, + status: 200, + json: async () => ({ ok: true, result: { id: 1, is_bot: false, username: "human" } }), + }, + ]); + const r = await validateTelegramToken(VALID_SHAPE, { fetchImpl }); + expect(r.ok).toBe(false); + }); + + test("timeout returns a timed-out error", async () => { + const slowFetch: typeof fetch = ((_url: string, opts?: { signal?: AbortSignal }) => { + return new Promise((_resolve, reject) => { + opts?.signal?.addEventListener("abort", () => { + const err = new Error("aborted"); + err.name = "AbortError"; + reject(err); + }); + }); + }) as unknown as typeof fetch; + const r = await validateTelegramToken(VALID_SHAPE, { fetchImpl: slowFetch, timeoutMs: 10 }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/timed out/); + }); +}); diff --git a/src/utils/telegram-validate.ts b/src/utils/telegram-validate.ts new file mode 100644 index 0000000..e41962b --- /dev/null +++ b/src/utils/telegram-validate.ts @@ -0,0 +1,82 @@ +/** + * Validate a Telegram bot token via the getMe endpoint. + * + * Consumed by both the onboarding wizard (bd v0n.23) and `golem doctor` (bd 49x) + * so the same "is this token usable" rules apply at config time and run time. + * + * Cheap idempotent GET; rate-limited by Telegram per-bot but a single call from + * the wizard or doctor stays comfortably under the limit. + */ + +export interface TelegramValidationOk { + ok: true; + botUsername: string; + botId: number; +} + +export interface TelegramValidationErr { + ok: false; + error: string; +} + +export type TelegramValidationResult = TelegramValidationOk | TelegramValidationErr; + +interface GetMeResponse { + ok: boolean; + result?: { id: number; is_bot: boolean; username?: string; first_name?: string }; + description?: string; +} + +interface Options { + timeoutMs?: number; + fetchImpl?: typeof fetch; +} + +const DEFAULT_TIMEOUT_MS = 5_000; + +/** Quick shape check before we send a network request. Matches `:`. */ +export function looksLikeTelegramToken(token: string): boolean { + return /^\d{6,}:[A-Za-z0-9_-]{20,}$/.test(token); +} + +export async function validateTelegramToken( + token: string, + opts: Options = {}, +): Promise { + const trimmed = token?.trim(); + if (!trimmed) return { ok: false, error: "token is empty" }; + if (!looksLikeTelegramToken(trimmed)) { + return { ok: false, error: "token format invalid (expected :)" }; + } + + const fetchImpl = opts.fetchImpl ?? fetch; + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); + + try { + const resp = await fetchImpl(`https://api.telegram.org/bot${trimmed}/getMe`, { + method: "GET", + signal: ctrl.signal, + }); + if (resp.status === 401) return { ok: false, error: "token rejected by Telegram (401)" }; + if (!resp.ok) return { ok: false, error: `Telegram returned HTTP ${resp.status}` }; + + const body = (await resp.json()) as GetMeResponse; + if (!body.ok || !body.result) { + return { ok: false, error: body.description || "Telegram returned ok=false" }; + } + if (!body.result.is_bot) { + return { ok: false, error: "credentials are not for a bot account" }; + } + return { + ok: true, + botId: body.result.id, + botUsername: body.result.username ?? body.result.first_name ?? "", + }; + } catch (err: unknown) { + if ((err as Error).name === "AbortError") return { ok: false, error: "request timed out" }; + return { ok: false, error: (err as Error).message }; + } finally { + clearTimeout(t); + } +} diff --git a/tsconfig.build.json b/tsconfig.build.json index e707937..8a54412 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,10 @@ { "extends": "./tsconfig.json", - "exclude": ["node_modules", "dist", "data", "src/__tests__"] + "exclude": [ + "node_modules", + "dist", + "data", + "src/**/*.test.ts", + "src/test-harness.ts" + ] } diff --git a/ui/app/onboarding/page.tsx b/ui/app/onboarding/page.tsx index 3159acf..f51ce0d 100644 --- a/ui/app/onboarding/page.tsx +++ b/ui/app/onboarding/page.tsx @@ -31,6 +31,7 @@ import { Wrench, ExternalLink, CheckCircle2, + AlertCircle, } from "lucide-react"; import type { OpenRouterModel } from "@/lib/types"; @@ -502,7 +503,43 @@ function StepTelegram({ onNext: () => void; onBack: () => void; }) { - const isValid = botToken.includes(":") && botToken.length > 30; + const looksValid = botToken.includes(":") && botToken.length > 30; + const [verifyStatus, setVerifyStatus] = useState<"idle" | "checking" | "ok" | "fail">("idle"); + const [verifyMessage, setVerifyMessage] = useState(""); + + // Re-arm verification whenever the token changes + useEffect(() => { + setVerifyStatus("idle"); + setVerifyMessage(""); + }, [botToken]); + + async function handleNext() { + if (!looksValid) return; + setVerifyStatus("checking"); + setVerifyMessage("Talking to Telegram..."); + try { + const resp = await fetch("/api/telegram/verify-token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: botToken }), + }); + const result = (await resp.json()) as + | { ok: true; botUsername: string } + | { ok: false; error: string }; + if (result.ok) { + setVerifyStatus("ok"); + setVerifyMessage(`Connected as @${result.botUsername}`); + // Brief pause so the user sees the confirmation, then advance + setTimeout(onNext, 600); + } else { + setVerifyStatus("fail"); + setVerifyMessage(result.error); + } + } catch (err) { + setVerifyStatus("fail"); + setVerifyMessage((err as Error).message); + } + } return (
@@ -537,6 +574,19 @@ function StepTelegram({ placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v..." className="font-mono" /> + {verifyStatus !== "idle" && ( +

+ {verifyMessage} +

+ )}
@@ -561,8 +611,8 @@ function StepTelegram({ -
@@ -910,21 +960,25 @@ function StepDone({ agentName }: { agentName: string }) { const router = useRouter(); const [restarting, setRestarting] = useState(false); const [ready, setReady] = useState(false); + const [failed, setFailed] = useState(false); async function handleRestart() { setRestarting(true); + setFailed(false); try { await fetch("/api/restart", { method: "POST" }); - // Poll for health + // Poll for health โ€” 30 attempts ร— 2s = 60s budget for (let i = 0; i < 30; i++) { await new Promise((r) => setTimeout(r, 2000)); try { const res = await fetch("/api/health"); - if (res.ok) { setReady(true); return; } + if (res.ok) { setReady(true); setRestarting(false); return; } } catch { /* still down */ } } + // exhausted the poll budget without seeing /api/health come back + setFailed(true); } catch { - toast.error("Restart failed"); + setFailed(true); } setRestarting(false); } @@ -935,7 +989,26 @@ function StepDone({ agentName }: { agentName: string }) { return (
- {!ready ? ( + {failed ? ( + <> +
+ +
+
+

Restart didn't complete in time

+

+ The platform took longer than 60 seconds to come back. It may still be + starting, or something went wrong. Check the daemon logs and try again. +

+

+ golem logs # or: journalctl --user -u golem +

+
+ + + ) : !ready ? ( <>
diff --git a/ui/next.config.ts b/ui/next.config.ts index 037096c..c2c276c 100644 --- a/ui/next.config.ts +++ b/ui/next.config.ts @@ -1,7 +1,22 @@ import type { NextConfig } from "next"; +import path from "node:path"; + +// Workspace root: / โ€” one level up from ui/. Tells Next.js where the +// real package.json + node_modules root lives so Turbopack can resolve `next` +// at build time, and so standalone bundling preserves the right relative +// structure (server.js lands at .next/standalone/ui/server.js). +const workspaceRoot = path.resolve(process.cwd(), ".."); const nextConfig: NextConfig = { devIndicators: false, + // Bundle into a self-contained server.js so the UI runs without npm-installing + // workspace deps on the target machine. Required for the `npm i -g golem-agent` + // story to work end-to-end. + output: "standalone", + outputFileTracingRoot: workspaceRoot, + turbopack: { + root: workspaceRoot, + }, async rewrites() { return [ {