refactor: remove legacy container and non-discord remnants

This commit is contained in:
Eyejoker
2026-03-20 01:07:46 +09:00
parent ea09560128
commit bb0628e8f4
82 changed files with 2712 additions and 10523 deletions

View File

@@ -6,7 +6,7 @@ import Database from 'better-sqlite3';
/**
* Tests for the environment check step.
*
* Verifies: config detection, Docker/AC detection, DB queries.
* Verifies: config detection, platform helpers, DB queries.
*/
describe('environment detection', () => {
@@ -28,7 +28,7 @@ describe('registered groups DB query', () => {
folder TEXT NOT NULL UNIQUE,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
agent_config TEXT,
requires_trigger INTEGER DEFAULT 1
)`);
});
@@ -96,7 +96,7 @@ describe('credentials detection', () => {
});
});
describe('Docker detection logic', () => {
describe('platform command detection', () => {
it('commandExists returns boolean', async () => {
const { commandExists } = await import('./platform.js');
expect(typeof commandExists('docker')).toBe('boolean');
@@ -104,18 +104,3 @@ describe('Docker detection logic', () => {
});
});
describe('channel auth detection', () => {
it('detects non-empty auth directory', () => {
const hasAuth = (authDir: string) => {
try {
return fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
} catch {
return false;
}
};
// Non-existent directory
expect(hasAuth('/tmp/nonexistent_auth_dir_xyz')).toBe(false);
});
});

View File

@@ -1,10 +1,7 @@
/**
* Step: groups — Fetch group metadata from messaging platforms, write to DB.
* WhatsApp requires an upfront sync (Baileys groupFetchAllParticipating).
* Other channels discover group names at runtime — this step auto-skips for them.
* Replaces 05-sync-groups.sh + 05b-list-groups.sh
* Step: groups — List known Discord groups from the local chat metadata store.
* Discord channel names are discovered at runtime, so the sync path is a no-op.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
@@ -28,7 +25,6 @@ function parseArgs(args: string[]): { list: boolean; limit: number } {
}
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const { list, limit } = parseArgs(args);
if (list) {
@@ -36,7 +32,7 @@ export async function run(args: string[]): Promise<void> {
return;
}
await syncGroups(projectRoot);
await syncGroups();
}
async function listGroups(limit: number): Promise<void> {
@@ -51,9 +47,9 @@ async function listGroups(limit: number): Promise<void> {
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 ?`,
WHERE jid LIKE 'dc:%' AND is_group = 1 AND jid <> '__group_sync__' AND name <> jid
ORDER BY last_message_time DESC
LIMIT ?`,
)
.all(limit) as Array<{ jid: string; name: string }>;
db.close();
@@ -63,167 +59,33 @@ async function listGroups(limit: number): Promise<void> {
}
}
async function syncGroups(projectRoot: string): Promise<void> {
// Only WhatsApp needs an upfront group sync; other channels resolve names at runtime.
// Detect WhatsApp by checking for auth credentials on disk.
const authDir = path.join(projectRoot, 'store', 'auth');
const hasWhatsAppAuth =
fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
if (!hasWhatsAppAuth) {
logger.info('WhatsApp auth not found — skipping group sync');
emitStatus('SYNC_GROUPS', {
BUILD: 'skipped',
SYNC: 'skipped',
GROUPS_IN_DB: 0,
REASON: 'whatsapp_not_configured',
STATUS: 'success',
LOG: 'logs/setup.log',
});
return;
}
// 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 sync script via a temp file to avoid shell escaping issues with node -e
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 tmpScript = path.join(projectRoot, '.tmp-group-sync.mjs');
fs.writeFileSync(tmpScript, syncScript, 'utf-8');
try {
const output = execSync(`node ${tmpScript}`, {
cwd: projectRoot,
encoding: 'utf-8',
timeout: 45000,
stdio: ['ignore', 'pipe', 'pipe'],
});
syncOk = output.includes('SYNCED:');
logger.info({ output: output.trim() }, 'Sync output');
} finally {
try { fs.unlinkSync(tmpScript); } catch { /* ignore cleanup errors */ }
}
} catch (err) {
logger.error({ err }, 'Sync failed');
}
// Count groups in DB using better-sqlite3 (no sqlite3 CLI)
let groupsInDb = 0;
async function syncGroups(): Promise<void> {
const dbPath = path.join(STORE_DIR, 'messages.db');
let groupsInDb = 0;
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__'",
`SELECT COUNT(*) as count FROM chats
WHERE jid LIKE 'dc:%' AND is_group = 1 AND jid <> '__group_sync__' AND name <> jid`,
)
.get() as { count: number };
groupsInDb = row.count;
db.close();
} catch {
// DB may not exist yet
} catch (err) {
logger.warn({ err }, 'Failed to count Discord groups during setup');
}
}
const status = syncOk ? 'success' : 'failed';
logger.info({ groupsInDb }, 'Discord groups are discovered at runtime');
emitStatus('SYNC_GROUPS', {
BUILD: buildOk ? 'success' : 'failed',
SYNC: syncOk ? 'success' : 'failed',
BUILD: 'skipped',
SYNC: 'skipped',
GROUPS_IN_DB: groupsInDb,
STATUS: status,
REASON: 'discord_runtime_sync',
STATUS: 'success',
LOG: 'logs/setup.log',
});
if (status === 'failed') process.exit(1);
}

View File

@@ -10,10 +10,9 @@ const STEPS: Record<
() => Promise<{ run: (args: string[]) => Promise<void> }>
> = {
environment: () => import('./environment.js'),
runners: () => import('./container.js'),
runners: () => import('./runners.js'),
groups: () => import('./groups.js'),
register: () => import('./register.js'),
mounts: () => import('./mounts.js'),
service: () => import('./service.js'),
verify: () => import('./verify.js'),
};

View File

@@ -1,115 +0,0 @@
/**
* Step: mounts — Write mount allowlist config file.
* Replaces 07-configure-mounts.sh
*/
import fs from 'fs';
import path from 'path';
import os from 'os';
import { logger } from '../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',
});
}

View File

@@ -17,7 +17,7 @@ function createTestDb(): Database.Database {
folder TEXT NOT NULL UNIQUE,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
agent_config TEXT,
requires_trigger INTEGER DEFAULT 1,
is_main INTEGER DEFAULT 0
)`);
@@ -34,7 +34,7 @@ describe('parameterized SQL registration', () => {
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)
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run(
'123@g.us',
@@ -67,7 +67,7 @@ describe('parameterized SQL registration', () => {
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run(
'456@g.us',
@@ -92,7 +92,7 @@ describe('parameterized SQL registration', () => {
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run(maliciousJid, 'Evil', 'evil', '@Andy', '2024-01-01T00:00:00.000Z', 1);
@@ -113,10 +113,10 @@ describe('parameterized SQL registration', () => {
it('handles requiresTrigger=false', () => {
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run(
'789@s.whatsapp.net',
'dc:789',
'Personal',
'main',
'@Andy',
@@ -126,7 +126,7 @@ describe('parameterized SQL registration', () => {
const row = db
.prepare('SELECT requires_trigger FROM registered_groups WHERE jid = ?')
.get('789@s.whatsapp.net') as { requires_trigger: number };
.get('dc:789') as { requires_trigger: number };
expect(row.requires_trigger).toBe(0);
});
@@ -134,12 +134,12 @@ describe('parameterized SQL registration', () => {
it('stores is_main flag', () => {
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main)
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger, is_main)
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
).run(
'789@s.whatsapp.net',
'dc:789',
'Personal',
'whatsapp_main',
'discord_main',
'@Andy',
'2024-01-01T00:00:00.000Z',
0,
@@ -148,7 +148,7 @@ describe('parameterized SQL registration', () => {
const row = db
.prepare('SELECT is_main FROM registered_groups WHERE jid = ?')
.get('789@s.whatsapp.net') as { is_main: number };
.get('dc:789') as { is_main: number };
expect(row.is_main).toBe(1);
});
@@ -156,12 +156,12 @@ describe('parameterized SQL registration', () => {
it('defaults is_main to 0', () => {
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run(
'123@g.us',
'Some Group',
'whatsapp_some-group',
'discord_some-group',
'@Andy',
'2024-01-01T00:00:00.000Z',
1,
@@ -177,7 +177,7 @@ describe('parameterized SQL registration', () => {
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)
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
);

View File

@@ -1,7 +1,7 @@
/**
* Step: register — Write channel registration config, create group folders.
*
* Accepts --channel to specify the messaging platform (whatsapp, telegram, slack, discord).
* NanoClaw is Discord-only, so registrations must target Discord channel IDs.
* Uses parameterized SQL queries to prevent injection.
*/
import fs from 'fs';
@@ -9,7 +9,7 @@ import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../src/config.js';
import { SERVICE_AGENT_TYPE, STORE_DIR } from '../src/config.js';
import { isValidGroupFolder } from '../src/group-folder.js';
import { logger } from '../src/logger.js';
import { emitStatus } from './status.js';
@@ -31,7 +31,7 @@ function parseArgs(args: string[]): RegisterArgs {
name: '',
trigger: '',
folder: '',
channel: 'whatsapp', // backward-compat: pre-refactor installs omit --channel
channel: 'discord',
requiresTrigger: true,
isMain: false,
assistantName: 'Andy',
@@ -91,10 +91,18 @@ export async function run(args: string[]): Promise<void> {
process.exit(4);
}
if (parsed.channel !== 'discord') {
emitStatus('REGISTER_CHANNEL', {
STATUS: 'failed',
ERROR: 'unsupported_channel',
LOG: 'logs/setup.log',
});
process.exit(4);
}
logger.info(parsed, 'Registering channel');
// Ensure data and store directories exist (store/ may not exist on
// fresh installs that skip WhatsApp auth, which normally creates it)
// Ensure data and store directories exist for fresh installs.
fs.mkdirSync(path.join(projectRoot, 'data'), { recursive: true });
fs.mkdirSync(STORE_DIR, { recursive: true });
@@ -106,22 +114,40 @@ export async function run(args: string[]): Promise<void> {
const db = new Database(dbPath);
// Ensure schema exists
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
jid TEXT PRIMARY KEY,
jid TEXT NOT NULL,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
folder TEXT NOT NULL,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
agent_config TEXT,
requires_trigger INTEGER DEFAULT 1,
is_main INTEGER DEFAULT 0
is_main INTEGER DEFAULT 0,
agent_type TEXT NOT NULL DEFAULT 'claude-code',
work_dir TEXT,
PRIMARY KEY (jid, agent_type),
UNIQUE (folder, agent_type)
)`);
try {
db.exec(
`ALTER TABLE registered_groups ADD COLUMN agent_type TEXT DEFAULT 'claude-code'`,
);
} catch {
/* column already exists */
}
try {
db.exec(`ALTER TABLE registered_groups ADD COLUMN work_dir TEXT`);
} catch {
/* column already exists */
}
const isMainInt = parsed.isMain ? 1 : 0;
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main)
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger, is_main, agent_type, work_dir)
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, NULL)`,
).run(
parsed.jid,
parsed.name,
@@ -130,6 +156,7 @@ export async function run(args: string[]): Promise<void> {
timestamp,
requiresTriggerInt,
isMainInt,
SERVICE_AGENT_TYPE,
);
db.close();

View File

@@ -1,6 +1,5 @@
/**
* Step: runners Build agent runners (no container needed).
* Agents run as direct host processes.
* Step: runners Build the Claude and Codex runner packages.
*/
import { execSync } from 'child_process';
import fs from 'fs';
@@ -29,14 +28,14 @@ export async function run(_args: string[]): Promise<void> {
// Verify runner entry points exist
const agentRunner = path.join(
projectRoot,
'container',
'runners',
'agent-runner',
'dist',
'index.js',
);
const codexRunner = path.join(
projectRoot,
'container',
'runners',
'codex-runner',
'dist',
'index.js',

View File

@@ -6,7 +6,6 @@
*/
import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import Database from 'better-sqlite3';
@@ -14,18 +13,12 @@ import Database from 'better-sqlite3';
import { STORE_DIR } from '../src/config.js';
import { readEnvFile } from '../src/env.js';
import { logger } from '../src/logger.js';
import {
getPlatform,
getServiceManager,
hasSystemd,
isRoot,
} from './platform.js';
import { getPlatform, getServiceManager, 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');
@@ -93,31 +86,10 @@ export async function run(_args: string[]): Promise<void> {
}
// 3. Check channel auth (detect configured channels by credentials)
const envVars = readEnvFile([
'TELEGRAM_BOT_TOKEN',
'SLACK_BOT_TOKEN',
'SLACK_APP_TOKEN',
'DISCORD_BOT_TOKEN',
]);
const envVars = readEnvFile(['DISCORD_BOT_TOKEN']);
const channelAuth: Record<string, string> = {};
// WhatsApp: check for auth credentials on disk
const authDir = path.join(projectRoot, 'store', 'auth');
if (fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0) {
channelAuth.whatsapp = 'authenticated';
}
// Token-based channels: check .env
if (process.env.TELEGRAM_BOT_TOKEN || envVars.TELEGRAM_BOT_TOKEN) {
channelAuth.telegram = 'configured';
}
if (
(process.env.SLACK_BOT_TOKEN || envVars.SLACK_BOT_TOKEN) &&
(process.env.SLACK_APP_TOKEN || envVars.SLACK_APP_TOKEN)
) {
channelAuth.slack = 'configured';
}
if (process.env.DISCORD_BOT_TOKEN || envVars.DISCORD_BOT_TOKEN) {
channelAuth.discord = 'configured';
}
@@ -141,16 +113,6 @@ export async function run(_args: string[]): Promise<void> {
}
}
// 5. 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' &&
@@ -168,7 +130,6 @@ export async function run(_args: string[]): Promise<void> {
CONFIGURED_CHANNELS: configuredChannels.join(','),
CHANNEL_AUTH: JSON.stringify(channelAuth),
REGISTERED_GROUPS: registeredGroups,
MOUNT_ALLOWLIST: mountAllowlist,
STATUS: status,
LOG: 'logs/setup.log',
});