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
2 changes: 1 addition & 1 deletion EXTENDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 7 additions & 8 deletions bin/cron-capture.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)"
4 changes: 3 additions & 1 deletion bin/kb.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// bin/kb.js — CLI entry point
// Commands: start, stop, mcp, register, ingest <path>, search <query>, 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);
Expand All @@ -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');
Expand Down Expand Up @@ -74,6 +75,7 @@ Commands:
register Register MCP server with Claude Code
ingest <path> Ingest a file or directory
search <query> 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)
Expand Down
2 changes: 1 addition & 1 deletion bin/weekly-synthesis.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
6 changes: 3 additions & 3 deletions kb-server-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down
7 changes: 4 additions & 3 deletions kb-server.service.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ Documentation=https://github.com/willynikes2/knowledge-base-server
[Service]
Type=simple
User=<YOUR_USER>
WorkingDirectory=<PATH_TO_KNOWLEDGE_BASE_SERVER>
WorkingDirectory=<HOME>
Environment="NODE_ENV=production"
Environment="PATH=<PATH_TO_NODE_BIN_DIR>:/usr/local/bin:/usr/bin:/bin"
ExecStart=<PATH_TO_NODE_BIN> <PATH_TO_KNOWLEDGE_BASE_SERVER>/bin/kb.js start
ExecStart=<PATH_TO_KB_BINARY> start
# Find kb path with: which kb
Restart=on-failure
RestartSec=5
StartLimitBurst=5
Expand All @@ -27,7 +28,7 @@ SyslogIdentifier=kb-server
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=<HOME>/.knowledge-base <PATH_TO_KNOWLEDGE_BASE_SERVER> <OBSIDIAN_VAULT_PATH> <HOME>/knowledgebase /tmp
ReadWritePaths=<HOME>/.knowledge-base <OBSIDIAN_VAULT_PATH> <HOME>/knowledgebase /tmp
ProtectHome=false

[Install]
Expand Down
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
56 changes: 41 additions & 15 deletions src/cli/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -154,15 +158,23 @@ 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

[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
Expand All @@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
Expand All @@ -190,12 +208,11 @@ function installLaunchd() {
<string>com.knowledgebase.server</string>
<key>ProgramArguments</key>
<array>
<string>${process.execPath}</string>
<string>${join(PROJECT_ROOT, 'bin', 'kb.js')}</string>
<string>${kbBin}</string>
<string>start</string>
</array>
<key>WorkingDirectory</key>
<string>${PROJECT_ROOT}</string>
<string>${HOME}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
Expand All @@ -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;
}
Expand Down Expand Up @@ -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'));
Expand Down Expand Up @@ -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({
Expand All @@ -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) {
Expand All @@ -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',
});
}

Expand Down
11 changes: 9 additions & 2 deletions src/paths.js
Original file line number Diff line number Diff line change
@@ -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 });
}
52 changes: 52 additions & 0 deletions tests/npx-compat.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
15 changes: 15 additions & 0 deletions tests/paths.test.js
Original file line number Diff line number Diff line change
@@ -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'));
});
});
30 changes: 30 additions & 0 deletions tests/setup.test.js
Original file line number Diff line number Diff line change
@@ -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'
);
});
});