diff --git a/EXTENDING.md b/EXTENDING.md index c29bdfe..b2cf0e6 100644 --- a/EXTENDING.md +++ b/EXTENDING.md @@ -321,7 +321,7 @@ journalctl -u kb-server -f - `ProtectSystem=strict` and `NoNewPrivileges=true` for security hardening - `Restart=on-failure` with 5 attempts per 60 seconds -Environment variables should be set in a `.env` file in the project root (loaded by `dotenv/config`). +Environment variables should be set in `~/.knowledge-base/.env` (loaded automatically on startup). Run `kb setup` to generate this file. ### Recipe 5: Add New Ingestion Source Type diff --git a/bin/cron-capture.sh b/bin/cron-capture.sh index a0db5f5..95a714d 100755 --- a/bin/cron-capture.sh +++ b/bin/cron-capture.sh @@ -4,11 +4,11 @@ export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" export PATH="$HOME/.nvm/versions/node/$(node -v 2>/dev/null || echo v22.22.1)/bin:/usr/local/bin:/usr/bin:$PATH" export OBSIDIAN_VAULT_PATH="${OBSIDIAN_VAULT_PATH:-$HOME/obsidian-vault}" -KB_DIR="${KB_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" +KB_DATA_DIR="$HOME/.knowledge-base" -# Load .env -if [ -f "$KB_DIR/.env" ]; then - set -a; source "$KB_DIR/.env"; set +a +# Load .env from canonical location +if [ -f "$KB_DATA_DIR/.env" ]; then + set -a; source "$KB_DATA_DIR/.env"; set +a fi # 1. Sync X bookmarks @@ -17,13 +17,12 @@ if [ -f ~/knowledgebase/sync-x-bookmarks.sh ]; then fi # 2. Capture X bookmarks to vault -cd "$KB_DIR" -node bin/kb.js capture-x 2>/dev/null +kb capture-x 2>/dev/null # 3. Reindex vault (safety net) -node bin/kb.js vault reindex 2>/dev/null +kb vault reindex 2>/dev/null # 4. Auto-classify new clippings/inbox notes -node bin/kb.js classify 2>/dev/null +kb classify 2>/dev/null echo "[cron-capture] Done at $(date)" diff --git a/bin/kb.js b/bin/kb.js index cca2010..abfb8a0 100755 --- a/bin/kb.js +++ b/bin/kb.js @@ -2,7 +2,7 @@ // bin/kb.js — CLI entry point // Commands: start, stop, mcp, register, ingest , search , status, setup -import 'dotenv/config'; +import '../src/paths.js'; // loads .env from ~/.knowledge-base/.env const command = process.argv[2]; const args = process.argv.slice(3); @@ -14,6 +14,7 @@ const commands = { register: () => import('../src/cli/register.js').then(m => m.register()), ingest: () => import('../src/cli/ingest-cli.js').then(m => m.ingest(args[0])), search: () => import('../src/cli/search-cli.js').then(m => m.search(args.join(' '))), + 'token-compare': () => import('../src/cli/token-compare.js').then(m => m.tokenCompare(args)), status: () => import('../src/cli/status.js').then(m => m.status()), 'capture-x': () => import('../src/capture/x-bookmarks.js').then(m => { const bookmarksPath = args[0] || (process.env.HOME + '/knowledgebase/x_bookmarks.md'); @@ -74,6 +75,7 @@ Commands: register Register MCP server with Claude Code ingest Ingest a file or directory search Search documents + token-compare Compare raw-doc vs KB-summary token cost status Show stats and server status vault reindex Reindex Obsidian vault classify Auto-classify new clippings/inbox notes (--dry-run to preview) diff --git a/bin/weekly-synthesis.js b/bin/weekly-synthesis.js index 05efbcb..b78d381 100755 --- a/bin/weekly-synthesis.js +++ b/bin/weekly-synthesis.js @@ -2,7 +2,7 @@ // Weekly synthesis job — run via cron or manually // Generates a synthesis prompt note that can be processed by an AI agent -import 'dotenv/config'; +import '../src/paths.js'; // loads .env from ~/.knowledge-base/.env import { getRecentNotes, generateSynthesisPrompt, writeSynthesisNote } from '../src/synthesis/weekly-review.js'; import { homedir } from 'os'; diff --git a/kb-server-install.sh b/kb-server-install.sh index 8a8e7e7..ebe1a6b 100755 --- a/kb-server-install.sh +++ b/kb-server-install.sh @@ -33,10 +33,10 @@ Documentation=https://github.com/willynikes2/knowledge-base-server [Service] Type=simple User=$KB_USER -WorkingDirectory=$SCRIPT_DIR +WorkingDirectory=$KB_HOME Environment="NODE_ENV=production" Environment="PATH=$NODE_DIR:/usr/local/bin:/usr/bin:/bin" -ExecStart=$NODE_BIN $SCRIPT_DIR/bin/kb.js start +ExecStart=$(which kb 2>/dev/null || echo "$NODE_BIN $SCRIPT_DIR/bin/kb.js") start Restart=on-failure RestartSec=5 StartLimitBurst=5 @@ -50,7 +50,7 @@ SyslogIdentifier=kb-server # Security hardening NoNewPrivileges=true ProtectSystem=strict -ReadWritePaths=$KB_HOME/.knowledge-base $SCRIPT_DIR $VAULT_PATH $KB_HOME/knowledgebase /tmp +ReadWritePaths=$KB_HOME/.knowledge-base $KB_HOME $VAULT_PATH $KB_HOME/knowledgebase /tmp ProtectHome=false [Install] diff --git a/kb-server.service.example b/kb-server.service.example index d341597..5e4065d 100644 --- a/kb-server.service.example +++ b/kb-server.service.example @@ -10,10 +10,11 @@ Documentation=https://github.com/willynikes2/knowledge-base-server [Service] Type=simple User= -WorkingDirectory= +WorkingDirectory= Environment="NODE_ENV=production" Environment="PATH=:/usr/local/bin:/usr/bin:/bin" -ExecStart= /bin/kb.js start +ExecStart= start +# Find kb path with: which kb Restart=on-failure RestartSec=5 StartLimitBurst=5 @@ -27,7 +28,7 @@ SyslogIdentifier=kb-server # Security hardening NoNewPrivileges=true ProtectSystem=strict -ReadWritePaths=/.knowledge-base /knowledgebase /tmp +ReadWritePaths=/.knowledge-base /knowledgebase /tmp ProtectHome=false [Install] diff --git a/package.json b/package.json index 1d0d37b..9715b9f 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,12 @@ "bin": { "kb": "./bin/kb.js" }, - "main": "./src/index.js", + "main": "./src/server.js", + "files": [ + "bin/", + "src/", + "openapi.json" + ], "engines": { "node": ">=18.0.0" }, diff --git a/src/cli/setup.js b/src/cli/setup.js index b417511..6b68eb1 100644 --- a/src/cli/setup.js +++ b/src/cli/setup.js @@ -3,12 +3,16 @@ import { randomBytes } from 'crypto'; import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { homedir, platform, release, type as osType } from 'os'; import { join, resolve } from 'path'; -import { fileURLToPath } from 'url'; import { execFileSync } from 'child_process'; +import { KB_DIR, ENV_PATH } from '../paths.js'; const HOME = homedir(); -// fileURLToPath handles Windows drive letters correctly (avoids C:\C:\ duplication) -const PROJECT_ROOT = resolve(fileURLToPath(new URL('.', import.meta.url)), '..', '..'); + +function isNpx() { + return !!(process.env.npm_execpath?.includes('npx') || + process.argv[1]?.includes('.npm/_npx') || + process.argv[1]?.includes('node_modules/.cache')); +} // --------------------------------------------------------------------------- // Utility helpers @@ -154,6 +158,14 @@ function buildEnvContent(cfg) { // --------------------------------------------------------------------------- function installSystemd() { + // Resolve kb binary path — use the actual resolved path, not just 'kb', + // because systemd PATH may not include npm bin directories + let kbBin; + try { + kbBin = execFileSync('which', ['kb'], { encoding: 'utf-8' }).trim(); + } catch { + kbBin = 'kb'; // fallback — user will need to fix PATH in unit + } const unit = `[Unit] Description=Knowledge Base Server After=network.target @@ -161,8 +173,8 @@ After=network.target [Service] Type=simple User=${process.env.USER || 'root'} -WorkingDirectory=${PROJECT_ROOT} -ExecStart=${process.execPath} ${join(PROJECT_ROOT, 'bin', 'kb.js')} start +WorkingDirectory=${HOME} +ExecStart=${kbBin} start Restart=on-failure RestartSec=5 Environment=NODE_ENV=production @@ -182,6 +194,12 @@ WantedBy=multi-user.target } function installLaunchd() { + let kbBin; + try { + kbBin = execFileSync('which', ['kb'], { encoding: 'utf-8' }).trim(); + } catch { + kbBin = '/usr/local/bin/kb'; + } const plist = ` @@ -190,12 +208,11 @@ function installLaunchd() { com.knowledgebase.server ProgramArguments - ${process.execPath} - ${join(PROJECT_ROOT, 'bin', 'kb.js')} + ${kbBin} start WorkingDirectory - ${PROJECT_ROOT} + ${HOME} RunAtLoad KeepAlive @@ -217,20 +234,21 @@ function generateDockerCompose(cfg) { const content = `version: "3.8" services: knowledge-base: - build: . + image: node:22-slim + command: ["npx", "knowledge-base-server", "start"] ports: - "${cfg.port}:${cfg.port}" volumes: - kb-data:/root/.knowledge-base ${cfg.vaultPath ? ` - ${cfg.vaultPath}:/vault` : ''} - env_file: - - .env + environment: + - NODE_ENV=production restart: unless-stopped volumes: kb-data: `; - const composePath = join(PROJECT_ROOT, 'docker-compose.yml'); + const composePath = join(KB_DIR, 'docker-compose.yml'); writeFileSync(composePath, content); return composePath; } @@ -308,7 +326,7 @@ function parseAutoArgs(args) { } function loadConfigFile() { - const configPath = join(PROJECT_ROOT, 'setup-config.json'); + const configPath = join(KB_DIR, 'setup-config.json'); if (!existsSync(configPath)) return null; try { return JSON.parse(readFileSync(configPath, 'utf-8')); @@ -419,7 +437,7 @@ function applyConfig(cfg) { // 1. Write .env const envContent = buildEnvContent(cfg); - const envPath = join(PROJECT_ROOT, '.env'); + const envPath = ENV_PATH; const envExisted = existsSync(envPath); writeFileSync(envPath, envContent); results.steps.push({ @@ -440,6 +458,14 @@ function applyConfig(cfg) { } // 3. Install service + // Warn npx users about persistent service limitations + if (isNpx() && cfg.deploy !== 'manual' && cfg.deploy !== 'docker') { + results.steps.push({ + action: 'Warning: npx detected', + hint: 'Persistent services (systemd/launchd/PM2) require a global install: npm i -g knowledge-base-server. The npx cache is ephemeral.', + }); + } + if (cfg.deploy === 'systemd') { const r = installSystemd(); if (r.ok) { @@ -464,7 +490,7 @@ function applyConfig(cfg) { } else if (cfg.deploy === 'pm2') { results.steps.push({ action: 'PM2 selected', - hint: 'Run: pm2 start bin/kb.js --name knowledge-base -- start', + hint: 'Run: pm2 start $(which kb) --name knowledge-base -- start', }); } diff --git a/src/paths.js b/src/paths.js index 1d61825..dab35fc 100644 --- a/src/paths.js +++ b/src/paths.js @@ -1,12 +1,19 @@ -import 'dotenv/config'; +import { config } from 'dotenv'; import { homedir } from 'os'; import { join } from 'path'; -import { mkdirSync } from 'fs'; +import { existsSync, mkdirSync } from 'fs'; export const KB_DIR = join(homedir(), '.knowledge-base'); export const FILES_DIR = join(KB_DIR, 'files'); export const DB_PATH = join(KB_DIR, 'kb.db'); export const CONFIG_PATH = join(KB_DIR, 'config.json'); export const PID_PATH = join(KB_DIR, 'kb.pid'); +export const ENV_PATH = join(KB_DIR, '.env'); mkdirSync(FILES_DIR, { recursive: true }); + +// Load .env from KB_DIR — the canonical config location. +// No CWD fallback: CWD is unreliable (could be $HOME via systemd, /tmp, or npx cache). +if (existsSync(ENV_PATH)) { + config({ path: ENV_PATH }); +} diff --git a/tests/npx-compat.test.js b/tests/npx-compat.test.js new file mode 100644 index 0000000..caa88e8 --- /dev/null +++ b/tests/npx-compat.test.js @@ -0,0 +1,52 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { execFileSync } from 'child_process'; +import { join } from 'path'; +import { existsSync } from 'fs'; +import { homedir } from 'os'; + +describe('npx compatibility', () => { + const kbBin = join(import.meta.dirname, '..', 'bin', 'kb.js'); + + it('should show usage when run from /tmp (no CWD .env needed)', () => { + const result = execFileSync(process.execPath, [kbBin], { + cwd: '/tmp', + encoding: 'utf-8', + timeout: 10000, + }); + assert.ok(result.includes('Usage: kb'), 'Should print usage'); + }); + + it('should load .env from ~/.knowledge-base/ when present', () => { + const envPath = join(homedir(), '.knowledge-base', '.env'); + if (!existsSync(envPath)) return; // skip on fresh install + try { + execFileSync(process.execPath, [kbBin, 'status'], { + cwd: '/tmp', + encoding: 'utf-8', + timeout: 10000, + }); + } catch (err) { + // Status may fail if server isn't running, but should NOT have dotenv errors + assert.ok( + !err.stderr?.includes('dotenv') && !err.stderr?.includes('ENOENT'), + 'Should not have dotenv/file errors' + ); + } + }); + + it('should have KB_DIR at ~/.knowledge-base', async () => { + const { KB_DIR } = await import('../src/paths.js'); + assert.strictEqual(KB_DIR, join(homedir(), '.knowledge-base')); + }); + + it('should have ENV_PATH at ~/.knowledge-base/.env', async () => { + const { ENV_PATH } = await import('../src/paths.js'); + assert.strictEqual(ENV_PATH, join(homedir(), '.knowledge-base', '.env')); + }); + + it('should not crash when openapi.json is loaded', async () => { + const openapiPath = join(import.meta.dirname, '..', 'openapi.json'); + assert.ok(existsSync(openapiPath), 'openapi.json should exist at package root'); + }); +}); diff --git a/tests/paths.test.js b/tests/paths.test.js new file mode 100644 index 0000000..bd4f0ec --- /dev/null +++ b/tests/paths.test.js @@ -0,0 +1,15 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { KB_DIR, ENV_PATH } from '../src/paths.js'; +import { join } from 'path'; +import { homedir } from 'os'; + +describe('paths', () => { + it('should export KB_DIR as ~/.knowledge-base', () => { + assert.strictEqual(KB_DIR, join(homedir(), '.knowledge-base')); + }); + + it('should export ENV_PATH inside KB_DIR', () => { + assert.strictEqual(ENV_PATH, join(homedir(), '.knowledge-base', '.env')); + }); +}); diff --git a/tests/setup.test.js b/tests/setup.test.js new file mode 100644 index 0000000..035c565 --- /dev/null +++ b/tests/setup.test.js @@ -0,0 +1,30 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +describe('setup config paths', () => { + it('should import setup module without error', async () => { + const mod = await import('../src/cli/setup.js'); + assert.ok(mod.setup, 'setup function should be exported'); + }); + + it('should not reference PROJECT_ROOT for config writes', () => { + const source = readFileSync( + join(import.meta.dirname, '..', 'src', 'cli', 'setup.js'), + 'utf-8' + ); + assert.ok( + !source.includes("join(PROJECT_ROOT, '.env')"), + 'Should not write .env to PROJECT_ROOT' + ); + assert.ok( + !source.includes("join(PROJECT_ROOT, 'setup-config.json')"), + 'Should not read setup-config.json from PROJECT_ROOT' + ); + assert.ok( + !source.includes("join(PROJECT_ROOT, 'docker-compose.yml')"), + 'Should not write docker-compose.yml to PROJECT_ROOT' + ); + }); +});