Skip to content

Commit d050991

Browse files
authored
feat(config): add walk-up boundary and ibmi config show command (#130)
* feat(config): add walk-up boundary and `ibmi config show` command Stop project config walk-up at homedir() to prevent ~/.ibmi/config.yaml from being loaded as both user and project config. Add `ibmi config show` command that displays merged configuration with per-setting file origins and active environment variable overrides. Signed-off-by: Adam Shedivy <ajshedivyaj@gmail.com> * fix(config): harden config show and walk-up boundary from review - Normalize paths with path.resolve() in findProjectConfigPath() to handle symlinks and trailing slash inconsistencies on macOS/Linux - Replace fragile .includes("PASS") masking with explicit SENSITIVE_ENV_VARS set for auditable, allowlist-based credential masking - Add explicit .action() to config parent command for consistent help output - Add test for malformed YAML error propagation through loadConfigLayers() Signed-off-by: Adam Shedivy <ajshedivyaj@gmail.com> --------- Signed-off-by: Adam Shedivy <ajshedivyaj@gmail.com>
1 parent ecb451a commit d050991

7 files changed

Lines changed: 853 additions & 18 deletions

File tree

server/src/cli/commands/completion.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ExitCode } from "../utils/exit-codes.js";
88

99
/** Top-level commands. */
1010
const COMMANDS = [
11+
"config",
1112
"system",
1213
"schemas",
1314
"tables",
@@ -21,6 +22,9 @@ const COMMANDS = [
2122
"completion",
2223
];
2324

25+
/** Config subcommands. */
26+
const CONFIG_SUBCOMMANDS = ["show"];
27+
2428
/** System subcommands. */
2529
const SYSTEM_SUBCOMMANDS = [
2630
"list",
@@ -57,12 +61,13 @@ function generateBash(): string {
5761
# Add to ~/.bashrc: eval "$(ibmi completion bash)"
5862
5963
_ibmi_completions() {
60-
local cur prev commands system_commands global_opts format_choices
64+
local cur prev commands config_commands system_commands global_opts format_choices
6165
COMPREPLY=()
6266
cur="\${COMP_WORDS[COMP_CWORD]}"
6367
prev="\${COMP_WORDS[COMP_CWORD-1]}"
6468
6569
commands="${COMMANDS.join(" ")}"
70+
config_commands="${CONFIG_SUBCOMMANDS.join(" ")}"
6671
system_commands="${SYSTEM_SUBCOMMANDS.join(" ")}"
6772
global_opts="${GLOBAL_OPTIONS.join(" ")}"
6873
format_choices="${FORMAT_CHOICES.join(" ")}"
@@ -91,6 +96,12 @@ _ibmi_completions() {
9196
return 0
9297
fi
9398
99+
# Complete config subcommands
100+
if [[ "\${COMP_WORDS[1]}" == "config" && \${COMP_CWORD} -eq 2 ]]; then
101+
COMPREPLY=( $(compgen -W "\${config_commands}" -- "\${cur}") )
102+
return 0
103+
fi
104+
94105
# Complete system subcommands
95106
if [[ "\${COMP_WORDS[1]}" == "system" && \${COMP_CWORD} -eq 2 ]]; then
96107
COMPREPLY=( $(compgen -W "\${system_commands}" -- "\${cur}") )
@@ -123,12 +134,16 @@ function generateZsh(): string {
123134
# Add to ~/.zshrc: eval "$(ibmi completion zsh)"
124135
125136
_ibmi() {
126-
local -a commands system_commands format_choices global_opts
137+
local -a commands config_commands system_commands format_choices global_opts
127138
128139
commands=(
129140
${COMMANDS.map((c) => ` '${c}:${c} command'`).join("\n")}
130141
)
131142
143+
config_commands=(
144+
${CONFIG_SUBCOMMANDS.map((c) => ` '${c}:${c}'`).join("\n")}
145+
)
146+
132147
system_commands=(
133148
${SYSTEM_SUBCOMMANDS.map((c) => ` '${c}:${c}'`).join("\n")}
134149
)
@@ -159,6 +174,9 @@ ${SYSTEM_SUBCOMMANDS.map((c) => ` '${c}:${c}'`).join("\n")}
159174
;;
160175
args)
161176
case $words[1] in
177+
config)
178+
_describe -t config_commands 'config subcommands' config_commands
179+
;;
162180
system)
163181
_describe -t system_commands 'system subcommands' system_commands
164182
;;
@@ -191,6 +209,13 @@ function generateFish(): string {
191209
);
192210
}
193211

212+
lines.push("", "# Config subcommands");
213+
for (const sub of CONFIG_SUBCOMMANDS) {
214+
lines.push(
215+
`complete -c ibmi -n '__fish_seen_subcommand_from config' -a '${sub}' -d '${sub}'`,
216+
);
217+
}
218+
194219
lines.push("", "# System subcommands");
195220
for (const sub of SYSTEM_SUBCOMMANDS) {
196221
lines.push(

server/src/cli/commands/config.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* @fileoverview `ibmi config` commands for inspecting CLI configuration.
3+
* Shows merged configuration with per-setting origin tracking.
4+
* @module cli/commands/config
5+
*/
6+
7+
import { Command } from "commander";
8+
import {
9+
loadConfig,
10+
loadConfigLayers,
11+
getUserConfigPath,
12+
type ConfigLayer,
13+
} from "../config/index.js";
14+
import { renderOutput, renderError } from "../formatters/output.js";
15+
import { ExitCode } from "../utils/exit-codes.js";
16+
import { getFormat } from "../utils/command-helpers.js";
17+
18+
/**
19+
* Determine which config layer a top-level key (`default` or `format`) came from.
20+
* Project layer takes precedence if it defines the key.
21+
*/
22+
function findOrigin(layers: ConfigLayer[], key: "default" | "format"): string {
23+
// Check project first (higher precedence)
24+
for (const layer of [...layers].reverse()) {
25+
if (layer.config && layer.config[key] !== undefined) {
26+
return layer.scope;
27+
}
28+
}
29+
return "unknown";
30+
}
31+
32+
/**
33+
* Determine which config layer a system definition came from.
34+
* Project layer takes precedence for same-named systems.
35+
*/
36+
function findSystemOrigin(layers: ConfigLayer[], name: string): string {
37+
for (const layer of [...layers].reverse()) {
38+
if (layer.config && name in layer.config.systems) {
39+
return layer.scope;
40+
}
41+
}
42+
return "unknown";
43+
}
44+
45+
/** Environment variables checked for config overrides. */
46+
const CONFIG_ENV_VARS = ["IBMI_SYSTEM", "DB2i_HOST", "DB2i_USER", "DB2i_PASS"];
47+
48+
/** Env vars whose values must be masked in output. */
49+
const SENSITIVE_ENV_VARS = new Set(["DB2i_PASS"]);
50+
51+
/** Return active environment variable overrides that affect config resolution. */
52+
function getActiveEnvOverrides(): { name: string; value: string }[] {
53+
const overrides: { name: string; value: string }[] = [];
54+
55+
for (const name of CONFIG_ENV_VARS) {
56+
const value = process.env[name];
57+
if (value) {
58+
overrides.push({ name, value: SENSITIVE_ENV_VARS.has(name) ? "****" : value });
59+
}
60+
}
61+
62+
return overrides;
63+
}
64+
65+
/**
66+
* Register `ibmi config` subcommands.
67+
*/
68+
export function registerConfigCommand(program: Command): void {
69+
const config = program
70+
.command("config")
71+
.description("Inspect CLI configuration")
72+
.action(() => {
73+
config.outputHelp();
74+
});
75+
76+
config
77+
.command("show")
78+
.description("Show active configuration with file origins")
79+
.action((_opts, cmd: Command) => {
80+
const format = getFormat(cmd);
81+
try {
82+
const layers = loadConfigLayers();
83+
const merged = loadConfig();
84+
const data: Record<string, unknown>[] = [];
85+
86+
// File layer status
87+
const userPath = getUserConfigPath();
88+
const userLayer = layers.find((l) => l.scope === "user");
89+
data.push({
90+
PROPERTY: "[user]",
91+
VALUE: userLayer?.exists ? "loaded" : "not found",
92+
SOURCE: userPath,
93+
});
94+
95+
const projectLayer = layers.find((l) => l.scope === "project");
96+
if (projectLayer) {
97+
data.push({
98+
PROPERTY: "[project]",
99+
VALUE: "loaded",
100+
SOURCE: projectLayer.path,
101+
});
102+
}
103+
104+
// Top-level settings
105+
if (merged.default) {
106+
data.push({
107+
PROPERTY: "default",
108+
VALUE: merged.default,
109+
SOURCE: findOrigin(layers, "default"),
110+
});
111+
}
112+
113+
if (merged.format) {
114+
data.push({
115+
PROPERTY: "format",
116+
VALUE: merged.format,
117+
SOURCE: findOrigin(layers, "format"),
118+
});
119+
}
120+
121+
// Systems
122+
for (const [name, sys] of Object.entries(merged.systems)) {
123+
data.push({
124+
PROPERTY: `systems.${name}`,
125+
VALUE: `${sys.host}:${sys.port} (${sys.user})`,
126+
SOURCE: findSystemOrigin(layers, name),
127+
});
128+
}
129+
130+
// Active env overrides
131+
for (const override of getActiveEnvOverrides()) {
132+
data.push({
133+
PROPERTY: override.name,
134+
VALUE: override.value,
135+
SOURCE: "environment",
136+
});
137+
}
138+
139+
renderOutput(data, format, { rowCount: data.length });
140+
} catch (err) {
141+
renderError(
142+
err instanceof Error ? err : new Error(String(err)),
143+
format,
144+
);
145+
process.exitCode = ExitCode.GENERAL;
146+
}
147+
});
148+
}

server/src/cli/config/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@
66
export { CliConfigSchema, SystemConfigSchema, validateConfig } from "./schema.js";
77
export {
88
loadConfig,
9+
loadConfigLayers,
910
saveUserConfig,
1011
upsertSystem,
1112
removeSystem,
1213
setDefaultSystem,
1314
getProjectConfigPath,
1415
getUserConfigPath,
1516
} from "./loader.js";
17+
export type { ConfigLayer } from "./loader.js";
1618
export { resolveSystem } from "./resolver.js";
1719
export {
1820
expandEnvVars,

server/src/cli/config/loader.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,19 @@ const USER_CONFIG_DIR = path.join(homedir(), ".ibmi");
2424

2525
/**
2626
* Walk up from cwd to find the nearest .ibmi/config.yaml.
27-
* Stops at the filesystem root. Returns null if not found.
27+
* Stops at the home directory boundary (user config lives there)
28+
* and at the filesystem root. Returns null if not found.
2829
*/
2930
function findProjectConfigPath(): string | null {
30-
let dir = process.cwd();
31+
let dir = path.resolve(process.cwd());
3132
const root = path.parse(dir).root;
33+
const home = path.resolve(homedir());
3234

3335
while (true) {
36+
// ~/.ibmi/config.yaml is the user config — never treat it as project config
37+
if (dir === home) {
38+
return null;
39+
}
3440
const candidate = path.join(dir, PROJECT_CONFIG_DIR, CONFIG_FILE);
3541
if (existsSync(candidate)) {
3642
return candidate;
@@ -147,6 +153,47 @@ export function loadConfig(): CliConfig {
147153
return expandSystemEnvVars(config);
148154
}
149155

156+
/** Metadata about a single configuration layer (user or project). */
157+
export interface ConfigLayer {
158+
/** Which config layer this represents. */
159+
scope: "user" | "project";
160+
/** Absolute path to the config file. */
161+
path: string;
162+
/** Whether the config file exists on disk. */
163+
exists: boolean;
164+
/** Parsed config, or null if the file doesn't exist. */
165+
config: CliConfig | null;
166+
}
167+
168+
/**
169+
* Load individual config layers without merging.
170+
* Used by `ibmi config show` to display per-setting origin information.
171+
*/
172+
export function loadConfigLayers(): ConfigLayer[] {
173+
const userPath = getUserConfigPath();
174+
const projectPath = findProjectConfigPath();
175+
176+
const layers: ConfigLayer[] = [
177+
{
178+
scope: "user",
179+
path: userPath,
180+
exists: existsSync(userPath),
181+
config: loadConfigFile(userPath),
182+
},
183+
];
184+
185+
if (projectPath) {
186+
layers.push({
187+
scope: "project",
188+
path: projectPath,
189+
exists: true,
190+
config: loadConfigFile(projectPath),
191+
});
192+
}
193+
194+
return layers;
195+
}
196+
150197
/**
151198
* Save a config to the user-level config file.
152199
*/

server/src/cli/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Command } from "commander";
1111
import { readFileSync } from "fs";
1212
import path from "path";
1313
import { fileURLToPath } from "url";
14+
import { registerConfigCommand } from "./commands/config.js";
1415
import { registerSystemCommand } from "./commands/system.js";
1516
import { registerSchemasCommand } from "./commands/schemas.js";
1617
import { registerTablesCommand } from "./commands/tables.js";
@@ -69,6 +70,7 @@ export function createProgram(): Command {
6970
.option("--output <path>", "Write output to file instead of stdout");
7071

7172
// Register command groups
73+
registerConfigCommand(program);
7274
registerSystemCommand(program);
7375
registerSchemasCommand(program);
7476
registerTablesCommand(program);

0 commit comments

Comments
 (0)