import assert from 'node:assert/strict'; import { mkdtemp, mkdir, copyFile, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import test from 'node:test'; import shellVerification from '../src/shared/shell-verification.cjs'; const { bashSyntaxCheckInvocation, bashSyntaxCheckFromTextInvocation, normalizeRelativePosixPath, validateShellScriptStructure, shouldRunExternalBash } = shellVerification; test('Bash syntax validation keeps Windows project roots in cwd and passes a relative POSIX path', () => { const invocation = bashSyntaxCheckInvocation('C:\\Projects\\ForgeFlow'); assert.equal(invocation.command, 'bash'); assert.deepEqual(invocation.args, ['-n', 'examples/server/forgeflow-deploy']); assert.equal(invocation.options.cwd, 'C:\\Projects\\ForgeFlow'); assert.equal(invocation.args[1].includes('\\'), false); assert.equal(/^[A-Za-z]:/.test(invocation.args[1]), false); }); test('Shell validation refuses absolute and escaping script paths', () => { assert.throws(() => normalizeRelativePosixPath('C:\\Projects\\ForgeFlow\\script.sh'), /must be relative/); assert.throws(() => normalizeRelativePosixPath('/tmp/script.sh'), /must be relative/); assert.throws(() => normalizeRelativePosixPath('../script.sh'), /may not escape/); }); test('Bash syntax validation works from a project root containing spaces', async (t) => { if (spawnSync('bash', ['--version'], { encoding: 'utf8' }).status !== 0) { t.skip('Bash is not available in this environment.'); return; } const tempBase = await mkdtemp(path.join(os.tmpdir(), 'forge flow verify ')); try { const relativeDirectory = path.join(tempBase, 'examples', 'server'); await mkdir(relativeDirectory, { recursive: true }); await copyFile(new URL('../examples/server/forgeflow-deploy', import.meta.url), path.join(relativeDirectory, 'forgeflow-deploy')); const invocation = bashSyntaxCheckInvocation(tempBase); const result = spawnSync(invocation.command, invocation.args, invocation.options); assert.equal(result.status, 0, result.stderr); } finally { try { await rm(tempBase, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }); } catch (error) { // Git Bash on Windows can retain a short-lived working-directory handle // after bash -n exits. Do not fail a successful syntax test solely because // Windows delayed releasing that temporary directory. if (!['EBUSY', 'EPERM', 'ENOTEMPTY'].includes(error?.code)) throw error; } } }); test('Bash syntax validation from text does not depend on a Windows working directory', () => { const invocation = bashSyntaxCheckFromTextInvocation('#!/usr/bin/env bash\nset -euo pipefail\necho ok\n'); assert.equal(invocation.command, 'bash'); assert.deepEqual(invocation.args, ['-n']); assert.equal(invocation.options.cwd, undefined); assert.match(invocation.options.input, /set -euo pipefail/); }); test('Bash syntax validation from text detects malformed scripts', (t) => { if (spawnSync('bash', ['--version'], { encoding: 'utf8' }).status !== 0) { t.skip('Bash is not available in this environment.'); return; } const invocation = bashSyntaxCheckFromTextInvocation('if true; then\n echo missing fi\n'); const result = spawnSync(invocation.command, invocation.args, invocation.options); assert.notEqual(result.status, 0); }); test('portable server-script validation does not require a local Bash executable', () => { const script = `#!/usr/bin/env bash set -Eeuo pipefail readonly CONFIG_FILE="/etc/forgeflow/targets.conf" echo "Target configuration must be owned by root" APP_DIR=/tmp/app SHA=0123456789012345678901234567890123456789 COMPOSE_FILE=docker-compose.yml write_status() { :; } exec 9>/tmp/test.lock flock -n 9 git -C "$APP_DIR" fetch origin main git -C "$APP_DIR" reset --hard "$SHA" docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans write_status "healthy" write_status "unhealthy" `; assert.equal(validateShellScriptStructure(script), true); }); test('portable server-script validation refuses missing deployment safety markers', () => { assert.throws( () => validateShellScriptStructure('#!/usr/bin/env bash\nset -Eeuo pipefail\necho unsafe\n'), /missing required safety marker/ ); }); test('Windows publication never depends on an external Bash shim', () => { assert.equal(shouldRunExternalBash('win32'), false); assert.equal(shouldRunExternalBash('linux'), true); assert.equal(shouldRunExternalBash('darwin'), true); });