Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` 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: <path>`. **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
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/guides/config-logging-actuators.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions docs/guides/configuration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>`.
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 <path>`.
3. `${ENV_VAR:default}` substitution inside values: environment first, then a base
configuration key of that name, then the default.

Expand Down
2 changes: 2 additions & 0 deletions examples/demo-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions examples/demo-app/resources/application.yml
Original file line number Diff line number Diff line change
@@ -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

Expand Down
3 changes: 3 additions & 0 deletions examples/llm-helper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions examples/llm-helper/resources/application.yml
Original file line number Diff line number Diff line change
@@ -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

Expand Down
21 changes: 7 additions & 14 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,25 +33,15 @@ async function main(): Promise<number> {
'Usage: mercury-serve <app.mjs> [--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');
Expand Down
83 changes: 67 additions & 16 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';

Expand All @@ -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<string, unknown> {
const overrides = new Map<string, unknown>();
Expand Down Expand Up @@ -65,21 +85,46 @@ function parseProperties(text: string): Map<string, unknown> {
export class AppConfig {
private store = new Map<string, unknown>();
private readonly overrides: Map<string, unknown>;
/**
* 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;
}
Expand Down Expand Up @@ -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;
}
12 changes: 6 additions & 6 deletions src/log-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve) => server.listen(port, host, resolve));
log.info(`${appName} - Event API service started on port ${port}`);
Expand Down
Loading
Loading