@forwardimpact/libcli 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # libcli
2
+
3
+ Shared CLI infrastructure for Forward Impact products.
4
+
5
+ ## Getting Started
6
+
7
+ ```js
8
+ import { createCli, HelpRenderer, SummaryRenderer } from '@forwardimpact/libcli';
9
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libcli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Shared CLI infrastructure for the Forward Impact monorepo",
5
5
  "license": "Apache-2.0",
6
6
  "author": "D. Olsson <hi@senzilla.io>",
package/src/cli.js CHANGED
@@ -7,6 +7,27 @@ export class Cli {
7
7
  #helpRenderer;
8
8
 
9
9
  constructor(definition, { process, helpRenderer }) {
10
+ if (definition.options) {
11
+ throw new Error(
12
+ `${definition.name}: "options" is no longer supported. ` +
13
+ `Use "globalOptions" for shared options and per-command "options" ` +
14
+ `for command-specific options.`,
15
+ );
16
+ }
17
+ if (definition.commands && definition.globalOptions) {
18
+ const globalNames = new Set(Object.keys(definition.globalOptions));
19
+ for (const cmd of definition.commands) {
20
+ if (!cmd.options) continue;
21
+ for (const name of Object.keys(cmd.options)) {
22
+ if (globalNames.has(name)) {
23
+ throw new Error(
24
+ `${definition.name}: option "${name}" in command ` +
25
+ `"${cmd.name}" collides with a global option`,
26
+ );
27
+ }
28
+ }
29
+ }
30
+ }
10
31
  this.#definition = definition;
11
32
  this.#proc = process;
12
33
  this.#helpRenderer = helpRenderer;
@@ -17,8 +38,14 @@ export class Cli {
17
38
  }
18
39
 
19
40
  parse(argv) {
41
+ const command = this.#findCommand(argv);
42
+
43
+ const globalOpts = this.#definition.globalOptions || {};
44
+ const commandOpts = command?.options || {};
45
+ const merged = { ...globalOpts, ...commandOpts };
46
+
20
47
  const options = {};
21
- for (const [name, opt] of Object.entries(this.#definition.options || {})) {
48
+ for (const [name, opt] of Object.entries(merged)) {
22
49
  options[name] = { type: opt.type };
23
50
  if (opt.short) options[name].short = opt.short;
24
51
  if (opt.default !== undefined) options[name].default = opt.default;
@@ -33,9 +60,13 @@ export class Cli {
33
60
 
34
61
  if (values.help) {
35
62
  if (values.json) {
36
- this.#helpRenderer.renderJson(this.#definition, this.#proc.stdout);
63
+ this.#helpRenderer.renderJson(
64
+ this.#definition,
65
+ this.#proc.stdout,
66
+ command,
67
+ );
37
68
  } else {
38
- this.#helpRenderer.render(this.#definition, this.#proc.stdout);
69
+ this.#helpRenderer.render(this.#definition, this.#proc.stdout, command);
39
70
  }
40
71
  return null;
41
72
  }
@@ -48,6 +79,20 @@ export class Cli {
48
79
  return { values, positionals };
49
80
  }
50
81
 
82
+ #findCommand(argv) {
83
+ const commands = this.#definition.commands;
84
+ if (!commands || commands.length === 0) return null;
85
+
86
+ const positionals = argv.filter((a) => !a.startsWith("-"));
87
+
88
+ for (let len = Math.min(positionals.length, 3); len > 0; len--) {
89
+ const candidate = positionals.slice(0, len).join(" ");
90
+ const found = commands.find((c) => c.name === candidate);
91
+ if (found) return found;
92
+ }
93
+ return null;
94
+ }
95
+
51
96
  showHelp() {
52
97
  this.#helpRenderer.render(this.#definition, this.#proc.stdout);
53
98
  }
package/src/help.js CHANGED
@@ -48,11 +48,11 @@ export class HelpRenderer {
48
48
  return lines;
49
49
  }
50
50
 
51
- #renderOptions(definition) {
52
- if (!definition.options) return [];
53
- const entries = Object.entries(definition.options);
51
+ #renderOptionSection(options, title) {
52
+ if (!options) return [];
53
+ const entries = Object.entries(options);
54
54
  if (entries.length === 0) return [];
55
- const lines = [this.#sectionHeader("Options:")];
55
+ const lines = [this.#sectionHeader(title)];
56
56
  const optStrings = entries.map(([name, opt]) => {
57
57
  let s = `--${name}`;
58
58
  if (opt.type === "string") s += `=<${opt.type}>`;
@@ -69,30 +69,99 @@ export class HelpRenderer {
69
69
  return lines;
70
70
  }
71
71
 
72
- #renderExamples(definition) {
73
- if (!definition.examples || definition.examples.length === 0) return [];
72
+ #renderExamplesArray(examples) {
73
+ if (!examples || examples.length === 0) return [];
74
74
  const lines = [this.#sectionHeader("Examples:")];
75
- for (const ex of definition.examples) {
75
+ for (const ex of examples) {
76
76
  lines.push(` ${ex}`);
77
77
  }
78
78
  lines.push("");
79
79
  return lines;
80
80
  }
81
81
 
82
- render(definition, stream) {
82
+ #renderExamples(definition) {
83
+ return this.#renderExamplesArray(definition.examples);
84
+ }
85
+
86
+ #renderHintLine(definition) {
87
+ if (!definition.commands || definition.commands.length === 0) return [];
88
+ return [
89
+ `Use ${definition.name} <command> --help for command-specific options.`,
90
+ "",
91
+ ];
92
+ }
93
+
94
+ #renderCommand(definition, stream, command) {
95
+ let header = `${definition.name} ${command.name}`;
96
+ if (command.args) header += ` ${command.args}`;
97
+ if (command.description) header += ` \u2014 ${command.description}`;
98
+ const formatted = supportsColor(this.#proc)
99
+ ? formatHeader(header, this.#proc)
100
+ : header;
101
+
102
+ let usage = `Usage: ${definition.name} ${command.name}`;
103
+ if (command.args) usage += ` ${command.args}`;
104
+ usage += " [options]";
105
+
106
+ const globalWithoutVersion = definition.globalOptions
107
+ ? Object.fromEntries(
108
+ Object.entries(definition.globalOptions).filter(
109
+ ([name]) => name !== "version",
110
+ ),
111
+ )
112
+ : null;
113
+
114
+ const lines = [
115
+ formatted,
116
+ "",
117
+ usage,
118
+ "",
119
+ ...this.#renderOptionSection(command.options, "Options:"),
120
+ ...this.#renderOptionSection(globalWithoutVersion, "Global options:"),
121
+ ...this.#renderExamplesArray(command.examples),
122
+ ];
123
+ stream.write(lines.join("\n"));
124
+ }
125
+
126
+ render(definition, stream, command) {
83
127
  const out = stream || this.#proc.stdout;
128
+ if (command) {
129
+ this.#renderCommand(definition, out, command);
130
+ return;
131
+ }
84
132
  const lines = [
85
133
  ...this.#renderHeader(definition),
86
134
  ...this.#renderUsage(definition),
87
135
  ...this.#renderCommands(definition),
88
- ...this.#renderOptions(definition),
136
+ ...this.#renderOptionSection(definition.globalOptions, "Options:"),
89
137
  ...this.#renderExamples(definition),
138
+ ...this.#renderHintLine(definition),
90
139
  ];
91
140
  out.write(lines.join("\n"));
92
141
  }
93
142
 
94
- renderJson(definition, stream) {
143
+ renderJson(definition, stream, command) {
95
144
  const out = stream || this.#proc.stdout;
96
- out.write(JSON.stringify(definition, null, 2) + "\n");
145
+ if (command) {
146
+ const globalWithoutVersion = definition.globalOptions
147
+ ? Object.fromEntries(
148
+ Object.entries(definition.globalOptions).filter(
149
+ ([name]) => name !== "version",
150
+ ),
151
+ )
152
+ : undefined;
153
+ const obj = {
154
+ name: command.name,
155
+ args: command.args,
156
+ description: command.description,
157
+ parent: definition.name,
158
+ options: command.options,
159
+ globalOptions: globalWithoutVersion,
160
+ examples: command.examples,
161
+ };
162
+ out.write(JSON.stringify(obj, null, 2) + "\n");
163
+ } else {
164
+ out.write(JSON.stringify(definition, null, 2) + "\n");
165
+ }
97
166
  }
98
167
  }