diff --git a/docs/plans/state.json b/docs/plans/state.json index d52c23f..6b9dd73 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -43,6 +43,18 @@ "test_status": "pnpm test:run + pnpm typecheck passing" }, + "deployment-port-env-override": { + "status": "completed", + "date": "2026-02-16", + "updated": "2026-02-16", + "summary": "Added PORT environment variable override support so PaaS deployments can bind the gateway to the platform-assigned port without requiring config changes.", + "files_modified": [ + "src/config/loader.ts", + "src/config/loader.test.ts" + ], + "test_status": "pnpm test:run + pnpm typecheck passing" + }, + "openclaw-gap-roadmap": { "file": "2026-02-15-openclaw-gap-roadmap.md", "status": "planned", @@ -2132,7 +2144,7 @@ }, "overall_progress": { - "total_test_count": 1692, + "total_test_count": 1694, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", diff --git a/src/config/loader.test.ts b/src/config/loader.test.ts index f2d2b5e..0175cda 100644 --- a/src/config/loader.test.ts +++ b/src/config/loader.test.ts @@ -102,6 +102,68 @@ telegram: rmSync(testDir, { recursive: true }); }); + + it('overrides server.port from PORT env var when set', () => { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'config.yaml'); + + const prevPort = process.env.PORT; + process.env.PORT = '3777'; + + writeFileSync(configPath, ` +telegram: + bot_token: "test-token" + allowed_chat_ids: [123] +server: + port: 18800 +models: + default: + provider: anthropic + model: claude-sonnet +`); + + const config = loadConfig(configPath); + expect(config.server.port).toBe(3777); + + if (prevPort !== undefined) { + process.env.PORT = prevPort; + } else { + delete process.env.PORT; + } + + rmSync(testDir, { recursive: true }); + }); + + it('ignores invalid PORT env var values', () => { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'config.yaml'); + + const prevPort = process.env.PORT; + process.env.PORT = 'not-a-number'; + + writeFileSync(configPath, ` +telegram: + bot_token: "test-token" + allowed_chat_ids: [123] +server: + port: 18800 +models: + default: + provider: anthropic + model: claude-sonnet +`); + + const config = loadConfig(configPath); + expect(config.server.port).toBe(18800); + + if (prevPort !== undefined) { + process.env.PORT = prevPort; + } else { + delete process.env.PORT; + } + + rmSync(testDir, { recursive: true }); + }); }); describe('loadConfig with overlay', () => { diff --git a/src/config/loader.ts b/src/config/loader.ts index a3e09fa..e753604 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -81,5 +81,18 @@ export function loadConfig(configPath: string, overlayPath?: string): Config { } const expandedConfig = expandEnvVarsInObject(rawConfig); - return configSchema.parse(expandedConfig); + const config = configSchema.parse(expandedConfig); + + // PaaS convention: if PORT is set, bind the gateway to it. + // This is intentionally an override (even if config.server.port is set) + // to make deployments on Fly/Railway/Render/Heroku-style platforms work. + const envPort = process.env.PORT; + if (envPort && /^\d+$/.test(envPort)) { + const port = Number(envPort); + if (Number.isFinite(port) && port > 0 && port <= 65535) { + config.server.port = port; + } + } + + return config; }