-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathbase-command.ts
More file actions
360 lines (312 loc) · 11.4 KB
/
base-command.ts
File metadata and controls
360 lines (312 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
/*
* Copyright (c) 2025, Salesforce, Inc.
* SPDX-License-Identifier: Apache-2
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
*/
import {Command, Flags, type Interfaces} from '@oclif/core';
import {loadConfig} from './config.js';
import type {ResolvedConfig, LoadConfigOptions, PluginSources} from './config.js';
import type {
ConfigSourcesHookOptions,
ConfigSourcesHookResult,
HttpMiddlewareHookOptions,
HttpMiddlewareHookResult,
} from './hooks.js';
import {setLanguage} from '../i18n/index.js';
import {configureLogger, getLogger, type LogLevel, type Logger} from '../logging/index.js';
import type {ExtraParamsConfig} from '../clients/middleware.js';
import type {ConfigSource} from '../config/types.js';
import {globalMiddlewareRegistry} from '../clients/middleware-registry.js';
export type Flags<T extends typeof Command> = Interfaces.InferredFlags<(typeof BaseCommand)['baseFlags'] & T['flags']>;
export type Args<T extends typeof Command> = Interfaces.InferredArgs<T['args']>;
const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'silent'] as const;
/**
* Base command class for B2C CLI tools.
*
* Environment variables for logging:
* - SFCC_LOG_TO_STDOUT: Send logs to stdout instead of stderr
* - SFCC_LOG_COLORIZE: Force colors on/off (default: auto-detect TTY)
* - SFCC_REDACT_SECRETS: Set to 'false' to disable secret redaction
* - NO_COLOR: Industry standard to disable colors
*/
export abstract class BaseCommand<T extends typeof Command> extends Command {
static baseFlags = {
'log-level': Flags.option({
description: 'Set logging verbosity level',
env: 'SFCC_LOG_LEVEL',
options: LOG_LEVELS,
helpGroup: 'GLOBAL',
})(),
debug: Flags.boolean({
char: 'D',
description: 'Enable debug logging (shorthand for --log-level debug)',
env: 'SFCC_DEBUG',
default: false,
helpGroup: 'GLOBAL',
}),
json: Flags.boolean({
description: 'Output logs as JSON lines',
default: false,
helpGroup: 'GLOBAL',
}),
lang: Flags.string({
char: 'L',
description: 'Language for messages (e.g., en, de). Also respects LANGUAGE env var.',
helpGroup: 'GLOBAL',
}),
config: Flags.string({
description: 'Path to config file (in dw.json format; defaults to ./dw.json)',
env: 'SFCC_CONFIG',
helpGroup: 'GLOBAL',
}),
instance: Flags.string({
char: 'i',
description: 'Instance name from configuration file (i.e. dw.json, etc)',
env: 'SFCC_INSTANCE',
helpGroup: 'GLOBAL',
}),
'working-directory': Flags.string({
description: 'Project working directory',
env: 'SFCC_WORKING_DIRECTORY',
helpGroup: 'GLOBAL',
}),
'extra-query': Flags.string({
description: 'Extra query parameters as JSON (e.g., \'{"debug":"true"}\')',
env: 'SFCC_EXTRA_QUERY',
helpGroup: 'GLOBAL',
hidden: true,
}),
'extra-body': Flags.string({
description: 'Extra body fields to merge as JSON (e.g., \'{"_internal":true}\')',
env: 'SFCC_EXTRA_BODY',
helpGroup: 'GLOBAL',
hidden: true,
}),
'extra-headers': Flags.string({
description: 'Extra HTTP headers as JSON (e.g., \'{"X-Custom-Header": "value"}\')',
env: 'SFCC_EXTRA_HEADERS',
helpGroup: 'GLOBAL',
hidden: true,
}),
};
protected flags!: Flags<T>;
protected args!: Args<T>;
protected resolvedConfig!: ResolvedConfig;
protected logger!: Logger;
/** High-priority config sources from plugins (inserted before defaults) */
protected pluginSourcesBefore: ConfigSource[] = [];
/** Low-priority config sources from plugins (inserted after defaults) */
protected pluginSourcesAfter: ConfigSource[] = [];
public async init(): Promise<void> {
await super.init();
const {args, flags} = await this.parse({
flags: this.ctor.flags,
baseFlags: (super.ctor as typeof BaseCommand).baseFlags,
args: this.ctor.args,
strict: this.ctor.strict,
});
this.flags = flags as Flags<T>;
this.args = args as Args<T>;
if (this.flags.lang) {
setLanguage(this.flags.lang);
}
this.configureLogging();
// Collect middleware from plugins before any API clients are created
await this.collectPluginHttpMiddleware();
// Collect config sources from plugins before loading configuration
await this.collectPluginConfigSources();
this.resolvedConfig = this.loadConfiguration();
}
/**
* Determine colorize setting based on env vars and TTY.
* Priority: NO_COLOR > SFCC_LOG_COLORIZE > TTY detection
*/
private shouldColorize(): boolean {
if (process.env.NO_COLOR !== undefined) {
return false;
}
// Default: colorize if stderr is a TTY
return process.stderr.isTTY ?? false;
}
protected configureLogging(): void {
let level: LogLevel = 'info';
if (this.flags['log-level']) {
level = this.flags['log-level'] as LogLevel;
} else if (this.flags.debug) {
level = 'debug';
}
// Default to stderr (fd 2), allow override to stdout (fd 1)
const fd = process.env.SFCC_LOG_TO_STDOUT ? 1 : 2;
// Redaction: default true, can be disabled
const redact = process.env.SFCC_REDACT_SECRETS !== 'false';
configureLogger({
level,
fd,
baseContext: {command: this.id},
json: this.flags.json,
colorize: this.shouldColorize(),
redact,
});
this.logger = getLogger();
}
/**
* Override oclif's log() to use pino.
*/
log(message?: string, ...args: unknown[]): void {
if (message !== undefined) {
this.logger.info(args.length > 0 ? `${message} ${args.join(' ')}` : message);
}
}
/**
* Override oclif's warn() to use pino.
*/
warn(input: string | Error): string | Error {
const message = input instanceof Error ? input.message : input;
this.logger.warn(message);
return input;
}
protected loadConfiguration(): ResolvedConfig {
const options: LoadConfigOptions = {
instance: this.flags.instance,
configPath: this.flags.config,
};
const pluginSources: PluginSources = {
before: this.pluginSourcesBefore,
after: this.pluginSourcesAfter,
};
return loadConfig({}, options, pluginSources);
}
/**
* Collects config sources from plugins via the `b2c:config-sources` hook.
*
* This method is called during command initialization, after flags are parsed
* but before configuration is resolved. It allows CLI plugins to provide
* custom ConfigSource implementations.
*
* Plugin sources are collected into two arrays based on their priority:
* - `pluginSourcesBefore`: High priority sources (override defaults)
* - `pluginSourcesAfter`: Low priority sources (fill gaps)
*/
protected async collectPluginConfigSources(): Promise<void> {
const hookOptions: ConfigSourcesHookOptions = {
instance: this.flags.instance,
configPath: this.flags.config,
flags: this.flags as Record<string, unknown>,
resolveOptions: {
instance: this.flags.instance,
configPath: this.flags.config,
},
};
const hookResult = await this.config.runHook('b2c:config-sources', hookOptions);
// Collect sources from all plugins that responded, respecting priority
for (const success of hookResult.successes) {
const result = success.result as ConfigSourcesHookResult | undefined;
if (!result?.sources?.length) continue;
if (result.priority === 'before') {
this.pluginSourcesBefore.push(...result.sources);
} else {
// Default priority is 'after'
this.pluginSourcesAfter.push(...result.sources);
}
}
// Log warnings for hook failures (don't break the CLI)
for (const failure of hookResult.failures) {
this.logger?.warn(`Plugin ${failure.plugin.name} b2c:config-sources hook failed: ${failure.error.message}`);
}
}
/**
* Collects HTTP middleware from plugins via the `b2c:http-middleware` hook.
*
* This method is called during command initialization, after flags are parsed
* but before any API clients are created. It allows CLI plugins to provide
* custom middleware that will be applied to all HTTP clients.
*
* Plugin middleware is registered with the global middleware registry.
*/
protected async collectPluginHttpMiddleware(): Promise<void> {
const hookOptions: HttpMiddlewareHookOptions = {
flags: this.flags as Record<string, unknown>,
};
const hookResult = await this.config.runHook('b2c:http-middleware', hookOptions);
// Register middleware from all plugins that responded
for (const success of hookResult.successes) {
const result = success.result as HttpMiddlewareHookResult | undefined;
if (!result?.providers?.length) continue;
for (const provider of result.providers) {
globalMiddlewareRegistry.register(provider);
this.logger?.debug(`Registered HTTP middleware provider: ${provider.name}`);
}
}
// Log warnings for hook failures (don't break the CLI)
for (const failure of hookResult.failures) {
this.logger?.warn(`Plugin ${failure.plugin.name} b2c:http-middleware hook failed: ${failure.error.message}`);
}
}
/**
* Handle errors thrown during command execution.
*
* Logs the error using the structured logger (including cause if available).
* In JSON mode, outputs a JSON error object to stdout instead of oclif's default format.
*/
protected async catch(err: Error & {exitCode?: number}): Promise<never> {
const exitCode = err.exitCode ?? 1;
// Log if logger is available (may not be if error during init)
if (this.logger) {
this.logger.error({cause: err?.cause}, err.message);
}
// In JSON mode, output structured error to stderr and exit
if (this.jsonEnabled()) {
const errorOutput = {
error: {
message: err.message,
code: exitCode,
...(err.cause ? {cause: String(err.cause)} : {}),
},
};
process.stderr.write(JSON.stringify(errorOutput) + '\n');
process.exit(exitCode);
}
// Use oclif's error() for proper exit code and display
this.error(err.message, {exit: exitCode});
}
public baseCommandTest(): void {
this.logger.info('BaseCommand initialized');
}
/**
* Parse extra params from --extra-query, --extra-body, and --extra-headers flags.
* Returns undefined if no extra params are specified.
*
* @returns ExtraParamsConfig or undefined
*/
protected getExtraParams(): ExtraParamsConfig | undefined {
const extraQuery = this.flags['extra-query'];
const extraBody = this.flags['extra-body'];
const extraHeaders = this.flags['extra-headers'];
if (!extraQuery && !extraBody && !extraHeaders) {
return undefined;
}
const config: ExtraParamsConfig = {};
if (extraQuery) {
try {
config.query = JSON.parse(extraQuery) as Record<string, string | number | boolean | undefined>;
} catch {
this.error(`Invalid JSON for --extra-query: ${extraQuery}`);
}
}
if (extraBody) {
try {
config.body = JSON.parse(extraBody) as Record<string, unknown>;
} catch {
this.error(`Invalid JSON for --extra-body: ${extraBody}`);
}
}
if (extraHeaders) {
try {
config.headers = JSON.parse(extraHeaders) as Record<string, string>;
} catch {
this.error(`Invalid JSON for --extra-headers: ${extraHeaders}`);
}
}
return config;
}
}