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:
123
setup/container.ts
Normal file
123
setup/container.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 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 '../src/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);
|
||||
}
|
||||
103
setup/environment.test.ts
Normal file
103
setup/environment.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
86
setup/environment.ts
Normal file
86
setup/environment.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 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 '../src/config.js';
|
||||
import { logger } from '../src/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',
|
||||
});
|
||||
}
|
||||
195
setup/groups.ts
Normal file
195
setup/groups.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 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 '../src/config.js';
|
||||
import { logger } from '../src/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);
|
||||
}
|
||||
52
setup/index.ts
Normal file
52
setup/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Setup CLI entry point.
|
||||
* Usage: npx tsx setup/index.ts --step <name> [args...]
|
||||
*/
|
||||
import { logger } from '../src/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 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();
|
||||
103
setup/mounts.ts
Normal file
103
setup/mounts.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 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 '../src/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',
|
||||
});
|
||||
}
|
||||
121
setup/platform.test.ts
Normal file
121
setup/platform.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// --- 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('matches the detected platform', () => {
|
||||
const platform = getPlatform();
|
||||
const result = getServiceManager();
|
||||
if (platform === 'macos') {
|
||||
expect(result).toBe('launchd');
|
||||
} else {
|
||||
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);
|
||||
});
|
||||
});
|
||||
130
setup/platform.ts
Normal file
130
setup/platform.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
165
setup/register.test.ts
Normal file
165
setup/register.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
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"');
|
||||
});
|
||||
});
|
||||
147
setup/register.ts
Normal file
147
setup/register.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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 '../src/config.js';
|
||||
import { logger } from '../src/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',
|
||||
});
|
||||
}
|
||||
134
setup/service.test.ts
Normal file
134
setup/service.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
336
setup/service.ts
Normal file
336
setup/service.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* 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 '../src/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',
|
||||
});
|
||||
}
|
||||
16
setup/status.ts
Normal file
16
setup/status.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 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'));
|
||||
}
|
||||
152
setup/verify.ts
Normal file
152
setup/verify.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 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 '../src/config.js';
|
||||
import { logger } from '../src/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);
|
||||
}
|
||||
310
setup/whatsapp-auth.ts
Normal file
310
setup/whatsapp-auth.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* 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 '../src/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">✓</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);
|
||||
}
|
||||
Reference in New Issue
Block a user