refactor: CI optimization, logging improvements, and codebase formatting (#456)

* fix(db): remove unique constraint on folder to support multi-channel agents

* ci: implement automated skill drift detection and self-healing PRs

* fix: align registration logic with Gavriel's feedback and fix build/test issues from Daniel Mi

* style: conform to prettier standards for CI validation

* test: fix branch naming inconsistency in CI (master vs main)

* fix(ci): robust module resolution by removing file extensions in scripts

* refactor(ci): simplify skill validation by removing redundant combination tests

* style: conform skills-engine to prettier, unify logging in index.ts and cleanup unused imports

* refactor: extract multi-channel DB changes to separate branch

Move channel column, folder suffix logic, and related migrations
to feat/multi-channel-db-v2 for independent review. This PR now
contains only CI/CD optimizations, Prettier formatting, and
logging improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Gabi Simons
2026-02-25 23:13:36 +02:00
committed by GitHub
parent bd2e236f73
commit 11c201088b
76 changed files with 2333 additions and 1308 deletions

View File

@@ -42,8 +42,13 @@ export async function run(args: string[]): Promise<void> {
// 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',
RUNTIME: runtime,
IMAGE: image,
BUILD_OK: false,
TEST_OK: false,
STATUS: 'failed',
ERROR: 'runtime_not_available',
LOG: 'logs/setup.log',
});
process.exit(2);
}
@@ -51,8 +56,13 @@ export async function run(args: string[]): Promise<void> {
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',
RUNTIME: runtime,
IMAGE: image,
BUILD_OK: false,
TEST_OK: false,
STATUS: 'failed',
ERROR: 'runtime_not_available',
LOG: 'logs/setup.log',
});
process.exit(2);
}
@@ -60,8 +70,13 @@ export async function run(args: string[]): Promise<void> {
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',
RUNTIME: runtime,
IMAGE: image,
BUILD_OK: false,
TEST_OK: false,
STATUS: 'failed',
ERROR: 'runtime_not_available',
LOG: 'logs/setup.log',
});
process.exit(2);
}
@@ -69,13 +84,19 @@ export async function run(args: string[]): Promise<void> {
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',
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 buildCmd =
runtime === 'apple-container' ? 'container build' : 'docker build';
const runCmd = runtime === 'apple-container' ? 'container' : 'docker';
// Build

View File

@@ -34,9 +34,9 @@ describe('registered groups DB query', () => {
});
it('returns 0 for empty table', () => {
const row = db.prepare(
'SELECT COUNT(*) as count FROM registered_groups',
).get() as { count: number };
const row = db
.prepare('SELECT COUNT(*) as count FROM registered_groups')
.get() as { count: number };
expect(row.count).toBe(0);
});
@@ -44,36 +44,54 @@ describe('registered groups DB query', () => {
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);
).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);
).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 };
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);
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);
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);
const hasCredentials =
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
expect(hasCredentials).toBe(false);
});
});

View File

