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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions bundles/actual-budget/manifest.json
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
{
"id": "actual-budget",
"name": "Actual Budget",
"version": "1.0.0",
"version": "1.0.1",
"description": "Privacy-first personal finance — track spending, manage budgets, and view financial reports",
"type": "bundle",
"author": "Crow",
"category": "finance",
"tags": ["budget", "finance", "money", "personal-finance", "self-hosted"],
"icon": "dollar",
"docker": { "composefile": "docker-compose.yml" },
"server": { "command": "node", "args": ["server/index.js"], "envKeys": ["ACTUAL_URL", "ACTUAL_PASSWORD"] },
"server": { "command": "node", "args": ["server/index.js"], "envKeys": ["ACTUAL_URL", "ACTUAL_PASSWORD", "ACTUAL_SERVER_PASSWORD"] },
"panel": "panel/actual-budget.js",
"panelRoutes": "panel/routes.js",
"skills": ["skills/actual-budget.md"],
"requires": { "env": ["ACTUAL_PASSWORD"], "min_ram_mb": 256, "min_disk_mb": 200 },
"requires": { "min_ram_mb": 256, "min_disk_mb": 200 },
"postInstall": { "script": "scripts/set-server-password.sh", "timeout_s": 180 },
"env_vars": [
{ "name": "ACTUAL_URL", "description": "Actual Budget server URL", "default": "http://localhost:5006", "required": true },
{ "name": "ACTUAL_PASSWORD", "description": "Actual server password", "required": true, "secret": true },
{ "name": "ACTUAL_PASSWORD", "description": "Password of an Actual server you already run elsewhere. Leave it empty for the Actual server Crow installs on this machine: Crow generates that one's password.", "required": false, "secret": true },
{ "name": "ACTUAL_SERVER_PASSWORD", "description": "Password of the Actual server Crow installs on this machine. Crow generates it, saves it in Settings → Passwords (this machine only) and sets it on the new server right after install. Log in to Actual with it.", "required": true, "secret": true, "generate": "secret", "keychain": true, "keychain_label": "server password", "keychain_url": "http://localhost:5006/" },
{ "name": "ACTUAL_SYNC_ID", "description": "Budget sync ID to auto-select budget", "required": false }
],
"ports": [5006],
"webUI": { "port": 5006, "path": "/", "label": "Actual Budget" },
"notes": "Privacy-first budgeting. Data stays local. Set ACTUAL_PASSWORD to match your Actual server password."
"notes": "Privacy-first budgeting. Data stays local. Crow sets a generated password on the Actual server it installs (Settings → Passwords). To use an Actual server you already run, set ACTUAL_URL and ACTUAL_PASSWORD instead."
}
54 changes: 41 additions & 13 deletions bundles/actual-budget/panel/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,33 @@
*/

import { Router } from "express";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";

const ACTUAL_URL = () => (process.env.ACTUAL_URL || "http://localhost:5006").replace(/\/+$/, "");
const ACTUAL_PASSWORD = () => process.env.ACTUAL_PASSWORD || "";

function isLoopbackUrl(url) {
try {
const host = new URL(url).hostname.replace(/^\[|\]$/g, "").toLowerCase();
return host === "localhost" || host === "::1" || /^127(?:\.\d{1,3}){3}$/.test(host);
} catch {
return false;
}
}

/** The generated password of the Actual server Crow installed here, from THIS instance's
* installed copy (co-hosted instances each have their own CROW_HOME). */
function generatedServerPassword() {
try {
const envPath = join(process.env.CROW_HOME || join(homedir(), ".crow"), "bundles", "actual-budget", ".env");
const line = readFileSync(envPath, "utf8").split("\n").reverse().find((l) => l.startsWith("ACTUAL_SERVER_PASSWORD="));
return line ? line.slice("ACTUAL_SERVER_PASSWORD=".length).trim().replace(/^"(.*)"$/, "$1") : "";
} catch {
return "";
}
}

const ACTUAL_SYNC_ID = () => process.env.ACTUAL_SYNC_ID || "";

