/**
* @fileoverview The Docker Compose deployment's first-run path: what
* `docker/Start-Codeman.sh` does on a machine that has never run it.
*
* 1. Preflight: a missing `docker`, a missing or too-old Compose plugin and an
* unreachable daemon each stop the script with the fix named, before any
* question is asked or any file is written. The permission case points at
* the docker group, never at sudo (a root run cannot do the first-run setup).
* 2. Setup: with no `docker/.env`, the script writes one generated FROM
* `.env.example`, so every key the example sets is present. That is the
* exact check the in-app updater runs (`diffRequiredEnvKeys`), and a
* generated file missing a key would block the user's next update. The file
* is 0600, the generated password is alphanumeric (Compose's dotenv
* interpolates `$` and treats ` #` as a comment), and a data folder that
* would collide with a native install's `~/.codeman`, `$HOME` itself or the
* image build context is refused.
* 3. An existing `docker/.env` is never rewritten: the update path
* (Update-Codeman.sh hands off to this script) must stay byte-for-byte.
* 4. A `.env` still carrying the example password `changeme` is refused before
* anything is built or started.
*
* Runs the REAL script against a stub `docker` on PATH with stdin not a TTY,
* which is also the "no terminal attached, take the defaults" path.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createServer, type Server } from 'node:net';
import {
readFileSync,
mkdtempSync,
rmSync,
writeFileSync,
mkdirSync,
statSync,
existsSync,
symlinkSync,
} from 'node:fs';
import { execFileSync, spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { diffRequiredEnvKeys, parseEnvKeys } from '../src/web/self-update.js';
const ROOT = process.cwd();
const startScript = readFileSync(join(ROOT, 'docker/Start-Codeman.sh'), 'utf-8');
const updateScript = readFileSync(join(ROOT, 'docker/Update-Codeman.sh'), 'utf-8');
const example = readFileSync(join(ROOT, 'docker/.env.example'), 'utf-8');
const compose = readFileSync(join(ROOT, 'docker/docker-compose.yaml'), 'utf-8');
/** Absolute, so a run whose PATH deliberately lacks most tools can still start bash. */
const BASH = execFileSync('bash', ['-c', 'command -v bash'], { encoding: 'utf-8' }).trim();
/** The values the first run fills in; every other line must be the example's. */
const REWRITTEN_KEYS = ['TZ', 'CODEMAN_APPDATA_PATH', 'CODEMAN_CASES_PATH', 'CODEMAN_PORT', 'CODEMAN_PASSWORD'];
/**
* A stub `docker` that logs every invocation and answers the calls the script
* makes. `compose ... config --environment` sources the env file (bash reads
* single-quoted values the way Compose's dotenv does) and prints the keys the
* script reads.
*/
const STUB = [
'#!/usr/bin/env bash',
'echo "docker $*" >> "$CMDLOG"',
'if [[ "$1" == "info" ]]; then',
' if [[ -n "${STUB_INFO_ERR:-}" ]]; then echo "$STUB_INFO_ERR" >&2; exit 1; fi',
' echo 27.0.0; exit 0',
'fi',
'if [[ "$1" == "compose" ]]; then',
' if [[ -n "${STUB_NO_COMPOSE:-}" ]]; then echo "docker: unknown command: docker compose" >&2; exit 1; fi',
' if [[ "$2" == "version" ]]; then',
' if [[ "${3:-}" == "--short" ]]; then echo "${STUB_COMPOSE_VERSION:-2.30.0}"; else echo "Docker Compose version v${STUB_COMPOSE_VERSION:-2.30.0}"; fi',
' exit 0',
' fi',
' prev=""; envfile=""',
' for a in "$@"; do',
' if [[ "$prev" == "--env-file" ]]; then envfile="$a"; fi',
' prev="$a"',
' done',
' if [[ " $* " == *" config "* && " $* " == *" --environment "* ]]; then',
' set -a; source "$envfile"; set +a',
' DOCKER_SOCKET="${STUB_DOCKER_SOCKET:-$DOCKER_SOCKET}"',
' for k in CODEMAN_APPDATA_PATH CODEMAN_CASES_PATH DOCKER_SOCKET CODEMAN_PORT CODEMAN_USERNAME CODEMAN_PASSWORD; do',
' printf "%s=%s\\n" "$k" "${!k}"',
' done',
' exit 0',
' fi',
' if [[ " $* " == *" config "* && " $* " == *" --format json "* ]]; then printf \'{\\n "name": "codeman"\\n}\\n\'; exit 0; fi',
' if [[ " $* " == *" up "* && -n "${STUB_UP_FAIL:-}" ]]; then echo "network error pulling a layer" >&2; exit 1; fi',
' if [[ " $* " == *" ps -q codeman "* ]]; then echo cid123; exit 0; fi',
' if [[ " $* " == *" port codeman "* ]]; then echo "0.0.0.0:${@: -1}"; exit 0; fi',
' if [[ " $* " == *" logs "* ]]; then echo "FAKE-LOG: server crashed"; exit 0; fi',
' exit 0',
'fi',
'if [[ "$1" == "inspect" ]]; then echo "${STUB_STATE:-running|healthy|0}"; exit 0; fi',
'if [[ "$1" == "exec" ]]; then exit 1; fi',
'exit 0',
].join('\n');
interface Run {
status: number;
stdout: string;
stderr: string;
log: string[];
env: string | null;
envMode: number | null;
home: string;
dir: string;
}
/**
* Lays out `
/repo/docker/{Start-Codeman.sh,.env.example,docker-compose.yaml}`
* plus a stub `docker`, runs the script with a temp HOME and stdin closed (not a
* TTY), and returns what happened. `existingEnv` seeds `docker/.env` first.
*/
function runStart(
args: string[],
opts: {
env?: Record;
existingEnv?: string;
noDocker?: boolean;
/** A real Unix socket to use as DOCKER_SOCKET (the start path checks `-S`). */
socket?: string;
/** Run Update-Codeman.sh (which hands off to Start-Codeman.sh) instead. */
update?: boolean;
/** CODEMAN_APPDATA_PATH preset, built from the sandbox's own paths. */
appdata?: (p: { home: string; repo: string }) => string;
} = {}
): Run {
const dir = mkdtempSync(join(tmpdir(), 'codeman-start-setup-'));
try {
const home = join(dir, 'home');
const dockerDir = join(dir, 'repo', 'docker');
mkdirSync(home);
mkdirSync(dockerDir, { recursive: true });
writeFileSync(join(dockerDir, 'Start-Codeman.sh'), startScript);
writeFileSync(join(dockerDir, 'Update-Codeman.sh'), updateScript);
writeFileSync(join(dockerDir, '.env.example'), example);
writeFileSync(join(dockerDir, 'docker-compose.yaml'), compose);
// Hashed by the start path for the updater's fingerprint baseline.
writeFileSync(join(dockerDir, 'server.Dockerfile'), readFileSync(join(ROOT, 'docker/server.Dockerfile')));
if (opts.existingEnv !== undefined) writeFileSync(join(dockerDir, '.env'), opts.existingEnv);
const binDir = join(dir, 'bin');
mkdirSync(binDir);
let path: string;
if (opts.noDocker) {
// Only what the script runs before its `command -v docker` check, so the
// host's own docker (if any) cannot be found.
symlinkSync(
execFileSync('bash', ['-c', 'command -v dirname'], { encoding: 'utf-8' }).trim(),
join(binDir, 'dirname')
);
path = binDir;
} else {
const stubPath = join(binDir, 'docker');
writeFileSync(stubPath, STUB);
execFileSync('bash', ['-c', `chmod +x '${stubPath}'`]);
path = `${binDir}:${process.env.PATH}`;
}
const logPath = join(dir, 'cmdlog.txt');
writeFileSync(logPath, '');
const env: Record = { ...process.env, HOME: home, PATH: path, CMDLOG: logPath } as Record<
string,
string
>;
// A developer shell exporting any of these would change the defaults under test.
for (const k of ['CODEMAN_APPDATA_PATH', 'CODEMAN_PORT', 'CODEMAN_PASSWORD', 'CODEMAN_NONINTERACTIVE'])
delete env[k];
Object.assign(env, opts.env ?? {});
if (opts.appdata) env.CODEMAN_APPDATA_PATH = opts.appdata({ home, repo: join(dir, 'repo') });
if (opts.socket) env.STUB_DOCKER_SOCKET = opts.socket;
const entry = opts.update ? 'Update-Codeman.sh' : 'Start-Codeman.sh';
const res = spawnSync(BASH, [join(dockerDir, entry), ...args], {
env,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
const envPath = join(dockerDir, '.env');
const hasEnv = existsSync(envPath);
return {
status: res.status ?? 1,
stdout: res.stdout,
stderr: res.stderr,
log: readFileSync(logPath, 'utf-8')
.split('\n')
.filter((l) => l.trim()),
env: hasEnv ? readFileSync(envPath, 'utf-8') : null,
envMode: hasEnv ? statSync(envPath).mode & 0o777 : null,
home,
dir,
};
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
/** The single (unquoted or single-quoted) value of KEY in a dotenv text. */
function envValue(text: string, key: string): string | undefined {
const line = text.split('\n').find((l) => l.startsWith(`${key}=`));
if (line === undefined) return undefined;
const raw = line.slice(key.length + 1);
return raw.startsWith("'") && raw.endsWith("'") ? raw.slice(1, -1) : raw;
}
describe('Start-Codeman.sh first run (no docker/.env yet)', () => {
it('parses under bash -n', () => {
execFileSync('bash', ['-n', join(ROOT, 'docker/Start-Codeman.sh')]);
});
it('writes docker/.env from .env.example with every key the updater requires, mode 0600', () => {
const r = runStart(['--setup-only']);
expect(r.status, r.stderr).toBe(0);
expect(r.env).not.toBeNull();
const env = r.env as string;
expect(r.envMode).toBe(0o600);
// The in-app updater's own check: no key the example sets may be missing.
expect(diffRequiredEnvKeys(example, env)).toEqual([]);
expect(parseEnvKeys(env)).toEqual(parseEnvKeys(example));
// --setup-only stops before Compose is asked anything about the stack.
expect(r.log.some((l) => / (up|build|config)( |$)/.test(l))).toBe(false);
});
it('changes only the five first-run values and keeps every other line of the example', () => {
const r = runStart(['--setup-only']);
const generated = (r.env as string).split('\n');
const exampleLines = example.split('\n');
// Header comments first, then the example line for line.
const offset = generated.length - exampleLines.length;
expect(offset).toBeGreaterThan(0);
for (let i = 0; i < exampleLines.length; i++) {
const want = exampleLines[i];
const got = generated[i + offset];
const key = want.match(/^([A-Z_][A-Z0-9_]*)=/)?.[1];
if (key && REWRITTEN_KEYS.includes(key)) {
expect(got.startsWith(`${key}=`)).toBe(true);
} else {
expect(got).toBe(want);
}
}
});
it('defaults: a data folder of its own under HOME, cases inside it, a strong alphanumeric password', () => {
const r = runStart(['--setup-only']);
const env = r.env as string;
const appdata = envValue(env, 'CODEMAN_APPDATA_PATH');
expect(appdata).toBe(join(r.home, 'codeman-docker'));
expect(envValue(env, 'CODEMAN_CASES_PATH')).toBe(join(r.home, 'codeman-docker', 'codeman-cases'));
expect(appdata).not.toContain('.codeman');
const password = envValue(env, 'CODEMAN_PASSWORD') as string;
expect(password).toMatch(/^[A-Za-z0-9]{24}$/);
expect(password).not.toBe('changeme');
expect(envValue(env, 'CODEMAN_PORT')).toMatch(/^\d+$/);
expect(envValue(env, 'TZ')).toMatch(/^[A-Za-z0-9_+/-]+$/);
// A generated password is shown once, since nobody else knows it.
expect(r.stdout).toContain(password);
expect(r.stdout).toMatch(/No questions asked/);
});
it('takes presets from the environment and quotes values Compose would otherwise interpolate', () => {
const r = runStart(['--setup-only'], {
env: { CODEMAN_PASSWORD: 'pa$$ #word', CODEMAN_PORT: '4567', CODEMAN_APPDATA_PATH: '/srv/My Data/codeman/' },
});
expect(r.status, r.stderr).toBe(0);
const lines = (r.env as string).split('\n');
expect(lines).toContain("CODEMAN_PASSWORD='pa$$ #word'");
expect(lines).toContain('CODEMAN_PORT=4567');
// Trailing slash dropped, the space kept by quoting.
expect(lines).toContain("CODEMAN_APPDATA_PATH='/srv/My Data/codeman'");
expect(lines).toContain("CODEMAN_CASES_PATH='/srv/My Data/codeman/codeman-cases'");
// A password the user chose is never echoed.
expect(r.stdout).not.toContain('pa$$ #word');
});
it.each([
['HOME itself', ({ home }: { home: string }) => home, /folder of its own/],
['HOME typed as ~', () => '~', /folder of its own/],
['a native install state dir', ({ home }: { home: string }) => join(home, '.codeman'), /installed directly/],
['inside a native install state dir', () => '~/.codeman/docker', /installed directly/],
['a relative path', () => 'codeman-data', /absolute path/],
['the checkout, which is the image build context', ({ repo }: { repo: string }) => repo, /copied into the image/],
['a folder inside the checkout', ({ repo }: { repo: string }) => join(repo, 'data'), /copied into the image/],
])('refuses %s as the data folder and writes nothing', (_name, appdata, reason) => {
const r = runStart(['--setup-only'], { appdata });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(reason);
expect(r.env).toBeNull();
});
it.each([
['a single quote', "it's-a-password"],
['the example placeholder', 'changeme'],
['fewer than 8 characters', 'short'],
])('refuses a preset password with %s', (_name, password) => {
const r = runStart(['--setup-only'], { env: { CODEMAN_PASSWORD: password } });
expect(r.status).toBe(1);
expect(r.env).toBeNull();
});
it('never rewrites an existing docker/.env', () => {
const existing = '# hand-written\nCODEMAN_PASSWORD=mine-and-only-mine\nCODEMAN_APPDATA_PATH=/x\n';
const r = runStart(['--setup-only'], { existingEnv: existing });
expect(r.status, r.stderr).toBe(0);
expect(r.env).toBe(existing);
expect(r.stdout).toMatch(/already exists/);
});
});
describe('Start-Codeman.sh preflight', () => {
it('names the docker group (not sudo) when the account cannot reach the daemon', () => {
const r = runStart(['--setup-only'], {
env: {
STUB_INFO_ERR: 'permission denied while trying to connect to the docker API at unix:///var/run/docker.sock',
},
});
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/sudo usermod -aG docker /);
expect(r.stderr).toMatch(/log out and back in/);
expect(r.env).toBeNull();
});
it('says to start Docker when the daemon is not running, quoting what Docker said', () => {
const r = runStart(['--setup-only'], {
env: { STUB_INFO_ERR: 'failed to connect to the docker API at unix:///var/run/docker.sock' },
});
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/daemon is not reachable/);
expect(r.stderr).toContain('failed to connect to the docker API');
expect(r.env).toBeNull();
});
it('refuses a Compose older than 2.27.2, which has no `config --environment`', () => {
const r = runStart(['--setup-only'], { env: { STUB_COMPOSE_VERSION: '2.27.0' } });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/Compose 2\.27\.0 is too old; Codeman needs 2\.27\.2 or newer/);
expect(r.env).toBeNull();
});
it('accepts newer Compose majors (v5 here) and a v-prefixed version', () => {
expect(runStart(['--setup-only'], { env: { STUB_COMPOSE_VERSION: '5.5.0' } }).status).toBe(0);
expect(runStart(['--setup-only'], { env: { STUB_COMPOSE_VERSION: 'v2.27.2' } }).status).toBe(0);
});
it('explains a missing Compose plugin', () => {
const r = runStart(['--setup-only'], { env: { STUB_NO_COMPOSE: '1' } });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/Compose v2 plugin is missing/);
});
it('explains a missing docker command', () => {
const r = runStart(['--setup-only'], { noDocker: true });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/Docker is not installed/);
});
});
describe('Start-Codeman.sh on an existing install', () => {
it('refuses to start while CODEMAN_PASSWORD is still `changeme`, before building anything', () => {
const existing = example; // a straight copy of the example, never edited
const r = runStart([], { existingEnv: existing });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/still the example value "changeme"/);
expect(r.log.some((l) => / (up|build|down)( |$)/.test(l))).toBe(false);
expect(r.env).toBe(existing);
});
it('Update-Codeman.sh refuses `changeme` BEFORE its build and `down`, so the stack is never left stopped', () => {
// An existing appdata dir, so the check this is about is the one reached.
const existing = example.replace(/^CODEMAN_APPDATA_PATH=.*$/m, `CODEMAN_APPDATA_PATH=${tmpdir()}`);
const r = runStart([], { existingEnv: existing, update: true });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/still the example value "changeme"/);
expect(r.stderr).toMatch(/Nothing was stopped/);
expect(r.log.some((l) => / (build|down|up)( |$)/.test(l))).toBe(false);
});
it('rejects an unrecognised argument and prints usage for --help', () => {
expect(runStart(['--bogus']).status).toBe(1);
const help = runStart(['--help']);
expect(help.status).toBe(0);
expect(help.stdout).toMatch(/--setup-only/);
});
});
/**
* The whole start path against the stub: no `docker/.env`, so setup, then
* `up`, the readiness wait and the summary. Needs a real Unix socket for the
* `DOCKER_SOCKET` check, which Windows cannot provide reliably (see the note in
* docker-entrypoint.test.ts), so it runs on Linux and macOS only.
*/
describe.skipIf(process.platform === 'win32')('Start-Codeman.sh first run, start to summary', () => {
let sockDir = '';
let sockPath = '';
let server: Server | null = null;
beforeAll(async () => {
sockDir = mkdtempSync(join(tmpdir(), 'codeman-start-sock-'));
sockPath = join(sockDir, 'docker.sock');
server = createServer();
await new Promise((resolve) => server!.listen(sockPath, resolve));
});
afterAll(async () => {
await new Promise((resolve) => (server ? server.close(() => resolve()) : resolve()));
rmSync(sockDir, { recursive: true, force: true });
});
it('ends on the URL, the generated password and the log/stop commands once the container is healthy', () => {
const r = runStart([], { socket: sockPath, env: { CODEMAN_PORT: '4321' } });
expect(r.status, r.stderr).toBe(0);
const password = envValue(r.env as string, 'CODEMAN_PASSWORD') as string;
expect(r.stdout).toMatch(/Waiting for Codeman to answer\.\.\. ready\./);
expect(r.stdout).toContain('http://localhost:4321');
// Printed again at the end, since the build output has scrolled the first one away.
expect(r.stdout.split(password).length - 1).toBe(2);
expect(r.stdout).toMatch(/Logs +cd .* && docker compose logs -f codeman/);
expect(r.stdout).toMatch(/Stop +cd .* && docker compose down/);
const up = r.log.findIndex((l) => / up --build -d/.test(l));
expect(up).toBeGreaterThan(-1);
expect(r.log.findIndex((l) => l.startsWith('docker inspect'))).toBeGreaterThan(up);
});
it('reports a container that keeps restarting, with its last log lines, and exits 1', () => {
const r = runStart([], { socket: sockPath, env: { STUB_STATE: 'restarting||1' } });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/did not come up \(container restarting\)/);
expect(r.stderr).toContain('FAKE-LOG: server crashed');
expect(r.stdout).not.toMatch(/http:\/\/localhost/);
});
it('names the next step when `docker compose up` fails', () => {
const r = runStart([], { socket: sockPath, env: { STUB_UP_FAIL: '1' } });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/`docker compose up --build` failed/);
expect(r.stderr).toMatch(/rerunning this script resumes/);
});
it('--no-wait prints the summary without waiting on the container', () => {
const r = runStart(['--no-wait'], { socket: sockPath, env: { STUB_STATE: 'running|starting|0' } });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toMatch(/--no-wait given/);
expect(r.log.some((l) => l.startsWith('docker inspect'))).toBe(false);
});
});
describe('version_older_than', () => {
const cases: Array<[string, string, boolean]> = [
['2.27.0', '2.27.2', true],
['2.27.1', '2.27.2', true],
['2.27.2', '2.27.2', false],
['2.28.0', '2.27.2', false],
['2.9.0', '2.27.2', true],
['5.5.0', '2.27.2', false],
['1.29.2', '2.27.2', true],
['', '2.27.2', false],
['dev', '2.27.2', false],
['2.27.2-desktop.1', '2.27.2', false],
];
it.each(cases)('%s older than %s: %s', (have, need, older) => {
const script = [
'set -euo pipefail',
`eval "$(sed -n '/^version_older_than() {/,/^}/p' "$1")"`,
'if version_older_than "$2" "$3"; then echo yes; else echo no; fi',
].join('\n');
const out = execFileSync('bash', ['-c', script, '_', join(ROOT, 'docker/Start-Codeman.sh'), have, need], {
encoding: 'utf-8',
}).trim();
expect(out).toBe(older ? 'yes' : 'no');
});
});