Skip to content

Commit d211744

Browse files
committed
feat: implement Config Bus namespace support, add man page pager integration, and update apcore-js dependency to 0.15.1
1 parent 5a01594 commit d211744

File tree

9 files changed

+205
-12
lines changed

9 files changed

+205
-12
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "apcore-cli",
3-
"version": "0.4.0",
3+
"version": "0.5.0",
44
"description": "CLI wrapper for the apcore core SDK — exposes apcore modules as CLI commands",
55
"type": "module",
66
"main": "./dist/index.js",
@@ -27,7 +27,7 @@
2727
"node": ">=18.0.0"
2828
},
2929
"peerDependencies": {
30-
"apcore-js": ">=0.14.0",
30+
"apcore-js": ">=0.15.1",
3131
"apcore-toolkit": ">=0.4.0"
3232
},
3333
"peerDependenciesMeta": {

pnpm-lock.yaml

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/config.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,50 @@ export const DEFAULTS: Record<string, unknown> = {
1919
"cli.stdin_buffer_limit": 10_485_760,
2020
"cli.auto_approve": false,
2121
"cli.help_text_max_length": 1000,
22+
// Namespace-mode aliases (apcore >= 0.15.0 Config Bus)
23+
"apcore-cli.stdin_buffer_limit": 10_485_760,
24+
"apcore-cli.auto_approve": false,
25+
"apcore-cli.help_text_max_length": 1000,
26+
"apcore-cli.logging_level": "WARNING",
2227
};
2328

29+
/** Namespace key ↔ legacy key mapping for backward compatibility. */
30+
const NAMESPACE_TO_LEGACY: Record<string, string> = {
31+
"apcore-cli.stdin_buffer_limit": "cli.stdin_buffer_limit",
32+
"apcore-cli.auto_approve": "cli.auto_approve",
33+
"apcore-cli.help_text_max_length": "cli.help_text_max_length",
34+
"apcore-cli.logging_level": "logging.level",
35+
};
36+
const LEGACY_TO_NAMESPACE: Record<string, string> = Object.fromEntries(
37+
Object.entries(NAMESPACE_TO_LEGACY).map(([k, v]) => [v, k]),
38+
);
39+
40+
/**
41+
* Register the apcore-cli Config Bus namespace (apcore >= 0.15.0).
42+
* Safe to call even when apcore-js is unavailable or < 0.15.0.
43+
*/
44+
export function registerConfigNamespace(): void {
45+
try {
46+
// Dynamic import to avoid hard failure when apcore-js is not available
47+
// eslint-disable-next-line @typescript-eslint/no-require-imports
48+
const { Config } = require("apcore-js");
49+
if (typeof Config?.registerNamespace === "function") {
50+
Config.registerNamespace({
51+
name: "apcore-cli",
52+
envPrefix: "APCORE_CLI",
53+
defaults: {
54+
stdin_buffer_limit: 10_485_760,
55+
auto_approve: false,
56+
help_text_max_length: 1000,
57+
logging_level: "WARNING",
58+
},
59+
});
60+
}
61+
} catch {
62+
// apcore-js not installed or < 0.15.0 — graceful no-op
63+
}
64+
}
65+
2466
// ---------------------------------------------------------------------------
2567
// ConfigResolver
2668
// ---------------------------------------------------------------------------
@@ -64,11 +106,18 @@ export class ConfigResolver {
64106
}
65107
}
66108

67-
// Tier 3: Config file
109+
// Tier 3: Config file (try both namespace and legacy keys)
68110
const fileValue = this.resolveFromFile(key);
69111
if (fileValue !== undefined) {
70112
return fileValue;
71113
}
114+
const altKey = NAMESPACE_TO_LEGACY[key] ?? LEGACY_TO_NAMESPACE[key];
115+
if (altKey) {
116+
const altFileValue = this.resolveFromFile(altKey);
117+
if (altFileValue !== undefined) {
118+
return altFileValue;
119+
}
120+
}
72121

73122
// Tier 4: Defaults
74123
return DEFAULTS[key];

