cli-forge 1.2.3 → 1.3.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/dist/index.d.ts +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/internal-cli.d.ts +14 -2
- package/dist/lib/internal-cli.js +61 -3
- package/dist/lib/internal-cli.js.map +1 -1
- package/dist/lib/prompt-types.d.ts +44 -0
- package/dist/lib/prompt-types.js +3 -0
- package/dist/lib/prompt-types.js.map +1 -0
- package/dist/lib/public-api.d.ts +45 -12
- package/dist/lib/public-api.js.map +1 -1
- package/dist/lib/resolve-prompts.d.ts +13 -0
- package/dist/lib/resolve-prompts.js +121 -0
- package/dist/lib/resolve-prompts.js.map +1 -0
- package/dist/prompt-providers/clack.d.ts +29 -0
- package/dist/prompt-providers/clack.js +136 -0
- package/dist/prompt-providers/clack.js.map +1 -0
- package/package.json +11 -2
- package/src/index.ts +1 -0
- package/src/lib/composable-builder.ts +3 -3
- package/src/lib/internal-cli.spec.ts +300 -0
- package/src/lib/internal-cli.ts +80 -7
- package/src/lib/prompt-types.ts +48 -0
- package/src/lib/public-api.ts +31 -19
- package/src/lib/resolve-prompts.spec.ts +311 -0
- package/src/lib/resolve-prompts.ts +156 -0
- package/src/prompt-providers/clack.spec.ts +376 -0
- package/src/prompt-providers/clack.ts +169 -0
- package/tsconfig.lib.json.tsbuildinfo +1 -1
- package/typedoc.json +10 -0
- package/.eslintrc.json +0 -36
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { InternalOptionConfig } from '@cli-forge/parser';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Static prompt configuration for an option.
|
|
5
|
+
* - `true` — always prompt
|
|
6
|
+
* - `string` — always prompt with this label
|
|
7
|
+
* - `false` — never prompt
|
|
8
|
+
*/
|
|
9
|
+
export type PromptConfig = boolean | string;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Full prompt configuration, including dynamic resolution.
|
|
13
|
+
* When a function is provided, it receives accumulated args and returns
|
|
14
|
+
* a static PromptConfig. Returning null/undefined from the callback
|
|
15
|
+
* is treated as falsy (don't prompt).
|
|
16
|
+
*/
|
|
17
|
+
export type PromptOptionConfig<TArgs = unknown> =
|
|
18
|
+
| PromptConfig
|
|
19
|
+
| ((args: Partial<TArgs>) => PromptConfig | null | undefined);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* An option that needs prompting, passed to prompt providers.
|
|
23
|
+
*/
|
|
24
|
+
export interface PromptOption {
|
|
25
|
+
/** The option name (key) */
|
|
26
|
+
name: string;
|
|
27
|
+
/** The full option config from the parser, with resolved prompt value */
|
|
28
|
+
config: InternalOptionConfig & { prompt?: PromptConfig };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A prompt provider that can fulfill missing option values interactively.
|
|
33
|
+
*/
|
|
34
|
+
export interface PromptProvider {
|
|
35
|
+
/**
|
|
36
|
+
* If provided, this provider only handles options where filter returns true.
|
|
37
|
+
* Providers without filters act as fallbacks.
|
|
38
|
+
*/
|
|
39
|
+
filter?: (name: string, config: InternalOptionConfig) => boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Prompt for a single option. Called per-option if promptBatch is not defined.
|
|
42
|
+
*/
|
|
43
|
+
prompt?: (option: PromptOption) => Promise<unknown>;
|
|
44
|
+
/**
|
|
45
|
+
* Prompt for multiple options at once. Preferred over prompt when available.
|
|
46
|
+
*/
|
|
47
|
+
promptBatch?: (options: PromptOption[]) => Promise<Record<string, unknown>>;
|
|
48
|
+
}
|
package/src/lib/public-api.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-empty-object-type */
|
|
2
2
|
import {
|
|
3
3
|
ArrayOptionConfig,
|
|
4
4
|
BooleanOptionConfig,
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from '@cli-forge/parser';
|
|
19
19
|
|
|
20
20
|
import { InternalCLI } from './internal-cli';
|
|
21
|
+
import type { PromptOptionConfig, PromptProvider } from './prompt-types';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Extracts the command name from a Command type.
|
|
@@ -87,7 +88,7 @@ export type CommandToChildEntry<T, TParentCLI = undefined> = {
|
|
|
87
88
|
export interface CLI<
|
|
88
89
|
TArgs extends ParsedArgs = ParsedArgs,
|
|
89
90
|
THandlerReturn = void,
|
|
90
|
-
// eslint-disable-next-line @typescript-eslint/
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
91
92
|
TChildren = {},
|
|
92
93
|
TParent = undefined
|
|
93
94
|
> {
|
|
@@ -121,7 +122,7 @@ export interface CLI<
|
|
|
121
122
|
TCommandArgs extends TArgs,
|
|
122
123
|
TChildHandlerReturn,
|
|
123
124
|
TKey extends string,
|
|
124
|
-
// eslint-disable-next-line @typescript-eslint/
|
|
125
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
125
126
|
TChildChildren = {}
|
|
126
127
|
>(
|
|
127
128
|
key: TKey,
|
|
@@ -424,6 +425,17 @@ export interface CLI<
|
|
|
424
425
|
handler: ErrorHandler
|
|
425
426
|
): CLI<TArgs, THandlerReturn, TChildren, TParent>;
|
|
426
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Registers a prompt provider for interactive option fulfillment.
|
|
430
|
+
* Multiple providers can be registered. Filtered providers are checked first
|
|
431
|
+
* (in registration order), then fallback providers (no filter).
|
|
432
|
+
*
|
|
433
|
+
* @param provider The prompt provider to register.
|
|
434
|
+
*/
|
|
435
|
+
withPromptProvider(
|
|
436
|
+
provider: PromptProvider
|
|
437
|
+
): CLI<TArgs, THandlerReturn, TChildren, TParent>;
|
|
438
|
+
|
|
427
439
|
/**
|
|
428
440
|
* Registers a new option for the CLI command. This option will be accessible
|
|
429
441
|
* within the command handler, as well as any subcommands.
|
|
@@ -441,7 +453,7 @@ export interface CLI<
|
|
|
441
453
|
const TProps extends Record<string, { type: string }>
|
|
442
454
|
>(
|
|
443
455
|
name: TOption,
|
|
444
|
-
config: ObjectOptionConfig<TCoerce, TProps>
|
|
456
|
+
config: ObjectOptionConfig<TCoerce, TProps> & { prompt?: PromptOptionConfig<TArgs> }
|
|
445
457
|
): CLI<
|
|
446
458
|
TArgs &
|
|
447
459
|
MakeUndefinedPropertiesOptional<{
|
|
@@ -460,7 +472,7 @@ export interface CLI<
|
|
|
460
472
|
const TConfig extends StringOptionConfig<any, any>
|
|
461
473
|
>(
|
|
462
474
|
name: TOption,
|
|
463
|
-
config: TConfig
|
|
475
|
+
config: TConfig & { prompt?: PromptOptionConfig<TArgs> }
|
|
464
476
|
): CLI<
|
|
465
477
|
TArgs &
|
|
466
478
|
MakeUndefinedPropertiesOptional<{
|
|
@@ -476,7 +488,7 @@ export interface CLI<
|
|
|
476
488
|
const TConfig extends NumberOptionConfig<any, any>
|
|
477
489
|
>(
|
|
478
490
|
name: TOption,
|
|
479
|
-
config: TConfig
|
|
491
|
+
config: TConfig & { prompt?: PromptOptionConfig<TArgs> }
|
|
480
492
|
): CLI<
|
|
481
493
|
TArgs &
|
|
482
494
|
MakeUndefinedPropertiesOptional<{
|
|
@@ -492,7 +504,7 @@ export interface CLI<
|
|
|
492
504
|
const TConfig extends BooleanOptionConfig<any, any>
|
|
493
505
|
>(
|
|
494
506
|
name: TOption,
|
|
495
|
-
config: TConfig
|
|
507
|
+
config: TConfig & { prompt?: PromptOptionConfig<TArgs> }
|
|
496
508
|
): CLI<
|
|
497
509
|
TArgs &
|
|
498
510
|
MakeUndefinedPropertiesOptional<{
|
|
@@ -508,7 +520,7 @@ export interface CLI<
|
|
|
508
520
|
const TConfig extends ArrayOptionConfig<any, any>
|
|
509
521
|
>(
|
|
510
522
|
name: TOption,
|
|
511
|
-
config: TConfig
|
|
523
|
+
config: TConfig & { prompt?: PromptOptionConfig<TArgs> }
|
|
512
524
|
): CLI<
|
|
513
525
|
TArgs &
|
|
514
526
|
MakeUndefinedPropertiesOptional<{
|
|
@@ -524,7 +536,7 @@ export interface CLI<
|
|
|
524
536
|
const TOptionConfig extends OptionConfig<any, any, any>
|
|
525
537
|
>(
|
|
526
538
|
name: TOption,
|
|
527
|
-
config: TOptionConfig
|
|
539
|
+
config: TOptionConfig & { prompt?: PromptOptionConfig<TArgs> }
|
|
528
540
|
): CLI<
|
|
529
541
|
TArgs &
|
|
530
542
|
MakeUndefinedPropertiesOptional<{
|
|
@@ -551,7 +563,7 @@ export interface CLI<
|
|
|
551
563
|
const TProps extends Record<string, { type: string }>
|
|
552
564
|
>(
|
|
553
565
|
name: TOption,
|
|
554
|
-
config: ObjectOptionConfig<TCoerce, TProps>
|
|
566
|
+
config: ObjectOptionConfig<TCoerce, TProps> & { prompt?: PromptOptionConfig<TArgs> }
|
|
555
567
|
): CLI<
|
|
556
568
|
TArgs &
|
|
557
569
|
MakeUndefinedPropertiesOptional<{
|
|
@@ -570,7 +582,7 @@ export interface CLI<
|
|
|
570
582
|
const TConfig extends StringOptionConfig<any, any>
|
|
571
583
|
>(
|
|
572
584
|
name: TOption,
|
|
573
|
-
config: TConfig
|
|
585
|
+
config: TConfig & { prompt?: PromptOptionConfig<TArgs> }
|
|
574
586
|
): CLI<
|
|
575
587
|
TArgs &
|
|
576
588
|
MakeUndefinedPropertiesOptional<{
|
|
@@ -586,7 +598,7 @@ export interface CLI<
|
|
|
586
598
|
const TConfig extends NumberOptionConfig<any, any>
|
|
587
599
|
>(
|
|
588
600
|
name: TOption,
|
|
589
|
-
config: TConfig
|
|
601
|
+
config: TConfig & { prompt?: PromptOptionConfig<TArgs> }
|
|
590
602
|
): CLI<
|
|
591
603
|
TArgs &
|
|
592
604
|
MakeUndefinedPropertiesOptional<{
|
|
@@ -602,7 +614,7 @@ export interface CLI<
|
|
|
602
614
|
const TConfig extends BooleanOptionConfig<any, any>
|
|
603
615
|
>(
|
|
604
616
|
name: TOption,
|
|
605
|
-
config: TConfig
|
|
617
|
+
config: TConfig & { prompt?: PromptOptionConfig<TArgs> }
|
|
606
618
|
): CLI<
|
|
607
619
|
TArgs &
|
|
608
620
|
MakeUndefinedPropertiesOptional<{
|
|
@@ -618,7 +630,7 @@ export interface CLI<
|
|
|
618
630
|
const TConfig extends ArrayOptionConfig<any, any>
|
|
619
631
|
>(
|
|
620
632
|
name: TOption,
|
|
621
|
-
config: TConfig
|
|
633
|
+
config: TConfig & { prompt?: PromptOptionConfig<TArgs> }
|
|
622
634
|
): CLI<
|
|
623
635
|
TArgs &
|
|
624
636
|
MakeUndefinedPropertiesOptional<{
|
|
@@ -634,7 +646,7 @@ export interface CLI<
|
|
|
634
646
|
const TOptionConfig extends OptionConfig<any, any, any>
|
|
635
647
|
>(
|
|
636
648
|
name: TOption,
|
|
637
|
-
config: TOptionConfig
|
|
649
|
+
config: TOptionConfig & { prompt?: PromptOptionConfig<TArgs> }
|
|
638
650
|
): CLI<
|
|
639
651
|
TArgs &
|
|
640
652
|
MakeUndefinedPropertiesOptional<{
|
|
@@ -928,13 +940,13 @@ export interface CLICommandOptions<
|
|
|
928
940
|
/**
|
|
929
941
|
* The children commands that exist before the builder runs.
|
|
930
942
|
*/
|
|
931
|
-
// eslint-disable-next-line @typescript-eslint/
|
|
943
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
932
944
|
TInitialChildren = {},
|
|
933
945
|
TParent = any,
|
|
934
946
|
/**
|
|
935
947
|
* The children commands after the builder runs (includes TInitialChildren plus any added by builder).
|
|
936
948
|
*/
|
|
937
|
-
// eslint-disable-next-line @typescript-eslint/
|
|
949
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
938
950
|
TChildren = {}
|
|
939
951
|
> {
|
|
940
952
|
/**
|
|
@@ -1080,7 +1092,7 @@ export type SDKChildren<TChildren> = {
|
|
|
1080
1092
|
* Container commands (no handler) are not callable but still provide access to children.
|
|
1081
1093
|
*/
|
|
1082
1094
|
export type SDKCommand<TArgs, THandlerReturn, TChildren> =
|
|
1083
|
-
// eslint-disable-next-line @typescript-eslint/
|
|
1095
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
1084
1096
|
// THandlerReturn extends void | undefined
|
|
1085
1097
|
// ? SDKChildren<TChildren> // No handler = just children (not callable)
|
|
1086
1098
|
SDKInvokable<TArgs, THandlerReturn> & SDKChildren<TChildren>;
|
|
@@ -1094,7 +1106,7 @@ export type SDKCommand<TArgs, THandlerReturn, TChildren> =
|
|
|
1094
1106
|
export function cli<
|
|
1095
1107
|
TArgs extends ParsedArgs,
|
|
1096
1108
|
THandlerReturn = void,
|
|
1097
|
-
// eslint-disable-next-line @typescript-eslint/
|
|
1109
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
1098
1110
|
TChildren = {},
|
|
1099
1111
|
TName extends string = string
|
|
1100
1112
|
>(
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { resolvePrompts } from './resolve-prompts';
|
|
3
|
+
import type { InternalOptionConfig } from '@cli-forge/parser';
|
|
4
|
+
import type { PromptProvider } from './prompt-types';
|
|
5
|
+
|
|
6
|
+
function makeConfig(
|
|
7
|
+
overrides: Partial<InternalOptionConfig> & { key: string }
|
|
8
|
+
): InternalOptionConfig {
|
|
9
|
+
return {
|
|
10
|
+
type: 'string',
|
|
11
|
+
...overrides,
|
|
12
|
+
} as InternalOptionConfig;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('resolvePrompts', () => {
|
|
16
|
+
it('should return empty object when no options need prompting', async () => {
|
|
17
|
+
const result = await resolvePrompts({
|
|
18
|
+
configuredOptions: {
|
|
19
|
+
name: makeConfig({ key: 'name' }),
|
|
20
|
+
},
|
|
21
|
+
configuredImplies: {},
|
|
22
|
+
promptConfigs: new Map(),
|
|
23
|
+
providers: [],
|
|
24
|
+
currentArgs: { name: 'Alice' },
|
|
25
|
+
});
|
|
26
|
+
expect(result).toEqual({});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should prompt for required options missing values', async () => {
|
|
30
|
+
const provider: PromptProvider = {
|
|
31
|
+
prompt: vi.fn().mockResolvedValue('prompted-value'),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const result = await resolvePrompts({
|
|
35
|
+
configuredOptions: {
|
|
36
|
+
name: makeConfig({ key: 'name', required: true }),
|
|
37
|
+
},
|
|
38
|
+
configuredImplies: {},
|
|
39
|
+
promptConfigs: new Map(),
|
|
40
|
+
providers: [provider],
|
|
41
|
+
currentArgs: {},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(result).toEqual({ name: 'prompted-value' });
|
|
45
|
+
expect(provider.prompt).toHaveBeenCalledOnce();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should not prompt for required options that already have values', async () => {
|
|
49
|
+
const provider: PromptProvider = {
|
|
50
|
+
prompt: vi.fn().mockResolvedValue('prompted-value'),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const result = await resolvePrompts({
|
|
54
|
+
configuredOptions: {
|
|
55
|
+
name: makeConfig({ key: 'name', required: true }),
|
|
56
|
+
},
|
|
57
|
+
configuredImplies: {},
|
|
58
|
+
promptConfigs: new Map(),
|
|
59
|
+
providers: [provider],
|
|
60
|
+
currentArgs: { name: 'existing' },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(result).toEqual({});
|
|
64
|
+
expect(provider.prompt).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should call prompt callback with current args', async () => {
|
|
68
|
+
const promptFn = vi.fn().mockReturnValue('Enter token');
|
|
69
|
+
const provider: PromptProvider = {
|
|
70
|
+
prompt: vi.fn().mockResolvedValue('token-value'),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
await resolvePrompts({
|
|
74
|
+
configuredOptions: {
|
|
75
|
+
token: makeConfig({ key: 'token' }),
|
|
76
|
+
},
|
|
77
|
+
configuredImplies: {},
|
|
78
|
+
promptConfigs: new Map([['token', promptFn]]),
|
|
79
|
+
providers: [provider],
|
|
80
|
+
currentArgs: { someFlag: true },
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(promptFn).toHaveBeenCalledWith({ someFlag: true });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should not prompt when callback returns null', async () => {
|
|
87
|
+
const promptFn = vi.fn().mockReturnValue(null);
|
|
88
|
+
const provider: PromptProvider = {
|
|
89
|
+
prompt: vi.fn().mockResolvedValue('value'),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const result = await resolvePrompts({
|
|
93
|
+
configuredOptions: {
|
|
94
|
+
token: makeConfig({ key: 'token' }),
|
|
95
|
+
},
|
|
96
|
+
configuredImplies: {},
|
|
97
|
+
promptConfigs: new Map([['token', promptFn]]),
|
|
98
|
+
providers: [provider],
|
|
99
|
+
currentArgs: {},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(result).toEqual({});
|
|
103
|
+
expect(provider.prompt).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should prompt for implied options when trigger is set and implied option is missing', async () => {
|
|
107
|
+
const provider: PromptProvider = {
|
|
108
|
+
prompt: vi.fn().mockResolvedValue('value'),
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const result = await resolvePrompts({
|
|
112
|
+
configuredOptions: {
|
|
113
|
+
output: makeConfig({ key: 'output' }),
|
|
114
|
+
format: makeConfig({ key: 'format' }),
|
|
115
|
+
},
|
|
116
|
+
configuredImplies: { output: new Set(['format']) },
|
|
117
|
+
promptConfigs: new Map(),
|
|
118
|
+
providers: [provider],
|
|
119
|
+
currentArgs: { output: '/tmp/out' },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(result).toEqual({ format: 'value' });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should not prompt for implied options when trigger is not set', async () => {
|
|
126
|
+
const provider: PromptProvider = {
|
|
127
|
+
prompt: vi.fn().mockResolvedValue('value'),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const result = await resolvePrompts({
|
|
131
|
+
configuredOptions: {
|
|
132
|
+
output: makeConfig({ key: 'output' }),
|
|
133
|
+
format: makeConfig({ key: 'format' }),
|
|
134
|
+
},
|
|
135
|
+
configuredImplies: { output: new Set(['format']) },
|
|
136
|
+
promptConfigs: new Map(),
|
|
137
|
+
providers: [provider],
|
|
138
|
+
currentArgs: {},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(result).toEqual({});
|
|
142
|
+
expect(provider.prompt).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should group options by matched provider for batch calls', async () => {
|
|
146
|
+
const batchProvider: PromptProvider = {
|
|
147
|
+
filter: (name) => name.startsWith('db'),
|
|
148
|
+
promptBatch: vi
|
|
149
|
+
.fn()
|
|
150
|
+
.mockResolvedValue({ dbHost: 'localhost', dbPort: 5432 }),
|
|
151
|
+
};
|
|
152
|
+
const fallbackProvider: PromptProvider = {
|
|
153
|
+
prompt: vi.fn().mockResolvedValue('fallback'),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const result = await resolvePrompts({
|
|
157
|
+
configuredOptions: {
|
|
158
|
+
dbHost: makeConfig({ key: 'dbHost' }),
|
|
159
|
+
dbPort: makeConfig({ key: 'dbPort' }),
|
|
160
|
+
name: makeConfig({ key: 'name' }),
|
|
161
|
+
},
|
|
162
|
+
configuredImplies: {},
|
|
163
|
+
promptConfigs: new Map([
|
|
164
|
+
['dbHost', true],
|
|
165
|
+
['dbPort', true],
|
|
166
|
+
['name', true],
|
|
167
|
+
]),
|
|
168
|
+
providers: [batchProvider, fallbackProvider],
|
|
169
|
+
currentArgs: {},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(batchProvider.promptBatch).toHaveBeenCalledOnce();
|
|
173
|
+
expect(fallbackProvider.prompt).toHaveBeenCalledOnce();
|
|
174
|
+
expect(result).toEqual({
|
|
175
|
+
dbHost: 'localhost',
|
|
176
|
+
dbPort: 5432,
|
|
177
|
+
name: 'fallback',
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should skip options where prompt is false', async () => {
|
|
182
|
+
const provider: PromptProvider = {
|
|
183
|
+
prompt: vi.fn().mockResolvedValue('value'),
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const result = await resolvePrompts({
|
|
187
|
+
configuredOptions: {
|
|
188
|
+
name: makeConfig({ key: 'name', required: true }),
|
|
189
|
+
},
|
|
190
|
+
configuredImplies: {},
|
|
191
|
+
promptConfigs: new Map([['name', false]]),
|
|
192
|
+
providers: [provider],
|
|
193
|
+
currentArgs: {},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
expect(result).toEqual({});
|
|
197
|
+
expect(provider.prompt).not.toHaveBeenCalled();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should throw when options need prompting but no provider matches', async () => {
|
|
201
|
+
await expect(
|
|
202
|
+
resolvePrompts({
|
|
203
|
+
configuredOptions: {
|
|
204
|
+
name: makeConfig({ key: 'name' }),
|
|
205
|
+
},
|
|
206
|
+
configuredImplies: {},
|
|
207
|
+
promptConfigs: new Map([['name', true]]),
|
|
208
|
+
providers: [],
|
|
209
|
+
currentArgs: {},
|
|
210
|
+
})
|
|
211
|
+
).rejects.toThrow(/no prompt provider/i);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should skip internal options (help, version, unmatched, --)', async () => {
|
|
215
|
+
const provider: PromptProvider = {
|
|
216
|
+
prompt: vi.fn().mockResolvedValue('value'),
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const result = await resolvePrompts({
|
|
220
|
+
configuredOptions: {
|
|
221
|
+
help: makeConfig({ key: 'help', required: true }),
|
|
222
|
+
version: makeConfig({ key: 'version', required: true }),
|
|
223
|
+
unmatched: makeConfig({ key: 'unmatched', required: true }),
|
|
224
|
+
'--': makeConfig({ key: '--', required: true }),
|
|
225
|
+
},
|
|
226
|
+
configuredImplies: {},
|
|
227
|
+
promptConfigs: new Map(),
|
|
228
|
+
providers: [provider],
|
|
229
|
+
currentArgs: {},
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(result).toEqual({});
|
|
233
|
+
expect(provider.prompt).not.toHaveBeenCalled();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should prefer promptBatch over prompt when both are available', async () => {
|
|
237
|
+
let batchCalled = false;
|
|
238
|
+
const provider: PromptProvider = {
|
|
239
|
+
promptBatch: vi.fn().mockImplementation(async (options) => {
|
|
240
|
+
batchCalled = true;
|
|
241
|
+
const result: Record<string, unknown> = {};
|
|
242
|
+
for (const opt of options) {
|
|
243
|
+
result[opt.name] = 'batch-value';
|
|
244
|
+
}
|
|
245
|
+
return result;
|
|
246
|
+
}),
|
|
247
|
+
prompt: vi.fn().mockImplementation(async () => {
|
|
248
|
+
throw new Error('Should not be called when promptBatch exists');
|
|
249
|
+
}),
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const result = await resolvePrompts({
|
|
253
|
+
configuredOptions: {
|
|
254
|
+
a: makeConfig({ key: 'a' }),
|
|
255
|
+
b: makeConfig({ key: 'b' }),
|
|
256
|
+
},
|
|
257
|
+
configuredImplies: {},
|
|
258
|
+
promptConfigs: new Map([
|
|
259
|
+
['a', true],
|
|
260
|
+
['b', true],
|
|
261
|
+
]),
|
|
262
|
+
providers: [provider],
|
|
263
|
+
currentArgs: {},
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
expect(batchCalled).toBe(true);
|
|
267
|
+
expect(provider.prompt).not.toHaveBeenCalled();
|
|
268
|
+
expect(result).toEqual({ a: 'batch-value', b: 'batch-value' });
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should prompt with string label when prompt config is a string', async () => {
|
|
272
|
+
const provider: PromptProvider = {
|
|
273
|
+
prompt: vi.fn().mockResolvedValue('value'),
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
await resolvePrompts({
|
|
277
|
+
configuredOptions: {
|
|
278
|
+
name: makeConfig({ key: 'name' }),
|
|
279
|
+
},
|
|
280
|
+
configuredImplies: {},
|
|
281
|
+
promptConfigs: new Map([['name', 'What is your name?']]),
|
|
282
|
+
providers: [provider],
|
|
283
|
+
currentArgs: {},
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
expect(provider.prompt).toHaveBeenCalledOnce();
|
|
287
|
+
const calledOption = (provider.prompt as ReturnType<typeof vi.fn>).mock
|
|
288
|
+
.calls[0][0];
|
|
289
|
+
expect(calledOption.name).toBe('name');
|
|
290
|
+
expect(calledOption.config.prompt).toBe('What is your name?');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should not prompt for non-required options without explicit prompt config', async () => {
|
|
294
|
+
const provider: PromptProvider = {
|
|
295
|
+
prompt: vi.fn().mockResolvedValue('value'),
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const result = await resolvePrompts({
|
|
299
|
+
configuredOptions: {
|
|
300
|
+
name: makeConfig({ key: 'name' }),
|
|
301
|
+
},
|
|
302
|
+
configuredImplies: {},
|
|
303
|
+
promptConfigs: new Map(),
|
|
304
|
+
providers: [provider],
|
|
305
|
+
currentArgs: {},
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(result).toEqual({});
|
|
309
|
+
expect(provider.prompt).not.toHaveBeenCalled();
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { InternalOptionConfig } from '@cli-forge/parser';
|
|
2
|
+
import type {
|
|
3
|
+
PromptConfig,
|
|
4
|
+
PromptOption,
|
|
5
|
+
PromptOptionConfig,
|
|
6
|
+
PromptProvider,
|
|
7
|
+
} from './prompt-types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Collects options that need prompting, matches them to providers,
|
|
11
|
+
* executes prompts, and returns the prompted values.
|
|
12
|
+
*/
|
|
13
|
+
export async function resolvePrompts(opts: {
|
|
14
|
+
configuredOptions: Record<string, InternalOptionConfig>;
|
|
15
|
+
configuredImplies: Record<string, Set<string>>;
|
|
16
|
+
promptConfigs: Map<string, PromptOptionConfig<any>>;
|
|
17
|
+
providers: PromptProvider[];
|
|
18
|
+
currentArgs: Record<string, unknown>;
|
|
19
|
+
}): Promise<Record<string, unknown>> {
|
|
20
|
+
const {
|
|
21
|
+
configuredOptions,
|
|
22
|
+
configuredImplies,
|
|
23
|
+
promptConfigs,
|
|
24
|
+
providers,
|
|
25
|
+
currentArgs,
|
|
26
|
+
} = opts;
|
|
27
|
+
|
|
28
|
+
// Step 1: Collect promptable options
|
|
29
|
+
const promptableOptions: PromptOption[] = [];
|
|
30
|
+
|
|
31
|
+
for (const [name, config] of Object.entries(configuredOptions)) {
|
|
32
|
+
// Skip internal options
|
|
33
|
+
if (
|
|
34
|
+
name === 'help' ||
|
|
35
|
+
name === 'version' ||
|
|
36
|
+
name === 'unmatched' ||
|
|
37
|
+
name === '--'
|
|
38
|
+
) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Already has a value — skip unless prompt is explicitly true/string
|
|
43
|
+
const hasValue = currentArgs[name] !== undefined;
|
|
44
|
+
|
|
45
|
+
const promptSetting = promptConfigs.get(name);
|
|
46
|
+
let resolved: PromptConfig | null | undefined;
|
|
47
|
+
|
|
48
|
+
if (typeof promptSetting === 'function') {
|
|
49
|
+
resolved = promptSetting(currentArgs);
|
|
50
|
+
// Callback: null/undefined treated as false
|
|
51
|
+
if (resolved === null || resolved === undefined) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
} else if (promptSetting !== undefined) {
|
|
55
|
+
// Static value
|
|
56
|
+
resolved = promptSetting;
|
|
57
|
+
} else {
|
|
58
|
+
// Not specified: prompt only if required and missing value
|
|
59
|
+
if (hasValue) continue;
|
|
60
|
+
|
|
61
|
+
const isRequired = config.required === true;
|
|
62
|
+
const isImplied = isOptionImplied(name, configuredImplies, currentArgs);
|
|
63
|
+
|
|
64
|
+
if (!isRequired && !isImplied) continue;
|
|
65
|
+
if (providers.length === 0) continue; // No providers, let validation handle it
|
|
66
|
+
|
|
67
|
+
resolved = true; // Will prompt
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (resolved === false) continue;
|
|
71
|
+
if (hasValue && resolved !== true && typeof resolved !== 'string') continue;
|
|
72
|
+
|
|
73
|
+
promptableOptions.push({
|
|
74
|
+
name,
|
|
75
|
+
config: {
|
|
76
|
+
...config,
|
|
77
|
+
prompt: resolved === true ? true : resolved ?? undefined,
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (promptableOptions.length === 0) {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Step 2: Match options to providers
|
|
87
|
+
const filteredProviders = providers.filter((p) => p.filter);
|
|
88
|
+
const fallbackProviders = providers.filter((p) => !p.filter);
|
|
89
|
+
|
|
90
|
+
const providerGroups = new Map<PromptProvider, PromptOption[]>();
|
|
91
|
+
const unmatchedOptions: PromptOption[] = [];
|
|
92
|
+
|
|
93
|
+
for (const option of promptableOptions) {
|
|
94
|
+
let matched = false;
|
|
95
|
+
for (const provider of filteredProviders) {
|
|
96
|
+
if (provider.filter!(option.name, option.config)) {
|
|
97
|
+
if (!providerGroups.has(provider)) {
|
|
98
|
+
providerGroups.set(provider, []);
|
|
99
|
+
}
|
|
100
|
+
providerGroups.get(provider)!.push(option);
|
|
101
|
+
matched = true;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (!matched) {
|
|
106
|
+
unmatchedOptions.push(option);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Assign unmatched options to first fallback provider
|
|
111
|
+
if (unmatchedOptions.length > 0) {
|
|
112
|
+
if (fallbackProviders.length === 0) {
|
|
113
|
+
const names = unmatchedOptions.map((o) => `'${o.name}'`).join(', ');
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Option(s) ${names} require prompting but no prompt provider is available`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const fallback = fallbackProviders[0];
|
|
119
|
+
if (!providerGroups.has(fallback)) {
|
|
120
|
+
providerGroups.set(fallback, []);
|
|
121
|
+
}
|
|
122
|
+
providerGroups.get(fallback)!.push(...unmatchedOptions);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Step 3: Execute prompts
|
|
126
|
+
const results: Record<string, unknown> = {};
|
|
127
|
+
|
|
128
|
+
for (const [provider, options] of providerGroups) {
|
|
129
|
+
if (provider.promptBatch) {
|
|
130
|
+
const batchResults = await provider.promptBatch(options);
|
|
131
|
+
Object.assign(results, batchResults);
|
|
132
|
+
} else if (provider.prompt) {
|
|
133
|
+
for (const option of options) {
|
|
134
|
+
results[option.name] = await provider.prompt(option);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return results;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Check if an option is implied by another option that has been set.
|
|
144
|
+
*/
|
|
145
|
+
function isOptionImplied(
|
|
146
|
+
name: string,
|
|
147
|
+
configuredImplies: Record<string, Set<string>>,
|
|
148
|
+
currentArgs: Record<string, unknown>
|
|
149
|
+
): boolean {
|
|
150
|
+
for (const [trigger, implied] of Object.entries(configuredImplies)) {
|
|
151
|
+
if (implied.has(name) && currentArgs[trigger] !== undefined) {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|