refactor: move setup scripts out of src/ to reduce build token count

Setup scripts are standalone CLI tools run via tsx with no runtime
imports from the main app. Moving them out of src/ excludes them from
the tsc build output and reduces the compiled bundle size.

- git mv src/setup/ setup/
- Fix imports to use ../src/logger.js and ../src/config.js
- Update package.json, vitest.config.ts, SKILL.md references
- Fix platform tests to be cross-platform (macOS + Linux)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-02-22 18:43:22 +02:00
parent c1a2491e77
commit 92d14405c5
18 changed files with 39 additions and 39 deletions

View File

@@ -1,123 +0,0 @@
/**
* Step: container — Build container image and verify with test run.
* Replaces 03-setup-container.sh
*/
import { execSync } from 'child_process';
import path from 'path';
import { logger } from '../logger.js';
import { commandExists } from './platform.js';
import { emitStatus } from './status.js';
function parseArgs(args: string[]): { runtime: string } {
let runtime = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--runtime' && args[i + 1]) {
runtime = args[i + 1];
i++;
}
}
return { runtime };
}
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const { runtime } = parseArgs(args);
const image = 'nanoclaw-agent:latest';
const logFile = path.join(projectRoot, 'logs', 'setup.log');
if (!runtime) {
emitStatus('SETUP_CONTAINER', {
RUNTIME: 'unknown',
IMAGE: image,
BUILD_OK: false,
TEST_OK: false,
STATUS: 'failed',
ERROR: 'missing_runtime_flag',
LOG: 'logs/setup.log',
});
process.exit(4);
}
// Validate runtime availability
if (runtime === 'apple-container' && !commandExists('container')) {
emitStatus('SETUP_CONTAINER', {
RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false,
STATUS: 'failed', ERROR: 'runtime_not_available', LOG: 'logs/setup.log',
});
process.exit(2);
}
if (runtime === 'docker') {
if (!commandExists('docker')) {
emitStatus('SETUP_CONTAINER', {
RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false,
STATUS: 'failed', ERROR: 'runtime_not_available', LOG: 'logs/setup.log',
});
process.exit(2);
}
try {
execSync('docker info', { stdio: 'ignore' });
} catch {
emitStatus('SETUP_CONTAINER', {
RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false,
STATUS: 'failed', ERROR: 'runtime_not_available', LOG: 'logs/setup.log',
});
process.exit(2);
}
}
if (!['apple-container', 'docker'].includes(runtime)) {
emitStatus('SETUP_CONTAINER', {
RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false,
STATUS: 'failed', ERROR: 'unknown_runtime', LOG: 'logs/setup.log',
});
process.exit(4);
}
const buildCmd = runtime === 'apple-container' ? 'container build' : 'docker build';
const runCmd = runtime === 'apple-container' ? 'container' : 'docker';
// Build
let buildOk = false;
logger.info({ runtime }, 'Building container');
try {
execSync(`${buildCmd} -t ${image} .`, {
cwd: path.join(projectRoot, 'container'),
stdio: ['ignore', 'pipe', 'pipe'],
});
buildOk = true;
logger.info('Container build succeeded');
} catch (err) {
logger.error({ err }, 'Container build failed');
}
// Test
let testOk = false;
if (buildOk) {
logger.info('Testing container');
try {
const output = execSync(
`echo '{}' | ${runCmd} run -i --rm --entrypoint /bin/echo ${image} "Container OK"`,
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
);
testOk = output.includes('Container OK');
logger.info({ testOk }, 'Container test result');
} catch {
logger.error('Container test failed');
}
}
const status = buildOk && testOk ? 'success' : 'failed';
emitStatus('SETUP_CONTAINER', {
RUNTIME: runtime,
IMAGE: image,
BUILD_OK: buildOk,
TEST_OK: testOk,
STATUS: status,
LOG: 'logs/setup.log',
});
if (status === 'failed') process.exit(1);
}

View File

