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);
- });
-});