Files
ansiblings/packages/keyman/src/keyman.config.ts
T
Benjamin Diedrichsen 270cbe628a [keyman] warn on unknown config keys, report which files were read, drop the inert merge machinery
Three things about .keymanrc.json.

`z.object` strips a key it does not know, so `{"vaultroot": "…"}` was
indistinguishable from an empty file: the vault stayed at the default and
nothing said why. Now warned per file, listing the known keys, because for a
casing slip naming the alternatives is most of the help. Warned rather than
fatal — this module degrades to defaults throughout — and warned inside the
per-file loop, the only place the filename exists: z.strictObject on the
merged result cannot say which file said it. The known-key list is derived
from the schema shape, so it cannot drift.

`--print-config` now includes `configFiles`, in merge order. That was the one
question it could not answer, and it existed only as unstructured stderr from
loadConfig — the wrong half of the output for it. Assembled in
describeConfig() rather than in cli.ts, which is excluded from coverage.

And the `resolution` machinery is gone: roughly 45 lines that could not change
an outcome, because every schema property is a string and both strategies
return the child's value for primitives. Its one test passed either way.
mergeConfigs is now a spread. The divergence from nopy, where the same
machinery is load-bearing, is recorded in the comment above it.
2026-07-30 15:15:40 +02:00

222 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { z } from 'zod';
/**
* Configuration schema for keyman
*/
const KeymanConfigSchema = z.object({
vaultRoot: z.string().default('vault'),
keysDir: z.string().default('keys'),
tmpDir: z.string().default('tmp'),
ageKeyFile: z.string().default('age.key'),
});
export type KeymanConfig = z.infer<typeof KeymanConfigSchema>;
/** Raw config file structure */
export type KeymanConfigFile = Partial<KeymanConfig>;
/** Every key a config file may set. */
const KNOWN_KEYS = Object.keys(KeymanConfigSchema.shape) as (keyof KeymanConfig)[];
/**
* Default configuration values
*/
const DEFAULT_CONFIG: KeymanConfig = {
vaultRoot: 'vault',
keysDir: 'keys',
tmpDir: 'tmp',
ageKeyFile: 'age.key',
};
const CONFIG_FILENAME = '.keymanrc.json';
/**
* Path-based properties that should be resolved relative to config file location
*/
const PATH_PROPERTIES: (keyof KeymanConfig)[] = ['vaultRoot'];
/**
* Resolves path properties in a config object relative to the config file's directory
* @param configFile The raw config file contents
* @param configDir Directory containing the config file
* @returns Config with path properties resolved to absolute paths
*/
function resolvePathsRelativeToConfig(
configFile: KeymanConfigFile,
configDir: string
): KeymanConfigFile {
const resolved = { ...configFile };
for (const prop of PATH_PROPERTIES) {
const value = configFile[prop];
if (typeof value === 'string' && !path.isAbsolute(value)) {
resolved[prop] = path.resolve(configDir, value);
}
}
return resolved;
}
/**
* Finds all config files by traversing upwards from cwd to root
* Returns configs in order from root to cwd (parent first, child last)
* @param startDir Directory to start searching from
* @returns Array of paths to .keymanrc.json files
*/
function findConfigFiles(startDir: string): string[] {
const configPaths: string[] = [];
let currentDir = startDir;
// Traverse upwards
while (true) {
const configPath = path.join(currentDir, CONFIG_FILENAME);
if (fs.existsSync(configPath)) {
configPaths.unshift(configPath); // Add to front (root first)
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
break; // Reached root
}
currentDir = parentDir;
}
// Also check home directory (lowest priority)
const homeConfig = path.join(os.homedir(), CONFIG_FILENAME);
if (fs.existsSync(homeConfig) && !configPaths.includes(homeConfig)) {
configPaths.unshift(homeConfig);
}
return configPaths;
}
/**
* Reports keys a config file sets that keyman does not read.
*
* `z.object` strips them silently, so `{"vaultroot": "…"}` used to be
* indistinguishable from an empty file — the vault quietly stayed at the default
* and nothing said why. Warned rather than fatal, which is this module's posture
* throughout, and warned *here* because this is the only place the filename is in
* hand: `z.strictObject` on the merged result cannot name the file that said it.
*/
function warnUnknownKeys(configFile: KeymanConfigFile, configPath: string): void {
const unknown = Object.keys(configFile).filter(
(key) => !KNOWN_KEYS.includes(key as keyof KeymanConfig)
);
if (unknown.length > 0) {
console.warn(
`⚠️ ${configPath}: ignoring unknown ${unknown.length === 1 ? 'key' : 'keys'} ${unknown.join(', ')}. Known keys: ${KNOWN_KEYS.join(', ')}.`
);
}
}
/**
* Merges a child config into a parent config.
*
* Every property is a string, so a child simply wins. keyman deliberately has
* none of nopy's `resolution` machinery: deep-merge and array-concatenation
* strategies are meaningful there because its config holds arrays and objects,
* and here they would be 45 lines that cannot change an outcome.
*/
function mergeConfigs(parent: KeymanConfig, childFile: KeymanConfigFile): KeymanConfig {
return { ...parent, ...childFile };
}
/**
* Loads configuration from .keymanrc.json files
*
* Searches for `.keymanrc.json` by traversing upwards from cwd to root.
* Multiple config files are merged, with child configs overriding parent configs.
*
* @returns Validated keyman configuration
*/
export function loadConfig(): KeymanConfig {
const startDir = process.cwd();
const configPaths = findConfigFiles(startDir);
if (configPaths.length === 0) {
console.error('️ No .keymanrc.json found, using default configuration');
return DEFAULT_CONFIG;
}
// Start with defaults and merge each config file
let config: KeymanConfig = { ...DEFAULT_CONFIG };
for (const configPath of configPaths) {
try {
const content = fs.readFileSync(configPath, 'utf-8');
const rawConfig = JSON.parse(content) as KeymanConfigFile;
warnUnknownKeys(rawConfig, configPath);
// Resolve path properties relative to the config file's directory
const configDir = path.dirname(configPath);
const resolvedConfig = resolvePathsRelativeToConfig(rawConfig, configDir);
config = mergeConfigs(config, resolvedConfig);
console.error(`✅ Loaded configuration from ${configPath}`);
} catch (error) {
if (error instanceof SyntaxError) {
console.warn(`⚠️ Skipping invalid JSON in ${configPath}: ${error.message}`);
} else {
console.warn(`⚠️ Skipping config ${configPath}: ${error}`);
}
// Continue with other configs instead of failing entirely
}
}
// Validate the final merged result
try {
return KeymanConfigSchema.parse(config);
} catch (error) {
if (error instanceof z.ZodError) {
console.error('❌ ERROR: Invalid merged configuration:');
error.issues.forEach((err) => {
console.error(` - ${err.path.join('.')}: ${err.message}`);
});
}
console.error('️ Falling back to default configuration');
return DEFAULT_CONFIG;
}
}
/**
* Resolves configuration paths relative to VAULT_ROOT or current directory
* @param config The keyman configuration
* @returns Resolved absolute paths
*/
export function resolveConfigPaths(config: KeymanConfig) {
// VAULT_ROOT environment variable takes precedence
const vaultRoot = path.resolve(process.env.VAULT_ROOT ?? config.vaultRoot);
return {
vaultRoot,
keysDir: path.resolve(vaultRoot, config.keysDir),
tmpDir: path.resolve(vaultRoot, config.tmpDir),
keyPath: path.resolve(vaultRoot, config.ageKeyFile),
};
}
/**
* Gets the paths of all discovered config files (for debugging)
* @returns Array of paths to .keymanrc.json files, ordered from root to cwd
*/
export function getConfigPaths(): string[] {
return findConfigFiles(process.cwd());
}
/**
* What `--print-config` prints.
*
* `configFiles` is the question the flag could not answer before: which files
* were read, in the order they were merged. It existed only as unstructured
* stderr from `loadConfig`, which is exactly the wrong place for it — the JSON is
* the machine-readable half.
*/
export function describeConfig(): ReturnType<typeof resolveConfigPaths> & {
configFiles: string[];
} {
return { ...resolveConfigPaths(loadConfig()), configFiles: getConfigPaths() };
}