goke 6.5.1 → 6.6.0
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 +39 -9
- package/dist/__test__/index.test.js +16 -6
- package/dist/__test__/readme-examples.test.d.ts +5 -0
- package/dist/__test__/readme-examples.test.d.ts.map +1 -0
- package/dist/__test__/readme-examples.test.js +169 -0
- package/dist/__test__/types.test-d.js +242 -1
- package/dist/goke.d.ts +122 -17
- package/dist/goke.d.ts.map +1 -1
- package/dist/goke.js +35 -5
- package/dist/runtime-node.js +2 -2
- package/package.json +2 -2
- package/src/__test__/index.test.ts +16 -6
- package/src/__test__/readme-examples.test.ts +225 -0
- package/src/__test__/types.test-d.ts +271 -1
- package/src/goke.ts +186 -20
- package/src/runtime-node.ts +2 -2
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Type-level tests for schema-based option inference.
|
|
3
3
|
* These tests verify that TypeScript infers the correct types from
|
|
4
|
-
* option names (template literals) and StandardJSONSchemaV1 schemas
|
|
4
|
+
* option names (template literals) and StandardJSONSchemaV1 schemas,
|
|
5
|
+
* and that `.action()` callbacks receive fully-typed positional args
|
|
6
|
+
* and options objects.
|
|
5
7
|
*
|
|
6
8
|
* These use expectTypeOf from vitest for compile-time type assertions.
|
|
7
9
|
*/
|
|
8
10
|
import { describe, test, expectTypeOf } from 'vitest'
|
|
11
|
+
import { z } from 'zod'
|
|
9
12
|
import type { StandardTypedV1, StandardJSONSchemaV1 } from '../coerce.js'
|
|
13
|
+
import type { GokeExecutionContext } from '../goke.js'
|
|
10
14
|
import goke from '../index.js'
|
|
11
15
|
|
|
12
16
|
// ─── Import type helpers from Command.ts ───
|
|
@@ -72,6 +76,11 @@ describe('type-level: IsOptionalOption', () => {
|
|
|
72
76
|
})
|
|
73
77
|
})
|
|
74
78
|
|
|
79
|
+
// Every action callback's options param is extended with `{ '--': string[] }`
|
|
80
|
+
// because the runtime always populates that key. Use this alias everywhere
|
|
81
|
+
// we used to write `{}` to mean "no user-declared options".
|
|
82
|
+
type Base = { '--': string[] }
|
|
83
|
+
|
|
75
84
|
describe('type-level: InferSchemaOutput', () => {
|
|
76
85
|
test('infers output from StandardTypedV1', () => {
|
|
77
86
|
type Schema = StandardTypedV1<unknown, number>
|
|
@@ -167,3 +176,264 @@ describe('type-level: middleware use() callback inference', () => {
|
|
|
167
176
|
})
|
|
168
177
|
})
|
|
169
178
|
})
|
|
179
|
+
|
|
180
|
+
describe('type-level: command() .action() positional args inference', () => {
|
|
181
|
+
test('command with no args → action receives only (options, ctx)', () => {
|
|
182
|
+
goke('test')
|
|
183
|
+
.command('deploy', 'Deploy the app')
|
|
184
|
+
.action((options, ctx) => {
|
|
185
|
+
expectTypeOf(options).toEqualTypeOf<Base>()
|
|
186
|
+
expectTypeOf(ctx).toEqualTypeOf<GokeExecutionContext>()
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('command with one required arg → action receives (arg, options, ctx)', () => {
|
|
191
|
+
goke('test')
|
|
192
|
+
.command('get <id>', 'Fetch a resource by id')
|
|
193
|
+
.action((id, options, ctx) => {
|
|
194
|
+
expectTypeOf(id).toEqualTypeOf<string>()
|
|
195
|
+
expectTypeOf(options).toEqualTypeOf<Base>()
|
|
196
|
+
expectTypeOf(ctx).toEqualTypeOf<GokeExecutionContext>()
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('command with two required args → action receives both as strings', () => {
|
|
201
|
+
goke('test')
|
|
202
|
+
.command('convert <input> <output>', 'Convert file formats')
|
|
203
|
+
.action((input, output, options) => {
|
|
204
|
+
expectTypeOf(input).toEqualTypeOf<string>()
|
|
205
|
+
expectTypeOf(output).toEqualTypeOf<string>()
|
|
206
|
+
expectTypeOf(options).toEqualTypeOf<Base>()
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('command with optional arg → arg type includes undefined', () => {
|
|
211
|
+
goke('test')
|
|
212
|
+
.command('run [script]', 'Run a script')
|
|
213
|
+
.action((script, options) => {
|
|
214
|
+
expectTypeOf(script).toEqualTypeOf<string | undefined>()
|
|
215
|
+
expectTypeOf(options).toEqualTypeOf<Base>()
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('command with variadic required arg → arg is string[]', () => {
|
|
220
|
+
goke('test')
|
|
221
|
+
.command('exec <...args>', 'Run a binary with args')
|
|
222
|
+
.action((args, options) => {
|
|
223
|
+
expectTypeOf(args).toEqualTypeOf<string[]>()
|
|
224
|
+
expectTypeOf(options).toEqualTypeOf<Base>()
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('command with variadic optional arg → arg is string[]', () => {
|
|
229
|
+
goke('test')
|
|
230
|
+
.command('run [...rest]', 'Variadic optional')
|
|
231
|
+
.action((rest, options) => {
|
|
232
|
+
expectTypeOf(rest).toEqualTypeOf<string[]>()
|
|
233
|
+
expectTypeOf(options).toEqualTypeOf<Base>()
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test('multi-word command with required arg', () => {
|
|
238
|
+
goke('test')
|
|
239
|
+
.command('mcp getNodeXml <id>', 'Get XML for a node')
|
|
240
|
+
.action((id, options) => {
|
|
241
|
+
expectTypeOf(id).toEqualTypeOf<string>()
|
|
242
|
+
expectTypeOf(options).toEqualTypeOf<Base>()
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('default command with one positional arg', () => {
|
|
247
|
+
goke('test')
|
|
248
|
+
.command('<file>', 'Default command')
|
|
249
|
+
.action((file, options) => {
|
|
250
|
+
expectTypeOf(file).toEqualTypeOf<string>()
|
|
251
|
+
expectTypeOf(options).toEqualTypeOf<Base>()
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('mixed required and optional positional args', () => {
|
|
256
|
+
goke('test')
|
|
257
|
+
.command('send <to> [cc]', 'Send a message')
|
|
258
|
+
.action((to, cc, options) => {
|
|
259
|
+
expectTypeOf(to).toEqualTypeOf<string>()
|
|
260
|
+
expectTypeOf(cc).toEqualTypeOf<string | undefined>()
|
|
261
|
+
expectTypeOf(options).toEqualTypeOf<Base>()
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
describe('type-level: command() .action() option inference', () => {
|
|
267
|
+
test('single schema-based option is visible on options param', () => {
|
|
268
|
+
goke('test')
|
|
269
|
+
.command('serve', 'Start server')
|
|
270
|
+
.option('--port <port>', z.number())
|
|
271
|
+
.action((options, ctx) => {
|
|
272
|
+
expectTypeOf(options.port).toEqualTypeOf<number>()
|
|
273
|
+
expectTypeOf(ctx).toEqualTypeOf<GokeExecutionContext>()
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
test('multiple schema-based options are accumulated', () => {
|
|
278
|
+
goke('test')
|
|
279
|
+
.command('serve', 'Start server')
|
|
280
|
+
.option('--port <port>', z.number())
|
|
281
|
+
.option('--host <host>', z.string())
|
|
282
|
+
.option('--verbose', z.boolean())
|
|
283
|
+
.action((options) => {
|
|
284
|
+
expectTypeOf(options.port).toEqualTypeOf<number>()
|
|
285
|
+
expectTypeOf(options.host).toEqualTypeOf<string>()
|
|
286
|
+
// Boolean flag is optional (no <...> brackets)
|
|
287
|
+
expectTypeOf(options.verbose).toEqualTypeOf<boolean | undefined>()
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test('required vs optional option shape', () => {
|
|
292
|
+
goke('test')
|
|
293
|
+
.command('cmd', 'Command')
|
|
294
|
+
.option('--name <name>', z.string())
|
|
295
|
+
.option('--count [count]', z.number())
|
|
296
|
+
.action((options) => {
|
|
297
|
+
expectTypeOf(options.name).toEqualTypeOf<string>()
|
|
298
|
+
expectTypeOf(options.count).toEqualTypeOf<number | undefined>()
|
|
299
|
+
})
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test('camelCase conversion for kebab-case option names', () => {
|
|
303
|
+
goke('test')
|
|
304
|
+
.command('build', 'Build')
|
|
305
|
+
.option('--out-dir <dir>', z.string())
|
|
306
|
+
.option('--my-long-flag <val>', z.string())
|
|
307
|
+
.action((options) => {
|
|
308
|
+
expectTypeOf(options.outDir).toEqualTypeOf<string>()
|
|
309
|
+
expectTypeOf(options.myLongFlag).toEqualTypeOf<string>()
|
|
310
|
+
})
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
test('options combined with positional args', () => {
|
|
314
|
+
goke('test')
|
|
315
|
+
.command('convert <input> <output>', 'Convert file format')
|
|
316
|
+
.option('--quality <quality>', z.number())
|
|
317
|
+
.option('--format <format>', z.enum(['png', 'jpg', 'webp']))
|
|
318
|
+
.action((input, output, options, ctx) => {
|
|
319
|
+
expectTypeOf(input).toEqualTypeOf<string>()
|
|
320
|
+
expectTypeOf(output).toEqualTypeOf<string>()
|
|
321
|
+
expectTypeOf(options.quality).toEqualTypeOf<number>()
|
|
322
|
+
expectTypeOf(options.format).toEqualTypeOf<'png' | 'jpg' | 'webp'>()
|
|
323
|
+
expectTypeOf(ctx).toEqualTypeOf<GokeExecutionContext>()
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
test('global options from Goke are visible inside command actions', () => {
|
|
328
|
+
goke('test')
|
|
329
|
+
.option('--verbose', z.boolean())
|
|
330
|
+
.command('serve', 'Start server')
|
|
331
|
+
.option('--port <port>', z.number())
|
|
332
|
+
.action((options) => {
|
|
333
|
+
// Global option from cli.option()
|
|
334
|
+
expectTypeOf(options.verbose).toEqualTypeOf<boolean | undefined>()
|
|
335
|
+
// Command-local option
|
|
336
|
+
expectTypeOf(options.port).toEqualTypeOf<number>()
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
test('untyped option (string description) produces loose value type', () => {
|
|
341
|
+
goke('test')
|
|
342
|
+
.command('serve', 'Start server')
|
|
343
|
+
.option('--port <port>', 'Port number')
|
|
344
|
+
.action((options) => {
|
|
345
|
+
// Without a schema the runtime still guarantees required value options are strings.
|
|
346
|
+
expectTypeOf(options.port).toEqualTypeOf<string>()
|
|
347
|
+
})
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
test('untyped optional value options surface as string | undefined', () => {
|
|
351
|
+
goke('test')
|
|
352
|
+
.command('serve', 'Start server')
|
|
353
|
+
.option('--host [host]', 'Optional host override')
|
|
354
|
+
.option('--verbose', 'Verbose output')
|
|
355
|
+
.action((options) => {
|
|
356
|
+
// `[value]` options always resolve to `string | undefined`:
|
|
357
|
+
// - omitted → undefined
|
|
358
|
+
// - `--host` → '' (flag present, no value)
|
|
359
|
+
// - `--host example` → 'example'
|
|
360
|
+
expectTypeOf(options.host).toEqualTypeOf<string | undefined>()
|
|
361
|
+
expectTypeOf(options.verbose).toEqualTypeOf<boolean | undefined>()
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
test('accessing a non-existent option in action is a type error', () => {
|
|
366
|
+
goke('test')
|
|
367
|
+
.command('serve', 'Start server')
|
|
368
|
+
.option('--port <port>', z.number())
|
|
369
|
+
.action((options) => {
|
|
370
|
+
expectTypeOf(options.port).toEqualTypeOf<number>()
|
|
371
|
+
// @ts-expect-error nonExistent was never declared
|
|
372
|
+
options.nonExistent
|
|
373
|
+
})
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
test('accessing a non-existent positional arg in action is a type error', () => {
|
|
377
|
+
goke('test')
|
|
378
|
+
.command('get <id>', 'Fetch resource')
|
|
379
|
+
.action((id, options, ctx, ...rest) => {
|
|
380
|
+
expectTypeOf(id).toEqualTypeOf<string>()
|
|
381
|
+
expectTypeOf(options).toEqualTypeOf<Base>()
|
|
382
|
+
expectTypeOf(ctx).toEqualTypeOf<GokeExecutionContext>()
|
|
383
|
+
// No more positional slots — rest should be empty
|
|
384
|
+
expectTypeOf(rest).toEqualTypeOf<[]>()
|
|
385
|
+
})
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
test('action callback can omit trailing params (fewer-args is valid)', () => {
|
|
389
|
+
// Dropping context is fine
|
|
390
|
+
goke('test')
|
|
391
|
+
.command('serve', 'Start server')
|
|
392
|
+
.option('--port <port>', z.number())
|
|
393
|
+
.action((options) => {
|
|
394
|
+
expectTypeOf(options.port).toEqualTypeOf<number>()
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
// Dropping everything is fine
|
|
398
|
+
goke('test')
|
|
399
|
+
.command('serve', 'Start server')
|
|
400
|
+
.option('--port <port>', z.number())
|
|
401
|
+
.action(() => {})
|
|
402
|
+
})
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
describe('type-level: README TypeScript examples', () => {
|
|
406
|
+
test('README TypeScript example infers positional args and typed options', () => {
|
|
407
|
+
goke('my-program')
|
|
408
|
+
.command('serve <entry>', 'Start the app')
|
|
409
|
+
.option('--port <port>', z.number().default(3000).describe('Port number'))
|
|
410
|
+
.option('--watch', 'Watch files')
|
|
411
|
+
.action((entry, options, { console, process }) => {
|
|
412
|
+
expectTypeOf(entry).toEqualTypeOf<string>()
|
|
413
|
+
expectTypeOf(options.port).toEqualTypeOf<number>()
|
|
414
|
+
expectTypeOf(options.watch).toEqualTypeOf<boolean | undefined>()
|
|
415
|
+
expectTypeOf(console.log).toBeFunction()
|
|
416
|
+
expectTypeOf(process.cwd).toEqualTypeOf<string>()
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
test('README global options and middleware example stays typed end-to-end', () => {
|
|
421
|
+
goke('mycli')
|
|
422
|
+
.option('--verbose', z.boolean().default(false).describe('Enable verbose logging'))
|
|
423
|
+
.option('--api-url [url]', z.string().default('https://api.example.com').describe('API base URL'))
|
|
424
|
+
.use((options, { process }) => {
|
|
425
|
+
expectTypeOf(options.verbose).toEqualTypeOf<boolean | undefined>()
|
|
426
|
+
expectTypeOf(options.apiUrl).toEqualTypeOf<string | undefined>()
|
|
427
|
+
expectTypeOf(process.stdin).toEqualTypeOf<string>()
|
|
428
|
+
})
|
|
429
|
+
.command('deploy <env>', 'Deploy to an environment')
|
|
430
|
+
.option('--dry-run', 'Preview without deploying')
|
|
431
|
+
.action((env, options, ctx) => {
|
|
432
|
+
expectTypeOf(env).toEqualTypeOf<string>()
|
|
433
|
+
expectTypeOf(options.verbose).toEqualTypeOf<boolean | undefined>()
|
|
434
|
+
expectTypeOf(options.apiUrl).toEqualTypeOf<string | undefined>()
|
|
435
|
+
expectTypeOf(options.dryRun).toEqualTypeOf<boolean | undefined>()
|
|
436
|
+
expectTypeOf(ctx).toEqualTypeOf<GokeExecutionContext>()
|
|
437
|
+
})
|
|
438
|
+
})
|
|
439
|
+
})
|
package/src/goke.ts
CHANGED
|
@@ -348,6 +348,111 @@ type OptionEntry<RawName extends string, Schema> =
|
|
|
348
348
|
? { [K in ExtractOptionName<RawName>]?: InferSchemaOutput<Schema> }
|
|
349
349
|
: { [K in ExtractOptionName<RawName>]: InferSchemaOutput<Schema> }
|
|
350
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Infer the raw runtime value shape for an option declared without a schema.
|
|
353
|
+
*
|
|
354
|
+
* Required value options (`--port <port>`) always reach actions as strings.
|
|
355
|
+
* Optional value options (`--host [host]`) reach actions as strings: the
|
|
356
|
+
* empty string `''` when the flag is passed bare (`--host`), the given
|
|
357
|
+
* value when passed with one (`--host example.com`), and `undefined` when
|
|
358
|
+
* the flag is omitted entirely. This lets callers use a single `typeof`
|
|
359
|
+
* check and, if they really care, distinguish "omitted" from "present but
|
|
360
|
+
* empty" via `=== undefined` vs `=== ''`.
|
|
361
|
+
* Plain flags (`--verbose`) are booleans.
|
|
362
|
+
*/
|
|
363
|
+
type UntypedOptionValue<RawName extends string> =
|
|
364
|
+
RawName extends `${string}<${string}>` ? string :
|
|
365
|
+
RawName extends `${string}[${string}]` ? string :
|
|
366
|
+
boolean | undefined
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Build the option type entry for a `.option()` call that uses a plain
|
|
370
|
+
* description (no schema).
|
|
371
|
+
*/
|
|
372
|
+
type UntypedOptionEntry<RawName extends string> =
|
|
373
|
+
RawName extends `${string}<${string}>`
|
|
374
|
+
? { [K in ExtractOptionName<RawName>]: UntypedOptionValue<RawName> }
|
|
375
|
+
: { [K in ExtractOptionName<RawName>]?: UntypedOptionValue<RawName> }
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Tokenize a command raw name by splitting on whitespace.
|
|
379
|
+
* "mcp getNodeXml <id>" → ["mcp", "getNodeXml", "<id>"]
|
|
380
|
+
* "" → []
|
|
381
|
+
*/
|
|
382
|
+
type TokenizeName<S extends string, Acc extends readonly string[] = []> =
|
|
383
|
+
S extends `${infer Head} ${infer Rest}`
|
|
384
|
+
? TokenizeName<Rest, [...Acc, Head]>
|
|
385
|
+
: S extends ''
|
|
386
|
+
? Acc
|
|
387
|
+
: [...Acc, S]
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Given a single token, return the corresponding positional arg type or
|
|
391
|
+
* `never` if the token is not a bracketed arg.
|
|
392
|
+
*
|
|
393
|
+
* `<id>` → string (required)
|
|
394
|
+
* `[id]` → string | undefined (optional)
|
|
395
|
+
* `<...files>` → string[] (variadic required)
|
|
396
|
+
* `[...files]` → string[] (variadic optional)
|
|
397
|
+
* Anything else → never (filtered out by ExtractCommandArgs)
|
|
398
|
+
*/
|
|
399
|
+
type TokenToArgType<T extends string> =
|
|
400
|
+
T extends `<...${string}>` ? string[] :
|
|
401
|
+
T extends `[...${string}]` ? string[] :
|
|
402
|
+
T extends `<${string}>` ? string :
|
|
403
|
+
T extends `[${string}]` ? string | undefined :
|
|
404
|
+
never
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Filter a tokenized command raw name down to the positional arg tokens
|
|
408
|
+
* and map each to its inferred type.
|
|
409
|
+
*/
|
|
410
|
+
type ExtractCommandArgs<T extends readonly string[]> =
|
|
411
|
+
T extends readonly [infer Head extends string, ...infer Tail extends string[]]
|
|
412
|
+
? [TokenToArgType<Head>] extends [never]
|
|
413
|
+
? ExtractCommandArgs<Tail>
|
|
414
|
+
: [TokenToArgType<Head>, ...ExtractCommandArgs<Tail>]
|
|
415
|
+
: []
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Extract the tuple of positional arg types from a command raw name.
|
|
419
|
+
*
|
|
420
|
+
* "mcp getNodeXml <id>" → [string]
|
|
421
|
+
* "convert <input> <output>" → [string, string]
|
|
422
|
+
* "run [script]" → [string | undefined]
|
|
423
|
+
* "exec [...args]" → [string[]]
|
|
424
|
+
* "deploy" → []
|
|
425
|
+
*/
|
|
426
|
+
type ExtractPositionalArgs<RawName extends string> =
|
|
427
|
+
ExtractCommandArgs<TokenizeName<RawName>>
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Everything after a literal `--` on the command line is collected into
|
|
431
|
+
* `options['--']` as a string array (empty when `--` is absent). This key
|
|
432
|
+
* is always present at runtime, so it's merged into every action's options
|
|
433
|
+
* type regardless of which options the user declared.
|
|
434
|
+
*/
|
|
435
|
+
type DoubleDashOptions = { '--': string[] }
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Build the full argument tuple passed to a command's action callback.
|
|
439
|
+
*
|
|
440
|
+
* Format: [...positionalArgs, options, executionContext]
|
|
441
|
+
*
|
|
442
|
+
* This matches the runtime behavior in Goke.runMatchedCommand(): the action
|
|
443
|
+
* is called with positional args from the parsed command, then the parsed
|
|
444
|
+
* options object, then the injected GokeExecutionContext.
|
|
445
|
+
*
|
|
446
|
+
* The options type is always extended with `{ '--': string[] }` because the
|
|
447
|
+
* parser always populates that key (see `Goke.parse()`).
|
|
448
|
+
*/
|
|
449
|
+
type ActionArgs<RawName extends string, Opts> =
|
|
450
|
+
[
|
|
451
|
+
...ExtractPositionalArgs<RawName>,
|
|
452
|
+
Opts & DoubleDashOptions,
|
|
453
|
+
GokeExecutionContext,
|
|
454
|
+
]
|
|
455
|
+
|
|
351
456
|
interface CommandArg {
|
|
352
457
|
required: boolean
|
|
353
458
|
value: string
|
|
@@ -368,7 +473,7 @@ type HelpCallback = (sections: HelpSection[]) => void | HelpSection[]
|
|
|
368
473
|
|
|
369
474
|
type CommandExample = ((bin: string) => string) | string
|
|
370
475
|
|
|
371
|
-
class Command {
|
|
476
|
+
class Command<RawName extends string = string, Opts = {}> {
|
|
372
477
|
options: Option[]
|
|
373
478
|
aliasNames: string[]
|
|
374
479
|
/* Parsed command name */
|
|
@@ -383,7 +488,7 @@ class Command {
|
|
|
383
488
|
_hidden?: boolean
|
|
384
489
|
|
|
385
490
|
constructor(
|
|
386
|
-
public rawName:
|
|
491
|
+
public rawName: RawName,
|
|
387
492
|
public description: string,
|
|
388
493
|
public config: CommandConfig = {},
|
|
389
494
|
public cli: Goke<any>
|
|
@@ -426,7 +531,9 @@ class Command {
|
|
|
426
531
|
*
|
|
427
532
|
* The second argument is either a description string or a StandardJSONSchemaV1
|
|
428
533
|
* schema. When a schema is provided, description and default are extracted from
|
|
429
|
-
* the JSON Schema automatically
|
|
534
|
+
* the JSON Schema automatically, and the option's type is tracked on the
|
|
535
|
+
* Command's `Opts` type parameter so that subsequent `.action()` callbacks
|
|
536
|
+
* receive a fully-typed options object.
|
|
430
537
|
*
|
|
431
538
|
* @example
|
|
432
539
|
* ```ts
|
|
@@ -438,10 +545,16 @@ class Command {
|
|
|
438
545
|
* ```
|
|
439
546
|
*/
|
|
440
547
|
option<
|
|
441
|
-
|
|
548
|
+
OptionRawName extends string,
|
|
442
549
|
S extends StandardJSONSchemaV1
|
|
443
|
-
>(
|
|
444
|
-
|
|
550
|
+
>(
|
|
551
|
+
rawName: OptionRawName,
|
|
552
|
+
schema: S,
|
|
553
|
+
): Command<RawName, Opts & OptionEntry<OptionRawName, S>>
|
|
554
|
+
option<OptionRawName extends string>(
|
|
555
|
+
rawName: OptionRawName,
|
|
556
|
+
description?: string,
|
|
557
|
+
): Command<RawName, Opts & UntypedOptionEntry<OptionRawName>>
|
|
445
558
|
option(rawName: string, descriptionOrSchema?: string | StandardJSONSchemaV1): any {
|
|
446
559
|
const option = new Option(rawName, descriptionOrSchema)
|
|
447
560
|
this.options.push(option)
|
|
@@ -458,7 +571,24 @@ class Command {
|
|
|
458
571
|
return this
|
|
459
572
|
}
|
|
460
573
|
|
|
461
|
-
|
|
574
|
+
/**
|
|
575
|
+
* Register the action callback that runs when this command is matched.
|
|
576
|
+
*
|
|
577
|
+
* The callback receives positional args extracted from the command's raw name,
|
|
578
|
+
* followed by the parsed options object and the injected GokeExecutionContext.
|
|
579
|
+
*
|
|
580
|
+
* Positional arg types are inferred from the raw name at the type level:
|
|
581
|
+
* `command('convert <input> <output>')` → `(input: string, output: string, options, ctx)`
|
|
582
|
+
* `command('run [script]')` → `(script: string | undefined, options, ctx)`
|
|
583
|
+
* `command('exec [...args]')` → `(args: string[], options, ctx)`
|
|
584
|
+
*
|
|
585
|
+
* The options object is typed according to every `.option()` call chained
|
|
586
|
+
* on this command, plus any global options declared on the parent Goke
|
|
587
|
+
* instance before `.command()` was called.
|
|
588
|
+
*/
|
|
589
|
+
action(
|
|
590
|
+
callback: (...args: ActionArgs<RawName, Opts>) => unknown | Promise<unknown>,
|
|
591
|
+
): this {
|
|
462
592
|
this.commandAction = callback
|
|
463
593
|
return this
|
|
464
594
|
}
|
|
@@ -919,14 +1049,14 @@ interface ParsedArgv {
|
|
|
919
1049
|
}
|
|
920
1050
|
}
|
|
921
1051
|
|
|
922
|
-
class Goke<Opts
|
|
1052
|
+
class Goke<Opts = {}> extends EventEmitter {
|
|
923
1053
|
/** The program name to display in help and version message */
|
|
924
1054
|
name: string
|
|
925
|
-
commands: Command[]
|
|
1055
|
+
commands: Command<any, any>[]
|
|
926
1056
|
/** Middleware functions that run before the matched command action, in registration order */
|
|
927
1057
|
middlewares: Array<{ action: (options: any, context: GokeExecutionContext) => void | Promise<void> }>
|
|
928
1058
|
globalCommand: GlobalCommand
|
|
929
|
-
matchedCommand?: Command
|
|
1059
|
+
matchedCommand?: Command<any, any>
|
|
930
1060
|
matchedCommandName?: string
|
|
931
1061
|
/**
|
|
932
1062
|
* Raw CLI arguments
|
|
@@ -1056,10 +1186,24 @@ class Goke<Opts extends Record<string, any> = {}> extends EventEmitter {
|
|
|
1056
1186
|
}
|
|
1057
1187
|
|
|
1058
1188
|
/**
|
|
1059
|
-
* Add a sub-command
|
|
1189
|
+
* Add a sub-command.
|
|
1190
|
+
*
|
|
1191
|
+
* The returned Command is parameterized by the literal `rawName` (so positional
|
|
1192
|
+
* args can be inferred at the type level) and by this Goke's accumulated global
|
|
1193
|
+
* `Opts` (so global options declared before `.command()` are visible inside
|
|
1194
|
+
* `.action()` callbacks alongside the command's own options).
|
|
1060
1195
|
*/
|
|
1061
|
-
command
|
|
1062
|
-
|
|
1196
|
+
command<CommandRawName extends string>(
|
|
1197
|
+
rawName: CommandRawName,
|
|
1198
|
+
description?: string,
|
|
1199
|
+
config?: CommandConfig,
|
|
1200
|
+
): Command<CommandRawName, Opts> {
|
|
1201
|
+
const command = new Command<CommandRawName, Opts>(
|
|
1202
|
+
rawName,
|
|
1203
|
+
description || '',
|
|
1204
|
+
config,
|
|
1205
|
+
this,
|
|
1206
|
+
)
|
|
1063
1207
|
command.globalCommand = this.globalCommand
|
|
1064
1208
|
this.commands.push(command)
|
|
1065
1209
|
return command
|
|
@@ -1071,13 +1215,21 @@ class Goke<Opts extends Record<string, any> = {}> extends EventEmitter {
|
|
|
1071
1215
|
* Which is also applied to sub-commands.
|
|
1072
1216
|
*
|
|
1073
1217
|
* When a StandardJSONSchemaV1 schema is provided, the return type is narrowed
|
|
1074
|
-
* to include the inferred option type — enabling type-safe `.use()` callbacks
|
|
1218
|
+
* to include the inferred option type — enabling type-safe `.use()` callbacks
|
|
1219
|
+
* and typed `options` params inside command `.action()` handlers.
|
|
1220
|
+
*
|
|
1221
|
+
* When a plain description string is provided, the option is still tracked on
|
|
1222
|
+
* the Goke's `Opts` type, but with a loose `string | boolean | undefined` value
|
|
1223
|
+
* type (since no coercion schema is available).
|
|
1075
1224
|
*/
|
|
1076
1225
|
option<
|
|
1077
1226
|
RawName extends string,
|
|
1078
1227
|
S extends StandardJSONSchemaV1
|
|
1079
1228
|
>(rawName: RawName, schema: S): Goke<Opts & OptionEntry<RawName, S>>
|
|
1080
|
-
option
|
|
1229
|
+
option<RawName extends string>(
|
|
1230
|
+
rawName: RawName,
|
|
1231
|
+
description?: string,
|
|
1232
|
+
): Goke<Opts & UntypedOptionEntry<RawName>>
|
|
1081
1233
|
option(rawName: string, descriptionOrSchema?: string | StandardJSONSchemaV1): any {
|
|
1082
1234
|
const option = new Option(rawName, descriptionOrSchema)
|
|
1083
1235
|
this.globalCommand.options.push(option)
|
|
@@ -1106,7 +1258,12 @@ class Goke<Opts extends Record<string, any> = {}> extends EventEmitter {
|
|
|
1106
1258
|
* })
|
|
1107
1259
|
* ```
|
|
1108
1260
|
*/
|
|
1109
|
-
use(
|
|
1261
|
+
use(
|
|
1262
|
+
callback: (
|
|
1263
|
+
options: Opts & DoubleDashOptions,
|
|
1264
|
+
context: GokeExecutionContext,
|
|
1265
|
+
) => void | Promise<void>,
|
|
1266
|
+
): this {
|
|
1110
1267
|
this.middlewares.push({ action: callback })
|
|
1111
1268
|
return this
|
|
1112
1269
|
}
|
|
@@ -1459,7 +1616,9 @@ class Goke<Opts extends Record<string, any> = {}> extends EventEmitter {
|
|
|
1459
1616
|
//
|
|
1460
1617
|
// When mri returns `true` for value-taking options, it means "flag present, no value given".
|
|
1461
1618
|
// For required options (<...>), the sentinel is preserved so checkOptionValue() throws.
|
|
1462
|
-
// For optional options ([...])
|
|
1619
|
+
// For optional options ([...]) we want a single, uniform shape: `string`
|
|
1620
|
+
// with `''` meaning "flag present but no value" — callers get clean
|
|
1621
|
+
// `string | undefined` types instead of `string | boolean | undefined`.
|
|
1463
1622
|
const requiredValueOptions = new Set<string>()
|
|
1464
1623
|
const optionalValueOptions = new Set<string>()
|
|
1465
1624
|
for (const cliOption of cliOptions) {
|
|
@@ -1485,18 +1644,25 @@ class Goke<Opts extends Record<string, any> = {}> extends EventEmitter {
|
|
|
1485
1644
|
// When value is boolean `true` and the option takes a value, it's mri's sentinel
|
|
1486
1645
|
// for "flag present, no value given":
|
|
1487
1646
|
// - Required options (<...>): preserve `true` so checkOptionValue() throws
|
|
1488
|
-
// - Optional options ([...]) with schema: replace with `undefined`
|
|
1489
|
-
//
|
|
1647
|
+
// - Optional options ([...]) with schema: replace with `undefined` so
|
|
1648
|
+
// any `.default(...)` on the schema kicks in (e.g. z.number().default(30)
|
|
1649
|
+
// should produce 30, not try to coerce the `''` empty-string sentinel).
|
|
1490
1650
|
const schemaInfo = schemaMap.get(key)
|
|
1491
1651
|
if (schemaInfo && value !== undefined) {
|
|
1492
1652
|
if (value === true && requiredValueOptions.has(key)) {
|
|
1493
1653
|
// Keep sentinel for checkOptionValue() to detect
|
|
1494
1654
|
} else if (value === true && optionalValueOptions.has(key)) {
|
|
1495
|
-
// Optional value not given — schema expects a typed value, so return undefined
|
|
1496
1655
|
value = undefined
|
|
1497
1656
|
} else {
|
|
1498
1657
|
value = coerceBySchema(value, schemaInfo.jsonSchema, schemaInfo.optionName)
|
|
1499
1658
|
}
|
|
1659
|
+
} else if (value === true && optionalValueOptions.has(key)) {
|
|
1660
|
+
// Untyped optional-value flag with no schema: normalize bare `true`
|
|
1661
|
+
// to `''` so callers get a clean `string | undefined` shape. `''`
|
|
1662
|
+
// means "flag passed with no argument", distinct from `undefined`
|
|
1663
|
+
// (flag omitted). This matches the new type inference that treats
|
|
1664
|
+
// `[value]` as `string` instead of `string | boolean`.
|
|
1665
|
+
value = ''
|
|
1500
1666
|
}
|
|
1501
1667
|
|
|
1502
1668
|
setDotProp(options, keys, value)
|
package/src/runtime-node.ts
CHANGED
|
@@ -12,7 +12,7 @@ const fs: GokeFs = nodeFs
|
|
|
12
12
|
|
|
13
13
|
function openInBrowser(url: string): void {
|
|
14
14
|
if (!process.stdout.isTTY) {
|
|
15
|
-
process.
|
|
15
|
+
process.stdout.write(url + '\n')
|
|
16
16
|
return
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -25,7 +25,7 @@ function openInBrowser(url: string): void {
|
|
|
25
25
|
execSync(`xdg-open ${JSON.stringify(url)}`, { stdio: 'ignore' })
|
|
26
26
|
}
|
|
27
27
|
} catch {
|
|
28
|
-
process.
|
|
28
|
+
process.stdout.write(url + '\n')
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|