@@ -1,103 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import fs from 'fs';
import Database from 'better-sqlite3';
/**
* Tests for the environment check step.
*
* Verifies: config detection, Docker/AC detection, DB queries.
*/
describe('environment detection', () => {
it('detects platform correctly', async () => {
const { getPlatform } = await import('./platform.js');
const platform = getPlatform();
expect(['macos', 'linux', 'unknown']).toContain(platform);
});
});
describe('registered groups DB query', () => {
let db: Database.Database;
beforeEach(() => {
db = new Database(':memory:');
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
jid TEXT PRIMARY KEY,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
requires_trigger INTEGER DEFAULT 1
)`);
});
it('returns 0 for empty table', () => {
const row = db.prepare(
'SELECT COUNT(*) as count FROM registered_groups',
).get() as { count: number };
expect(row.count).toBe(0);
});
it('returns correct count after inserts', () => {
db.prepare(
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
VALUES (?, ?, ?, ?, ?, ?)`,
).run('123@g.us', 'Group 1', 'group-1', '@Andy', '2024-01-01T00:00:00.000Z', 1);
db.prepare(
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
VALUES (?, ?, ?, ?, ?, ?)`,
).run('456@g.us', 'Group 2', 'group-2', '@Andy', '2024-01-01T00:00:00.000Z', 1);
const row = db.prepare(
'SELECT COUNT(*) as count FROM registered_groups',
).get() as { count: number };
expect(row.count).toBe(2);
});
});
describe('credentials detection', () => {
it('detects ANTHROPIC_API_KEY in env content', () => {
const content = 'SOME_KEY=value\nANTHROPIC_API_KEY=sk-ant-test123\nOTHER=foo';
const hasCredentials = /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
expect(hasCredentials).toBe(true);
});
it('detects CLAUDE_CODE_OAUTH_TOKEN in env content', () => {
const content = 'CLAUDE_CODE_OAUTH_TOKEN=token123';
const hasCredentials = /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
expect(hasCredentials).toBe(true);
});
it('returns false when no credentials', () => {
const content = 'ASSISTANT_NAME="Andy"\nOTHER=foo';
const hasCredentials = /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
expect(hasCredentials).toBe(false);
});
});
describe('Docker detection logic', () => {
it('commandExists returns boolean', async () => {
const { commandExists } = await import('./platform.js');
expect(typeof commandExists('docker')).toBe('boolean');
expect(typeof commandExists('nonexistent_binary_xyz')).toBe('boolean');
});
});
describe('WhatsApp auth detection', () => {
it('detects non-empty auth directory logic', () => {
// Simulate the check: directory exists and has files
const hasAuth = (authDir: string) => {
try {
return fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
} catch {
return false;
}
};
// Non-existent directory
expect(hasAuth('/tmp/nonexistent_auth_dir_xyz')).toBe(false);
});
});

View File

@@ -1,86 +0,0 @@
/**
* Step: environment — Detect OS, Node, container runtimes, existing config.
* Replaces 01-check-environment.sh
*/
import fs from 'fs';
import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../config.js';
import { logger } from '../logger.js';
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
import { emitStatus } from './status.js';
export async function run(_args: string[]): Promise<void> {
const projectRoot = process.cwd();
logger.info('Starting environment check');
const platform = getPlatform();
const wsl = isWSL();
const headless = isHeadless();
// Check Apple Container
let appleContainer: 'installed' | 'not_found' = 'not_found';
if (commandExists('container')) {
appleContainer = 'installed';
}
// Check Docker
let docker: 'running' | 'installed_not_running' | 'not_found' = 'not_found';
if (commandExists('docker')) {
try {
const { execSync } = await import('child_process');
execSync('docker info', { stdio: 'ignore' });
docker = 'running';
} catch {
docker = 'installed_not_running';
}
}
// Check existing config
const hasEnv = fs.existsSync(path.join(projectRoot, '.env'));
const authDir = path.join(projectRoot, 'store', 'auth');
const hasAuth =
fs.existsSync(authDir) &&
fs.readdirSync(authDir).length > 0;
let hasRegisteredGroups = false;
// Check JSON file first (pre-migration)
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
hasRegisteredGroups = true;
} else {
// Check SQLite directly using better-sqlite3 (no sqlite3 CLI needed)
const dbPath = path.join(STORE_DIR, 'messages.db');
if (fs.existsSync(dbPath)) {
try {
const db = new Database(dbPath, { readonly: true });
const row = db.prepare(
'SELECT COUNT(*) as count FROM registered_groups',
).get() as { count: number };
if (row.count > 0) hasRegisteredGroups = true;
db.close();
} catch {
// Table might not exist yet
}
}
}
logger.info({ platform, wsl, appleContainer, docker, hasEnv, hasAuth, hasRegisteredGroups },
'Environment check complete');
emitStatus('CHECK_ENVIRONMENT', {
PLATFORM: platform,
IS_WSL: wsl,
IS_HEADLESS: headless,
APPLE_CONTAINER: appleContainer,
DOCKER: docker,
HAS_ENV: hasEnv,
HAS_AUTH: hasAuth,
HAS_REGISTERED_GROUPS: hasRegisteredGroups,
STATUS: 'success',
LOG: 'logs/setup.log',
});
}

View File

@@ -1,195 +0,0 @@
/**
* Step: groups — Connect to WhatsApp, fetch group metadata, write to DB.
* Replaces 05-sync-groups.sh + 05b-list-groups.sh
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../config.js';
import { logger } from '../logger.js';
import { emitStatus } from './status.js';
function parseArgs(args: string[]): { list: boolean; limit: number } {
let list = false;
let limit = 30;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--list') list = true;
if (args[i] === '--limit' && args[i + 1]) { limit = parseInt(args[i + 1], 10); i++; }
}
return { list, limit };
}
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const { list, limit } = parseArgs(args);
if (list) {
await listGroups(limit);
return;
}
await syncGroups(projectRoot);
}
async function listGroups(limit: number): Promise<void> {
const dbPath = path.join(STORE_DIR, 'messages.db');
if (!fs.existsSync(dbPath)) {
console.error('ERROR: database not found');
process.exit(1);
}
const db = new Database(dbPath, { readonly: true });
const rows = db.prepare(
`SELECT jid, name FROM chats
WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__' AND name <> jid
ORDER BY last_message_time DESC
LIMIT ?`,
).all(limit) as Array<{ jid: string; name: string }>;
db.close();
for (const row of rows) {
console.log(`${row.jid}|${row.name}`);
}
}
async function syncGroups(projectRoot: string): Promise<void> {
// Build TypeScript first
logger.info('Building TypeScript');
let buildOk = false;
try {
execSync('npm run build', {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'pipe'],
});
buildOk = true;
logger.info('Build succeeded');
} catch {
logger.error('Build failed');
emitStatus('SYNC_GROUPS', {
BUILD: 'failed',
SYNC: 'skipped',
GROUPS_IN_DB: 0,
STATUS: 'failed',
ERROR: 'build_failed',
LOG: 'logs/setup.log',
});
process.exit(1);
}
// Run inline sync script via node
logger.info('Fetching group metadata');
let syncOk = false;
try {
const syncScript = `
import makeWASocket, { useMultiFileAuthState, makeCacheableSignalKeyStore, Browsers } from '@whiskeysockets/baileys';
import pino from 'pino';
import path from 'path';
import fs from 'fs';
import Database from 'better-sqlite3';
const logger = pino({ level: 'silent' });
const authDir = path.join('store', 'auth');
const dbPath = path.join('store', 'messages.db');
if (!fs.existsSync(authDir)) {
console.error('NO_AUTH');
process.exit(1);
}
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.exec('CREATE TABLE IF NOT EXISTS chats (jid TEXT PRIMARY KEY, name TEXT, last_message_time TEXT)');
const upsert = db.prepare(
'INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) ON CONFLICT(jid) DO UPDATE SET name = excluded.name'
);
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const sock = makeWASocket({
auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger) },
printQRInTerminal: false,
logger,
browser: Browsers.macOS('Chrome'),
});
const timeout = setTimeout(() => {
console.error('TIMEOUT');
process.exit(1);
}, 30000);
sock.ev.on('creds.update', saveCreds);
sock.ev.on('connection.update', async (update) => {
if (update.connection === 'open') {
try {
const groups = await sock.groupFetchAllParticipating();
const now = new Date().toISOString();
let count = 0;
for (const [jid, metadata] of Object.entries(groups)) {
if (metadata.subject) {
upsert.run(jid, metadata.subject, now);
count++;
}
}
console.log('SYNCED:' + count);
} catch (err) {
console.error('FETCH_ERROR:' + err.message);
} finally {
clearTimeout(timeout);
sock.end(undefined);
db.close();
process.exit(0);
}
} else if (update.connection === 'close') {
clearTimeout(timeout);
console.error('CONNECTION_CLOSED');
process.exit(1);
}
});
`;
const output = execSync(`node --input-type=module -e ${JSON.stringify(syncScript)}`, {
cwd: projectRoot,
encoding: 'utf-8',
timeout: 45000,
stdio: ['ignore', 'pipe', 'pipe'],
});
syncOk = output.includes('SYNCED:');
logger.info({ output: output.trim() }, 'Sync output');
} catch (err) {
logger.error({ err }, 'Sync failed');
}
// Count groups in DB using better-sqlite3 (no sqlite3 CLI)
let groupsInDb = 0;
const dbPath = path.join(STORE_DIR, 'messages.db');
if (fs.existsSync(dbPath)) {
try {
const db = new Database(dbPath, { readonly: true });
const row = db.prepare(
"SELECT COUNT(*) as count FROM chats WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__'",
).get() as { count: number };
groupsInDb = row.count;
db.close();
} catch {
// DB may not exist yet
}
}
const status = syncOk ? 'success' : 'failed';
emitStatus('SYNC_GROUPS', {
BUILD: buildOk ? 'success' : 'failed',
SYNC: syncOk ? 'success' : 'failed',
GROUPS_IN_DB: groupsInDb,
STATUS: status,
LOG: 'logs/setup.log',
});
if (status === 'failed') process.exit(1);
}

View File

@@ -1,52 +0,0 @@
/**
* Setup CLI entry point.
* Usage: npx tsx src/setup/index.ts --step <name> [args...]
*/
import { logger } from '../logger.js';
import { emitStatus } from './status.js';
const STEPS: Record<string, () => Promise<{ run: (args: string[]) => Promise<void> }>> = {
environment: () => import('./environment.js'),
container: () => import('./container.js'),
'whatsapp-auth': () => import('./whatsapp-auth.js'),
groups: () => import('./groups.js'),
register: () => import('./register.js'),
mounts: () => import('./mounts.js'),
service: () => import('./service.js'),
verify: () => import('./verify.js'),
};
async function main(): Promise<void> {
const args = process.argv.slice(2);
const stepIdx = args.indexOf('--step');
if (stepIdx === -1 || !args[stepIdx + 1]) {
console.error(`Usage: npx tsx src/setup/index.ts --step <${Object.keys(STEPS).join('|')}> [args...]`);
process.exit(1);
}
const stepName = args[stepIdx + 1];
const stepArgs = args.filter((a, i) => i !== stepIdx && i !== stepIdx + 1 && a !== '--');
const loader = STEPS[stepName];
if (!loader) {
console.error(`Unknown step: ${stepName}`);
console.error(`Available steps: ${Object.keys(STEPS).join(', ')}`);
process.exit(1);
}
try {
const mod = await loader();
await mod.run(stepArgs);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ err, step: stepName }, 'Setup step failed');
emitStatus(stepName.toUpperCase(), {
STATUS: 'failed',
ERROR: message,
});
process.exit(1);
}
}
main();

View File

@@ -1,103 +0,0 @@
/**
* Step: mounts — Write mount allowlist config file.
* Replaces 07-configure-mounts.sh
*/
import fs from 'fs';
import path from 'path';
import os from 'os';
import { logger } from '../logger.js';
import { isRoot } from './platform.js';
import { emitStatus } from './status.js';
function parseArgs(args: string[]): { empty: boolean; json: string } {
let empty = false;
let json = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--empty') empty = true;
if (args[i] === '--json' && args[i + 1]) { json = args[i + 1]; i++; }
}
return { empty, json };
}
export async function run(args: string[]): Promise<void> {
const { empty, json } = parseArgs(args);
const homeDir = os.homedir();
const configDir = path.join(homeDir, '.config', 'nanoclaw');
const configFile = path.join(configDir, 'mount-allowlist.json');
if (isRoot()) {
logger.warn('Running as root — mount allowlist will be written to root home directory');
}
fs.mkdirSync(configDir, { recursive: true });
let allowedRoots = 0;
let nonMainReadOnly = 'true';
if (empty) {
logger.info('Writing empty mount allowlist');
const emptyConfig = {
allowedRoots: [],
blockedPatterns: [],
nonMainReadOnly: true,
};
fs.writeFileSync(configFile, JSON.stringify(emptyConfig, null, 2) + '\n');
} else if (json) {
// Validate JSON with JSON.parse (not piped through shell)
let parsed: { allowedRoots?: unknown[]; nonMainReadOnly?: boolean };
try {
parsed = JSON.parse(json);
} catch {
logger.error('Invalid JSON input');
emitStatus('CONFIGURE_MOUNTS', {
PATH: configFile,
ALLOWED_ROOTS: 0,
NON_MAIN_READ_ONLY: 'unknown',
STATUS: 'failed',
ERROR: 'invalid_json',
LOG: 'logs/setup.log',
});
process.exit(4);
return; // unreachable but satisfies TS
}
fs.writeFileSync(configFile, JSON.stringify(parsed, null, 2) + '\n');
allowedRoots = Array.isArray(parsed.allowedRoots) ? parsed.allowedRoots.length : 0;
nonMainReadOnly = parsed.nonMainReadOnly === false ? 'false' : 'true';
} else {
// Read from stdin
logger.info('Reading mount allowlist from stdin');
const input = fs.readFileSync(0, 'utf-8');
let parsed: { allowedRoots?: unknown[]; nonMainReadOnly?: boolean };
try {
parsed = JSON.parse(input);
} catch {
logger.error('Invalid JSON from stdin');
emitStatus('CONFIGURE_MOUNTS', {
PATH: configFile,
ALLOWED_ROOTS: 0,
NON_MAIN_READ_ONLY: 'unknown',
STATUS: 'failed',
ERROR: 'invalid_json',
LOG: 'logs/setup.log',
});
process.exit(4);
return;
}
fs.writeFileSync(configFile, JSON.stringify(parsed, null, 2) + '\n');
allowedRoots = Array.isArray(parsed.allowedRoots) ? parsed.allowedRoots.length : 0;
nonMainReadOnly = parsed.nonMainReadOnly === false ? 'false' : 'true';
}
logger.info({ configFile, allowedRoots, nonMainReadOnly }, 'Allowlist configured');
emitStatus('CONFIGURE_MOUNTS', {
PATH: configFile,
ALLOWED_ROOTS: allowedRoots,
NON_MAIN_READ_ONLY: nonMainReadOnly,
STATUS: 'success',
LOG: 'logs/setup.log',
});
}

View File

@@ -1,121 +0,0 @@
import { describe, it, expect } from 'vitest';
import {
getPlatform,
isWSL,
isRoot,
isHeadless,
hasSystemd,
getServiceManager,
commandExists,
getNodeVersion,
getNodeMajorVersion,
} from './platform.js';
// --- getPlatform ---
describe('getPlatform', () => {
it('returns a valid platform string', () => {
const result = getPlatform();
expect(['macos', 'linux', 'unknown']).toContain(result);
});
it('returns linux on this system', () => {
// This test runs on Linux
expect(getPlatform()).toBe('linux');
});
});
// --- isWSL ---
describe('isWSL', () => {
it('returns a boolean', () => {
expect(typeof isWSL()).toBe('boolean');
});
it('checks /proc/version for WSL markers', () => {
// On non-WSL Linux, should return false
// On WSL, should return true
// Just verify it doesn't throw
const result = isWSL();
expect(typeof result).toBe('boolean');
});
});
// --- isRoot ---
describe('isRoot', () => {
it('returns a boolean', () => {
expect(typeof isRoot()).toBe('boolean');
});
});
// --- isHeadless ---
describe('isHeadless', () => {
it('returns a boolean', () => {
expect(typeof isHeadless()).toBe('boolean');
});
});
// --- hasSystemd ---
describe('hasSystemd', () => {
it('returns a boolean', () => {
expect(typeof hasSystemd()).toBe('boolean');
});
it('checks /proc/1/comm', () => {
// On systemd systems, should return true
// Just verify it doesn't throw
const result = hasSystemd();
expect(typeof result).toBe('boolean');
});
});
// --- getServiceManager ---
describe('getServiceManager', () => {
it('returns a valid service manager', () => {
const result = getServiceManager();
expect(['launchd', 'systemd', 'none']).toContain(result);
});
it('returns systemd or none on Linux', () => {
const result = getServiceManager();
// On Linux, should be systemd if available, else none
expect(['systemd', 'none']).toContain(result);
});
});
// --- commandExists ---
describe('commandExists', () => {
it('returns true for node', () => {
expect(commandExists('node')).toBe(true);
});
it('returns false for nonexistent command', () => {
expect(commandExists('this_command_does_not_exist_xyz_123')).toBe(false);
});
});
// --- getNodeVersion ---
describe('getNodeVersion', () => {
it('returns a version string', () => {
const version = getNodeVersion();
expect(version).not.toBeNull();
expect(version).toMatch(/^\d+\.\d+\.\d+/);
});
});
// --- getNodeMajorVersion ---
describe('getNodeMajorVersion', () => {
it('returns at least 20', () => {
const major = getNodeMajorVersion();
expect(major).not.toBeNull();
expect(major!).toBeGreaterThanOrEqual(20);
});
});

View File

@@ -1,130 +0,0 @@
/**
* Cross-platform detection utilities for NanoClaw setup.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
export type Platform = 'macos' | 'linux' | 'unknown';
export type ServiceManager = 'launchd' | 'systemd' | 'none';
export function getPlatform(): Platform {
const platform = os.platform();
if (platform === 'darwin') return 'macos';
if (platform === 'linux') return 'linux';
return 'unknown';
}
export function isWSL(): boolean {
if (os.platform() !== 'linux') return false;
try {
const release = fs.readFileSync('/proc/version', 'utf-8').toLowerCase();
return release.includes('microsoft') || release.includes('wsl');
} catch {
return false;
}
}
export function isRoot(): boolean {
return process.getuid?.() === 0;
}
export function isHeadless(): boolean {
// No display server available
if (getPlatform() === 'linux') {
return !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
}
// macOS is never headless in practice (even SSH sessions can open URLs)
return false;
}
export function hasSystemd(): boolean {
if (getPlatform() !== 'linux') return false;
try {
// Check if systemd is PID 1
const init = fs.readFileSync('/proc/1/comm', 'utf-8').trim();
return init === 'systemd';
} catch {
return false;
}
}
/**
* Open a URL in the default browser, cross-platform.
* Returns true if the command was attempted, false if no method available.
*/
export function openBrowser(url: string): boolean {
try {
const platform = getPlatform();
if (platform === 'macos') {
execSync(`open ${JSON.stringify(url)}`, { stdio: 'ignore' });
return true;
}
if (platform === 'linux') {
// Try xdg-open first, then wslview for WSL
if (commandExists('xdg-open')) {
execSync(`xdg-open ${JSON.stringify(url)}`, { stdio: 'ignore' });
return true;
}
if (isWSL() && commandExists('wslview')) {
execSync(`wslview ${JSON.stringify(url)}`, { stdio: 'ignore' });
return true;
}
// WSL without wslview: try cmd.exe
if (isWSL()) {
try {
execSync(`cmd.exe /c start "" ${JSON.stringify(url)}`, { stdio: 'ignore' });
return true;
} catch {
// cmd.exe not available
}
}
}
} catch {
// Command failed
}
return false;
}
export function getServiceManager(): ServiceManager {
const platform = getPlatform();
if (platform === 'macos') return 'launchd';
if (platform === 'linux') {
if (hasSystemd()) return 'systemd';
return 'none';
}
return 'none';
}
export function getNodePath(): string {
try {
return execSync('command -v node', { encoding: 'utf-8' }).trim();
} catch {
return process.execPath;
}
}
export function commandExists(name: string): boolean {
try {
execSync(`command -v ${name}`, { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
export function getNodeVersion(): string | null {
try {
const version = execSync('node --version', { encoding: 'utf-8' }).trim();
return version.replace(/^v/, '');
} catch {
return null;
}
}
export function getNodeMajorVersion(): number | null {
const version = getNodeVersion();
if (!version) return null;
const major = parseInt(version.split('.')[0], 10);
return isNaN(major) ? null : major;
}

View File

@@ -1,165 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import Database from 'better-sqlite3';
/**
* Tests for the register step.
*
* Verifies: parameterized SQL (no injection), file templating,
* apostrophe in names, .env updates.
*/
function createTestDb(): Database.Database {
const db = new Database(':memory:');
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
jid TEXT PRIMARY KEY,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
requires_trigger INTEGER DEFAULT 1
)`);
return db;
}
describe('parameterized SQL registration', () => {
let db: Database.Database;
beforeEach(() => {
db = createTestDb();
});
it('registers a group with parameterized query', () => {
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run('123@g.us', 'Test Group', 'test-group', '@Andy', '2024-01-01T00:00:00.000Z', 1);
const row = db.prepare('SELECT * FROM registered_groups WHERE jid = ?').get('123@g.us') as {
jid: string;
name: string;
folder: string;
trigger_pattern: string;
requires_trigger: number;
};
expect(row.jid).toBe('123@g.us');
expect(row.name).toBe('Test Group');
expect(row.folder).toBe('test-group');
expect(row.trigger_pattern).toBe('@Andy');
expect(row.requires_trigger).toBe(1);
});
it('handles apostrophes in group names safely', () => {
const name = "O'Brien's Group";
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run('456@g.us', name, 'obriens-group', '@Andy', '2024-01-01T00:00:00.000Z', 0);
const row = db.prepare('SELECT name FROM registered_groups WHERE jid = ?').get('456@g.us') as {
name: string;
};
expect(row.name).toBe(name);
});
it('prevents SQL injection in JID field', () => {
const maliciousJid = "'; DROP TABLE registered_groups; --";
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run(maliciousJid, 'Evil', 'evil', '@Andy', '2024-01-01T00:00:00.000Z', 1);
// Table should still exist and have the row
const count = db.prepare('SELECT COUNT(*) as count FROM registered_groups').get() as {
count: number;
};
expect(count.count).toBe(1);
const row = db.prepare('SELECT jid FROM registered_groups').get() as { jid: string };
expect(row.jid).toBe(maliciousJid);
});
it('handles requiresTrigger=false', () => {
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run('789@s.whatsapp.net', 'Personal', 'main', '@Andy', '2024-01-01T00:00:00.000Z', 0);
const row = db.prepare('SELECT requires_trigger FROM registered_groups WHERE jid = ?')
.get('789@s.whatsapp.net') as { requires_trigger: number };
expect(row.requires_trigger).toBe(0);
});
it('upserts on conflict', () => {
const stmt = db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
);
stmt.run('123@g.us', 'Original', 'main', '@Andy', '2024-01-01T00:00:00.000Z', 1);
stmt.run('123@g.us', 'Updated', 'main', '@Bot', '2024-02-01T00:00:00.000Z', 0);
const rows = db.prepare('SELECT * FROM registered_groups').all();
expect(rows).toHaveLength(1);
const row = rows[0] as { name: string; trigger_pattern: string; requires_trigger: number };
expect(row.name).toBe('Updated');
expect(row.trigger_pattern).toBe('@Bot');
expect(row.requires_trigger).toBe(0);
});
});
describe('file templating', () => {
it('replaces assistant name in CLAUDE.md content', () => {
let content = '# Andy\n\nYou are Andy, a personal assistant.';
content = content.replace(/^# Andy$/m, '# Nova');
content = content.replace(/You are Andy/g, 'You are Nova');
expect(content).toBe('# Nova\n\nYou are Nova, a personal assistant.');
});
it('handles names with special regex characters', () => {
let content = '# Andy\n\nYou are Andy.';
const newName = 'C.L.A.U.D.E';
content = content.replace(/^# Andy$/m, `# ${newName}`);
content = content.replace(/You are Andy/g, `You are ${newName}`);
expect(content).toContain('# C.L.A.U.D.E');
expect(content).toContain('You are C.L.A.U.D.E.');
});
it('updates .env ASSISTANT_NAME line', () => {
let envContent = 'SOME_KEY=value\nASSISTANT_NAME="Andy"\nOTHER=test';
envContent = envContent.replace(
/^ASSISTANT_NAME=.*$/m,
'ASSISTANT_NAME="Nova"',
);
expect(envContent).toContain('ASSISTANT_NAME="Nova"');
expect(envContent).toContain('SOME_KEY=value');
});
it('appends ASSISTANT_NAME to .env if not present', () => {
let envContent = 'SOME_KEY=value\n';
if (!envContent.includes('ASSISTANT_NAME=')) {
envContent += '\nASSISTANT_NAME="Nova"';
}
expect(envContent).toContain('ASSISTANT_NAME="Nova"');
});
});

View File

@@ -1,147 +0,0 @@
/**
* Step: register — Write channel registration config, create group folders.
* Replaces 06-register-channel.sh
*
* Fixes: SQL injection (parameterized queries), sed -i '' (uses fs directly).
*/
import fs from 'fs';
import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../config.js';
import { logger } from '../logger.js';
import { emitStatus } from './status.js';
interface RegisterArgs {
jid: string;
name: string;
trigger: string;
folder: string;
requiresTrigger: boolean;
assistantName: string;
}
function parseArgs(args: string[]): RegisterArgs {
const result: RegisterArgs = {
jid: '',
name: '',
trigger: '',
folder: '',
requiresTrigger: true,
assistantName: 'Andy',
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--jid': result.jid = args[++i] || ''; break;
case '--name': result.name = args[++i] || ''; break;
case '--trigger': result.trigger = args[++i] || ''; break;
case '--folder': result.folder = args[++i] || ''; break;
case '--no-trigger-required': result.requiresTrigger = false; break;
case '--assistant-name': result.assistantName = args[++i] || 'Andy'; break;
}
}
return result;
}
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const parsed = parseArgs(args);
if (!parsed.jid || !parsed.name || !parsed.trigger || !parsed.folder) {
emitStatus('REGISTER_CHANNEL', {
STATUS: 'failed',
ERROR: 'missing_required_args',
LOG: 'logs/setup.log',
});
process.exit(4);
}
logger.info(parsed, 'Registering channel');
// Ensure data directory exists
fs.mkdirSync(path.join(projectRoot, 'data'), { recursive: true });
// Write to SQLite using parameterized queries (no SQL injection)
const dbPath = path.join(STORE_DIR, 'messages.db');
const timestamp = new Date().toISOString();
const requiresTriggerInt = parsed.requiresTrigger ? 1 : 0;
const db = new Database(dbPath);
// Ensure schema exists
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
jid TEXT PRIMARY KEY,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
requires_trigger INTEGER DEFAULT 1
)`);
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run(parsed.jid, parsed.name, parsed.folder, parsed.trigger, timestamp, requiresTriggerInt);
db.close();
logger.info('Wrote registration to SQLite');
// Create group folders
fs.mkdirSync(path.join(projectRoot, 'groups', parsed.folder, 'logs'), { recursive: true });
// Update assistant name in CLAUDE.md files if different from default
let nameUpdated = false;
if (parsed.assistantName !== 'Andy') {
logger.info({ from: 'Andy', to: parsed.assistantName }, 'Updating assistant name');
const mdFiles = [
path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'),
path.join(projectRoot, 'groups', 'main', 'CLAUDE.md'),
];
for (const mdFile of mdFiles) {
if (fs.existsSync(mdFile)) {
let content = fs.readFileSync(mdFile, 'utf-8');
content = content.replace(/^# Andy$/m, `# ${parsed.assistantName}`);
content = content.replace(/You are Andy/g, `You are ${parsed.assistantName}`);
fs.writeFileSync(mdFile, content);
logger.info({ file: mdFile }, 'Updated CLAUDE.md');
}
}
// Update .env
const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) {
let envContent = fs.readFileSync(envFile, 'utf-8');
if (envContent.includes('ASSISTANT_NAME=')) {
envContent = envContent.replace(
/^ASSISTANT_NAME=.*$/m,
`ASSISTANT_NAME="${parsed.assistantName}"`,
);
} else {
envContent += `\nASSISTANT_NAME="${parsed.assistantName}"`;
}
fs.writeFileSync(envFile, envContent);
} else {
fs.writeFileSync(envFile, `ASSISTANT_NAME="${parsed.assistantName}"\n`);
}
logger.info('Set ASSISTANT_NAME in .env');
nameUpdated = true;
}
emitStatus('REGISTER_CHANNEL', {
JID: parsed.jid,
NAME: parsed.name,
FOLDER: parsed.folder,
TRIGGER: parsed.trigger,
REQUIRES_TRIGGER: parsed.requiresTrigger,
ASSISTANT_NAME: parsed.assistantName,
NAME_UPDATED: nameUpdated,
STATUS: 'success',
LOG: 'logs/setup.log',
});
}

