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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,7 +19,6 @@ describe('getPlatform', () => {
|
||||
const result = getPlatform();
|
||||
expect(['macos', 'linux', 'unknown']).toContain(result);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// --- isWSL ---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user