Migrate setup from bash scripts to cross-platform Node.js modules (#382)
* refactor: migrate setup from bash scripts to cross-platform Node.js modules Replace 9 bash scripts + qr-auth.html with a two-phase setup system: a bash bootstrap (setup.sh) for Node.js/npm verification, and TypeScript modules (src/setup/) for everything else. Resolves cross-platform issues: sed -i replaced with fs operations, sqlite3 CLI replaced with better-sqlite3, browser opening made cross-platform, service management supports launchd/ systemd/WSL nohup fallback, SQL injection prevented with parameterized queries. Add Linux systemctl equivalents alongside macOS launchctl commands in 8 skill files and CLAUDE.md. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: setup migration issues — pairing code, systemd fallback, nohup escaping - Emit WhatsApp pairing code immediately when received, before polling for auth completion. Previously the code was only shown in the final status block after auth succeeded — a catch-22 since the user needs the code to authenticate. (whatsapp-auth.ts) - Add systemd user session pre-check before attempting to write the user-level service unit. Falls back to nohup wrapper when user-level systemd is unavailable (e.g. su session without login/D-Bus). (service.ts) - Rewrite nohup wrapper template using array join instead of template literal to fix shell variable escaping (\\$ → $). (service.ts) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: detect stale docker group and kill orphaned processes on Linux systemd * fix: remove redundant shell option from execSync to fix TS2769 execSync already runs in a shell by default; the explicit `shell: true` caused a type error with @types/node which expects string, not boolean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: hide QR browser auth option on headless Linux Emit IS_HEADLESS from environment step and condition SKILL.md to only show pairing code + QR terminal when no display server is available (headless Linux without WSL). WSL is excluded from the headless gate because browser opening works via Windows interop. 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:
147
src/setup/register.ts
Normal file
147
src/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 '../config.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
interface RegisterArgs {
|
||||
jid: string;
|
||||
name: string;
|
||||
trigger: string;
|
||||
folder: string;
|
||||
requiresTrigger: boolean;
|
||||
assistantName: string;
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): RegisterArgs {
|
||||
const result: RegisterArgs = {
|
||||
jid: '',
|
||||
name: '',
|
||||
trigger: '',
|
||||
folder: '',
|
||||
requiresTrigger: true,
|
||||
assistantName: 'Andy',
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--jid': result.jid = args[++i] || ''; break;
|
||||
case '--name': result.name = args[++i] || ''; break;
|
||||
case '--trigger': result.trigger = args[++i] || ''; break;
|
||||
case '--folder': result.folder = args[++i] || ''; break;
|
||||
case '--no-trigger-required': result.requiresTrigger = false; break;
|
||||
case '--assistant-name': result.assistantName = args[++i] || 'Andy'; break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
const parsed = parseArgs(args);
|
||||
|
||||
if (!parsed.jid || !parsed.name || !parsed.trigger || !parsed.folder) {
|
||||
emitStatus('REGISTER_CHANNEL', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'missing_required_args',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
logger.info(parsed, 'Registering channel');
|
||||
|
||||
// Ensure data directory exists
|
||||
fs.mkdirSync(path.join(projectRoot, 'data'), { recursive: true });
|
||||
|
||||
// Write to SQLite using parameterized queries (no SQL injection)
|
||||
const dbPath = path.join(STORE_DIR, 'messages.db');
|
||||
const timestamp = new Date().toISOString();
|
||||
const requiresTriggerInt = parsed.requiresTrigger ? 1 : 0;
|
||||
|
||||
const db = new Database(dbPath);
|
||||
// Ensure schema exists
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
|
||||
jid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
trigger_pattern TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL,
|
||||
container_config TEXT,
|
||||
requires_trigger INTEGER DEFAULT 1
|
||||
)`);
|
||||
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups
|
||||
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||
).run(parsed.jid, parsed.name, parsed.folder, parsed.trigger, timestamp, requiresTriggerInt);
|
||||
|
||||
db.close();
|
||||
logger.info('Wrote registration to SQLite');
|
||||
|
||||
// Create group folders
|
||||
fs.mkdirSync(path.join(projectRoot, 'groups', parsed.folder, 'logs'), { recursive: true });
|
||||
|
||||
// Update assistant name in CLAUDE.md files if different from default
|
||||
let nameUpdated = false;
|
||||
if (parsed.assistantName !== 'Andy') {
|
||||
logger.info({ from: 'Andy', to: parsed.assistantName }, 'Updating assistant name');
|
||||
|
||||
const mdFiles = [
|
||||
path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'),
|
||||
path.join(projectRoot, 'groups', 'main', 'CLAUDE.md'),
|
||||
];
|
||||
|
||||
for (const mdFile of mdFiles) {
|
||||
if (fs.existsSync(mdFile)) {
|
||||
let content = fs.readFileSync(mdFile, 'utf-8');
|
||||
content = content.replace(/^# Andy$/m, `# ${parsed.assistantName}`);
|
||||
content = content.replace(/You are Andy/g, `You are ${parsed.assistantName}`);
|
||||
fs.writeFileSync(mdFile, content);
|
||||
logger.info({ file: mdFile }, 'Updated CLAUDE.md');
|
||||
}
|
||||
}
|
||||
|
||||
// Update .env
|
||||
const envFile = path.join(projectRoot, '.env');
|
||||
if (fs.existsSync(envFile)) {
|
||||
let envContent = fs.readFileSync(envFile, 'utf-8');
|
||||
if (envContent.includes('ASSISTANT_NAME=')) {
|
||||
envContent = envContent.replace(
|
||||
/^ASSISTANT_NAME=.*$/m,
|
||||
`ASSISTANT_NAME="${parsed.assistantName}"`,
|
||||
);
|
||||
} else {
|
||||
envContent += `\nASSISTANT_NAME="${parsed.assistantName}"`;
|
||||
}
|
||||
fs.writeFileSync(envFile, envContent);
|
||||
} else {
|
||||
fs.writeFileSync(envFile, `ASSISTANT_NAME="${parsed.assistantName}"\n`);
|
||||
}
|
||||
logger.info('Set ASSISTANT_NAME in .env');
|
||||
nameUpdated = true;
|
||||
}
|
||||
|
||||
emitStatus('REGISTER_CHANNEL', {
|
||||
JID: parsed.jid,
|
||||
NAME: parsed.name,
|
||||
FOLDER: parsed.folder,
|
||||
TRIGGER: parsed.trigger,
|
||||
REQUIRES_TRIGGER: parsed.requiresTrigger,
|
||||
ASSISTANT_NAME: parsed.assistantName,
|
||||
NAME_UPDATED: nameUpdated,
|
||||
STATUS: 'success',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user