View File

@@ -1,134 +0,0 @@
import { describe, it, expect } from 'vitest';
import path from 'path';
/**
* Tests for service configuration generation.
*
* These tests verify the generated content of plist/systemd/nohup configs
* without actually loading services.
*/
// Helper: generate a plist string the same way service.ts does
function generatePlist(nodePath: string, projectRoot: string, homeDir: string): string {
return `<?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">
<dict>
<key>Label</key>
<string>com.nanoclaw</string>
<key>ProgramArguments</key>
<array>
<string>${nodePath}</string>
<string>${projectRoot}/dist/index.js</string>
</array>
<key>WorkingDirectory</key>
<string>${projectRoot}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin</string>
<key>HOME</key>
<string>${homeDir}</string>
</dict>
<key>StandardOutPath</key>
<string>${projectRoot}/logs/nanoclaw.log</string>
<key>StandardErrorPath</key>
<string>${projectRoot}/logs/nanoclaw.error.log</string>
</dict>
</plist>`;
}
function generateSystemdUnit(
nodePath: string,
projectRoot: string,
homeDir: string,
isSystem: boolean,
): string {
return `[Unit]
Description=NanoClaw Personal Assistant
After=network.target
[Service]
Type=simple
ExecStart=${nodePath} ${projectRoot}/dist/index.js
WorkingDirectory=${projectRoot}
Restart=always
RestartSec=5
Environment=HOME=${homeDir}
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin
StandardOutput=append:${projectRoot}/logs/nanoclaw.log
StandardError=append:${projectRoot}/logs/nanoclaw.error.log
[Install]
WantedBy=${isSystem ? 'multi-user.target' : 'default.target'}`;
}
describe('plist generation', () => {
it('contains the correct label', () => {
const plist = generatePlist('/usr/local/bin/node', '/home/user/nanoclaw', '/home/user');
expect(plist).toContain('<string>com.nanoclaw</string>');
});
it('uses the correct node path', () => {
const plist = generatePlist('/opt/node/bin/node', '/home/user/nanoclaw', '/home/user');
expect(plist).toContain('<string>/opt/node/bin/node</string>');
});
it('points to dist/index.js', () => {
const plist = generatePlist('/usr/local/bin/node', '/home/user/nanoclaw', '/home/user');
expect(plist).toContain('/home/user/nanoclaw/dist/index.js');
});
it('sets log paths', () => {
const plist = generatePlist('/usr/local/bin/node', '/home/user/nanoclaw', '/home/user');
expect(plist).toContain('nanoclaw.log');
expect(plist).toContain('nanoclaw.error.log');
});
});
describe('systemd unit generation', () => {
it('user unit uses default.target', () => {
const unit = generateSystemdUnit('/usr/bin/node', '/home/user/nanoclaw', '/home/user', false);
expect(unit).toContain('WantedBy=default.target');
});
it('system unit uses multi-user.target', () => {
const unit = generateSystemdUnit('/usr/bin/node', '/home/user/nanoclaw', '/home/user', true);
expect(unit).toContain('WantedBy=multi-user.target');
});
it('contains restart policy', () => {
const unit = generateSystemdUnit('/usr/bin/node', '/home/user/nanoclaw', '/home/user', false);
expect(unit).toContain('Restart=always');
expect(unit).toContain('RestartSec=5');
});
it('sets correct ExecStart', () => {
const unit = generateSystemdUnit('/usr/bin/node', '/srv/nanoclaw', '/home/user', false);
expect(unit).toContain('ExecStart=/usr/bin/node /srv/nanoclaw/dist/index.js');
});
});
describe('WSL nohup fallback', () => {
it('generates a valid wrapper script', () => {
const projectRoot = '/home/user/nanoclaw';
const nodePath = '/usr/bin/node';
const pidFile = path.join(projectRoot, 'nanoclaw.pid');
// Simulate what service.ts generates
const wrapper = `#!/bin/bash
set -euo pipefail
cd ${JSON.stringify(projectRoot)}
nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot)}/dist/index.js >> ${JSON.stringify(projectRoot)}/logs/nanoclaw.log 2>> ${JSON.stringify(projectRoot)}/logs/nanoclaw.error.log &
echo $! > ${JSON.stringify(pidFile)}`;
expect(wrapper).toContain('#!/bin/bash');
expect(wrapper).toContain('nohup');
expect(wrapper).toContain(nodePath);
expect(wrapper).toContain('nanoclaw.pid');
});
});