let authToken = null;
Expand All @@ -25,18 +49,22 @@ async function getToken() {
const timeout = setTimeout(() => controller.abort(), 10000);

try {
const res = await fetch(`${ACTUAL_URL()}/account/login`, {
method: "POST",
signal: controller.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: ACTUAL_PASSWORD() }),
});

if (!res.ok) throw new Error("Actual Budget login failed — check ACTUAL_PASSWORD");
const data = await res.json();
authToken = data.data?.token || data.token;
if (!authToken) throw new Error("No token returned from login");
return authToken;
// Typed password first, then (loopback only) the generated one: a stale typed value
// must not lock the panel out of the server Crow set up.
const candidates = [...new Set([process.env.ACTUAL_PASSWORD, isLoopbackUrl(ACTUAL_URL()) ? generatedServerPassword() : ""].filter(Boolean))];
for (const password of candidates) {
const res = await fetch(`${ACTUAL_URL()}/account/login`, {
method: "POST",
signal: controller.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (!res.ok && res.status !== 400 && res.status !== 401) throw new Error(`Actual Budget login error: ${res.status}`);
const data = res.ok ? await res.json().catch(() => ({})) : {};
authToken = data.data?.token || data.token || null;
if (authToken) return authToken;
}
throw new Error("Actual Budget login failed. The generated server password is in Settings → Passwords; if this server has another one, type it into ACTUAL_PASSWORD in Configure.");
} catch (err) {
if (err.name === "AbortError") throw new Error("Actual Budget login timed out");
if (err.message.includes("fetch failed") || err.message.includes("ECONNREFUSED")) {
Expand Down
55 changes: 55 additions & 0 deletions bundles/actual-budget/scripts/set-server-password.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# postInstall: give the Actual server Crow just installed its generated password, right
# away, so nobody who opens the web UI first can set their own (Actual's first-run
# "bootstrap" is first caller wins). Loopback only: the container is published on
# 127.0.0.1:5006. Never prints the password. Idempotent: a server that already has a
# password is left alone (Actual itself refuses a second bootstrap).
set -euo pipefail
ENV_FILE="${CROW_BUNDLE_DIR:-.}/.env"
URL="${ACTUAL_BOOTSTRAP_URL:-http://127.0.0.1:5006}" # override: tests only (hookEnv never passes it)
[[ "$URL" =~ ^http://127\.0\.0\.1:[0-9]+$ ]] || { echo "Refusing a non-loopback Actual URL."; exit 0; }

pw="$( (sed -n 's/^ACTUAL_SERVER_PASSWORD=//p' "$ENV_FILE" 2>/dev/null || true) | tail -n 1)"
pw="${pw%\"}"; pw="${pw#\"}"
if [ -z "$pw" ]; then
echo "No generated server password in the extension settings; nothing to set."
exit 0
fi
if ! [[ "$pw" =~ ^[A-Za-z0-9_-]+$ ]]; then
echo "The server password is not in the generated format; set it in Actual's web UI instead."
exit 0
fi
command -v curl >/dev/null || { echo "curl is not installed; Crow sets the password the first time it connects instead."; exit 0; }

state=""
for _ in $(seq 1 "${ACTUAL_BOOTSTRAP_TRIES:-20}"); do
state="$(curl -fsS --max-time 5 "$URL/account/needs-bootstrap" 2>/dev/null || true)"
[ -n "$state" ] && break
sleep 2
done
if [ -z "$state" ]; then
echo "The Actual server did not answer within 2 minutes; Crow sets the password the first time it connects instead."
exit 0
fi
already_set() {
# Kept data from an earlier install has its own password: say so when ours doesn't open it.
login="$(printf '{"password":"%s"}' "$pw" | curl -sS --max-time 10 -H 'Content-Type: application/json' --data-binary @- "$URL/account/login" || true)"
if printf '%s' "$login" | grep -q '"token"'; then
echo "The Actual server already has this password; left unchanged."
else
echo "The Actual server kept its password from an earlier install, so the one Crow generated does not open it. Type the old password into ACTUAL_PASSWORD in Configure (and ignore the generated one in Settings → Passwords)."
fi
}
if ! printf '%s' "$state" | grep -q '"bootstrapped":false'; then
already_set
exit 0
fi
# The body goes in on stdin, so the password never appears in a process list.
resp="$(printf '{"password":"%s"}' "$pw" | curl -sS --max-time 10 -H 'Content-Type: application/json' --data-binary @- "$URL/account/bootstrap" || true)"
if printf '%s' "$resp" | grep -q '"status":"ok"'; then
echo "Set the generated password on the Actual server (Settings → Passwords)."
elif printf '%s' "$resp" | grep -q 'already-bootstrapped'; then
already_set
else
echo "Could not set the Actual server password; Crow tries again the first time it connects."
fi
83 changes: 83 additions & 0 deletions bundles/actual-budget/server/bootstrap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* First-run password for the Actual server Crow deployed (queue item 30, fix B).
*
* Crow generates ACTUAL_SERVER_PASSWORD at install and saves it in the Crow keychain; the
* postInstall hook (scripts/set-server-password.sh) sets it right away, and this is the
* fallback when that could not run. A new
* Actual server has no password yet ("needs bootstrap"), so before the first login we
* set it: GET /account/needs-bootstrap, and only when it reports bootstrapped:false,
* POST /account/bootstrap { password }. The server itself refuses a second bootstrap
* ("already-bootstrapped"), so a server someone already set up is never changed.
*
* Only for a LOOPBACK server (the bundle's own container is published on 127.0.0.1).
* A remote Actual server the user points Crow at is theirs to set up; Crow never sends
* it a password it chose.
*/

export function isLoopbackUrl(url) {
try {
const host = new URL(url).hostname.replace(/^\[|\]$/g, "").toLowerCase();
return host === "localhost" || host === "::1" || /^127(?:\.\d{1,3}){3}$/.test(host);
} catch {
return false;
}
}

/**
* The password to log in with: ACTUAL_PASSWORD when set (typed: a server the user runs
* elsewhere), else ACTUAL_SERVER_PASSWORD (generated for the local server) — but that one
* only for a loopback URL, so it never leaves this machine.
*/
export function effectivePassword(url, env = process.env) {
if (env.ACTUAL_PASSWORD) return env.ACTUAL_PASSWORD;
return isLoopbackUrl(url) ? (env.ACTUAL_SERVER_PASSWORD || "") : "";
}

/**
* Passwords to try, in order: the typed one, then (loopback only) the generated one. A
* stale typed value (kept in the gateway .env from an older install) must not lock the
* user out of the server Crow just set up with the generated password.
*/
export function loginCandidates(url, env = process.env) {
const out = [env.ACTUAL_PASSWORD, isLoopbackUrl(url) ? env.ACTUAL_SERVER_PASSWORD : ""].filter((v) => typeof v === "string" && v !== "");
return [...new Set(out)];
}

/**
* @returns {Promise<"bootstrapped"|"ready"|"skipped">}
* bootstrapped this call set the password
* ready the server already had one
* skipped no password, a non-loopback server, or the check failed (login decides)
*/
export async function ensureBootstrapped(baseUrl, password, { fetchImpl = globalThis.fetch, timeoutMs = 10000 } = {}) {
if (!password || !isLoopbackUrl(baseUrl)) return "skipped";
const base = String(baseUrl).replace(/\/+$/, "");
const call = async (path, init = {}) => {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), timeoutMs);
try {
return await fetchImpl(`${base}${path}`, { ...init, signal: ctl.signal });
} finally {
clearTimeout(timer);
}
};
let state;
try {
const res = await call("/account/needs-bootstrap");
if (!res.ok) return "skipped";
state = await res.json();
} catch {
return "skipped";
}
if (state?.data?.bootstrapped !== false) return "ready";
const res = await call("/account/bootstrap", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
let body = null;
try { body = await res.json(); } catch { /* reason unknown */ }
if (res.ok && body?.status === "ok") return "bootstrapped";
if (body?.reason === "already-bootstrapped") return "ready";
throw new Error(`Could not set the Actual server password (${body?.reason || res.status})`);
}
37 changes: 18 additions & 19 deletions bundles/actual-budget/server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,16 @@
* - Update budget amounts
* - Generate spending reports
*
* Auth: POST /account/login with { password } to get a Bearer token.
* Auth: POST /account/login with { password } to get a Bearer token. A new local
* server is given the generated password first (bootstrap.js).
* Amounts are stored in cents (integer). Negative = expense, positive = income.
*/

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { ensureBootstrapped, loginCandidates } from "./bootstrap.js";

const ACTUAL_URL = (process.env.ACTUAL_URL || "http://localhost:5006").replace(/\/+$/, "");
const ACTUAL_PASSWORD = process.env.ACTUAL_PASSWORD || "";
const ACTUAL_SYNC_ID = process.env.ACTUAL_SYNC_ID || "";

let authToken = null;
Expand All @@ -34,24 +35,22 @@ async function getToken() {
const timeout = setTimeout(() => controller.abort(), 10000);

try {
const res = await fetch(`${ACTUAL_URL}/account/login`, {
method: "POST",
signal: controller.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: ACTUAL_PASSWORD }),
});

if (!res.ok) {
if (res.status === 401 || res.status === 400) {
throw new Error("Actual Budget login failed — check ACTUAL_PASSWORD");
}
throw new Error(`Actual Budget login error: ${res.status} ${res.statusText}`);
// A new server Crow deployed has no password yet: set the GENERATED one (never a
// typed value, which may be stale from an older install), loopback only.
await ensureBootstrapped(ACTUAL_URL, process.env.ACTUAL_SERVER_PASSWORD || "");
for (const password of loginCandidates(ACTUAL_URL, process.env)) {
const res = await fetch(`${ACTUAL_URL}/account/login`, {
method: "POST",
signal: controller.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (!res.ok && res.status !== 400 && res.status !== 401) throw new Error(`Actual Budget login error: ${res.status} ${res.statusText}`);
const data = res.ok ? await res.json().catch(() => ({})) : {};
authToken = data.data?.token || data.token || null;
if (authToken) return authToken;
}

const data = await res.json();
authToken = data.data?.token || data.token;
if (!authToken) throw new Error("No token returned from Actual Budget login");
return authToken;
throw new Error("Actual Budget login failed. The generated server password is in Settings → Passwords; if this server has another password (changed in Actual, or kept data from an earlier install), type it into ACTUAL_PASSWORD in Configure.");
} catch (err) {
if (err.name === "AbortError") {
throw new Error("Actual Budget login timed out after 10s");
Expand Down
2 changes: 1 addition & 1 deletion bundles/actual-budget/skills/actual-budget.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,6 @@ tools:
## Error Handling

- If Actual Budget is unreachable: "Can't connect to Actual Budget at the configured URL. Make sure the server is running."
- If auth fails: "Actual Budget rejected the password. Check ACTUAL_PASSWORD in settings."
- If auth fails: "Actual Budget rejected the password. The generated server password is in Settings → Passwords; if this server kept an older password (changed in Actual, or data from an earlier install), type it into ACTUAL_PASSWORD in Configure."
- If no budgets found: "No budgets found. Create a budget in Actual Budget first, then set ACTUAL_SYNC_ID if you have multiple budgets."
- If a query returns empty: the budget may not have data for that period yet
4 changes: 2 additions & 2 deletions bundles/audiobookshelf/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ AUDIOBOOKSHELF_URL=http://localhost:13378
AUDIOBOOKSHELF_API_KEY=

# Docker mode only — host paths for media
AUDIOBOOKSHELF_AUDIOBOOK_PATH=/path/to/audiobooks
AUDIOBOOKSHELF_PODCAST_PATH=/path/to/podcasts
# AUDIOBOOKSHELF_AUDIOBOOK_PATH=/path/to/audiobooks (example — set your own)
# AUDIOBOOKSHELF_PODCAST_PATH=/path/to/podcasts (example — set your own)
6 changes: 3 additions & 3 deletions bundles/audiobookshelf/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "audiobookshelf",
"name": "Audiobookshelf",
"version": "1.0.0",
"version": "1.0.1",
"description": "Self-hosted audiobook and podcast server — search, browse, and track listening progress",
"type": "bundle",
"author": "Crow",
Expand All @@ -17,8 +17,8 @@
"env_vars": [
{ "name": "AUDIOBOOKSHELF_URL", "description": "Audiobookshelf server URL", "default": "http://localhost:13378", "required": true },
{ "name": "AUDIOBOOKSHELF_API_KEY", "description": "Audiobookshelf API token (Settings > Users > your user)", "required": true, "secret": true },
{ "name": "AUDIOBOOKSHELF_AUDIOBOOK_PATH", "description": "Host path for audiobook files (Docker mode)", "required": false },
{ "name": "AUDIOBOOKSHELF_PODCAST_PATH", "description": "Host path for podcast files (Docker mode)", "required": false }
{ "name": "AUDIOBOOKSHELF_AUDIOBOOK_PATH", "description": "Host path for audiobook files (Docker mode).", "required": false },
{ "name": "AUDIOBOOKSHELF_PODCAST_PATH", "description": "Host path for podcast files (Docker mode).", "required": false }
],
"ports": [13378],
"webUI": { "port": 13378, "path": "/", "label": "Audiobookshelf" },
Expand Down
2 changes: 1 addition & 1 deletion bundles/calibre-server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
CALIBRE_URL=http://localhost:8081

# Host path to your Calibre library folder (required for Docker mode)
CALIBRE_LIBRARY_PATH=/path/to/calibre/library
# CALIBRE_LIBRARY_PATH=/path/to/calibre/library (example — set your own)

# HTTP Basic auth credentials (if authentication is enabled on the server)
CALIBRE_USERNAME=
Expand Down
2 changes: 1 addition & 1 deletion bundles/calibre-server/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "calibre-server",
"name": "Calibre Server",
"version": "1.0.0",
"version": "1.0.1",
"description": "OPDS content server for ebooks — search, browse, and download from your Calibre library",
"type": "bundle",
"author": "Crow",
Expand Down
2 changes: 1 addition & 1 deletion bundles/calibre-web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ CALIBRE_WEB_USERNAME=
CALIBRE_WEB_PASSWORD=

# Host path to the directory containing your Calibre metadata.db (required for Docker mode)
CALIBRE_WEB_DB_PATH=/path/to/calibre/library
# CALIBRE_WEB_DB_PATH=/path/to/calibre/library (example — set your own)
2 changes: 1 addition & 1 deletion bundles/calibre-web/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "calibre-web",
"name": "Calibre-Web",
"version": "1.0.0",
"version": "1.0.1",
"description": "Web-based ebook reader and library manager for Calibre",
"type": "bundle",
"author": "Crow",
Expand Down
4 changes: 2 additions & 2 deletions bundles/gotosocial/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

# Public domain dedicated to GoToSocial (must be a subdomain; subpath mounts
# break ActivityPub federation because actors are URL-keyed).
GTS_HOST=gts.example.com
# GTS_HOST=gts.example.com (example — set your own)

# Account handle domain. Usually the same as GTS_HOST. If different, you
# must add a .well-known/webfinger delegation on the apex (caddy_set_wellknown).
GTS_ACCOUNT_DOMAIN=gts.example.com
# GTS_ACCOUNT_DOMAIN=gts.example.com (example — set your own)

# Internal URL the Crow MCP server uses to reach GoToSocial. Leave as-is
# when running over the shared crow-federation docker network.
Expand Down
2 changes: 1 addition & 1 deletion bundles/gotosocial/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ services:
- default
- crow-federation
environment:
GTS_HOST: ${GTS_HOST}
GTS_HOST: ${GTS_HOST:?Set GTS_HOST to your public domain}
GTS_ACCOUNT_DOMAIN: ${GTS_ACCOUNT_DOMAIN:-${GTS_HOST}}
GTS_PROTOCOL: https
GTS_PORT: "8080"
Expand Down
Loading
Loading