src/errors.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,13 @@ export const EXIT_CODES = {
8383
CONFIG_INVALID: 47,
8484
SCHEMA_CIRCULAR_REF: 48,
8585
ACL_DENIED: 77,
86+
// Config Bus errors (apcore >= 0.15.0)
87+
CONFIG_NAMESPACE_RESERVED: 78,
88+
CONFIG_NAMESPACE_DUPLICATE: 78,
89+
CONFIG_ENV_PREFIX_CONFLICT: 78,
90+
CONFIG_MOUNT_ERROR: 66,
91+
CONFIG_BIND_ERROR: 65,
92+
ERROR_FORMATTER_DUPLICATE: 70,
8693
KEYBOARD_INTERRUPT: 130,
8794
} as const;
8895

@@ -130,11 +137,19 @@ export function exitCodeForError(error: unknown): ExitCode {
130137
SCHEMA_CIRCULAR_REF: EXIT_CODES.SCHEMA_CIRCULAR_REF,
131138
APPROVAL_DENIED: EXIT_CODES.APPROVAL_DENIED,
132139
APPROVAL_TIMEOUT: EXIT_CODES.APPROVAL_TIMEOUT,
140+
APPROVAL_PENDING: EXIT_CODES.APPROVAL_DENIED,
133141
CONFIG_NOT_FOUND: EXIT_CODES.CONFIG_NOT_FOUND,
134142
CONFIG_INVALID: EXIT_CODES.CONFIG_INVALID,
135143
MODULE_EXECUTE_ERROR: EXIT_CODES.MODULE_EXECUTE_ERROR,
136144
MODULE_TIMEOUT: EXIT_CODES.MODULE_TIMEOUT,
137145
ACL_DENIED: EXIT_CODES.ACL_DENIED,
146+
// Config Bus errors (apcore >= 0.15.0)
147+
CONFIG_NAMESPACE_RESERVED: EXIT_CODES.CONFIG_NAMESPACE_RESERVED,
148+
CONFIG_NAMESPACE_DUPLICATE: EXIT_CODES.CONFIG_NAMESPACE_DUPLICATE,
149+
CONFIG_ENV_PREFIX_CONFLICT: EXIT_CODES.CONFIG_ENV_PREFIX_CONFLICT,
150+
CONFIG_MOUNT_ERROR: EXIT_CODES.CONFIG_MOUNT_ERROR,
151+
CONFIG_BIND_ERROR: EXIT_CODES.CONFIG_BIND_ERROR,
152+
ERROR_FORMATTER_DUPLICATE: EXIT_CODES.ERROR_FORMATTER_DUPLICATE,
138153
};
139154
if (code && code in codeMap) {
140155
return codeMap[code];

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export { getDisplay, getCliDisplayFields } from "./display-helpers.js";
1919
export { registerInitCommand } from "./init-cmd.js";
2020

2121
// Configuration
22-
export { ConfigResolver, DEFAULTS } from "./config.js";
22+
export { ConfigResolver, DEFAULTS, registerConfigNamespace } from "./config.js";
2323

2424
// Discovery
2525
export { registerDiscoveryCommands } from "./discovery.js";

src/main.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import { formatExecResult } from "./output.js";
1616
import { setLogLevel } from "./logger.js";
1717
import { registerInitCommand } from "./init-cmd.js";
1818
import { getDisplay } from "./display-helpers.js";
19+
import { registerConfigNamespace } from "./config.js";
20+
import { configureManHelp } from "./shell.js";
1921
import type { Executor, ModuleDescriptor } from "./cli.js";
2022

2123
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -96,6 +98,8 @@ export function createCli(
9698
verbose = false,
9799
): Command {
98100
verboseHelp = verbose;
101+
// Register Config Bus namespace (apcore >= 0.15.0)
102+
registerConfigNamespace();
99103
// Resolve program name
100104
const resolvedProgName = progName ?? path.basename(process.argv[1] ?? "apcore-cli") ?? "apcore-cli";
101105

@@ -120,9 +124,19 @@ export function createCli(
120124
?? "./extensions";
121125
void resolvedExtDir; // Will be used when apcore-js registry is wired
122126

127+
// Footer hints for discoverability
128+
program.addHelpText("after", [
129+
"",
130+
"Use --help --verbose to show all options (including built-in apcore options).",
131+
"Use --help --man to display a formatted man page.",
132+
].join("\n"));
133+
123134
// Register init command for scaffolding
124135
registerInitCommand(program);
125136

137+
// Register --help --man support
138+
configureManHelp(program, resolvedProgName, VERSION);
139+
126140
// Hook to apply optional toolkit integration before command execution.
127141
// Commander actions are async, so we can set up toolkit state lazily.
128142
program.hook("preAction", async (thisCommand) => {

src/shell.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import { readFileSync } from "node:fs";
88
import { fileURLToPath } from "node:url";
99
import * as path from "node:path";
10+
import { spawnSync } from "node:child_process";
1011
import { Command, Help, Option } from "commander";
1112
import { EXIT_CODES } from "./errors.js";
1213

@@ -516,8 +517,41 @@ export function configureManHelp(
516517
// Intercept help to output roff when --man is set
517518
program.addHelpText("beforeAll", () => {
518519
if (program.opts().man) {
519-
process.stdout.write(buildProgramManPage(program, progName, version, description, docsUrl));
520-
process.stdout.write("\n");
520+
const roff = buildProgramManPage(program, progName, version, description, docsUrl) + "\n";
521+
522+
// If stdout is a TTY, render through a pager; otherwise output raw roff
523+
// (allows piping/redirection like `reach --help --man > reach.1`)
524+
if (process.stdout.isTTY) {
525+
// Try mandoc first (available on macOS/BSD), then groff, then fall back to raw output
526+
const pagers: Array<{ cmd: string; args: string[] }> = [
527+
{ cmd: "mandoc", args: ["-a"] },
528+
{ cmd: "groff", args: ["-man", "-Tutf8"] },
529+
];
530+
let rendered = false;
531+
for (const { cmd, args } of pagers) {
532+
const result = spawnSync(cmd, args, {
533+
input: roff,
534+
stdio: ["pipe", "pipe", "pipe"],
535+
encoding: "utf-8",
536+
});
537+
if (result.status === 0 && result.stdout) {
538+
const pager = process.env.PAGER || "less";
539+
const pagerResult = spawnSync(pager, ["-R"], {
540+
input: result.stdout,
541+
stdio: ["pipe", "inherit", "inherit"],
542+
});
543+
if (pagerResult.status !== null) {
544+
rendered = true;
545+
break;
546+
}
547+
}
548+
}
549+
if (!rendered) {
550+
process.stdout.write(roff);
551+
}
552+
} else {
553+
process.stdout.write(roff);
554+
}
521555
process.exit(0);
522556
}
523557
return "";

tests/config.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,4 +196,49 @@ describe("ConfigResolver", () => {
196196
expect(mockReadFileSync).toHaveBeenCalledTimes(1);
197197
});
198198
});
199+
200+
// ---- Task 4: Namespace-aware config resolution (apcore >= 0.15.0) ----
201+
202+
describe("namespace-aware config resolution", () => {
203+
it("DEFAULTS contain namespace keys", () => {
204+
expect(DEFAULTS).toHaveProperty("apcore-cli.stdin_buffer_limit");
205+
expect(DEFAULTS).toHaveProperty("apcore-cli.auto_approve");
206+
expect(DEFAULTS).toHaveProperty("apcore-cli.help_text_max_length");
207+
expect(DEFAULTS).toHaveProperty("apcore-cli.logging_level");
208+
});
209+
210+
it("resolves namespace key from legacy config file", () => {
211+
mockFileContent(yaml.dump({ cli: { stdin_buffer_limit: 5242880 } }));
212+
const resolver = new ConfigResolver({}, "apcore.yaml");
213+
expect(resolver.resolve("apcore-cli.stdin_buffer_limit")).toBe(5242880);
214+
});
215+
216+
it("resolves legacy key from namespace config file", () => {
217+
mockFileContent(
218+
yaml.dump({ "apcore-cli": { auto_approve: true } }),
219+
);
220+
const resolver = new ConfigResolver({}, "apcore.yaml");
221+
expect(resolver.resolve("cli.auto_approve")).toBe(true);
222+
});
223+
224+
it("direct key takes precedence over alternate", () => {
225+
mockFileContent(
226+
yaml.dump({
227+
cli: { help_text_max_length: 500 },
228+
"apcore-cli": { help_text_max_length: 2000 },
229+
}),
230+
);
231+
const resolver = new ConfigResolver({}, "apcore.yaml");
232+
expect(resolver.resolve("cli.help_text_max_length")).toBe(500);
233+
expect(resolver.resolve("apcore-cli.help_text_max_length")).toBe(2000);
234+
});
235+
236+
it("returns namespace default when no file present", () => {
237+
mockFileNotFound();
238+
const resolver = new ConfigResolver();
239+
expect(resolver.resolve("apcore-cli.stdin_buffer_limit")).toBe(10_485_760);
240+
expect(resolver.resolve("apcore-cli.auto_approve")).toBe(false);
241+
expect(resolver.resolve("apcore-cli.logging_level")).toBe("WARNING");
242+
});
243+
});
199244
});

tests/errors.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,40 @@ describe("exitCodeForError", () => {
111111
it("maps unknown errors to exit code 1", () => {
112112
expect(exitCodeForError(new Error("unknown"))).toBe(EXIT_CODES.MODULE_EXECUTE_ERROR);
113113
});
114+
115+
// Config Bus error codes (apcore >= 0.15.0)
116+
it("maps APPROVAL_PENDING to exit code 46", () => {
117+
const err = Object.assign(new Error("test"), { code: "APPROVAL_PENDING" });
118+
expect(exitCodeForError(err)).toBe(EXIT_CODES.APPROVAL_DENIED);
119+
});
120+
121+
it("maps CONFIG_NAMESPACE_RESERVED to exit code 78", () => {
122+
const err = Object.assign(new Error("test"), { code: "CONFIG_NAMESPACE_RESERVED" });
123+
expect(exitCodeForError(err)).toBe(EXIT_CODES.CONFIG_NAMESPACE_RESERVED);
124+
});
125+
126+
it("maps CONFIG_NAMESPACE_DUPLICATE to exit code 78", () => {
127+
const err = Object.assign(new Error("test"), { code: "CONFIG_NAMESPACE_DUPLICATE" });
128+
expect(exitCodeForError(err)).toBe(EXIT_CODES.CONFIG_NAMESPACE_DUPLICATE);
129+
});
130+
131+
it("maps CONFIG_ENV_PREFIX_CONFLICT to exit code 78", () => {
132+
const err = Object.assign(new Error("test"), { code: "CONFIG_ENV_PREFIX_CONFLICT" });
133+
expect(exitCodeForError(err)).toBe(EXIT_CODES.CONFIG_ENV_PREFIX_CONFLICT);
134+
});
135+
136+
it("maps CONFIG_MOUNT_ERROR to exit code 66", () => {
137+
const err = Object.assign(new Error("test"), { code: "CONFIG_MOUNT_ERROR" });
138+
expect(exitCodeForError(err)).toBe(EXIT_CODES.CONFIG_MOUNT_ERROR);
139+
});
140+
141+
it("maps CONFIG_BIND_ERROR to exit code 65", () => {
142+
const err = Object.assign(new Error("test"), { code: "CONFIG_BIND_ERROR" });
143+
expect(exitCodeForError(err)).toBe(EXIT_CODES.CONFIG_BIND_ERROR);
144+
});
145+
146+
it("maps ERROR_FORMATTER_DUPLICATE to exit code 70", () => {
147+
const err = Object.assign(new Error("test"), { code: "ERROR_FORMATTER_DUPLICATE" });
148+
expect(exitCodeForError(err)).toBe(EXIT_CODES.ERROR_FORMATTER_DUPLICATE);
149+
});
114150
});

0 commit comments

Comments
 (0)