View File

@@ -1,336 +0,0 @@
/**
* Step: service — Generate and load service manager config.
* Replaces 08-setup-service.sh
*
* Fixes: Root→system systemd, WSL nohup fallback, no `|| true` swallowing errors.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { logger } from '../logger.js';
import {
getPlatform,
getNodePath,
getServiceManager,
hasSystemd,
isRoot,
isWSL,
} from './platform.js';
import { emitStatus } from './status.js';
export async function run(_args: string[]): Promise<void> {
const projectRoot = process.cwd();
const platform = getPlatform();
const nodePath = getNodePath();
const homeDir = os.homedir();
logger.info({ platform, nodePath, projectRoot }, 'Setting up service');
// Build first
logger.info('Building TypeScript');
try {
execSync('npm run build', {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'pipe'],
});
logger.info('Build succeeded');
} catch {
logger.error('Build failed');
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: 'unknown',
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
STATUS: 'failed',
ERROR: 'build_failed',
LOG: 'logs/setup.log',
});
process.exit(1);
}
fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true });
if (platform === 'macos') {
setupLaunchd(projectRoot, nodePath, homeDir);
} else if (platform === 'linux') {
setupLinux(projectRoot, nodePath, homeDir);
} else {
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: 'unknown',
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
STATUS: 'failed',
ERROR: 'unsupported_platform',
LOG: 'logs/setup.log',
});
process.exit(1);
}
}
function setupLaunchd(projectRoot: string, nodePath: string, homeDir: string): void {
const plistPath = path.join(homeDir, 'Library', 'LaunchAgents', 'com.nanoclaw.plist');
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
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">
<dict>
<key>Label</key>
<string>com.nanoclaw</string>
<key>ProgramArguments</key>
<array>
<string>${nodePath}</string>
<string>${projectRoot}/dist/index.js</string>
</array>
<key>WorkingDirectory</key>
<string>${projectRoot}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin</string>
<key>HOME</key>
<string>${homeDir}</string>
</dict>
<key>StandardOutPath</key>
<string>${projectRoot}/logs/nanoclaw.log</string>
<key>StandardErrorPath</key>
<string>${projectRoot}/logs/nanoclaw.error.log</string>
</dict>
</plist>`;
fs.writeFileSync(plistPath, plist);
logger.info({ plistPath }, 'Wrote launchd plist');
try {
execSync(`launchctl load ${JSON.stringify(plistPath)}`, { stdio: 'ignore' });
logger.info('launchctl load succeeded');
} catch {
logger.warn('launchctl load failed (may already be loaded)');
}
// Verify
let serviceLoaded = false;
try {
const output = execSync('launchctl list', { encoding: 'utf-8' });
serviceLoaded = output.includes('com.nanoclaw');
} catch {
// launchctl list failed
}
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: 'launchd',
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
PLIST_PATH: plistPath,
SERVICE_LOADED: serviceLoaded,
STATUS: 'success',
LOG: 'logs/setup.log',
});
}
function setupLinux(projectRoot: string, nodePath: string, homeDir: string): void {
const serviceManager = getServiceManager();
if (serviceManager === 'systemd') {
setupSystemd(projectRoot, nodePath, homeDir);
} else {
// WSL without systemd or other Linux without systemd
setupNohupFallback(projectRoot, nodePath, homeDir);
}
}
/**
* Kill any orphaned nanoclaw node processes left from previous runs or debugging.
* Prevents WhatsApp "conflict" disconnects when two instances connect simultaneously.
*/
function killOrphanedProcesses(projectRoot: string): void {
try {
execSync(`pkill -f '${projectRoot}/dist/index\\.js' || true`, {
stdio: 'ignore',
});
logger.info('Stopped any orphaned nanoclaw processes');
} catch {
// pkill not available or no orphans
}
}
/**
* Detect stale docker group membership in the user systemd session.
*
* When a user is added to the docker group mid-session, the user systemd
* daemon (user@UID.service) keeps the old group list from login time.
* Docker works in the terminal but not in the service context.
*
* Only relevant on Linux with user-level systemd (not root, not macOS, not WSL nohup).
*/
function checkDockerGroupStale(): boolean {
try {
execSync('systemd-run --user --pipe --wait docker info', {
stdio: 'pipe',
timeout: 10000,
});
return false; // Docker works from systemd session
} catch {
// Check if docker works from the current shell (to distinguish stale group vs broken docker)
try {
execSync('docker info', { stdio: 'pipe', timeout: 5000 });
return true; // Works in shell but not systemd session → stale group
} catch {
return false; // Docker itself is not working, different issue
}
}
}
function setupSystemd(projectRoot: string, nodePath: string, homeDir: string): void {
const runningAsRoot = isRoot();
// Root uses system-level service, non-root uses user-level
let unitPath: string;
let systemctlPrefix: string;
if (runningAsRoot) {
unitPath = '/etc/systemd/system/nanoclaw.service';
systemctlPrefix = 'systemctl';
logger.info('Running as root — installing system-level systemd unit');
} else {
// Check if user-level systemd session is available
try {
execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
} catch {
logger.warn('systemd user session not available — falling back to nohup wrapper');
setupNohupFallback(projectRoot, nodePath, homeDir);
return;
}
const unitDir = path.join(homeDir, '.config', 'systemd', 'user');
fs.mkdirSync(unitDir, { recursive: true });
unitPath = path.join(unitDir, 'nanoclaw.service');
systemctlPrefix = 'systemctl --user';
}
const unit = `[Unit]
Description=NanoClaw Personal Assistant
After=network.target
[Service]
Type=simple
ExecStart=${nodePath} ${projectRoot}/dist/index.js
WorkingDirectory=${projectRoot}
Restart=always
RestartSec=5
Environment=HOME=${homeDir}
Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin
StandardOutput=append:${projectRoot}/logs/nanoclaw.log
StandardError=append:${projectRoot}/logs/nanoclaw.error.log
[Install]
WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
fs.writeFileSync(unitPath, unit);
logger.info({ unitPath }, 'Wrote systemd unit');
// Detect stale docker group before starting (user systemd only)
const dockerGroupStale = !runningAsRoot && checkDockerGroupStale();
if (dockerGroupStale) {
logger.warn(
'Docker group not active in systemd session — user was likely added to docker group mid-session',
);
}
// Kill orphaned nanoclaw processes to avoid WhatsApp conflict errors
killOrphanedProcesses(projectRoot);
// Enable and start
try {
execSync(`${systemctlPrefix} daemon-reload`, { stdio: 'ignore' });
} catch (err) {
logger.error({ err }, 'systemctl daemon-reload failed');
}
try {
execSync(`${systemctlPrefix} enable nanoclaw`, { stdio: 'ignore' });
} catch (err) {
logger.error({ err }, 'systemctl enable failed');
}
try {
execSync(`${systemctlPrefix} start nanoclaw`, { stdio: 'ignore' });
} catch (err) {
logger.error({ err }, 'systemctl start failed');
}
// Verify
let serviceLoaded = false;
try {
execSync(`${systemctlPrefix} is-active nanoclaw`, { stdio: 'ignore' });
serviceLoaded = true;
} catch {
// Not active
}
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: runningAsRoot ? 'systemd-system' : 'systemd-user',
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
UNIT_PATH: unitPath,
SERVICE_LOADED: serviceLoaded,
...(dockerGroupStale ? { DOCKER_GROUP_STALE: true } : {}),
STATUS: 'success',
LOG: 'logs/setup.log',
});
}
function setupNohupFallback(projectRoot: string, nodePath: string, homeDir: string): void {
logger.warn('No systemd detected — generating nohup wrapper script');
const wrapperPath = path.join(projectRoot, 'start-nanoclaw.sh');
const pidFile = path.join(projectRoot, 'nanoclaw.pid');
const lines = [
'#!/bin/bash',
'# start-nanoclaw.sh — Start NanoClaw without systemd',
`# To stop: kill \\$(cat ${pidFile})`,
'',
'set -euo pipefail',
'',
`cd ${JSON.stringify(projectRoot)}`,
'',
'# Stop existing instance if running',
`if [ -f ${JSON.stringify(pidFile)} ]; then`,
` OLD_PID=$(cat ${JSON.stringify(pidFile)} 2>/dev/null || echo "")`,
' if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then',
' echo "Stopping existing NanoClaw (PID $OLD_PID)..."',
' kill "$OLD_PID" 2>/dev/null || true',
' sleep 2',
' fi',
'fi',
'',
'echo "Starting NanoClaw..."',
`nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot + '/dist/index.js')} \\`,
` >> ${JSON.stringify(projectRoot + '/logs/nanoclaw.log')} \\`,
` 2>> ${JSON.stringify(projectRoot + '/logs/nanoclaw.error.log')} &`,
'',
`echo $! > ${JSON.stringify(pidFile)}`,
'echo "NanoClaw started (PID $!)"',
`echo "Logs: tail -f ${projectRoot}/logs/nanoclaw.log"`,
];
const wrapper = lines.join('\n') + '\n';
fs.writeFileSync(wrapperPath, wrapper, { mode: 0o755 });
logger.info({ wrapperPath }, 'Wrote nohup wrapper script');
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: 'nohup',
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
WRAPPER_PATH: wrapperPath,
SERVICE_LOADED: false,
FALLBACK: 'wsl_no_systemd',
STATUS: 'success',
LOG: 'logs/setup.log',
});
}

