zinkee 0.1.11 → 0.1.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zinkee",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "CLI for Zinkee API v2",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,10 @@
1
1
  import { commandRegistry } from "./command-registry.js";
2
+ import {
3
+ collectOperandTokens,
4
+ createDefaultOptionMetadata,
5
+ findCommandPath,
6
+ type OptionMetadata,
7
+ } from "./utils/argv-rewrite.js";
2
8
  import { CliError } from "./utils/errors.js";
3
9
  import { getResolvedCommandExamples } from "./utils/examples.js";
4
10
  import { buildJsonSuccess } from "./utils/output.js";
@@ -24,34 +30,6 @@ function getStdout(stdout?: CommandIo): CommandIo {
24
30
  return stdout ?? process.stdout;
25
31
  }
26
32
 
27
- function resolveExampleCommand(tokens: readonly string[]): {
28
- commandName: string;
29
- remainingTokens: readonly string[];
30
- } {
31
- const commands = Object.keys(commandRegistry).sort(
32
- (left, right) => right.split(" ").length - left.split(" ").length,
33
- );
34
-
35
- for (const commandName of commands) {
36
- const commandTokens = commandName.split(" ");
37
- const matches = commandTokens.every((token, index) => tokens[index] === token);
38
-
39
- if (matches) {
40
- return {
41
- commandName,
42
- remainingTokens: tokens.slice(commandTokens.length),
43
- };
44
- }
45
- }
46
-
47
- const commandTokens = tokens.filter((token) => !token.startsWith("-"));
48
-
49
- return {
50
- commandName: commandTokens.join(" "),
51
- remainingTokens: [],
52
- };
53
- }
54
-
55
33
  function extractExampleFilters(tokens: readonly string[]): { type?: string } {
56
34
  const filters: { type?: string } = {};
57
35
 
@@ -110,22 +88,26 @@ function extractExampleOverrides(tokens: readonly string[]): {
110
88
  return overrides;
111
89
  }
112
90
 
113
- export function extractExampleRequest(argv: readonly string[]): ExampleRequest | null {
114
- const exampleIndex = argv.indexOf("--example");
115
-
116
- if (exampleIndex === -1) {
91
+ export function extractExampleRequest(
92
+ argv: readonly string[],
93
+ optionMeta: OptionMetadata = createDefaultOptionMetadata(),
94
+ ): ExampleRequest | null {
95
+ if (!argv.includes("--example")) {
117
96
  return null;
118
97
  }
119
98
 
120
- const requestTokens = argv.slice(exampleIndex + 1);
121
- const { commandName, remainingTokens } = resolveExampleCommand(requestTokens);
99
+ const userArgs = argv.slice(2);
100
+ const operands = collectOperandTokens(userArgs, optionMeta, { skipFlag: "--example" });
101
+ const cmdPath = findCommandPath(operands, commandRegistry);
122
102
 
123
- if (commandName.length === 0) {
103
+ if (!cmdPath) {
124
104
  throw new CliError("invalid_cli_usage", 'Provide a command after "--example".');
125
105
  }
126
106
 
107
+ const remainingTokens = userArgs.slice(cmdPath.lastArgvIndex + 1);
108
+
127
109
  return {
128
- commandName,
110
+ commandName: cmdPath.commandName,
129
111
  filters: extractExampleFilters(remainingTokens),
130
112
  overrides: extractExampleOverrides(argv),
131
113
  json: argv.includes("--json"),
package/src/cli-runner.ts CHANGED
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import chalk from "chalk";
5
5
 
6
6
  import { extractExampleRequest, showExamples } from "./cli-examples.js";
7
+ import { commandRegistry } from "./command-registry.js";
7
8
  import {
8
9
  extractCompletionFlag,
9
10
  handleCompletionFlag,
@@ -11,6 +12,10 @@ import {
11
12
  runCompletionRequest,
12
13
  } from "./completion/runtime.js";
13
14
  import { buildProgram } from "./program.js";
15
+ import {
16
+ buildOptionMetadata,
17
+ hoistGlobalOptions,
18
+ } from "./utils/argv-rewrite.js";
14
19
  import {
15
20
  formatCliErrorJson,
16
21
  formatCliErrorText,
@@ -55,7 +60,9 @@ export async function runCli(argv: string[]): Promise<void> {
55
60
  return;
56
61
  }
57
62
 
58
- const exampleRequest = extractExampleRequest(argv);
63
+ const program = buildProgram();
64
+ const optionMeta = buildOptionMetadata(program);
65
+ const exampleRequest = extractExampleRequest(argv, optionMeta);
59
66
 
60
67
  if (exampleRequest) {
61
68
  await showExamples(
@@ -71,7 +78,8 @@ export async function runCli(argv: string[]): Promise<void> {
71
78
  throw new ExampleDisplayed();
72
79
  }
73
80
 
74
- await buildProgram().parseAsync(argv);
81
+ const rewrittenArgv = hoistGlobalOptions(argv, optionMeta, commandRegistry);
82
+ await program.parseAsync(rewrittenArgv);
75
83
  } catch (error) {
76
84
  if (error instanceof ExampleDisplayed) {
77
85
  process.exitCode = 0;
package/src/index.test.ts CHANGED
@@ -69,6 +69,58 @@ describe("project bootstrap", () => {
69
69
  });
70
70
  });
71
71
 
72
+ it("extracts example requests when --example appears at the end of argv", () => {
73
+ expect(
74
+ extractExampleRequest(["node", "zinkee", "schemas", "list", "--example"]),
75
+ ).toEqual({
76
+ commandName: "schemas list",
77
+ filters: {},
78
+ overrides: {},
79
+ json: false,
80
+ });
81
+ });
82
+
83
+ it("extracts example filters when --example appears after the filter flags", () => {
84
+ expect(
85
+ extractExampleRequest([
86
+ "node",
87
+ "zinkee",
88
+ "schemas",
89
+ "fields",
90
+ "create",
91
+ "contacts",
92
+ "--type",
93
+ "option",
94
+ "--example",
95
+ ]),
96
+ ).toEqual({
97
+ commandName: "schemas fields create",
98
+ filters: { type: "option" },
99
+ overrides: {},
100
+ json: false,
101
+ });
102
+ });
103
+
104
+ it("extracts example overrides regardless of --example position", () => {
105
+ expect(
106
+ extractExampleRequest([
107
+ "node",
108
+ "zinkee",
109
+ "schemas",
110
+ "list",
111
+ "--profile",
112
+ "staging",
113
+ "--example",
114
+ "--json",
115
+ ]),
116
+ ).toEqual({
117
+ commandName: "schemas list",
118
+ filters: {},
119
+ overrides: { profile: "staging" },
120
+ json: true,
121
+ });
122
+ });
123
+
72
124
  it("shows field examples filtered by type", async () => {
73
125
  let output = "";
74
126
 
@@ -0,0 +1,149 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { commandRegistry } from "../command-registry.js";
4
+ import { buildProgram } from "../program.js";
5
+ import {
6
+ buildOptionMetadata,
7
+ hoistGlobalOptions,
8
+ } from "./argv-rewrite.js";
9
+
10
+ function rewrite(userArgs: readonly string[]): string[] {
11
+ const optionMeta = buildOptionMetadata(buildProgram());
12
+ return hoistGlobalOptions(["node", "zinkee", ...userArgs], optionMeta, commandRegistry);
13
+ }
14
+
15
+ describe("hoistGlobalOptions", () => {
16
+ it("moves boolean global flags before the subcommand path", () => {
17
+ expect(rewrite(["schemas", "list", "--json"])).toEqual([
18
+ "node",
19
+ "zinkee",
20
+ "--json",
21
+ "schemas",
22
+ "list",
23
+ ]);
24
+ });
25
+
26
+ it("moves global value flags together with their value", () => {
27
+ expect(rewrite(["schemas", "list", "--profile", "staging"])).toEqual([
28
+ "node",
29
+ "zinkee",
30
+ "--profile",
31
+ "staging",
32
+ "schemas",
33
+ "list",
34
+ ]);
35
+ });
36
+
37
+ it("moves multiple globals preserving their relative order", () => {
38
+ expect(
39
+ rewrite(["schemas", "list", "--json", "--profile", "staging", "--read-only"]),
40
+ ).toEqual([
41
+ "node",
42
+ "zinkee",
43
+ "--json",
44
+ "--profile",
45
+ "staging",
46
+ "--read-only",
47
+ "schemas",
48
+ "list",
49
+ ]);
50
+ });
51
+
52
+ it("hoists globals that are interleaved between subcommand path tokens", () => {
53
+ expect(rewrite(["schemas", "--json", "fields", "create"])).toEqual([
54
+ "node",
55
+ "zinkee",
56
+ "--json",
57
+ "schemas",
58
+ "fields",
59
+ "create",
60
+ ]);
61
+ });
62
+
63
+ it("does not touch global flags that already appear before the subcommand path", () => {
64
+ expect(rewrite(["--json", "schemas", "list"])).toEqual([
65
+ "node",
66
+ "zinkee",
67
+ "--json",
68
+ "schemas",
69
+ "list",
70
+ ]);
71
+ });
72
+
73
+ it("preserves --api-key when it appears after profiles add (subcommand-local option)", () => {
74
+ expect(
75
+ rewrite(["profiles", "add", "myProfile", "--api-key", "abc", "--base-url", "https://x"]),
76
+ ).toEqual([
77
+ "node",
78
+ "zinkee",
79
+ "profiles",
80
+ "add",
81
+ "myProfile",
82
+ "--api-key",
83
+ "abc",
84
+ "--base-url",
85
+ "https://x",
86
+ ]);
87
+ });
88
+
89
+ it("preserves --api-key/--base-url for profiles update while still hoisting other globals", () => {
90
+ expect(
91
+ rewrite(["profiles", "update", "myProfile", "--api-key", "abc", "--json"]),
92
+ ).toEqual([
93
+ "node",
94
+ "zinkee",
95
+ "--json",
96
+ "profiles",
97
+ "update",
98
+ "myProfile",
99
+ "--api-key",
100
+ "abc",
101
+ ]);
102
+ });
103
+
104
+ it("supports the --foo=bar syntax for global value flags", () => {
105
+ expect(rewrite(["schemas", "list", "--profile=staging"])).toEqual([
106
+ "node",
107
+ "zinkee",
108
+ "--profile=staging",
109
+ "schemas",
110
+ "list",
111
+ ]);
112
+ });
113
+
114
+ it("returns argv unchanged when no subcommand path is found", () => {
115
+ expect(rewrite(["--json"])).toEqual(["node", "zinkee", "--json"]);
116
+ });
117
+
118
+ it("respects -- and stops hoisting after it", () => {
119
+ expect(
120
+ rewrite(["schemas", "list", "--", "--json", "trailing"]),
121
+ ).toEqual([
122
+ "node",
123
+ "zinkee",
124
+ "schemas",
125
+ "list",
126
+ "--",
127
+ "--json",
128
+ "trailing",
129
+ ]);
130
+ });
131
+ });
132
+
133
+ describe("buildOptionMetadata", () => {
134
+ it("registers local options for leaf subcommands", () => {
135
+ const optionMeta = buildOptionMetadata(buildProgram());
136
+ const profilesAdd = optionMeta.localOptionsByCommand.get("profiles add");
137
+
138
+ expect(profilesAdd?.has("--api-key")).toBe(true);
139
+ expect(profilesAdd?.has("--base-url")).toBe(true);
140
+ });
141
+
142
+ it("recognises global value vs boolean flags", () => {
143
+ const optionMeta = buildOptionMetadata(buildProgram());
144
+
145
+ expect(optionMeta.valueFlags.has("--profile")).toBe(true);
146
+ expect(optionMeta.booleanFlags.has("--json")).toBe(true);
147
+ expect(optionMeta.booleanFlags.has("--read-only")).toBe(true);
148
+ });
149
+ });
@@ -0,0 +1,269 @@
1
+ import type { Command } from "commander";
2
+
3
+ export const HOISTABLE_GLOBAL_FLAGS = {
4
+ "--profile": "value",
5
+ "--base-url": "value",
6
+ "--api-key": "value",
7
+ "--read-only": "boolean",
8
+ "--json": "boolean",
9
+ } as const satisfies Record<string, "value" | "boolean">;
10
+
11
+ type HoistableFlagName = keyof typeof HOISTABLE_GLOBAL_FLAGS;
12
+
13
+ function isHoistableFlag(flagName: string): flagName is HoistableFlagName {
14
+ return Object.hasOwn(HOISTABLE_GLOBAL_FLAGS, flagName);
15
+ }
16
+
17
+ export interface OptionMetadata {
18
+ valueFlags: Set<string>;
19
+ booleanFlags: Set<string>;
20
+ localOptionsByCommand: Map<string, Set<string>>;
21
+ }
22
+
23
+ function getCommandPath(command: Command): string {
24
+ const parts: string[] = [];
25
+ let cursor: Command | null = command;
26
+ while (cursor && cursor.parent) {
27
+ parts.unshift(cursor.name());
28
+ cursor = cursor.parent;
29
+ }
30
+ return parts.join(" ");
31
+ }
32
+
33
+ export function buildOptionMetadata(program: Command): OptionMetadata {
34
+ const valueFlags = new Set<string>();
35
+ const booleanFlags = new Set<string>();
36
+ const localOptionsByCommand = new Map<string, Set<string>>();
37
+
38
+ function visit(command: Command, isRoot: boolean): void {
39
+ const localFlags = new Set<string>();
40
+
41
+ for (const option of command.options) {
42
+ if (!option.long) continue;
43
+
44
+ if (option.required || option.optional) {
45
+ valueFlags.add(option.long);
46
+ } else {
47
+ booleanFlags.add(option.long);
48
+ }
49
+
50
+ if (!isRoot) {
51
+ localFlags.add(option.long);
52
+ }
53
+ }
54
+
55
+ if (!isRoot) {
56
+ localOptionsByCommand.set(getCommandPath(command), localFlags);
57
+ }
58
+
59
+ for (const sub of command.commands) {
60
+ visit(sub, false);
61
+ }
62
+ }
63
+
64
+ visit(program, true);
65
+ return { valueFlags, booleanFlags, localOptionsByCommand };
66
+ }
67
+
68
+ export function createDefaultOptionMetadata(): OptionMetadata {
69
+ return {
70
+ valueFlags: new Set([
71
+ "--profile",
72
+ "--base-url",
73
+ "--api-key",
74
+ "--install-completion",
75
+ "--show-completion",
76
+ ]),
77
+ booleanFlags: new Set([
78
+ "--read-only",
79
+ "--json",
80
+ "--example",
81
+ "--help",
82
+ ]),
83
+ localOptionsByCommand: new Map(),
84
+ };
85
+ }
86
+
87
+ interface FlagSplit {
88
+ flagName: string;
89
+ inlineValue: string | undefined;
90
+ }
91
+
92
+ function isFlagToken(token: string): boolean {
93
+ return token.length > 1 && token.startsWith("-");
94
+ }
95
+
96
+ function splitFlagToken(token: string): FlagSplit {
97
+ if (token.startsWith("--") && token.includes("=")) {
98
+ const eqIdx = token.indexOf("=");
99
+ return {
100
+ flagName: token.slice(0, eqIdx),
101
+ inlineValue: token.slice(eqIdx + 1),
102
+ };
103
+ }
104
+ return { flagName: token, inlineValue: undefined };
105
+ }
106
+
107
+ function flagConsumesValue(flagName: string, optionMeta: OptionMetadata): boolean {
108
+ if (optionMeta.valueFlags.has(flagName)) {
109
+ return true;
110
+ }
111
+ if (optionMeta.booleanFlags.has(flagName)) {
112
+ return false;
113
+ }
114
+ return true;
115
+ }
116
+
117
+ interface OperandPosition {
118
+ index: number;
119
+ token: string;
120
+ }
121
+
122
+ export function collectOperandTokens(
123
+ userArgs: readonly string[],
124
+ optionMeta: OptionMetadata,
125
+ options: { skipFlag?: string } = {},
126
+ ): OperandPosition[] {
127
+ const operands: OperandPosition[] = [];
128
+ let i = 0;
129
+ while (i < userArgs.length) {
130
+ const token = userArgs[i] as string;
131
+
132
+ if (token === "--") {
133
+ break;
134
+ }
135
+
136
+ if (isFlagToken(token)) {
137
+ const { flagName, inlineValue } = splitFlagToken(token);
138
+
139
+ if (options.skipFlag !== undefined && flagName === options.skipFlag) {
140
+ i += 1;
141
+ continue;
142
+ }
143
+
144
+ if (inlineValue !== undefined) {
145
+ i += 1;
146
+ continue;
147
+ }
148
+
149
+ const consumes = flagConsumesValue(flagName, optionMeta);
150
+ const next = userArgs[i + 1];
151
+ if (consumes && next !== undefined && !isFlagToken(next)) {
152
+ i += 2;
153
+ } else {
154
+ i += 1;
155
+ }
156
+ continue;
157
+ }
158
+
159
+ operands.push({ index: i, token });
160
+ i += 1;
161
+ }
162
+ return operands;
163
+ }
164
+
165
+ export interface CommandPathMatch {
166
+ commandName: string;
167
+ firstArgvIndex: number;
168
+ lastArgvIndex: number;
169
+ }
170
+
171
+ export function findCommandPath(
172
+ operands: readonly OperandPosition[],
173
+ registry: Record<string, unknown>,
174
+ ): CommandPathMatch | null {
175
+ if (operands.length === 0) return null;
176
+
177
+ const commandsByLength = Object.keys(registry).sort(
178
+ (left, right) => right.split(" ").length - left.split(" ").length,
179
+ );
180
+
181
+ for (let start = 0; start < operands.length; start += 1) {
182
+ for (const commandName of commandsByLength) {
183
+ const parts = commandName.split(" ");
184
+ if (start + parts.length > operands.length) continue;
185
+
186
+ let matches = true;
187
+ for (let j = 0; j < parts.length; j += 1) {
188
+ if (operands[start + j]!.token !== parts[j]) {
189
+ matches = false;
190
+ break;
191
+ }
192
+ }
193
+
194
+ if (matches) {
195
+ return {
196
+ commandName,
197
+ firstArgvIndex: operands[start]!.index,
198
+ lastArgvIndex: operands[start + parts.length - 1]!.index,
199
+ };
200
+ }
201
+ }
202
+ }
203
+
204
+ return null;
205
+ }
206
+
207
+ export function hoistGlobalOptions(
208
+ argv: readonly string[],
209
+ optionMeta: OptionMetadata,
210
+ registry: Record<string, unknown>,
211
+ ): string[] {
212
+ if (argv.length < 3) return [...argv];
213
+
214
+ const userArgs = argv.slice(2);
215
+ const operands = collectOperandTokens(userArgs, optionMeta);
216
+ const cmdPath = findCommandPath(operands, registry);
217
+
218
+ if (!cmdPath) return [...argv];
219
+
220
+ const localOptions =
221
+ optionMeta.localOptionsByCommand.get(cmdPath.commandName) ?? new Set<string>();
222
+
223
+ const hoisted: string[] = [];
224
+ const remaining: string[] = [];
225
+ let i = 0;
226
+
227
+ while (i < userArgs.length) {
228
+ const token = userArgs[i] as string;
229
+
230
+ if (token === "--") {
231
+ remaining.push(...userArgs.slice(i));
232
+ break;
233
+ }
234
+
235
+ if (!isFlagToken(token)) {
236
+ remaining.push(token);
237
+ i += 1;
238
+ continue;
239
+ }
240
+
241
+ const { flagName, inlineValue } = splitFlagToken(token);
242
+
243
+ if (!isHoistableFlag(flagName)) {
244
+ remaining.push(token);
245
+ i += 1;
246
+ continue;
247
+ }
248
+
249
+ const isAfterCommandPath = i > cmdPath.lastArgvIndex;
250
+ const collidesWithLocal = localOptions.has(flagName);
251
+
252
+ const target = isAfterCommandPath && collidesWithLocal ? remaining : hoisted;
253
+ target.push(token);
254
+
255
+ const spec = HOISTABLE_GLOBAL_FLAGS[flagName];
256
+ if (spec === "value" && inlineValue === undefined) {
257
+ const next = userArgs[i + 1];
258
+ if (next !== undefined && !isFlagToken(next)) {
259
+ target.push(next);
260
+ i += 2;
261
+ continue;
262
+ }
263
+ }
264
+
265
+ i += 1;
266
+ }
267
+
268
+ return [argv[0] as string, argv[1] as string, ...hoisted, ...remaining];
269
+ }