import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { EventEmitter } from 'node:events'; import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { env } from '../config/env.js'; import { buildHealthCheckUrls, buildRestartFailureMessage, buildServerCommandApiRestartUrl, listServerCommands, resolveDockerSocketPath, restartServerCommand, } from './server-command-service.js'; test('buildRestartFailureMessage includes exit info and stderr output', () => { const message = buildRestartFailureMessage( 'TEST', Object.assign(new Error('Command failed'), { code: 1, stderr: 'no such service: app', stdout: '', }), ); assert.match(message, /TEST 재기동에 실패했습니다\./); assert.match(message, /exit:1/); assert.match(message, /no such service: app/); }); test('listServerCommands uses app as the default test restart service', async () => { const commands = await listServerCommands(); const testCommand = commands.find((item) => item.key === 'test'); assert.ok(testCommand); assert.equal(testCommand.serviceName, 'app'); }); test('listServerCommands exposes prod restart command', async () => { const commands = await listServerCommands(); const prodCommand = commands.find((item) => item.key === 'prod'); assert.ok(prodCommand); assert.equal(prodCommand.serviceName, 'prod-app'); assert.match(prodCommand.commandScript, /\/etc\/commands\/server-command\/restart-prod\.sh$/); }); test('listServerCommands resolves restart script from main project when project root fallback is needed', async () => { const commands = await listServerCommands(); const testCommand = commands.find((item) => item.key === 'test'); assert.ok(testCommand); assert.match(testCommand.commandScript, /\/etc\/commands\/server-command\/restart-test\.sh$/); assert.notEqual(testCommand.commandScript, '/etc/commands/server-command/restart-test.sh'); }); test('test, release and prod restart scripts fall back to Docker socket when docker CLI is unavailable', () => { const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url); const testScript = fs.readFileSync(new URL('restart-test.sh', commandsRoot), 'utf8'); const relScript = fs.readFileSync(new URL('restart-rel.sh', commandsRoot), 'utf8'); const prodScript = fs.readFileSync(new URL('restart-prod.sh', commandsRoot), 'utf8'); const workServerScript = fs.readFileSync(new URL('restart-work-server.sh', commandsRoot), 'utf8'); const socketRestartScript = fs.readFileSync(new URL('restart-via-docker-socket.mjs', commandsRoot), 'utf8'); assert.match(testScript, /command -v docker >/); assert.match(testScript, /git fetch "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/); assert.match(testScript, /git pull --ff-only "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/); assert.match(testScript, /SERVER_COMMAND_TEST_GIT_REMOTE="\$\{SERVER_COMMAND_TEST_GIT_REMOTE:-origin\}"/); assert.match(testScript, /SERVER_COMMAND_TEST_GIT_BRANCH="\$\{SERVER_COMMAND_TEST_GIT_BRANCH:-main\}"/); assert.match(testScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" restart "\$SERVER_COMMAND_SERVICE"/); assert.match(testScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "\$SERVER_COMMAND_SERVICE"/); assert.match(testScript, /restart-via-docker-socket\.mjs/); assert.match(testScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-app-1\}"/); assert.match(relScript, /command -v docker >/); assert.match( relScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$SERVER_COMMAND_SERVICE"/, ); assert.match(relScript, /restart-via-docker-socket\.mjs/); assert.match(relScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-release\}"/); assert.match(prodScript, /command -v docker >/); assert.match(prodScript, /git pull --ff-only "\$SERVER_COMMAND_PROD_GIT_REMOTE" "\$SERVER_COMMAND_PROD_GIT_BRANCH"/); assert.match(prodScript, /SERVER_COMMAND_PROD_GIT_REMOTE="\$\{SERVER_COMMAND_PROD_GIT_REMOTE:-origin\}"/); assert.match(prodScript, /SERVER_COMMAND_PROD_GIT_BRANCH="\$\{SERVER_COMMAND_PROD_GIT_BRANCH:-main\}"/); assert.match( prodScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$SERVER_COMMAND_SERVICE"/, ); assert.match(prodScript, /restart-via-docker-socket\.mjs/); assert.match(prodScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-prod\}"/); assert.match( workServerScript, /docker compose -f etc\/servers\/work-server\/docker-compose\.yml up -d --build --force-recreate --no-deps work-server/, ); assert.doesNotMatch(workServerScript, /kill -HUP 1/); assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/); }); test('prod restart script pulls the configured remote main branch before restart', () => { const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url); const prodScript = fs.readFileSync(new URL('restart-prod.sh', commandsRoot), 'utf8'); assert.match(prodScript, /CURRENT_BRANCH=\$\(git rev-parse --abbrev-ref HEAD 2>\/dev\/null \|\| true\)/); assert.match(prodScript, /git -c "credential\.helper=store --file=\$CREDENTIAL_STORE" pull --ff-only/); assert.match(prodScript, /git pull --ff-only "\$SERVER_COMMAND_PROD_GIT_REMOTE" "\$SERVER_COMMAND_PROD_GIT_BRANCH"/); }); test('test restart script pulls the configured remote main branch before restart', () => { const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url); const testScript = fs.readFileSync(new URL('restart-test.sh', commandsRoot), 'utf8'); assert.match(testScript, /git fetch "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/); assert.match(testScript, /git switch "\$SERVER_COMMAND_TEST_GIT_BRANCH"/); assert.match(testScript, /git pull --ff-only "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/); }); test('work-server package dev script does not use watch mode and rebuilds before start', async () => { const packageJsonPath = new URL('../../package.json', import.meta.url); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { scripts?: Record; }; assert.equal(packageJson.scripts?.dev, 'npm run build && npm run start'); assert.doesNotMatch(String(packageJson.scripts?.dev ?? ''), /\bwatch\b/i); }); test('buildServerCommandApiRestartUrl replaces key placeholders on configured command api endpoint', () => { assert.equal( buildServerCommandApiRestartUrl('http://127.0.0.1:3200/', '/commands/{key}/restart', 'work-server'), 'http://127.0.0.1:3200/commands/work-server/restart', ); }); test('buildServerCommandApiRestartUrl avoids duplicating an existing base path prefix', () => { assert.equal( buildServerCommandApiRestartUrl( 'http://127.0.0.1:3211/api', '/api/server-commands/{key}/actions/restart', 'test', ), 'http://127.0.0.1:3211/api/server-commands/test/actions/restart', ); }); test('buildHealthCheckUrls adds localhost fallbacks for command-runner', () => { assert.deepEqual(buildHealthCheckUrls('command-runner', 'http://host.docker.internal:3211/health'), [ 'http://host.docker.internal:3211/health', 'http://127.0.0.1:3211/health', 'http://localhost:3211/health', ]); assert.deepEqual(buildHealthCheckUrls('work-server', 'http://host.docker.internal:3100/health'), [ 'http://host.docker.internal:3100/health', ]); }); test('resolveDockerSocketPath prefers explicit socket path and falls back to unix DOCKER_HOST', () => { assert.equal( resolveDockerSocketPath({ SERVER_COMMAND_DOCKER_SOCKET: '/custom/docker.sock', DOCKER_HOST: 'unix:///run/user/1000/docker.sock', }), '/custom/docker.sock', ); assert.equal( resolveDockerSocketPath({ DOCKER_HOST: 'unix:///run/user/1000/docker.sock', }), '/run/user/1000/docker.sock', ); }); test('restartServerCommand delegates to configured command api when base url is provided', async (t) => { const originalBaseUrl = env.SERVER_COMMAND_API_BASE_URL; const originalAccessToken = env.SERVER_COMMAND_API_ACCESS_TOKEN; const originalPathTemplate = env.SERVER_COMMAND_API_RESTART_PATH_TEMPLATE; env.SERVER_COMMAND_API_BASE_URL = 'http://127.0.0.1:3200/api'; env.SERVER_COMMAND_API_ACCESS_TOKEN = 'local-command-token'; env.SERVER_COMMAND_API_RESTART_PATH_TEMPLATE = '/commands/{key}/restart'; const fetchMock = t.mock.method(globalThis, 'fetch', async (input: string | URL | Request, init?: RequestInit) => { assert.equal(String(input), 'http://127.0.0.1:3200/api/commands/test/restart'); assert.equal(init?.method, 'POST'); const headers = new Headers(init?.headers); assert.equal(headers.get('X-Access-Token'), 'local-command-token'); return new Response( JSON.stringify({ restartState: 'accepted', item: { key: 'test', label: 'TEST', composeStatus: 'restarting', }, commandOutput: 'restart accepted', }), { status: 200, headers: { 'content-type': 'application/json', }, }, ); }); try { const result = await restartServerCommand('test'); assert.equal(fetchMock.mock.callCount(), 1); assert.equal(result.restartState, 'accepted'); assert.equal(result.commandOutput, 'restart accepted'); assert.equal(result.server.key, 'test'); assert.equal(result.server.composeStatus, 'restarting'); } finally { env.SERVER_COMMAND_API_BASE_URL = originalBaseUrl; env.SERVER_COMMAND_API_ACCESS_TOKEN = originalAccessToken; env.SERVER_COMMAND_API_RESTART_PATH_TEMPLATE = originalPathTemplate; } }); test('restartServerCommand surfaces deferred restart script failures for work-server', async (t) => { const originalBaseUrl = env.SERVER_COMMAND_API_BASE_URL; env.SERVER_COMMAND_API_BASE_URL = ''; const childProcessModule = (await import('node:child_process')) as { spawn: (...args: unknown[]) => unknown }; const spawnMock = t.mock.method( childProcessModule, 'spawn', (...spawnArgs: unknown[]) => { const args = Array.isArray(spawnArgs[1]) ? (spawnArgs[1] as string[]) : []; const shellCommand = String(args[1] ?? ''); const logPath = shellCommand.match(/>"([^"]+\.log)"/)?.[1] ?? ''; const statusPath = shellCommand.match(/>"([^"]+\.status)"/)?.[1] ?? ''; queueMicrotask(() => { void writeFile(statusPath, '1', 'utf8'); void writeFile(logPath, 'docker compose failed', 'utf8'); }); const child = new EventEmitter() as EventEmitter & { unref(): void }; child.unref = () => undefined; queueMicrotask(() => { child.emit('spawn'); }); return child; }, ); try { await assert.rejects(() => restartServerCommand('work-server'), /WORK-SERVER 재기동에 실패했습니다\./); assert.equal(spawnMock.mock.callCount(), 1); } finally { env.SERVER_COMMAND_API_BASE_URL = originalBaseUrl; } }); test('listServerCommands marks command-runner online when localhost fallback responds', async (t) => { const fetchMock = t.mock.method(globalThis, 'fetch', async (input: string | URL | Request) => { const url = String(input); if (url === 'http://host.docker.internal:3211/health') { throw new Error('fetch failed'); } if (url === 'http://127.0.0.1:3211/health') { return new Response(JSON.stringify({ ok: true, service: 'server-command-runner' }), { status: 200, headers: { 'content-type': 'application/json', }, }); } return new Response('ok', { status: 200 }); }); const commands = await listServerCommands(); const runnerCommand = commands.find((item) => item.key === 'command-runner'); assert.ok(runnerCommand); assert.equal(fetchMock.mock.calls.some((call) => String(call.arguments[0]) === 'http://host.docker.internal:3211/health'), true); assert.equal(fetchMock.mock.calls.some((call) => String(call.arguments[0]) === 'http://127.0.0.1:3211/health'), true); assert.equal(runnerCommand.availability, 'online'); assert.equal(runnerCommand.httpStatus, 200); assert.match(String(runnerCommand.errorMessage ?? ''), /fallback health check succeeded via http:\/\/127\.0\.0\.1:3211\/health/); }); test('listServerCommands ignores public codex chat resources when checking app source updates', async () => { const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-app-source-scan-')); const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT; const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT; const buildTargetPath = '/tmp/ai-code-test-app-dist/index.html'; const previousBuildContents = fs.existsSync(buildTargetPath) ? await fs.promises.readFile(buildTargetPath) : null; const previousBuildStat = fs.existsSync(buildTargetPath) ? await fs.promises.stat(buildTargetPath) : null; try { const staleDate = new Date('2026-04-19T00:00:00.000Z'); await mkdir(path.join(tempRoot, 'src'), { recursive: true }); await mkdir(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource'), { recursive: true }); await writeFile(path.join(tempRoot, 'src', 'main.tsx'), 'export const app = true;\n', 'utf8'); await writeFile(path.join(tempRoot, 'index.html'), '\n', 'utf8'); await writeFile(path.join(tempRoot, 'package.json'), '{"name":"tmp"}\n', 'utf8'); await writeFile(path.join(tempRoot, 'tsconfig.json'), '{}\n', 'utf8'); await writeFile(path.join(tempRoot, 'tsconfig.app.json'), '{}\n', 'utf8'); await writeFile(path.join(tempRoot, 'vite.config.ts'), 'export default {};\n', 'utf8'); await writeFile(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource', 'note.txt'), 'resource only\n', 'utf8'); await Promise.all([ fs.promises.utimes(path.join(tempRoot, 'src', 'main.tsx'), staleDate, staleDate), fs.promises.utimes(path.join(tempRoot, 'index.html'), staleDate, staleDate), fs.promises.utimes(path.join(tempRoot, 'package.json'), staleDate, staleDate), fs.promises.utimes(path.join(tempRoot, 'tsconfig.json'), staleDate, staleDate), fs.promises.utimes(path.join(tempRoot, 'tsconfig.app.json'), staleDate, staleDate), fs.promises.utimes(path.join(tempRoot, 'vite.config.ts'), staleDate, staleDate), ]); await mkdir(path.dirname(buildTargetPath), { recursive: true }); await writeFile(buildTargetPath, '\n', 'utf8'); const buildDate = new Date('2026-04-20T00:00:00.000Z'); await fs.promises.utimes(buildTargetPath, buildDate, buildDate); const resourceDate = new Date('2026-04-28T00:00:00.000Z'); await fs.promises.utimes(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource', 'note.txt'), resourceDate, resourceDate); env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot; env.SERVER_COMMAND_PROJECT_ROOT = tempRoot; const commands = await listServerCommands(); const testCommand = commands.find((item) => item.key === 'test'); assert.ok(testCommand); assert.equal(testCommand.buildRequired, false); assert.notEqual(testCommand.latestSourceChangePath, 'public/.codex_chat/session/resource/note.txt'); } finally { env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot; env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot; if (previousBuildContents) { await writeFile(buildTargetPath, previousBuildContents, 'utf8'); if (previousBuildStat) { await fs.promises.utimes(buildTargetPath, previousBuildStat.atime, previousBuildStat.mtime); } } else { await rm(buildTargetPath, { force: true }); } await rm(tempRoot, { recursive: true, force: true }); } });