View File

@@ -1,16 +0,0 @@
/**
* Structured status block output for setup steps.
* Each step emits a block that the SKILL.md LLM can parse.
*/
export function emitStatus(
step: string,
fields: Record<string, string | number | boolean>,
): void {
const lines = [`=== NANOCLAW SETUP: ${step} ===`];
for (const [key, value] of Object.entries(fields)) {
lines.push(`${key}: ${value}`);
}
lines.push('=== END ===');
console.log(lines.join('\n'));
}

View File

@@ -1,152 +0,0 @@
/**
* Step: verify — End-to-end health check of the full installation.
* Replaces 09-verify.sh
*
* Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../config.js';
import { logger } from '../logger.js';
import { getPlatform, getServiceManager, hasSystemd, isRoot } from './platform.js';
import { emitStatus } from './status.js';
export async function run(_args: string[]): Promise<void> {
const projectRoot = process.cwd();
const platform = getPlatform();
const homeDir = os.homedir();
logger.info('Starting verification');
// 1. Check service status
let service = 'not_found';
const mgr = getServiceManager();
if (mgr === 'launchd') {
try {
const output = execSync('launchctl list', { encoding: 'utf-8' });
if (output.includes('com.nanoclaw')) {
// Check if it has a PID (actually running)
const line = output.split('\n').find((l) => l.includes('com.nanoclaw'));
if (line) {
const pidField = line.trim().split(/\s+/)[0];
service = pidField !== '-' && pidField ? 'running' : 'stopped';
}
}
} catch {
// launchctl not available
}
} else if (mgr === 'systemd') {
const prefix = isRoot() ? 'systemctl' : 'systemctl --user';
try {
execSync(`${prefix} is-active nanoclaw`, { stdio: 'ignore' });
service = 'running';
} catch {
try {
const output = execSync(`${prefix} list-unit-files`, { encoding: 'utf-8' });
if (output.includes('nanoclaw')) {
service = 'stopped';
}
} catch {
// systemctl not available
}
}
} else {
// Check for nohup PID file
const pidFile = path.join(projectRoot, 'nanoclaw.pid');
if (fs.existsSync(pidFile)) {
try {
const pid = fs.readFileSync(pidFile, 'utf-8').trim();
if (pid) {
execSync(`kill -0 ${pid}`, { stdio: 'ignore' });
service = 'running';
}
} catch {
service = 'stopped';
}
}
}
logger.info({ service }, 'Service status');
// 2. Check container runtime
let containerRuntime = 'none';
try {
execSync('command -v container', { stdio: 'ignore' });
containerRuntime = 'apple-container';
} catch {
try {
execSync('docker info', { stdio: 'ignore' });
containerRuntime = 'docker';
} catch {
// No runtime
}
}
// 3. Check credentials
let credentials = 'missing';
const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) {
const envContent = fs.readFileSync(envFile, 'utf-8');
if (/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(envContent)) {
credentials = 'configured';
}
}
// 4. Check WhatsApp auth
let whatsappAuth = 'not_found';
const authDir = path.join(projectRoot, 'store', 'auth');
if (fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0) {
whatsappAuth = 'authenticated';
}
// 5. Check registered groups (using better-sqlite3, not sqlite3 CLI)
let registeredGroups = 0;
const dbPath = path.join(STORE_DIR, 'messages.db');
if (fs.existsSync(dbPath)) {
try {
const db = new Database(dbPath, { readonly: true });
const row = db.prepare(
'SELECT COUNT(*) as count FROM registered_groups',
).get() as { count: number };
registeredGroups = row.count;
db.close();
} catch {
// Table might not exist
}
}
// 6. Check mount allowlist
let mountAllowlist = 'missing';
if (fs.existsSync(path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'))) {
mountAllowlist = 'configured';
}
// Determine overall status
const status =
service === 'running' &&
credentials !== 'missing' &&
whatsappAuth !== 'not_found' &&
registeredGroups > 0
? 'success'
: 'failed';
logger.info({ status }, 'Verification complete');
emitStatus('VERIFY', {
SERVICE: service,
CONTAINER_RUNTIME: containerRuntime,
CREDENTIALS: credentials,
WHATSAPP_AUTH: whatsappAuth,
REGISTERED_GROUPS: registeredGroups,
MOUNT_ALLOWLIST: mountAllowlist,
STATUS: status,
LOG: 'logs/setup.log',
});
if (status === 'failed') process.exit(1);
}

View File

@@ -1,310 +0,0 @@
/**
* Step: whatsapp-auth — Full WhatsApp auth flow with polling.
* Replaces 04-auth-whatsapp.sh
*/
import { execSync, spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { logger } from '../logger.js';
import { openBrowser, isHeadless } from './platform.js';
import { emitStatus } from './status.js';
const QR_AUTH_TEMPLATE = `<!DOCTYPE html>
<html><head><title>NanoClaw - WhatsApp Auth</title>
<meta http-equiv="refresh" content="3">
<style>
body { font-family: -apple-system, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: white; border-radius: 16px; padding: 40px; box-shadow: 0 4px 24px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
h2 { margin: 0 0 8px; }
.timer { font-size: 18px; color: #666; margin: 12px 0; }
.timer.urgent { color: #e74c3c; font-weight: bold; }
.instructions { color: #666; font-size: 14px; margin-top: 16px; }
svg { width: 280px; height: 280px; }
</style></head><body>
<div class="card">
<h2>Scan with WhatsApp</h2>
<div class="timer" id="timer">Expires in <span id="countdown">60</span>s</div>
<div id="qr">{{QR_SVG}}</div>
<div class="instructions">Settings \\u2192 Linked Devices \\u2192 Link a Device</div>
</div>
<script>
var startKey = 'nanoclaw_qr_start';
var start = localStorage.getItem(startKey);
if (!start) { start = Date.now().toString(); localStorage.setItem(startKey, start); }
var elapsed = Math.floor((Date.now() - parseInt(start)) / 1000);
var remaining = Math.max(0, 60 - elapsed);
var countdown = document.getElementById('countdown');
var timer = document.getElementById('timer');
countdown.textContent = remaining;
if (remaining <= 10) timer.classList.add('urgent');
if (remaining <= 0) {
timer.textContent = 'QR code expired \\u2014 a new one will appear shortly';
timer.classList.add('urgent');
localStorage.removeItem(startKey);
}
</script></body></html>`;
const SUCCESS_HTML = `<!DOCTYPE html>
<html><head><title>NanoClaw - Connected!</title>
<style>
body { font-family: -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: white; border-radius: 16px; padding: 40px; box-shadow: 0 4px 24px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
h2 { color: #27ae60; margin: 0 0 8px; }
p { color: #666; }
.check { font-size: 64px; margin-bottom: 16px; }
</style></head><body>
<div class="card">
<div class="check">&#10003;</div>
<h2>Connected to WhatsApp</h2>
<p>You can close this tab.</p>
</div>
<script>localStorage.removeItem('nanoclaw_qr_start');</script>
</body></html>`;
function parseArgs(args: string[]): { method: string; phone: string } {
let method = '';
let phone = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--method' && args[i + 1]) { method = args[i + 1]; i++; }
if (args[i] === '--phone' && args[i + 1]) { phone = args[i + 1]; i++; }
}
return { method, phone };
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function readFileSafe(filePath: string): string {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return '';
}
}
function getPhoneNumber(projectRoot: string): string {
try {
const creds = JSON.parse(
fs.readFileSync(path.join(projectRoot, 'store', 'auth', 'creds.json'), 'utf-8'),
);
if (creds.me?.id) {
return creds.me.id.split(':')[0].split('@')[0];
}
} catch {
// Not available yet
}
return '';
}
function emitAuthStatus(
method: string,
authStatus: string,
status: string,
extra: Record<string, string> = {},
): void {
const fields: Record<string, string> = {
AUTH_METHOD: method,
AUTH_STATUS: authStatus,
...extra,
STATUS: status,
LOG: 'logs/setup.log',
};
emitStatus('AUTH_WHATSAPP', fields);
}
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const { method, phone } = parseArgs(args);
const statusFile = path.join(projectRoot, 'store', 'auth-status.txt');
const qrFile = path.join(projectRoot, 'store', 'qr-data.txt');
if (!method) {
emitAuthStatus('unknown', 'failed', 'failed', { ERROR: 'missing_method_flag' });
process.exit(4);
}
// qr-terminal is a manual flow
if (method === 'qr-terminal') {
emitAuthStatus('qr-terminal', 'manual', 'manual', { PROJECT_PATH: projectRoot });
return;
}
if (method === 'pairing-code' && !phone) {
emitAuthStatus('pairing-code', 'failed', 'failed', { ERROR: 'missing_phone_number' });
process.exit(4);
}
if (!['qr-browser', 'pairing-code'].includes(method)) {
emitAuthStatus(method, 'failed', 'failed', { ERROR: 'unknown_method' });
process.exit(4);
}
// Clean stale state
logger.info({ method }, 'Starting WhatsApp auth');
try { fs.rmSync(path.join(projectRoot, 'store', 'auth'), { recursive: true, force: true }); } catch { /* ok */ }
try { fs.unlinkSync(qrFile); } catch { /* ok */ }
try { fs.unlinkSync(statusFile); } catch { /* ok */ }
// Start auth process in background
const authArgs = method === 'pairing-code'
? ['src/whatsapp-auth.ts', '--pairing-code', '--phone', phone]
: ['src/whatsapp-auth.ts'];
const authProc = spawn('npx', ['tsx', ...authArgs], {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
const logFile = path.join(projectRoot, 'logs', 'setup.log');
const logStream = fs.createWriteStream(logFile, { flags: 'a' });
authProc.stdout?.pipe(logStream);
authProc.stderr?.pipe(logStream);
// Cleanup on exit
const cleanup = () => {
try { authProc.kill(); } catch { /* ok */ }
};
process.on('exit', cleanup);
try {
if (method === 'qr-browser') {
await handleQrBrowser(projectRoot, statusFile, qrFile);
} else {
await handlePairingCode(projectRoot, statusFile, phone);
}
} finally {
cleanup();
process.removeListener('exit', cleanup);
}
}
async function handleQrBrowser(
projectRoot: string,
statusFile: string,
qrFile: string,
): Promise<void> {
// Poll for QR data (15s)
let qrReady = false;
for (let i = 0; i < 15; i++) {
const statusContent = readFileSafe(statusFile);
if (statusContent === 'already_authenticated') {
emitAuthStatus('qr-browser', 'already_authenticated', 'success');
return;
}
if (fs.existsSync(qrFile)) {
qrReady = true;
break;
}
await sleep(1000);
}
if (!qrReady) {
emitAuthStatus('qr-browser', 'failed', 'failed', { ERROR: 'qr_timeout' });
process.exit(3);
}
// Generate QR SVG and HTML
const qrData = fs.readFileSync(qrFile, 'utf-8');
try {
const svg = execSync(
`node -e "const QR=require('qrcode');const data=${JSON.stringify(qrData)};QR.toString(data,{type:'svg'},(e,s)=>{if(e)process.exit(1);process.stdout.write(s)})"`,
{ cwd: projectRoot, encoding: 'utf-8' },
);
const html = QR_AUTH_TEMPLATE.replace('{{QR_SVG}}', svg);
const htmlPath = path.join(projectRoot, 'store', 'qr-auth.html');
fs.writeFileSync(htmlPath, html);
// Open in browser (cross-platform)
if (!isHeadless()) {
const opened = openBrowser(htmlPath);
if (!opened) {
logger.warn('Could not open browser — display QR in terminal as fallback');
}
} else {
logger.info('Headless environment — QR HTML saved but browser not opened');
}
} catch (err) {
logger.error({ err }, 'Failed to generate QR HTML');
}
// Poll for completion (120s)
await pollAuthCompletion('qr-browser', statusFile, projectRoot);
}
async function handlePairingCode(
projectRoot: string,
statusFile: string,
phone: string,
): Promise<void> {
// Poll for pairing code (15s)
let pairingCode = '';
for (let i = 0; i < 15; i++) {
const statusContent = readFileSafe(statusFile);
if (statusContent === 'already_authenticated') {
emitAuthStatus('pairing-code', 'already_authenticated', 'success');
return;
}
if (statusContent.startsWith('pairing_code:')) {
pairingCode = statusContent.replace('pairing_code:', '');
break;
}
if (statusContent.startsWith('failed:')) {
emitAuthStatus('pairing-code', 'failed', 'failed', {
ERROR: statusContent.replace('failed:', ''),
});
process.exit(1);
}
await sleep(1000);
}
if (!pairingCode) {
emitAuthStatus('pairing-code', 'failed', 'failed', { ERROR: 'pairing_code_timeout' });
process.exit(3);
}
// Emit pairing code immediately so the caller can display it to the user
emitAuthStatus('pairing-code', 'pairing_code_ready', 'waiting', { PAIRING_CODE: pairingCode });
// Poll for completion (120s)
await pollAuthCompletion('pairing-code', statusFile, projectRoot, pairingCode);
}
async function pollAuthCompletion(
method: string,
statusFile: string,
projectRoot: string,
pairingCode?: string,
): Promise<void> {
const extra: Record<string, string> = {};
if (pairingCode) extra.PAIRING_CODE = pairingCode;
for (let i = 0; i < 60; i++) {
const content = readFileSafe(statusFile);
if (content === 'authenticated' || content === 'already_authenticated') {
// Write success page if qr-auth.html exists
const htmlPath = path.join(projectRoot, 'store', 'qr-auth.html');
if (fs.existsSync(htmlPath)) {
fs.writeFileSync(htmlPath, SUCCESS_HTML);
}
const phoneNumber = getPhoneNumber(projectRoot);
if (phoneNumber) extra.PHONE_NUMBER = phoneNumber;
emitAuthStatus(method, content, 'success', extra);
return;
}
if (content.startsWith('failed:')) {
const error = content.replace('failed:', '');
emitAuthStatus(method, 'failed', 'failed', { ERROR: error, ...extra });
process.exit(1);
}
await sleep(2000);
}
emitAuthStatus(method, 'failed', 'failed', { ERROR: 'timeout', ...extra });
process.exit(3);
}