From 36f18bc3ce484770af8b8107adfa9e7603c0395d Mon Sep 17 00:00:00 2001 From: Eric Law <39393654+acn-ericlaw@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:52:25 -0700 Subject: [PATCH] fix(config): the resources folder is the one next to the application file - never the working directory; a direct node app.mjs anchors on the script it ran --- CHANGELOG.md | 13 +++ README.md | 7 +- docs/guides/config-logging-actuators.md | 4 +- docs/guides/configuration-reference.md | 5 +- examples/demo-app/README.md | 2 + examples/demo-app/resources/application.yml | 4 +- examples/llm-helper/README.md | 3 + examples/llm-helper/resources/application.yml | 4 +- src/cli.ts | 21 ++-- src/config.ts | 83 ++++++++++--- src/log-context.ts | 12 +- src/server.ts | 1 + test/config.test.ts | 110 ++++++++++++++++-- 13 files changed, 213 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 420d57b..7715cc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,19 @@ credential checks are pinned by unit tests. **Upgrade note:** none - the Claude API backend is the default and behaves as before; `llm.backend` now also accepts `bedrock`. +### Changed + +1. **The `resources` folder is the one next to the application file; the working directory is never searched.** + `mercury-serve app.mjs` (`node dist/src/cli.js app.mjs` in this repository) reads `resources/application.yml` + (`.yaml`, `.properties`) beside `app.mjs`, wherever the command runs from, and an application that starts the + platform itself resolves the folder beside the script Node ran - the engines' classpath convention: the configuration + travels with the application and does not depend on the launch directory. Before, the working directory was searched + first and the application's folder only as a fallback, so a stray `resources/` in the launch directory could shadow + the application's own. `--config ` still names another file; the companion `app-log-context.yaml` follows the + same folder; `appConfig().source` is now the absolute path of the file found and `appConfig().resourcesDir` names the + folder; the startup log reports `Configuration: `. **Upgrade note:** an application that kept `resources/` in + the working directory with its application file elsewhere passes `--config`, or moves the folder next to the file. + ## 4.12.21 (2026-10-07) The lock-step round with the engines: the pack moves from 4.12.15 to 4.12.21, the number the Java engine and the diff --git a/README.md b/README.md index 3d33ac3..5f12b25 100644 --- a/README.md +++ b/README.md @@ -120,9 +120,10 @@ The same conventions as the engines, so a polyglot installation stays uniform: | `log.format` | `text`, `json` (pretty-printed) or `compact` (single-line JSONL) | `text` | | `log.level` | log level (`LOG_LEVEL` env var wins) | `INFO` | -Configuration lives in the `resources` folder, mirroring the engines: -`resources/application.yml` (or `.yaml` / `.properties`) in the working directory or next -to the application file, or an explicit `--config` path — see +Configuration lives in the `resources` folder next to the application file, mirroring the +engines' classpath resources: `resources/application.yml` (or `.yaml` / `.properties`) beside +the file `mercury-serve` is given — never the working directory, so the configuration does not +depend on where the app is launched from — or an explicit `--config` path; see [`examples/demo-app/resources/application.yml`](examples/demo-app/resources/application.yml) for a worked sample. Values support `${ENV_VAR:default}` substitution. Runtime parameter overrides use the same `-D` syntax as the Java engine and the Rust port — checked first on every read diff --git a/docs/guides/config-logging-actuators.md b/docs/guides/config-logging-actuators.md index fe4360f..919da3e 100644 --- a/docs/guides/config-logging-actuators.md +++ b/docs/guides/config-logging-actuators.md @@ -19,7 +19,9 @@ keywords: [configuration, resources, log format, actuator, health, kubernetes, l ## Configuration — the engines' conventions Configuration lives in the `resources` folder (`resources/application.yml`, `.yaml` -or `.properties`), in the working directory or next to the application file. Values +or `.properties`) next to the application file — the file `mercury-serve` is given — never +in the working directory, so the configuration does not depend on where the app is launched +from (`--config` names another file). Values support `${ENV_VAR:default}` substitution; `-Dkey=value` command-line arguments are runtime overrides checked first on every read — the same syntax as the Java engine's JVM system properties and the Rust port's `-D` arguments: diff --git a/docs/guides/configuration-reference.md b/docs/guides/configuration-reference.md index aaccc90..4d74c71 100644 --- a/docs/guides/configuration-reference.md +++ b/docs/guides/configuration-reference.md @@ -13,8 +13,9 @@ keywords: [configuration, reference, keys, substitution, overrides] 1. `-Dkey=value` command-line overrides (and programmatic `appConfig().set`) — checked first on every read, the engines' `f:setConfig` analog. -2. The configuration file: `resources/application.yml` | `.yaml` | `.properties`, in - the working directory or next to the application file, or `--config `. +2. The configuration file: `resources/application.yml` | `.yaml` | `.properties` next to + the application file (the file `mercury-serve` is given), never in the working directory; + or `--config `. 3. `${ENV_VAR:default}` substitution inside values: environment first, then a base configuration key of that name, then the default. diff --git a/examples/demo-app/README.md b/examples/demo-app/README.md index 6bc5691..a866013 100644 --- a/examples/demo-app/README.md +++ b/examples/demo-app/README.md @@ -23,6 +23,8 @@ npm run build node dist/src/cli.js examples/demo-app/demo-app.mjs ``` +The app reads `resources/application.yml` next to `demo-app.mjs`, whichever directory the command runs from. + To use it outside this repository, copy `demo-app.mjs` and its `resources` folder into your project, `npm install mercury-composable`, and run `npx mercury-serve demo-app.mjs` (the app imports `mercury-composable` by name, so the same file works in both places). diff --git a/examples/demo-app/resources/application.yml b/examples/demo-app/resources/application.yml index 846ec85..c1b953f 100644 --- a/examples/demo-app/resources/application.yml +++ b/examples/demo-app/resources/application.yml @@ -1,6 +1,6 @@ # Sample configuration - the engines' "resources" convention. -# mercury-serve loads resources/application.yml from the working directory, or from -# the resources folder next to the application file (this one). Any key can be +# mercury-serve loads resources/application.yml from the resources folder next to +# the application file (this one), never from the working directory. Any key can be # overridden at run time with the engines' -D syntax, e.g.: # node dist/src/cli.js examples/demo-app/demo-app.mjs -Drest.server.port=8090 -Dlog.format=compact diff --git a/examples/llm-helper/README.md b/examples/llm-helper/README.md index 366feeb..27228d8 100644 --- a/examples/llm-helper/README.md +++ b/examples/llm-helper/README.md @@ -24,6 +24,9 @@ export ANTHROPIC_API_KEY=... # from the environment, never from a node dist/src/cli.js examples/llm-helper/llm-helper.mjs ``` +The helper reads `resources/application.yml` next to `llm-helper.mjs`, whichever directory the command runs +from; `-Dkey=value` overrides any key. + To use it outside this repository, copy `llm-helper.mjs` and its `resources` folder into your project, `npm install mercury-composable @anthropic-ai/sdk`, and run `npx mercury-serve llm-helper.mjs` (the app imports `mercury-composable` by name, so the same file works in both places). The published package does diff --git a/examples/llm-helper/resources/application.yml b/examples/llm-helper/resources/application.yml index c6f3a32..ff156b8 100644 --- a/examples/llm-helper/resources/application.yml +++ b/examples/llm-helper/resources/application.yml @@ -1,6 +1,6 @@ # The LLM helper's configuration - the engines' "resources" convention. -# mercury-serve loads resources/application.yml from the working directory, or from the -# resources folder next to the application file (this one). Any key can be overridden at +# mercury-serve loads resources/application.yml from the resources folder next to the +# application file (this one), never from the working directory. Any key can be overridden at # run time with the engines' -D syntax, e.g.: # node dist/src/cli.js examples/llm-helper/llm-helper.mjs -Dllm.model=claude-haiku-4-5 diff --git a/src/cli.ts b/src/cli.ts index 93fb790..36d538b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,9 @@ * * mercury-serve app.mjs --port 8087 * mercury-serve app.mjs --config application.yml + * + * The configuration is the resources folder next to app.mjs - never the working + * directory - unless --config names a file. */ import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -30,25 +33,15 @@ async function main(): Promise { 'Usage: mercury-serve [--port n] [--host addr] [--config file]\n'); return 1; } - const { DEFAULT_CANDIDATES, loadConfig } = await import('./config.js'); + const { loadConfig } = await import('./config.js'); const appPath = path.resolve(args.app); if (!fs.existsSync(appPath)) { process.stderr.write(`Application file not found - ${appPath}\n`); return 1; } - let configPath = args.config; - if (!configPath && !DEFAULT_CANDIDATES.some((c: string) => fs.existsSync(c))) { - // fall back to a resources folder next to the application file - const appDir = path.dirname(appPath); - for (const candidate of DEFAULT_CANDIDATES) { - const probe = path.join(appDir, candidate); - if (fs.existsSync(probe)) { - configPath = probe; - break; - } - } - } - loadConfig(configPath); // before logging/server setup so their keys apply + // the resources folder is the one next to the application file, wherever the command runs + // from; --config names another file. Loaded before logging/server setup so their keys apply. + loadConfig(args.config, path.dirname(appPath)); // -Dkey=value runtime overrides are consumed by AppConfig from process.argv await import(pathToFileURL(appPath).href); const { defaultRegistry } = await import('./registry.js'); diff --git a/src/config.ts b/src/config.ts index 81e21c1..1b04c83 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,8 +2,12 @@ * Minimalist configuration management, consistent with the Mercury engines. * * Same conventions as the Java/Rust AppConfigReader: configuration lives in - * the resources folder (resources/application.yml | .yaml | .properties) with - * dotted keys; `-Dkey=value` command-line arguments are runtime parameter + * the resources folder NEXT TO THE APPLICATION FILE (resources/application.yml + * | .yaml | .properties beside the file mercury-serve is given, or beside the + * script Node ran), mirroring the engines' classpath resources - the working + * directory is never searched, so the configuration an application starts + * with does not depend on where it is launched from; `--config` names another + * file. Keys are dotted; `-Dkey=value` command-line arguments are runtime parameter * overrides checked first on every read — the same syntax as the Java * engine's JVM system properties and the Rust port's -D arguments (the * f:setConfig analog); `${ENV_VAR:default}` substitution resolves the @@ -15,6 +19,7 @@ * wins). */ import * as fs from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; import YAML from 'yaml'; import { asText } from './envelope.js'; @@ -28,6 +33,21 @@ export const DEFAULT_CANDIDATES = [ 'resources/application.properties' ]; +export const RESOURCES_FOLDER = 'resources'; + +/** + * The folder of the script Node ran (`node app.mjs`) - where a direct run finds + * its resources folder; undefined when there is no script (`node -e`, a REPL). + */ +export function mainScriptDir(): string | undefined { + const script = process.argv[1]; + return script ? dirname(resolve(script)) : undefined; +} + +function isFile(candidate: string): boolean { + return fs.existsSync(candidate) && fs.statSync(candidate).isFile(); +} + /** Extract -Dkey=value runtime overrides (Java/Rust engine syntax). */ export function parseDArgs(argv: string[]): Map { const overrides = new Map(); @@ -65,21 +85,46 @@ function parseProperties(text: string): Map { export class AppConfig { private store = new Map(); private readonly overrides: Map; + /** + * The configuration file loaded (an explicit path as given, else the absolute + * path of the file found next to the application file), or 'none'. + */ readonly source: string; + /** + * The resources folder companion files (app-log-context.yaml) share with the + * configuration file - beside an explicit file, else next to the application + * file; undefined when there is neither. + */ + readonly resourcesDir?: string; - constructor(path?: string, argv?: string[]) { + /** + * `path` names the configuration file explicitly; otherwise the file is one of + * DEFAULT_CANDIDATES under `appDir` - the application file's folder, defaulting + * to the folder of the script Node ran. The working directory is never searched. + */ + constructor(path?: string, argv?: string[], appDir?: string) { this.overrides = parseDArgs(argv ?? process.argv.slice(2)); - const candidates = path ? [path] : DEFAULT_CANDIDATES; let loaded = 'none'; - for (const candidate of candidates) { - if (candidate && fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { - this.load(candidate); - loaded = candidate; - break; + if (path) { + if (!isFile(path)) { + throw new Error(`Configuration file not found - ${path}`); + } + this.load(path); + loaded = path; + this.resourcesDir = dirname(resolve(path)); + } else { + const folder = appDir ?? mainScriptDir(); + if (folder) { + this.resourcesDir = join(folder, RESOURCES_FOLDER); + for (const candidate of DEFAULT_CANDIDATES) { + const probe = join(folder, candidate); + if (isFile(probe)) { + this.load(probe); + loaded = probe; + break; + } + } } - } - if (path && loaded === 'none') { - throw new Error(`Configuration file not found - ${path}`); } this.source = loaded; } @@ -161,14 +206,20 @@ export class AppConfig { let instance: AppConfig | undefined; -/** The shared AppConfig singleton (created on first use). */ +/** + * The shared AppConfig singleton (created on first use - a direct `node app.mjs` + * run resolves its resources folder next to the script). + */ export function appConfig(): AppConfig { instance ??= new AppConfig(); return instance; } -/** Replace the shared AppConfig (used by the CLI before startup). */ -export function loadConfig(path?: string): AppConfig { - instance = new AppConfig(path); +/** + * Replace the shared AppConfig (used by the CLI before startup, with the + * application file's folder as `appDir`). + */ +export function loadConfig(path?: string, appDir?: string): AppConfig { + instance = new AppConfig(path, undefined, appDir); return instance; } diff --git a/src/log-context.ts b/src/log-context.ts index e6ac837..84eebf3 100644 --- a/src/log-context.ts +++ b/src/log-context.ts @@ -137,12 +137,12 @@ function load(): { config: LogContextConfig; warning?: string } { if ((config.getProperty(FEATURE_FLAG, 'true') ?? 'true').toLowerCase() === 'false') { return { config: new LogContextConfig(undefined) }; } - // an application override replaces the default entirely - same resources - // convention as application.yml - const source = config.source; - const folder = source !== 'none' ? dirname(source) : 'resources'; - const candidate = join(folder || 'resources', CONFIG_FILE); - if (existsSync(candidate)) { + // an application override replaces the default entirely - it sits in the same + // resources folder as application.yml (next to the application file, or beside + // an explicit --config file); the working directory is never searched + const folder = config.resourcesDir; + const candidate = folder ? join(folder, CONFIG_FILE) : undefined; + if (candidate && existsSync(candidate)) { const section = contextSection(parse(readFileSync(candidate, 'utf-8')) ?? {}); if (!section) { // the engines log a warning and disable; mirror the outcome diff --git a/src/server.ts b/src/server.ts index e838cf9..f3261e8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -363,6 +363,7 @@ export class Platform { const visibility = service.isPrivate ? 'PRIVATE' : 'PUBLIC'; log.info(`Loaded ${visibility} ${service.route}, instances=${service.instances}`); } + log.info(`Configuration: ${config.source}`); const server = new EventApiServer(this.registry).createServer(); await new Promise((resolve) => server.listen(port, host, resolve)); log.info(`${appName} - Event API service started on port ${port}`); diff --git a/test/config.test.ts b/test/config.test.ts index 63a1a03..fc1827f 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -1,10 +1,12 @@ /** AppConfig tests: resources/ convention, -D overrides, ${ENV:default} substitution. */ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { test } from 'node:test'; -import { AppConfig, parseDArgs } from '../src/config.js'; +import { fileURLToPath } from 'node:url'; +import { AppConfig, mainScriptDir, parseDArgs } from '../src/config.js'; function tempFile(name: string, text: string): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mercury-config-')); @@ -60,18 +62,106 @@ test('${ENV:default} substitution', () => { assert.equal(config.get('missing'), undefined); // unresolved whole-value ref }); -test('resources location convention', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mercury-resources-')); - fs.mkdirSync(path.join(dir, 'resources')); - fs.writeFileSync(path.join(dir, 'resources', 'application.yml'), - "application:\n name: 'from-resources'\n"); +function tempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'mercury-resources-')); +} + +function withResources(dir: string, yaml: string): string { + fs.mkdirSync(path.join(dir, 'resources'), { recursive: true }); + const file = path.join(dir, 'resources', 'application.yml'); + fs.writeFileSync(file, yaml); + return file; +} + +test('resources next to the application file', () => { + const appDir = tempDir(); + const file = withResources(appDir, "application:\n name: 'demo-app'\nrest:\n server:\n port: 8086\n"); + const config = new AppConfig(undefined, [], appDir); + assert.equal(config.source, file); + assert.equal(config.resourcesDir, path.join(appDir, 'resources')); + assert.equal(config.get('application.name'), 'demo-app'); + assert.equal(config.get('rest.server.port'), 8086); +}); + +test('the working directory is never searched', () => { + // a decoy in the working directory, nothing next to the application file + const cwd = tempDir(); + withResources(cwd, "application:\n name: 'decoy'\n"); + const appDir = tempDir(); const previous = process.cwd(); - process.chdir(dir); + process.chdir(cwd); try { - const config = new AppConfig(undefined, []); - assert.equal(config.source, 'resources/application.yml'); - assert.equal(config.get('application.name'), 'from-resources'); + const config = new AppConfig(undefined, [], appDir); + assert.equal(config.source, 'none'); + assert.equal(config.get('application.name'), undefined); + assert.equal(config.resourcesDir, path.join(appDir, 'resources')); // where a companion file would sit } finally { process.chdir(previous); } }); + +test('a direct run anchors on the script node ran', () => { + // the lazy default resolves next to process.argv[1] + const appDir = tempDir(); + const file = withResources(appDir, "application:\n name: 'from-script'\n"); + const argv1 = process.argv[1]; + process.argv[1] = path.join(appDir, 'app.mjs'); + try { + assert.equal(mainScriptDir(), appDir); + const config = new AppConfig(undefined, []); + assert.equal(config.source, file); + assert.equal(config.get('application.name'), 'from-script'); + } finally { + process.argv[1] = argv1; + } +}); + +test('an explicit file keeps its companions beside it', () => { + const file = tempFile('application.yml', "application:\n name: 'explicit'\n"); + const config = new AppConfig(file, []); + assert.equal(config.source, file); + assert.equal(config.resourcesDir, path.dirname(file)); +}); + +/** An application file that reports its configuration and stops before any function registers. */ +function appInADecoyCwd(): { app: string; cwd: string; out: string; expected: string; root: string } { + const root = tempDir(); + const appDir = path.join(root, 'app'); + const expected = withResources(appDir, "application:\n name: 'from-app'\n"); + const configModule = new URL('../src/config.js', import.meta.url).href; + const app = path.join(appDir, 'app.mjs'); + fs.writeFileSync(app, [ + "import { writeFileSync } from 'node:fs';", + `import { appConfig } from ${JSON.stringify(configModule)};`, + "writeFileSync(process.env.MERCURY_TEST_OUT, appConfig().get('application.name') + ' ' + appConfig().source);", + 'process.exit(0); // before any function registers - the configuration is the point', + '' + ].join('\n')); + const cwd = path.join(root, 'cwd'); + withResources(cwd, "application:\n name: 'decoy'\n"); + return { app, cwd, out: path.join(root, 'out.txt'), expected, root }; +} + +function runFrom(cwd: string, out: string, args: string[]): string { + const result = spawnSync(process.execPath, args, + { cwd, env: { ...process.env, MERCURY_TEST_OUT: out }, encoding: 'utf-8', timeout: 120000 }); + assert.equal(result.status, 0, result.stderr); + return fs.readFileSync(out, 'utf-8'); +} + +test('node app.mjs resolves next to the script', () => { + // `node app.mjs` from a decoy working directory finds the resources folder next to app.mjs + const { app, cwd, out, expected } = appInADecoyCwd(); + assert.equal(runFrom(cwd, out, [app]), `from-app ${expected}`); +}); + +test('mercury-serve resolves next to the application file', () => { + // the CLI, run from a decoy working directory, loads the resources folder next to the app + const { app, cwd, out, expected, root } = appInADecoyCwd(); + const cli = fileURLToPath(new URL('../src/cli.js', import.meta.url)); + assert.equal(runFrom(cwd, out, [cli, app]), `from-app ${expected}`); + // --config names another file + const other = path.join(root, 'other.yml'); + fs.writeFileSync(other, "application:\n name: 'other'\n"); + assert.equal(runFrom(cwd, out, [cli, app, '--config', other]), `other ${other}`); +});