import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { existsSync } from 'node:fs'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { eq } from 'drizzle-orm'; import { closeDb, getDb } from '../db/client'; import { moduleConfigs, modules, secrets } from '../db/schema'; import { encryptSecret } from '../secrets/encryption'; import { generateMasterKey, writeMasterKey } from '../secrets/master-key'; import { upsertModuleConfig } from '../services/module-config'; import { resetTestDbPath } from '../test-utils/db-path'; import { runCli } from './index'; const TEST_DB_PATH = `./test-cli-${Date.now()}-${Math.random()}.db`; const TEST_MODULE_DIR = './test-cli-module'; const TEST_MASTER_KEY_PATH = './test-cli-master.key'; describe('CLI Integration', () => { beforeEach(async () => { // Set environment variables before any database access process.env.CELILO_DB_PATH = TEST_DB_PATH; process.env.CELILO_MASTER_KEY_PATH = TEST_MASTER_KEY_PATH; // Get database instance (will create using environment variable) const db = getDb(); // Create tables db.$client.run(` CREATE TABLE IF NOT EXISTS modules ( id TEXT PRIMARY KEY, name TEXT NOT NULL, version TEXT NOT NULL, description TEXT, state TEXT NOT NULL DEFAULT 'IMPORTED', manifest_data TEXT NOT NULL, source_path TEXT NOT NULL, imported_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()), error_message TEXT ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS module_configs ( id INTEGER PRIMARY KEY AUTOINCREMENT, module_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, value_json TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()), FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS capabilities ( id INTEGER PRIMARY KEY AUTOINCREMENT, module_id TEXT NOT NULL, capability_name TEXT NOT NULL, version TEXT NOT NULL, data TEXT NOT NULL, registered_at INTEGER NOT NULL DEFAULT (unixepoch()), FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS secrets ( id INTEGER PRIMARY KEY AUTOINCREMENT, module_id TEXT NOT NULL, name TEXT NOT NULL, encrypted_value TEXT NOT NULL, iv TEXT NOT NULL, auth_tag TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()), FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS system_config ( id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT NOT NULL UNIQUE, value TEXT NOT NULL, description TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()) ) `); // Clean up test directories if (existsSync(TEST_MODULE_DIR)) { await rm(TEST_MODULE_DIR, { recursive: true }); } // Create test module directory with manifest await mkdir(TEST_MODULE_DIR, { recursive: true }); await writeFile( join(TEST_MODULE_DIR, 'manifest.yml'), ` celilo_contract: "1.0" id: test-module name: Test Module version: 1.0.0 description: Test module for CLI `, ); // NOTE: the manifest file deliberately does NOT declare any required // secrets. The secret-set tests below construct their own manifest_data // JSON in their inner beforeEach (read by handleSecretSet from // module.manifestData), so they don't depend on this file. The // module-generate test, however, reads the FILE manifest via // loadInstalledManifest in services/config-interview.ts — if any // required secret is declared here, generate fails on the // non-interactive-and-no-responder probe. // Create master key for secret tests const masterKey = generateMasterKey(); await writeMasterKey(masterKey, TEST_MASTER_KEY_PATH); }); afterEach(async () => { // Close database connection closeDb(); // Clean up files if (existsSync(TEST_DB_PATH)) { await rm(TEST_DB_PATH); } const walPath = `${TEST_DB_PATH}-wal`; const shmPath = `${TEST_DB_PATH}-shm`; if (existsSync(walPath)) { await rm(walPath); } if (existsSync(shmPath)) { await rm(shmPath); } if (existsSync(TEST_MODULE_DIR)) { await rm(TEST_MODULE_DIR, { recursive: true }); } if (existsSync(TEST_MASTER_KEY_PATH)) { await rm(TEST_MASTER_KEY_PATH); } // Clear environment variables delete process.env.CELILO_MASTER_KEY_PATH; resetTestDbPath(); }); describe('help', () => { test('should display help with no arguments', async () => { const result = await runCli(['node', 'celilo']); expect(result.success).toBe(true); if (!result.success) return; expect(result.message).toContain('Celilo - Home Lab Orchestration System'); expect(result.message).toContain('Usage:'); expect(result.message).toContain('module import'); expect(result.message).toContain('module list'); }); test('should display help with help command', async () => { const result = await runCli(['node', 'celilo', 'help']); expect(result.success).toBe(true); if (!result.success) return; expect(result.message).toContain('Celilo - Home Lab Orchestration System'); }); }); describe('unknown commands', () => { test('should error on unknown command', async () => { const result = await runCli(['node', 'celilo', 'unknown']); expect(result.success).toBe(false); if (result.success) return; expect(result.error).toContain('Unknown command: unknown'); }); test('should error on unknown module subcommand', async () => { const result = await runCli(['node', 'celilo', 'module', 'unknown']); expect(result.success).toBe(false); if (result.success) return; expect(result.error).toContain('Unknown module subcommand: unknown'); }); test('should error on module without subcommand', async () => { const result = await runCli(['node', 'celilo', 'module']); expect(result.success).toBe(false); if (result.success) return; expect(result.error).toContain('Module subcommand required'); }); }); describe('module list', () => { test('should list no modules when empty', async () => { const result = await runCli(['node', 'celilo', 'module', 'list']); expect(result.success).toBe(true); if (!result.success) return; expect(result.message).toBe('No modules installed'); }); test('should list installed modules', async () => { const db = getDb(); // Insert test module db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test-module', 'Test Module', '1.0.0', '/path', '{}')`, ); const result = await runCli(['node', 'celilo', 'module', 'list']); expect(result.success).toBe(true); if (!result.success) return; expect(result.message).toContain('test-module'); expect(result.message).toContain('v1.0.0'); expect(result.message).toContain('IMPORTED'); }); test('should show module description', async () => { const db = getDb(); db.$client.run( `INSERT INTO modules (id, name, version, description, source_path, manifest_data) VALUES ('test-module', 'Test Module', '1.0.0', 'A test module', '/path', '{}')`, ); const result = await runCli(['node', 'celilo', 'module', 'list']); expect(result.success).toBe(true); if (!result.success) return; expect(result.message).toContain('A test module'); }); test('should show module error if present', async () => { const db = getDb(); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data, error_message) VALUES ('test-module', 'Test Module', '1.0.0', '/path', '{}', 'Something went wrong')`, ); const result = await runCli(['node', 'celilo', 'module', 'list']); expect(result.success).toBe(true); if (!result.success) return; expect(result.message).toContain('Error: Something went wrong'); }); }); describe('module config', () => { beforeEach(() => { const db = getDb(); // Insert test module with variable declarations const manifestData = JSON.stringify({ celilo_contract: '1.0', id: 'test-module', name: 'Test Module', version: '1.0.0', variables: { owns: [ { name: 'hostname', type: 'string', required: true }, { name: 'ip', type: 'string', required: false }, ], }, }); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test-module', 'Test Module', '1.0.0', '/path', '${manifestData}')`, ); }); test('should set config value', async () => { const result = await runCli([ 'node', 'celilo', 'module', 'config', 'set', 'test-module', 'hostname', 'myhost', ]); expect(result.success).toBe(true); if (!result.success) return; expect(result.message).toContain('Set config for test-module: hostname'); // Verify in database const db = getDb(); const config = db .select() .from(moduleConfigs) .where(eq(moduleConfigs.moduleId, 'test-module')) .get(); expect(config?.key).toBe('hostname'); expect(config?.value).toBe('myhost'); }); test('should update existing config value', async () => { const db = getDb(); // Set initial value upsertModuleConfig(db, 'test-module', 'hostname', 'oldhost'); const result = await runCli([ 'node', 'celilo', 'module', 'config', 'set', 'test-module', 'hostname', 'newhost', ]); expect(result.success).toBe(true); if (!result.success) return; expect(result.message).toContain('Set config for test-module: hostname'); // Verify update const config = db .select() .from(moduleConfigs) .where(eq(moduleConfigs.moduleId, 'test-module')) .get(); expect(config?.value).toBe('newhost'); }); test('should error on invalid config key', async () => { const result = await runCli([ 'node', 'celilo', 'module', 'config', 'set', 'test-module', 'invalid_key', 'value', ]); expect(result.success).toBe(false); if (result.success) return; expect(result.error).toContain('Invalid config key'); expect(result.error).toContain('Valid keys: hostname'); }); test('should error on missing module for config set', async () => { const result = await runCli([ 'node', 'celilo', 'module', 'config', 'set', 'nonexistent', 'key', 'value', ]); expect(result.success).toBe(false); if (result.success) return; expect(result.error).toContain('Module not found: nonexistent'); }); test('should get specific config value', async () => { const db = getDb(); upsertModuleConfig(db, 'test-module', 'hostname', 'myhost'); const result = await runCli([ 'node', 'celilo', 'module', 'config', 'get', 'test-module', 'hostname', ]); expect(result.success).toBe(true); if (!result.success) return; expect(result.message).toBe('hostname = myhost'); }); test('should get all config values', async () => { const db = getDb(); upsertModuleConfig(db, 'test-module', 'hostname', 'myhost'); upsertModuleConfig(db, 'test-module', 'ip', '192.168.0.50'); const result = await runCli(['node', 'celilo', 'module', 'config', 'get', 'test-module']); expect(result.success).toBe(true); if (!result.success) return; expect(result.message).toContain('hostname = myhost'); expect(result.message).toContain('ip = 192.168.0.50'); }); test('should error on missing config key', async () => { const result = await runCli([ 'node', 'celilo', 'module', 'config', 'get', 'test-module', 'missing', ]); expect(result.success).toBe(false); if (result.success) return; expect(result.error).toContain('Config key not found: missing'); }); test('should show message when no config set', async () => { const result = await runCli(['node', 'celilo', 'module', 'config', 'get', 'test-module']); expect(result.success).toBe(true); if (!result.success) return; expect(result.message).toContain('No configuration set for test-module'); }); test('should error on insufficient args for config set', async () => { const result = await runCli(['node', 'celilo', 'module', 'config', 'set', 'test-module']); expect(result.success).toBe(false); if (result.success) return; expect(result.error).toContain('Missing required arguments'); }); test('should error on missing config action', async () => { const result = await runCli(['node', 'celilo', 'module', 'config']); expect(result.success).toBe(false); if (result.success) return; expect(result.error).toContain('Config action required'); }); }); describe('secret set', () => { beforeEach(() => { const db = getDb(); // Insert test module with secrets manifest const manifestData = JSON.stringify({ celilo_contract: '1.0', id: 'test-module', name: 'Test Module', version: '1.0.0', secrets: { declares: [ { name: 'api_key', type: 'string', required: true, description: 'Test API key', }, ], }, }); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test-module', 'Test Module', '1.0.0', '/path', ?)`, [manifestData], ); }); test('should set encrypted secret', async () => { const result = await runCli([ 'node', 'celilo', 'module', 'secret', 'set', 'test-module', 'api_key', 'secret123', ]); expect(result.success).toBe(true); if (!result.success) return; expect(result.message).toContain('Set secret for test-module: api_key'); // Verify in database const db = getDb(); const secret = db.select().from(secrets).where(eq(secrets.moduleId, 'test-module')).get(); expect(secret?.name).toBe('api_key'); expect(secret?.encryptedValue).toBeDefined(); expect(secret?.iv).toBeDefined(); expect(secret?.authTag).toBeDefined(); // Ensure it's encrypted (not plaintext) expect(secret?.encryptedValue).not.toBe('secret123'); }); test('should update existing secret', async () => { const db = getDb(); // Set initial secret const masterKey = generateMasterKey(); const encrypted = encryptSecret('oldsecret', masterKey); db.insert(secrets) .values({ moduleId: 'test-module', name: 'api_key', encryptedValue: encrypted.encryptedValue, iv: encrypted.iv, authTag: encrypted.authTag, }) .run(); const result = await runCli([ 'node', 'celilo', 'module', 'secret', 'set', 'test-module', 'api_key', 'newsecret', ]); expect(result.success).toBe(true); if (!result.success) return; expect(result.message).toContain('Updated secret for test-module'); // Verify update const secret = db.select().from(secrets).where(eq(secrets.moduleId, 'test-module')).get(); // New encrypted value should be different expect(secret?.encryptedValue).not.toBe(encrypted.encryptedValue); }); test('should error on missing module for secret set', async () => { const result = await runCli([ 'node', 'celilo', 'module', 'secret', 'set', 'nonexistent', 'key', 'value', ]); expect(result.success).toBe(false); if (result.success) return; expect(result.error).toContain('Module not found: nonexistent'); }); test('should error on invalid secret name', async () => { const result = await runCli([ 'node', 'celilo', 'module', 'secret', 'set', 'test-module', 'invalid_secret_name', 'value', ]); expect(result.success).toBe(false); if (result.success) return; expect(result.error).toContain("Invalid secret 'invalid_secret_name'"); expect(result.error).toContain('Valid secrets: api_key'); }); test('should error on insufficient args', async () => { const result = await runCli(['node', 'celilo', 'module', 'secret', 'set', 'test-module']); expect(result.success).toBe(false); if (result.success) return; expect(result.error).toContain('Missing required arguments'); }); }); describe('module import', () => { test('should error on missing subcommand', async () => { const result = await runCli(['node', 'celilo', 'module', 'import']); expect(result.success).toBe(false); if (result.success) return; // `module import` now takes a positional (name or path), so the error // names what's missing rather than "subcommand required". expect(result.error).toContain('Module name or path required'); }); test('should error on non-existent path', async () => { const result = await runCli(['node', 'celilo', 'module', 'import', './nonexistent']); expect(result.success).toBe(false); if (result.success) return; expect(result.error).toContain('does not exist'); }); test('should import valid module', async () => { const result = await runCli(['node', 'celilo', 'module', 'import', TEST_MODULE_DIR]); expect(result.success).toBe(true); if (!result.success) return; expect(result.message).toContain('Successfully imported module: test-module'); // Verify in database const db = getDb(); const module = db.select().from(modules).where(eq(modules.id, 'test-module')).get(); expect(module?.name).toBe('Test Module'); expect(module?.version).toBe('1.0.0'); }); }); describe('module generate', () => { beforeEach(async () => { const db = getDb(); // Insert test module db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test-module', 'Test Module', '1.0.0', '${TEST_MODULE_DIR}', '{}')`, ); // Add config upsertModuleConfig(db, 'test-module', 'hostname', 'testhost'); // Create template directory await mkdir(join(TEST_MODULE_DIR, 'terraform'), { recursive: true }); await writeFile( join(TEST_MODULE_DIR, 'terraform', 'main.tf.tpl'), 'hostname = "$self:hostname"', ); }); test('should generate templates', async () => { const result = await runCli(['node', 'celilo', 'module', 'generate', 'test-module']); expect(result.success).toBe(true); if (!result.success) return; expect(result.message).toContain('Successfully generated 1 files'); expect(result.message).toContain('terraform/main.tf'); }); test('should error on missing module', async () => { const result = await runCli(['node', 'celilo', 'module', 'generate', 'nonexistent']); expect(result.success).toBe(false); if (result.success) return; expect(result.error).toContain('Module not found: nonexistent'); }); test('should error on insufficient args', async () => { const result = await runCli(['node', 'celilo', 'module', 'generate']); expect(result.success).toBe(false); if (result.success) return; expect(result.error).toContain('Missing required arguments'); }); }); });