@@ -43,9 +43,7 @@ export async function run(_args: string[]): Promise<void> {
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;
const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
let hasRegisteredGroups = false;
// Check JSON file first (pre-migration)
@@ -57,9 +55,9 @@ export async function run(_args: string[]): Promise<void> {
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 };
const row = db
.prepare('SELECT COUNT(*) as count FROM registered_groups')
.get() as { count: number };
if (row.count > 0) hasRegisteredGroups = true;
db.close();
} catch {
@@ -68,8 +66,18 @@ export async function run(_args: string[]): Promise<void> {
}
}
logger.info({ platform, wsl, appleContainer, docker, hasEnv, hasAuth, hasRegisteredGroups },
'Environment check complete');
logger.info(
{
platform,
wsl,
appleContainer,
docker,
hasEnv,
hasAuth,
hasRegisteredGroups,
},
'Environment check complete',
);
emitStatus('CHECK_ENVIRONMENT', {
PLATFORM: platform,

View File

@@ -17,7 +17,10 @@ function parseArgs(args: string[]): { list: boolean; limit: number } {
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++; }
if (args[i] === '--limit' && args[i + 1]) {
limit = parseInt(args[i + 1], 10);
i++;
}
}
return { list, limit };
}
@@ -43,12 +46,14 @@ async function listGroups(limit: number): Promise<void> {
}
const db = new Database(dbPath, { readonly: true });
const rows = db.prepare(
`SELECT jid, name FROM chats
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 }>;
)
.all(limit) as Array<{ jid: string; name: string }>;
db.close();
for (const row of rows) {
@@ -153,12 +158,15 @@ sock.ev.on('connection.update', async (update) => {
});
`;
const output = execSync(`node --input-type=module -e ${JSON.stringify(syncScript)}`, {
cwd: projectRoot,
encoding: 'utf-8',
timeout: 45000,
stdio: ['ignore', 'pipe', 'pipe'],
});
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) {
@@ -171,9 +179,11 @@ sock.ev.on('connection.update', async (update) => {
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 };
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 {

View File

@@ -5,7 +5,10 @@
import { logger } from '../src/logger.js';
import { emitStatus } from './status.js';
const STEPS: Record<string, () => Promise<{ run: (args: string[]) => Promise<void> }>> = {
const STEPS: Record<
string,
() => Promise<{ run: (args: string[]) => Promise<void> }>
> = {
environment: () => import('./environment.js'),
container: () => import('./container.js'),
'whatsapp-auth': () => import('./whatsapp-auth.js'),
@@ -21,12 +24,16 @@ async function main(): Promise<void> {
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...]`);
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 stepArgs = args.filter(
(a, i) => i !== stepIdx && i !== stepIdx + 1 && a !== '--',
);
const loader = STEPS[stepName];
if (!loader) {

View File

@@ -15,7 +15,10 @@ function parseArgs(args: string[]): { empty: boolean; json: string } {
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++; }
if (args[i] === '--json' && args[i + 1]) {
json = args[i + 1];
i++;
}
}
return { empty, json };
}
@@ -27,7 +30,9 @@ export async function run(args: string[]): Promise<void> {
const configFile = path.join(configDir, 'mount-allowlist.json');
if (isRoot()) {
logger.warn('Running as root — mount allowlist will be written to root home directory');
logger.warn(
'Running as root — mount allowlist will be written to root home directory',
);
}
fs.mkdirSync(configDir, { recursive: true });
@@ -63,7 +68,9 @@ export async function run(args: string[]): Promise<void> {
}
fs.writeFileSync(configFile, JSON.stringify(parsed, null, 2) + '\n');
allowedRoots = Array.isArray(parsed.allowedRoots) ? parsed.allowedRoots.length : 0;
allowedRoots = Array.isArray(parsed.allowedRoots)
? parsed.allowedRoots.length
: 0;
nonMainReadOnly = parsed.nonMainReadOnly === false ? 'false' : 'true';
} else {
// Read from stdin
@@ -87,11 +94,16 @@ export async function run(args: string[]): Promise<void> {
}
fs.writeFileSync(configFile, JSON.stringify(parsed, null, 2) + '\n');
allowedRoots = Array.isArray(parsed.allowedRoots) ? parsed.allowedRoots.length : 0;
allowedRoots = Array.isArray(parsed.allowedRoots)
? parsed.allowedRoots.length
: 0;
nonMainReadOnly = parsed.nonMainReadOnly === false ? 'false' : 'true';
}
logger.info({ configFile, allowedRoots, nonMainReadOnly }, 'Allowlist configured');
logger.info(
{ configFile, allowedRoots, nonMainReadOnly },
'Allowlist configured',
);
emitStatus('CONFIGURE_MOUNTS', {
PATH: configFile,

View File

@@ -19,7 +19,6 @@ describe('getPlatform', () => {
const result = getPlatform();
expect(['macos', 'linux', 'unknown']).toContain(result);
});
});
// --- isWSL ---

View File

@@ -73,7 +73,9 @@ export function openBrowser(url: string): boolean {
// WSL without wslview: try cmd.exe
if (isWSL()) {
try {
execSync(`cmd.exe /c start "" ${JSON.stringify(url)}`, { stdio: 'ignore' });
execSync(`cmd.exe /c start "" ${JSON.stringify(url)}`, {
stdio: 'ignore',
});
return true;
} catch {
// cmd.exe not available

View File

@@ -35,9 +35,18 @@ describe('parameterized SQL registration', () => {
`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);
).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 {
const row = db
.prepare('SELECT * FROM registered_groups WHERE jid = ?')
.get('123@g.us') as {
jid: string;
name: string;
folder: string;
@@ -59,9 +68,18 @@ describe('parameterized SQL registration', () => {
`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);
).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 {
const row = db
.prepare('SELECT name FROM registered_groups WHERE jid = ?')
.get('456@g.us') as {
name: string;
};
@@ -78,12 +96,16 @@ describe('parameterized SQL registration', () => {
).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 {
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 };
const row = db.prepare('SELECT jid FROM registered_groups').get() as {
jid: string;
};
expect(row.jid).toBe(maliciousJid);
});
@@ -92,9 +114,17 @@ describe('parameterized SQL registration', () => {
`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);
).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 = ?')
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);
@@ -107,13 +137,31 @@ describe('parameterized SQL registration', () => {
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);
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 };
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);

View File

@@ -35,12 +35,24 @@ function parseArgs(args: string[]): RegisterArgs {
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;
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;
}
}
@@ -95,18 +107,30 @@ export async function run(args: string[]): Promise<void> {
`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);
).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 });
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');
logger.info(
{ from: 'Andy', to: parsed.assistantName },
'Updating assistant name',
);
const mdFiles = [
path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'),
@@ -117,7 +141,10 @@ export async function run(args: string[]): Promise<void> {
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}`);
content = content.replace(
/You are Andy/g,
`You are ${parsed.assistantName}`,
);
fs.writeFileSync(mdFile, content);
logger.info({ file: mdFile }, 'Updated CLAUDE.md');
}

View File

@@ -9,7 +9,11 @@ import path from 'path';
*/
// Helper: generate a plist string the same way service.ts does
function generatePlist(nodePath: string, projectRoot: string, homeDir: string): string {
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">
@@ -69,22 +73,38 @@ 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');
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');
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');
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');
const plist = generatePlist(
'/usr/local/bin/node',
'/home/user/nanoclaw',
'/home/user',
);
expect(plist).toContain('nanoclaw.log');
expect(plist).toContain('nanoclaw.error.log');
});
@@ -92,24 +112,46 @@ describe('plist generation', () => {
describe('systemd unit generation', () => {
it('user unit uses default.target', () => {
const unit = generateSystemdUnit('/usr/bin/node', '/home/user/nanoclaw', '/home/user', false);
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);
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);
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');
const unit = generateSystemdUnit(
'/usr/bin/node',
'/srv/nanoclaw',
'/home/user',
false,
);
expect(unit).toContain(
'ExecStart=/usr/bin/node /srv/nanoclaw/dist/index.js',
);
});
});

