From af6fcb624b0355a08da9a2b6c2724c62c2b33478 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 04:27:24 +0100 Subject: [PATCH] fix: a first run's typed answers reach the stack, not just docker/.env (release review) Compose prefers the shell environment over the env file, so with CODEMAN_APPDATA_PATH or CODEMAN_PORT preset, a different typed answer went into docker/.env and the summary while the stack ran on the preset: state split across two folders and the next plain start mounted another home. Setup now unsets everything it wrote, so the file it just printed is what Compose reads. The test stub honours that precedence, and the questions get their first test through a real pty. Co-Authored-By: Claude Opus 5.5 --- docker/Start-Codeman.sh | 4 ++ test/docker-start-setup.test.ts | 66 +++++++++++++++++++++++++++------ 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/docker/Start-Codeman.sh b/docker/Start-Codeman.sh index d74eb49f..0b2806ce 100644 --- a/docker/Start-Codeman.sh +++ b/docker/Start-Codeman.sh @@ -411,6 +411,10 @@ run_first_run_setup() { setup_cases="$setup_appdata/codeman-cases" write_env_file first_run=1 + # Compose prefers the shell environment over the env file, so a preset still + # exported here would beat a different typed answer: docker/.env and the + # summary would name one folder and port and the stack would run on another. + unset TZ CODEMAN_APPDATA_PATH CODEMAN_CASES_PATH CODEMAN_PORT CODEMAN_PASSWORD if [[ -n "$generated_password" ]]; then password_note="$generated_password (generated; shown again once Codeman is up)" diff --git a/test/docker-start-setup.test.ts b/test/docker-start-setup.test.ts index a5981f32..bc71b740 100644 --- a/test/docker-start-setup.test.ts +++ b/test/docker-start-setup.test.ts @@ -34,6 +34,7 @@ import { statSync, existsSync, symlinkSync, + readdirSync, } from 'node:fs'; import { execFileSync, spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; @@ -53,9 +54,10 @@ const REWRITTEN_KEYS = ['TZ', 'CODEMAN_APPDATA_PATH', 'CODEMAN_CASES_PATH', 'COD /** * 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. + * makes. `compose ... config --environment` sources the env file and prints the + * keys the script reads, with the shell environment beating the file as it does + * in Compose. Bash reads a single-quoted value the way Compose's dotenv does + * except for a backslash before the closing quote: see `composeReads`. */ const STUB = [ '#!/usr/bin/env bash', @@ -83,10 +85,13 @@ const STUB = [ ' prev="$a"', ' done', ' if [[ " $* " == *" config "* && " $* " == *" --environment "* ]]; then', + ' keys="CODEMAN_APPDATA_PATH CODEMAN_CASES_PATH DOCKER_SOCKET CODEMAN_PORT CODEMAN_USERNAME CODEMAN_PASSWORD"', + ' for k in $keys; do if [[ -n "${!k+x}" ]]; then printf -v "shell_$k" %s "${!k}"; fi; done', ' 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}"', + ' for k in $keys; do', + ' shell="shell_$k"', + ' printf "%s=%s\\n" "$k" "${!shell-${!k}}"', ' done', ' exit 0', ' fi', @@ -111,6 +116,8 @@ interface Run { envMode: number | null; home: string; dir: string; + /** What was in HOME when the run ended; the sandbox is gone by the time a test reads this. */ + homeEntries: string[]; } /** @@ -130,6 +137,12 @@ function runStart( update?: boolean; /** CODEMAN_APPDATA_PATH preset, built from the sandbox's own paths. */ appdata?: (p: { home: string; repo: string }) => string; + /** + * Keystrokes for a run on a REAL terminal (a pty from util-linux `script`, + * so Linux only), which is the only way to reach the questions: the script + * reads stdin only when `-t 0` holds. + */ + terminal?: string; } = {} ): Run { const dir = mkdtempSync(join(tmpdir(), 'codeman-start-setup-')); @@ -177,17 +190,21 @@ function runStart( 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 entry = join(dockerDir, opts.update ? 'Update-Codeman.sh' : 'Start-Codeman.sh'); + const res = + opts.terminal === undefined + ? spawnSync(BASH, [entry, ...args], { env, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }) + : spawnSync('script', ['-qefc', [BASH, entry, ...args].join(' '), '/dev/null'], { + env, + encoding: 'utf-8', + input: opts.terminal, + }); const envPath = join(dockerDir, '.env'); const hasEnv = existsSync(envPath); return { status: res.status ?? 1, - stdout: res.stdout, + // A terminal turns every line end into CRLF. + stdout: res.stdout.replace(/\r/g, ''), stderr: res.stderr, log: readFileSync(logPath, 'utf-8') .split('\n') @@ -196,6 +213,7 @@ function runStart( envMode: hasEnv ? statSync(envPath).mode & 0o777 : null, home, dir, + homeEntries: readdirSync(home), }; } finally { rmSync(dir, { recursive: true, force: true }); @@ -427,6 +445,30 @@ describe.skipIf(process.platform === 'win32')('Start-Codeman.sh first run, start expect(r.log.findIndex((l) => l.startsWith('docker inspect'))).toBeGreaterThan(up); }); + it('runs on the port docker/.env holds, not on the preset the shell still exports', () => { + const r = runStart([], { socket: sockPath, env: { CODEMAN_PORT: '04444' } }); + expect(r.status, r.stderr).toBe(0); + expect(envValue(r.env as string, 'CODEMAN_PORT')).toBe('4444'); + expect(r.stdout).toContain('http://localhost:4444'); + expect(r.stdout).not.toContain('04444'); + }); + + it.skipIf(process.platform !== 'linux')('a typed answer beats a preset for the stack itself, not just the file', () => { + const r = runStart([], { + socket: sockPath, + appdata: ({ home }) => join(home, 'preset-data'), + env: { CODEMAN_PORT: '4444' }, + terminal: '~/typed-data\n5555\n\n', + }); + expect(r.status, r.stdout).toBe(0); + const env = r.env as string; + expect(envValue(env, 'CODEMAN_APPDATA_PATH')).toBe(join(r.home, 'typed-data')); + expect(envValue(env, 'CODEMAN_PORT')).toBe('5555'); + expect(r.stdout).toContain('http://localhost:5555'); + expect(r.homeEntries).toContain('typed-data'); + expect(r.homeEntries).not.toContain('preset-data'); + }); + 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);