From fc8adf622de294bb889b4f7ee75ae01ad7048a49 Mon Sep 17 00:00:00 2001 From: kjellbergzoey Date: Sun, 7 Jun 2026 13:31:40 +0000 Subject: [PATCH] feat: promote shared proxy/splash into the persistent "kiqr agent" service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Traefik reverse proxy and the splash fallback page were previously a standalone stack that was started on `kiqr up` and torn down whenever no project was running, causing start/stop churn when switching between projects. This refactors that shared infrastructure into a single, named, persistent background service — the "kiqr agent" — managed as the `kiqr-agent` Docker Compose project. It is kiqr core, persists independently of any project, and is the home for future shared infra (dashboard, mail catcher, the collaboration tunnel client). Changes: - Rename src/lib/traefik.ts -> src/lib/agent.ts with agent-oriented API: generateAgentCompose, writeAgentCompose, ensureAgentRunning, stopAgent, restartAgent, ensureKiqrNetwork, isAgentRunning, and a testable getAgentStatus(deps) returning {running, containers, port}. - Container names (kiqr-traefik, kiqr-splash) and the `kiqr` network are unchanged for backward compatibility; the compose file now carries a `name: kiqr-agent` project identity. - New `kiqr agent` command group: start, stop, restart, status, logs. - Lifecycle change: `kiqr up`/`kiqr restart` call ensureAgentRunning(); the agent runs with `restart: unless-stopped` and is no longer auto-stopped by `kiqr down`/`kiqr destroy`. It persists across projects until the user runs `kiqr agent stop`. - Migrate/expand tests (tests/lib/agent.test.ts), update README and help. Co-Authored-By: Claude Opus 4.8 --- README.md | 21 +++++ src/commands/agent/index.tsx | 34 ++++++++ src/commands/agent/logs.tsx | 30 +++++++ src/commands/agent/restart.tsx | 62 ++++++++++++++ src/commands/agent/start.tsx | 66 +++++++++++++++ src/commands/agent/status.tsx | 43 ++++++++++ src/commands/agent/stop.tsx | 42 +++++++++ src/commands/destroy.tsx | 7 -- src/commands/down.tsx | 7 -- src/commands/index.tsx | 4 + src/commands/restart.tsx | 6 +- src/commands/up.tsx | 6 +- src/lib/agent.ts | 150 +++++++++++++++++++++++++++++++++ src/lib/compose.ts | 2 +- src/lib/traefik.ts | 119 -------------------------- tests/lib/agent.test.ts | 100 ++++++++++++++++++++++ tests/lib/traefik.test.ts | 41 --------- 17 files changed, 559 insertions(+), 181 deletions(-) create mode 100644 src/commands/agent/index.tsx create mode 100644 src/commands/agent/logs.tsx create mode 100644 src/commands/agent/restart.tsx create mode 100644 src/commands/agent/start.tsx create mode 100644 src/commands/agent/status.tsx create mode 100644 src/commands/agent/stop.tsx create mode 100644 src/lib/agent.ts delete mode 100644 src/lib/traefik.ts create mode 100644 tests/lib/agent.test.ts delete mode 100644 tests/lib/traefik.test.ts diff --git a/README.md b/README.md index 91eceea..317d6bd 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,11 @@ Theme Name: My Theme | `kiqr open uploads` | Open the uploads folder | | `kiqr logs` | Show WordPress logs | | `kiqr destroy` | Remove all site data and start fresh | +| `kiqr agent start` | Start the kiqr agent (shared background service) | +| `kiqr agent stop` | Stop the kiqr agent | +| `kiqr agent restart` | Restart the kiqr agent | +| `kiqr agent status` | Show whether the agent is running | +| `kiqr agent logs` | Stream the agent container logs | ## Configuration @@ -108,6 +113,22 @@ Kiqr runs WordPress, MariaDB, and phpMyAdmin in Docker containers, with [Traefik Each developer on the team gets their own local hostname based on their computer name, so there are no port conflicts when working on the same network. +### The kiqr agent + +The reverse proxy and the splash fallback page are managed together as the **kiqr agent** — a single, persistent background service shared by every project on your machine. The agent starts automatically on your first `kiqr up` and keeps running across projects (it uses Docker's `restart: unless-stopped` policy), so switching between themes no longer churns the proxy up and down. + +`kiqr down` and `kiqr destroy` only affect the current project; the agent keeps running. Manage it explicitly with: + +```bash +kiqr agent status # is it running? +kiqr agent start # start it +kiqr agent stop # stop it (e.g. to free the port) +kiqr agent restart # restart it +kiqr agent logs # stream its logs +``` + +The agent runs as the `kiqr-agent` Docker Compose project and is where future shared infrastructure (a dashboard, a mail catcher, the collaboration tunnel client) will live. + ## License MIT diff --git a/src/commands/agent/index.tsx b/src/commands/agent/index.tsx new file mode 100644 index 0000000..1254599 --- /dev/null +++ b/src/commands/agent/index.tsx @@ -0,0 +1,34 @@ +import {Box, Text} from 'ink'; + +export const description = 'Manage the kiqr agent (shared background service)'; + +export default function AgentIndex() { + return ( + + Kiqr Agent + The shared background service powering every kiqr project + + Commands: + + {' '} + kiqr agent start Start the agent + + + {' '} + kiqr agent stop Stop the agent + + + {' '} + kiqr agent restart Restart the agent + + + {' '} + kiqr agent status Show whether the agent is running + + + {' '} + kiqr agent logs Stream the agent container logs + + + ); +} diff --git a/src/commands/agent/logs.tsx b/src/commands/agent/logs.tsx new file mode 100644 index 0000000..7d77968 --- /dev/null +++ b/src/commands/agent/logs.tsx @@ -0,0 +1,30 @@ +import {execSync} from 'node:child_process'; +import {Box, Text, useApp} from 'ink'; +import {useEffect} from 'react'; +import {writeAgentCompose} from '../../lib/agent.js'; + +export const description = 'Stream the kiqr agent logs'; + +export default function AgentLogs() { + const {exit} = useApp(); + + useEffect(() => { + const composePath = writeAgentCompose(); + + try { + execSync(`docker compose -f "${composePath}" logs -f`, { + stdio: 'inherit', + }); + } catch { + // User pressed Ctrl+C or the agent is not running + } + + exit(); + }, []); + + return ( + + Loading agent logs... + + ); +} diff --git a/src/commands/agent/restart.tsx b/src/commands/agent/restart.tsx new file mode 100644 index 0000000..7ed9aef --- /dev/null +++ b/src/commands/agent/restart.tsx @@ -0,0 +1,62 @@ +import {Box, Text, useApp} from 'ink'; +import {useState} from 'react'; +import type {Step} from '../../components/StepRunner.js'; +import StepRunner from '../../components/StepRunner.js'; +import {AGENT_PORT, restartAgent} from '../../lib/agent.js'; +import {isDockerInstalled, isDockerRunning} from '../../lib/docker.js'; + +export const description = 'Restart the kiqr agent'; + +export default function AgentRestart() { + const {exit} = useApp(); + const [complete, setComplete] = useState(false); + + const steps: Step[] = [ + { + label: 'Checking Docker...', + run: async () => { + if (!isDockerInstalled()) { + throw new Error( + 'Docker is not installed. Please install Docker Desktop from https://docker.com', + ); + } + if (!isDockerRunning()) { + throw new Error( + 'Docker is not running. Please start Docker Desktop and try again.', + ); + } + }, + }, + { + label: 'Restarting kiqr agent...', + run: async () => { + restartAgent(); + }, + }, + ]; + + return ( + + { + setComplete(true); + setTimeout(() => exit(), 100); + }} + onError={() => setTimeout(() => exit(new Error()), 100)} + /> + {complete && ( + + + The kiqr agent has been restarted. + + + Proxy: + + http://localhost:{AGENT_PORT} + + + )} + + ); +} diff --git a/src/commands/agent/start.tsx b/src/commands/agent/start.tsx new file mode 100644 index 0000000..5c8ce8a --- /dev/null +++ b/src/commands/agent/start.tsx @@ -0,0 +1,66 @@ +import {Box, Text, useApp} from 'ink'; +import {useState} from 'react'; +import type {Step} from '../../components/StepRunner.js'; +import StepRunner from '../../components/StepRunner.js'; +import {AGENT_PORT, ensureAgentRunning} from '../../lib/agent.js'; +import {isDockerInstalled, isDockerRunning} from '../../lib/docker.js'; + +export const description = 'Start the kiqr agent'; + +export default function AgentStart() { + const {exit} = useApp(); + const [complete, setComplete] = useState(false); + + const steps: Step[] = [ + { + label: 'Checking Docker...', + run: async () => { + if (!isDockerInstalled()) { + throw new Error( + 'Docker is not installed. Please install Docker Desktop from https://docker.com', + ); + } + if (!isDockerRunning()) { + throw new Error( + 'Docker is not running. Please start Docker Desktop and try again.', + ); + } + }, + }, + { + label: 'Starting kiqr agent...', + run: async () => { + ensureAgentRunning(); + }, + }, + ]; + + return ( + + { + setComplete(true); + setTimeout(() => exit(), 100); + }} + onError={() => setTimeout(() => exit(new Error()), 100)} + /> + {complete && ( + + + The kiqr agent is running. + + + Proxy: + + http://localhost:{AGENT_PORT} + + + + It stays up across projects until you run "kiqr agent stop". + + + )} + + ); +} diff --git a/src/commands/agent/status.tsx b/src/commands/agent/status.tsx new file mode 100644 index 0000000..8a28677 --- /dev/null +++ b/src/commands/agent/status.tsx @@ -0,0 +1,43 @@ +import {Box, Text, useApp} from 'ink'; +import {getAgentStatus} from '../../lib/agent.js'; + +export const description = 'Show the kiqr agent status'; + +export default function AgentStatus() { + const {exit} = useApp(); + const status = getAgentStatus(); + + setTimeout(() => exit(), 50); + + return ( + + Kiqr Agent + + {' '} + Status:{' '} + {status.running ? ( + + running + + ) : ( + + stopped + + )} + + + {' '} + Proxy: http://localhost:{status.port} + + + Containers + {status.containers.map((c) => ( + + {' '} + {c.running ? : } {c.name}{' '} + ({c.running ? 'running' : 'stopped'}) + + ))} + + ); +} diff --git a/src/commands/agent/stop.tsx b/src/commands/agent/stop.tsx new file mode 100644 index 0000000..7e66e99 --- /dev/null +++ b/src/commands/agent/stop.tsx @@ -0,0 +1,42 @@ +import {Box, Text, useApp} from 'ink'; +import {useState} from 'react'; +import type {Step} from '../../components/StepRunner.js'; +import StepRunner from '../../components/StepRunner.js'; +import {stopAgent} from '../../lib/agent.js'; + +export const description = 'Stop the kiqr agent'; + +export default function AgentStop() { + const {exit} = useApp(); + const [complete, setComplete] = useState(false); + + const steps: Step[] = [ + { + label: 'Stopping kiqr agent...', + run: async () => { + stopAgent(); + }, + }, + ]; + + return ( + + { + setComplete(true); + setTimeout(() => exit(), 100); + }} + onError={() => setTimeout(() => exit(new Error()), 100)} + /> + {complete && ( + + + The kiqr agent has been stopped. + + It will start again on your next "kiqr up". + + )} + + ); +} diff --git a/src/commands/destroy.tsx b/src/commands/destroy.tsx index b85fdd3..bc9596d 100644 --- a/src/commands/destroy.tsx +++ b/src/commands/destroy.tsx @@ -8,7 +8,6 @@ import StepRunner from '../components/StepRunner.js'; import {readProjectConfig} from '../lib/config.js'; import {runDockerCompose} from '../lib/docker.js'; import {getProjectRuntimeDir} from '../lib/paths.js'; -import {stopTraefikIfIdle} from '../lib/traefik.js'; import type {ProjectConfig} from '../types/config.js'; export const description = 'Stop and remove all site data (database, uploads, etc.)'; @@ -108,12 +107,6 @@ export default function Destroy() { } }, }, - { - label: 'Cleaning up...', - run: async () => { - stopTraefikIfIdle(); - }, - }, ]; return ( diff --git a/src/commands/down.tsx b/src/commands/down.tsx index 84a8a8b..6c7e057 100644 --- a/src/commands/down.tsx +++ b/src/commands/down.tsx @@ -6,7 +6,6 @@ import StepRunner from '../components/StepRunner.js'; import {readProjectConfig} from '../lib/config.js'; import {runDockerCompose} from '../lib/docker.js'; import {getProjectRuntimeDir} from '../lib/paths.js'; -import {stopTraefikIfIdle} from '../lib/traefik.js'; import type {ProjectConfig} from '../types/config.js'; export const description = 'Stop the WordPress development environment'; @@ -38,12 +37,6 @@ export default function Down() { runDockerCompose(composePath, 'down'); }, }, - { - label: 'Cleaning up...', - run: async () => { - stopTraefikIfIdle(); - }, - }, ]; return ( diff --git a/src/commands/index.tsx b/src/commands/index.tsx index 7476da3..f9a5566 100644 --- a/src/commands/index.tsx +++ b/src/commands/index.tsx @@ -49,6 +49,10 @@ export default function Index() { {' '} kiqr db Backup and restore the database + + {' '} + kiqr agent Manage the shared background service + {' '} kiqr logs Show WordPress logs diff --git a/src/commands/restart.tsx b/src/commands/restart.tsx index 6c085aa..3c8b32a 100644 --- a/src/commands/restart.tsx +++ b/src/commands/restart.tsx @@ -4,6 +4,7 @@ import {Box, Text, useApp} from 'ink'; import {useRef, useState} from 'react'; import type {Step} from '../components/StepRunner.js'; import StepRunner from '../components/StepRunner.js'; +import {ensureAgentRunning} from '../lib/agent.js'; import {writeProjectCompose} from '../lib/compose.js'; import {readLocalConfig, readProjectConfig, writeLocalConfig} from '../lib/config.js'; import { @@ -20,7 +21,6 @@ import { getProjectUploadsDir, } from '../lib/paths.js'; import {detectTheme} from '../lib/theme.js'; -import {ensureTraefikRunning} from '../lib/traefik.js'; import type {LocalConfig, ProjectConfig} from '../types/config.js'; export const description = 'Restart the WordPress development environment'; @@ -130,9 +130,9 @@ export default function Restart() { }, }, { - label: 'Starting reverse proxy...', + label: 'Starting kiqr agent...', run: async () => { - ensureTraefikRunning(); + ensureAgentRunning(); }, }, { diff --git a/src/commands/up.tsx b/src/commands/up.tsx index 72cdcf8..cedf6c4 100644 --- a/src/commands/up.tsx +++ b/src/commands/up.tsx @@ -6,6 +6,7 @@ import {Box, Text, useApp} from 'ink'; import {useRef, useState} from 'react'; import type {Step} from '../components/StepRunner.js'; import StepRunner from '../components/StepRunner.js'; +import {ensureAgentRunning} from '../lib/agent.js'; import {writeProjectCompose} from '../lib/compose.js'; import { projectConfigExists, @@ -28,7 +29,6 @@ import { getProjectUploadsDir, } from '../lib/paths.js'; import {detectTheme} from '../lib/theme.js'; -import {ensureTraefikRunning} from '../lib/traefik.js'; import type {LocalConfig, ProjectConfig} from '../types/config.js'; export const description = 'Start the WordPress development environment'; @@ -189,9 +189,9 @@ export default function Up() { }, }, { - label: 'Starting reverse proxy...', + label: 'Starting kiqr agent...', run: async () => { - ensureTraefikRunning(); + ensureAgentRunning(); }, }, { diff --git a/src/lib/agent.ts b/src/lib/agent.ts new file mode 100644 index 0000000..dec8dc0 --- /dev/null +++ b/src/lib/agent.ts @@ -0,0 +1,150 @@ +import {execSync} from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import YAML from 'yaml'; +import { + isContainerRunning as defaultIsContainerRunning, + runDockerCompose, +} from './docker.js'; +import {writeNginxSplashConf} from './nginx-splash.js'; +import {getTraefikDir} from './paths.js'; +import {writeSplashPage} from './splash.js'; + +/** + * The "kiqr agent" is the shared, persistent background service that backs + * every kiqr project. Today it bundles the Traefik reverse proxy and the + * splash fallback page; in the future it will also host the dashboard, a mail + * catcher, and the collaboration tunnel client. + * + * It runs as a single Docker Compose project (`kiqr-agent`) and persists + * independently of any individual project. Container names and the network + * are deliberately unchanged from the previous standalone Traefik stack so + * existing setups keep working. + */ + +export const KIQR_NETWORK = 'kiqr'; +export const AGENT_PROJECT = 'kiqr-agent'; +export const AGENT_PORT = 5477; + +export const AGENT_CONTAINERS = ['kiqr-traefik', 'kiqr-splash'] as const; + +const TRAEFIK_CONTAINER = 'kiqr-traefik'; +const SPLASH_CONTAINER = 'kiqr-splash'; + +export interface AgentStatus { + running: boolean; + containers: {name: string; running: boolean}[]; + port: number; +} + +export interface AgentStatusDeps { + isContainerRunning: (name: string) => boolean; +} + +export function generateAgentCompose(agentDir: string): string { + const compose = { + name: AGENT_PROJECT, + services: { + traefik: { + image: 'traefik:v2.11', + container_name: TRAEFIK_CONTAINER, + command: [ + '--providers.docker=true', + '--providers.docker.exposedbydefault=false', + `--providers.docker.network=${KIQR_NETWORK}`, + `--entrypoints.web.address=:${AGENT_PORT}`, + '--api.insecure=true', + ], + ports: [`${AGENT_PORT}:${AGENT_PORT}`], + volumes: ['/var/run/docker.sock:/var/run/docker.sock:ro'], + networks: [KIQR_NETWORK], + restart: 'unless-stopped', + }, + splash: { + image: 'nginx:1.30-alpine', + container_name: SPLASH_CONTAINER, + volumes: [ + `${path.join(agentDir, 'splash.html')}:/usr/share/nginx/html/splash.html:ro`, + `${path.join(agentDir, 'splash-nginx.conf')}:/etc/nginx/conf.d/default.conf:ro`, + ], + labels: [ + 'traefik.enable=true', + 'traefik.http.routers.kiqr-splash.rule=PathPrefix(`/`)', + 'traefik.http.routers.kiqr-splash.entrypoints=web', + 'traefik.http.routers.kiqr-splash.priority=1', + 'traefik.http.services.kiqr-splash.loadbalancer.server.port=80', + ], + networks: [KIQR_NETWORK], + restart: 'unless-stopped', + }, + }, + networks: { + [KIQR_NETWORK]: { + external: true, + }, + }, + }; + + return YAML.stringify(compose, {lineWidth: 0}); +} + +export function writeAgentCompose(dir?: string): string { + const agentDir = dir ?? getTraefikDir(); + fs.mkdirSync(agentDir, {recursive: true}); + + writeSplashPage(agentDir); + writeNginxSplashConf(agentDir); + + const filePath = path.join(agentDir, 'compose.yaml'); + fs.writeFileSync(filePath, generateAgentCompose(agentDir), 'utf-8'); + return filePath; +} + +export function ensureAgentRunning(dir?: string): void { + const agentDir = dir ?? getTraefikDir(); + const composePath = writeAgentCompose(agentDir); + ensureKiqrNetwork(); + runDockerCompose(composePath, 'up', ['-d']); +} + +export function stopAgent(dir?: string): void { + const agentDir = dir ?? getTraefikDir(); + const composePath = path.join(agentDir, 'compose.yaml'); + if (!fs.existsSync(composePath)) return; + try { + runDockerCompose(composePath, 'down'); + } catch { + // Already stopped + } +} + +export function restartAgent(dir?: string): void { + stopAgent(dir); + ensureAgentRunning(dir); +} + +export function getAgentStatus( + deps: AgentStatusDeps = {isContainerRunning: defaultIsContainerRunning}, +): AgentStatus { + const containers = AGENT_CONTAINERS.map((name) => ({ + name, + running: deps.isContainerRunning(name), + })); + return { + running: containers.every((c) => c.running), + containers, + port: AGENT_PORT, + }; +} + +export function isAgentRunning(): boolean { + return getAgentStatus().running; +} + +export function ensureKiqrNetwork(): void { + try { + execSync(`docker network create ${KIQR_NETWORK}`, {stdio: 'pipe'}); + } catch { + // Network may already exist + } +} diff --git a/src/lib/compose.ts b/src/lib/compose.ts index 09fc2ea..94d0a91 100644 --- a/src/lib/compose.ts +++ b/src/lib/compose.ts @@ -2,8 +2,8 @@ import fs from 'node:fs'; import path from 'node:path'; import YAML from 'yaml'; import type {RuntimeConfig} from '../providers/RuntimeProvider.js'; +import {KIQR_NETWORK} from './agent.js'; import {createRuntimeProvider} from './runtime.js'; -import {KIQR_NETWORK} from './traefik.js'; export function generateProjectCompose( config: RuntimeConfig, diff --git a/src/lib/traefik.ts b/src/lib/traefik.ts deleted file mode 100644 index e0f0b9a..0000000 --- a/src/lib/traefik.ts +++ /dev/null @@ -1,119 +0,0 @@ -import {execSync} from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -import YAML from 'yaml'; -import {runDockerCompose} from './docker.js'; -import {writeNginxSplashConf} from './nginx-splash.js'; -import {getTraefikDir} from './paths.js'; -import {writeSplashPage} from './splash.js'; - -export const KIQR_NETWORK = 'kiqr'; -const TRAEFIK_CONTAINER = 'kiqr-traefik'; - -export function generateTraefikCompose(traefikDir: string): string { - const compose = { - services: { - traefik: { - image: 'traefik:v2.11', - container_name: TRAEFIK_CONTAINER, - command: [ - '--providers.docker=true', - '--providers.docker.exposedbydefault=false', - `--providers.docker.network=${KIQR_NETWORK}`, - '--entrypoints.web.address=:5477', - '--api.insecure=true', - ], - ports: ['5477:5477'], - volumes: ['/var/run/docker.sock:/var/run/docker.sock:ro'], - networks: [KIQR_NETWORK], - restart: 'unless-stopped', - }, - splash: { - image: 'nginx:1.30-alpine', - container_name: 'kiqr-splash', - volumes: [ - `${path.join(traefikDir, 'splash.html')}:/usr/share/nginx/html/splash.html:ro`, - `${path.join(traefikDir, 'splash-nginx.conf')}:/etc/nginx/conf.d/default.conf:ro`, - ], - labels: [ - 'traefik.enable=true', - 'traefik.http.routers.kiqr-splash.rule=PathPrefix(`/`)', - 'traefik.http.routers.kiqr-splash.entrypoints=web', - 'traefik.http.routers.kiqr-splash.priority=1', - 'traefik.http.services.kiqr-splash.loadbalancer.server.port=80', - ], - networks: [KIQR_NETWORK], - restart: 'unless-stopped', - }, - }, - networks: { - [KIQR_NETWORK]: { - external: true, - }, - }, - }; - - return YAML.stringify(compose, {lineWidth: 0}); -} - -export function writeTraefikCompose(dir?: string): string { - const traefikDir = dir ?? getTraefikDir(); - fs.mkdirSync(traefikDir, {recursive: true}); - - writeSplashPage(traefikDir); - writeNginxSplashConf(traefikDir); - - const filePath = path.join(traefikDir, 'compose.yaml'); - fs.writeFileSync(filePath, generateTraefikCompose(traefikDir), 'utf-8'); - return filePath; -} - -export function ensureTraefikRunning(dir?: string): void { - const traefikDir = dir ?? getTraefikDir(); - const composePath = writeTraefikCompose(traefikDir); - ensureKiqrNetwork(); - runDockerCompose(composePath, 'up', ['-d']); -} - -export function stopTraefik(dir?: string): void { - const traefikDir = dir ?? getTraefikDir(); - const composePath = path.join(traefikDir, 'compose.yaml'); - if (!fs.existsSync(composePath)) return; - try { - runDockerCompose(composePath, 'down'); - } catch { - // Already stopped - } -} - -export function stopTraefikIfIdle(dir?: string): void { - if (hasRunningProjects()) return; - stopTraefik(dir); -} - -function hasRunningProjects(): boolean { - try { - const output = execSync( - `docker ps --filter "network=${KIQR_NETWORK}" --filter "status=running" --format "{{.Names}}"`, - {stdio: 'pipe'}, - ) - .toString() - .trim(); - - if (!output) return false; - - const kiqrInfra = new Set([TRAEFIK_CONTAINER, 'kiqr-splash']); - const running = output.split('\n').filter((name) => !kiqrInfra.has(name)); - return running.length > 0; - } catch { - return false; - } -} - -export function ensureKiqrNetwork(): void { - try { - execSync(`docker network create ${KIQR_NETWORK}`, {stdio: 'pipe'}); - } catch { - // Network may already exist - } -} diff --git a/tests/lib/agent.test.ts b/tests/lib/agent.test.ts new file mode 100644 index 0000000..47724a2 --- /dev/null +++ b/tests/lib/agent.test.ts @@ -0,0 +1,100 @@ +import {describe, expect, it} from 'vitest'; +import YAML from 'yaml'; +import { + AGENT_CONTAINERS, + AGENT_PORT, + AGENT_PROJECT, + generateAgentCompose, + getAgentStatus, +} from '../../src/lib/agent.js'; + +describe('generateAgentCompose', () => { + it('generates valid compose YAML for Traefik', () => { + const yaml = generateAgentCompose('/tmp/kiqr/agent'); + const parsed = YAML.parse(yaml); + expect(parsed.services.traefik).toBeDefined(); + expect(parsed.services.traefik.image).toContain('traefik'); + }); + + it('uses the kiqr-agent compose project name', () => { + const yaml = generateAgentCompose('/tmp/kiqr/agent'); + const parsed = YAML.parse(yaml); + expect(parsed.name).toBe(AGENT_PROJECT); + expect(AGENT_PROJECT).toBe('kiqr-agent'); + }); + + it('keeps the original container names for compatibility', () => { + const yaml = generateAgentCompose('/tmp/kiqr/agent'); + const parsed = YAML.parse(yaml); + expect(parsed.services.traefik.container_name).toBe('kiqr-traefik'); + expect(parsed.services.splash.container_name).toBe('kiqr-splash'); + }); + + it('exposes the agent port', () => { + const yaml = generateAgentCompose('/tmp/kiqr/agent'); + const parsed = YAML.parse(yaml); + const ports = parsed.services.traefik.ports; + expect(ports.some((p: string) => p.includes(String(AGENT_PORT)))).toBe(true); + }); + + it('creates kiqr network', () => { + const yaml = generateAgentCompose('/tmp/kiqr/agent'); + const parsed = YAML.parse(yaml); + expect(parsed.networks['kiqr']).toBeDefined(); + expect(parsed.networks['kiqr'].external).toBe(true); + }); + + it('mounts Docker socket', () => { + const yaml = generateAgentCompose('/tmp/kiqr/agent'); + const parsed = YAML.parse(yaml); + const volumes = parsed.services.traefik.volumes; + expect(volumes.some((v: string) => v.includes('docker.sock'))).toBe(true); + }); + + it('includes splash page container with lowest priority', () => { + const yaml = generateAgentCompose('/tmp/kiqr/agent'); + const parsed = YAML.parse(yaml); + expect(parsed.services.splash).toBeDefined(); + expect(parsed.services.splash.image).toBe('nginx:1.30-alpine'); + const labels = parsed.services.splash.labels; + expect(labels.some((l: string) => l.includes('priority=1'))).toBe(true); + }); +}); + +describe('getAgentStatus', () => { + it('reports running when all agent containers are up', () => { + const status = getAgentStatus({isContainerRunning: () => true}); + expect(status.running).toBe(true); + expect(status.port).toBe(AGENT_PORT); + expect(status.containers).toHaveLength(AGENT_CONTAINERS.length); + expect(status.containers.every((c) => c.running)).toBe(true); + }); + + it('reports not running when no agent containers are up', () => { + const status = getAgentStatus({isContainerRunning: () => false}); + expect(status.running).toBe(false); + expect(status.containers.every((c) => !c.running)).toBe(true); + }); + + it('reports not running when only some containers are up', () => { + const status = getAgentStatus({ + isContainerRunning: (name) => name === 'kiqr-traefik', + }); + expect(status.running).toBe(false); + const traefik = status.containers.find((c) => c.name === 'kiqr-traefik'); + const splash = status.containers.find((c) => c.name === 'kiqr-splash'); + expect(traefik?.running).toBe(true); + expect(splash?.running).toBe(false); + }); + + it('queries each known agent container by name', () => { + const queried: string[] = []; + getAgentStatus({ + isContainerRunning: (name) => { + queried.push(name); + return true; + }, + }); + expect(queried).toEqual([...AGENT_CONTAINERS]); + }); +}); diff --git a/tests/lib/traefik.test.ts b/tests/lib/traefik.test.ts deleted file mode 100644 index cfffbc4..0000000 --- a/tests/lib/traefik.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import {describe, expect, it} from 'vitest'; -import YAML from 'yaml'; -import {generateTraefikCompose} from '../../src/lib/traefik.js'; - -describe('generateTraefikCompose', () => { - it('generates valid compose YAML for Traefik', () => { - const yaml = generateTraefikCompose('/tmp/kiqr/traefik'); - const parsed = YAML.parse(yaml); - expect(parsed.services.traefik).toBeDefined(); - expect(parsed.services.traefik.image).toContain('traefik'); - }); - - it('exposes port 5477', () => { - const yaml = generateTraefikCompose('/tmp/kiqr/traefik'); - const parsed = YAML.parse(yaml); - const ports = parsed.services.traefik.ports; - expect(ports.some((p: string) => p.includes('5477'))).toBe(true); - }); - - it('creates kiqr network', () => { - const yaml = generateTraefikCompose('/tmp/kiqr/traefik'); - const parsed = YAML.parse(yaml); - expect(parsed.networks['kiqr']).toBeDefined(); - }); - - it('mounts Docker socket', () => { - const yaml = generateTraefikCompose('/tmp/kiqr/traefik'); - const parsed = YAML.parse(yaml); - const volumes = parsed.services.traefik.volumes; - expect(volumes.some((v: string) => v.includes('docker.sock'))).toBe(true); - }); - - it('includes splash page container with lowest priority', () => { - const yaml = generateTraefikCompose('/tmp/kiqr/traefik'); - const parsed = YAML.parse(yaml); - expect(parsed.services.splash).toBeDefined(); - expect(parsed.services.splash.image).toBe('nginx:1.30-alpine'); - const labels = parsed.services.splash.labels; - expect(labels.some((l: string) => l.includes('priority=1'))).toBe(true); - }); -});