View File

@@ -68,8 +68,17 @@ export async function run(_args: string[]): Promise<void> {
}
}
function setupLaunchd(projectRoot: string, nodePath: string, homeDir: string): void {
const plistPath = path.join(homeDir, 'Library', 'LaunchAgents', 'com.nanoclaw.plist');
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"?>
@@ -107,7 +116,9 @@ function setupLaunchd(projectRoot: string, nodePath: string, homeDir: string): v
logger.info({ plistPath }, 'Wrote launchd plist');
try {
execSync(`launchctl load ${JSON.stringify(plistPath)}`, { stdio: 'ignore' });
execSync(`launchctl load ${JSON.stringify(plistPath)}`, {
stdio: 'ignore',
});
logger.info('launchctl load succeeded');
} catch {
logger.warn('launchctl load failed (may already be loaded)');
@@ -133,7 +144,11 @@ function setupLaunchd(projectRoot: string, nodePath: string, homeDir: string): v
});
}
function setupLinux(projectRoot: string, nodePath: string, homeDir: string): void {
function setupLinux(
projectRoot: string,
nodePath: string,
homeDir: string,
): void {
const serviceManager = getServiceManager();
if (serviceManager === 'systemd') {
@@ -186,7 +201,11 @@ function checkDockerGroupStale(): boolean {
}
}
function setupSystemd(projectRoot: string, nodePath: string, homeDir: string): void {
function setupSystemd(
projectRoot: string,
nodePath: string,
homeDir: string,
): void {
const runningAsRoot = isRoot();
// Root uses system-level service, non-root uses user-level
@@ -202,7 +221,9 @@ function setupSystemd(projectRoot: string, nodePath: string, homeDir: string): v
try {
execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
} catch {
logger.warn('systemd user session not available — falling back to nohup wrapper');
logger.warn(
'systemd user session not available — falling back to nohup wrapper',
);
setupNohupFallback(projectRoot, nodePath, homeDir);
return;
}
@@ -284,7 +305,11 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
});
}
function setupNohupFallback(projectRoot: string, nodePath: string, homeDir: string): void {
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');

View File

@@ -13,7 +13,12 @@ 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 {
getPlatform,
getServiceManager,
hasSystemd,
isRoot,
} from './platform.js';
import { emitStatus } from './status.js';
export async function run(_args: string[]): Promise<void> {
@@ -48,7 +53,9 @@ export async function run(_args: string[]): Promise<void> {
service = 'running';
} catch {
try {
const output = execSync(`${prefix} list-unit-files`, { encoding: 'utf-8' });
const output = execSync(`${prefix} list-unit-files`, {
encoding: 'utf-8',
});
if (output.includes('nanoclaw')) {
service = 'stopped';
}
@@ -110,9 +117,9 @@ export async function run(_args: string[]): Promise<void> {
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 };
const row = db
.prepare('SELECT COUNT(*) as count FROM registered_groups')
.get() as { count: number };
registeredGroups = row.count;
db.close();
} catch {
@@ -122,7 +129,11 @@ export async function run(_args: string[]): Promise<void> {
// 6. Check mount allowlist
let mountAllowlist = 'missing';
if (fs.existsSync(path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'))) {
if (
fs.existsSync(
path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'),
)
) {
mountAllowlist = 'configured';
}

View File

@@ -66,8 +66,14 @@ 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++; }
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 };
}
@@ -87,7 +93,10 @@ function readFileSafe(filePath: string): string {
function getPhoneNumber(projectRoot: string): string {
try {
const creds = JSON.parse(
fs.readFileSync(path.join(projectRoot, 'store', 'auth', 'creds.json'), 'utf-8'),
fs.readFileSync(
path.join(projectRoot, 'store', 'auth', 'creds.json'),
'utf-8',
),
);
if (creds.me?.id) {
return creds.me.id.split(':')[0].split('@')[0];
@@ -121,18 +130,24 @@ export async function run(args: string[]): Promise<void> {
const qrFile = path.join(projectRoot, 'store', 'qr-data.txt');
if (!method) {
emitAuthStatus('unknown', 'failed', 'failed', { ERROR: 'missing_method_flag' });
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 });
emitAuthStatus('qr-terminal', 'manual', 'manual', {
PROJECT_PATH: projectRoot,
});
return;
}
if (method === 'pairing-code' && !phone) {
emitAuthStatus('pairing-code', 'failed', 'failed', { ERROR: 'missing_phone_number' });
emitAuthStatus('pairing-code', 'failed', 'failed', {
ERROR: 'missing_phone_number',
});
process.exit(4);
}
@@ -143,14 +158,30 @@ export async function run(args: string[]): Promise<void> {
// 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 */ }
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 authArgs =
method === 'pairing-code'
? ['src/whatsapp-auth.ts', '--pairing-code', '--phone', phone]
: ['src/whatsapp-auth.ts'];
const authProc = spawn('npx', ['tsx', ...authArgs], {
cwd: projectRoot,
@@ -165,7 +196,11 @@ export async function run(args: string[]): Promise<void> {
// Cleanup on exit
const cleanup = () => {
try { authProc.kill(); } catch { /* ok */ }
try {
authProc.kill();
} catch {
/* ok */
}
};
process.on('exit', cleanup);
@@ -221,10 +256,14 @@ async function handleQrBrowser(
if (!isHeadless()) {
const opened = openBrowser(htmlPath);
if (!opened) {
logger.warn('Could not open browser — display QR in terminal as fallback');
logger.warn(
'Could not open browser — display QR in terminal as fallback',
);
}
} else {
logger.info('Headless environment — QR HTML saved but browser not opened');
logger.info(
'Headless environment — QR HTML saved but browser not opened',
);
}
} catch (err) {
logger.error({ err }, 'Failed to generate QR HTML');
@@ -261,15 +300,24 @@ async function handlePairingCode(
}
if (!pairingCode) {
emitAuthStatus('pairing-code', 'failed', 'failed', { ERROR: 'pairing_code_timeout' });
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 });
emitAuthStatus('pairing-code', 'pairing_code_ready', 'waiting', {
PAIRING_CODE: pairingCode,
});
// Poll for completion (120s)
await pollAuthCompletion('pairing-code', statusFile, projectRoot, pairingCode);
await pollAuthCompletion(
'pairing-code',
statusFile,
projectRoot,
pairingCode,
);
}
async function pollAuthCompletion(