cli-forge 1.2.2 → 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/bin/commands/generate-documentation.js +1 -13
- package/dist/bin/commands/generate-documentation.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/internal-cli.d.ts +33 -2
- package/dist/lib/internal-cli.js +90 -4
- 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/lib/test-harness.js +1 -1
- package/dist/lib/test-harness.js.map +1 -1
- 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/bin/commands/generate-documentation.ts +1 -13
- 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 +117 -9
- 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/lib/test-harness.ts +1 -1
- 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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
2
|
import { InternalCLI } from './internal-cli';
|
|
3
3
|
import { cli } from './public-api';
|
|
4
|
+
import type { PromptProvider } from './prompt-types';
|
|
4
5
|
|
|
5
6
|
const ORIGINAL_CONSOLE_LOG = console.log;
|
|
6
7
|
|
|
@@ -1250,4 +1251,303 @@ describe('cliForge', () => {
|
|
|
1250
1251
|
expect(result.watch).toBe(true);
|
|
1251
1252
|
});
|
|
1252
1253
|
});
|
|
1254
|
+
|
|
1255
|
+
describe('prompt providers', () => {
|
|
1256
|
+
it('should register a prompt provider via withPromptProvider', () => {
|
|
1257
|
+
const provider: PromptProvider = {
|
|
1258
|
+
prompt: async () => 'test-value',
|
|
1259
|
+
};
|
|
1260
|
+
const app = cli('test').withPromptProvider(provider);
|
|
1261
|
+
// Should return CLI for chaining
|
|
1262
|
+
expect(app).toBeDefined();
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
it('should throw if provider has neither prompt nor promptBatch', () => {
|
|
1266
|
+
expect(() => {
|
|
1267
|
+
cli('test').withPromptProvider({} as any);
|
|
1268
|
+
}).toThrow(/must implement at least one of/);
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
it('should store prompt config from option registration', () => {
|
|
1272
|
+
const app = cli('test')
|
|
1273
|
+
.option('name', { type: 'string', prompt: true })
|
|
1274
|
+
.option('age', { type: 'number', prompt: 'How old are you?' })
|
|
1275
|
+
.option('debug', { type: 'boolean' });
|
|
1276
|
+
|
|
1277
|
+
const internal = app as unknown as InternalCLI;
|
|
1278
|
+
expect(internal.promptConfigs.get('name')).toBe(true);
|
|
1279
|
+
expect(internal.promptConfigs.get('age')).toBe('How old are you?');
|
|
1280
|
+
expect(internal.promptConfigs.has('debug')).toBe(false);
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
it('should store prompt config from positional registration', () => {
|
|
1284
|
+
const app = cli('test')
|
|
1285
|
+
.positional('file', { type: 'string', prompt: 'Which file?' });
|
|
1286
|
+
|
|
1287
|
+
const internal = app as unknown as InternalCLI;
|
|
1288
|
+
expect(internal.promptConfigs.get('file')).toBe('Which file?');
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
it('should not store prompt config when prompt is not provided', () => {
|
|
1292
|
+
const app = cli('test')
|
|
1293
|
+
.option('name', { type: 'string' });
|
|
1294
|
+
|
|
1295
|
+
const internal = app as unknown as InternalCLI;
|
|
1296
|
+
expect(internal.promptConfigs.size).toBe(0);
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
it('should store prompt callback config', () => {
|
|
1300
|
+
const promptFn = () => 'Enter value';
|
|
1301
|
+
const app = cli('test')
|
|
1302
|
+
.option('token', { type: 'string', prompt: promptFn });
|
|
1303
|
+
|
|
1304
|
+
const internal = app as unknown as InternalCLI;
|
|
1305
|
+
expect(internal.promptConfigs.get('token')).toBe(promptFn);
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
it('should propagate prompt providers to subcommands', async () => {
|
|
1309
|
+
const prompted: string[] = [];
|
|
1310
|
+
const provider: PromptProvider = {
|
|
1311
|
+
prompt: async (option) => {
|
|
1312
|
+
prompted.push(option.name);
|
|
1313
|
+
return 'value';
|
|
1314
|
+
},
|
|
1315
|
+
};
|
|
1316
|
+
|
|
1317
|
+
const app = cli('test')
|
|
1318
|
+
.withPromptProvider(provider)
|
|
1319
|
+
.command('sub', {
|
|
1320
|
+
builder: (cmd) =>
|
|
1321
|
+
cmd.option('name', { type: 'string', required: true }),
|
|
1322
|
+
handler: () => {},
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
await app.forge(['sub']);
|
|
1326
|
+
expect(prompted).toContain('name');
|
|
1327
|
+
});
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
describe('prompt resolution in forge', () => {
|
|
1331
|
+
it('should prompt for required options with no value when provider exists', async () => {
|
|
1332
|
+
const prompted: string[] = [];
|
|
1333
|
+
const provider: PromptProvider = {
|
|
1334
|
+
prompt: async (option) => {
|
|
1335
|
+
prompted.push(option.name);
|
|
1336
|
+
return option.name === 'name' ? 'Alice' : 42;
|
|
1337
|
+
},
|
|
1338
|
+
};
|
|
1339
|
+
|
|
1340
|
+
const app = cli('test', {
|
|
1341
|
+
handler: () => {},
|
|
1342
|
+
})
|
|
1343
|
+
.option('name', { type: 'string', required: true })
|
|
1344
|
+
.option('age', { type: 'number', required: true })
|
|
1345
|
+
.withPromptProvider(provider);
|
|
1346
|
+
|
|
1347
|
+
await app.forge([]);
|
|
1348
|
+
expect(prompted).toContain('name');
|
|
1349
|
+
expect(prompted).toContain('age');
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
it('should not prompt for options with values already provided', async () => {
|
|
1353
|
+
const prompted: string[] = [];
|
|
1354
|
+
const provider: PromptProvider = {
|
|
1355
|
+
prompt: async (option) => {
|
|
1356
|
+
prompted.push(option.name);
|
|
1357
|
+
return 'value';
|
|
1358
|
+
},
|
|
1359
|
+
};
|
|
1360
|
+
|
|
1361
|
+
const app = cli('test', {
|
|
1362
|
+
handler: () => {},
|
|
1363
|
+
})
|
|
1364
|
+
.option('name', { type: 'string', required: true })
|
|
1365
|
+
.withPromptProvider(provider);
|
|
1366
|
+
|
|
1367
|
+
await app.forge(['--name', 'Bob']);
|
|
1368
|
+
expect(prompted).not.toContain('name');
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
it('should prompt when prompt is true even if not required', async () => {
|
|
1372
|
+
const prompted: string[] = [];
|
|
1373
|
+
const provider: PromptProvider = {
|
|
1374
|
+
prompt: async (option) => {
|
|
1375
|
+
prompted.push(option.name);
|
|
1376
|
+
return 'value';
|
|
1377
|
+
},
|
|
1378
|
+
};
|
|
1379
|
+
|
|
1380
|
+
const app = cli('test', {
|
|
1381
|
+
handler: () => {},
|
|
1382
|
+
})
|
|
1383
|
+
.option('name', { type: 'string', prompt: true })
|
|
1384
|
+
.withPromptProvider(provider);
|
|
1385
|
+
|
|
1386
|
+
await app.forge([]);
|
|
1387
|
+
expect(prompted).toContain('name');
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
it('should still prompt when prompt is true even if value already provided', async () => {
|
|
1391
|
+
const prompted: string[] = [];
|
|
1392
|
+
let handlerArgs: any;
|
|
1393
|
+
const provider: PromptProvider = {
|
|
1394
|
+
prompt: async (option) => {
|
|
1395
|
+
prompted.push(option.name);
|
|
1396
|
+
return 'prompted-value';
|
|
1397
|
+
},
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
const app = cli('test', {
|
|
1401
|
+
handler: (args) => {
|
|
1402
|
+
handlerArgs = args;
|
|
1403
|
+
},
|
|
1404
|
+
})
|
|
1405
|
+
.option('name', { type: 'string', prompt: true })
|
|
1406
|
+
.withPromptProvider(provider);
|
|
1407
|
+
|
|
1408
|
+
await app.forge(['--name', 'cli-value']);
|
|
1409
|
+
expect(prompted).toContain('name');
|
|
1410
|
+
expect(handlerArgs.name).toBe('prompted-value');
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
it('should not prompt when prompt is false even if required', async () => {
|
|
1414
|
+
const prompted: string[] = [];
|
|
1415
|
+
const provider: PromptProvider = {
|
|
1416
|
+
prompt: async (option) => {
|
|
1417
|
+
prompted.push(option.name);
|
|
1418
|
+
return 'value';
|
|
1419
|
+
},
|
|
1420
|
+
};
|
|
1421
|
+
|
|
1422
|
+
const app = cli('test', {
|
|
1423
|
+
handler: () => {},
|
|
1424
|
+
})
|
|
1425
|
+
.option('name', { type: 'string', required: true, prompt: false })
|
|
1426
|
+
.withPromptProvider(provider);
|
|
1427
|
+
|
|
1428
|
+
// This will throw due to required validation, but should not prompt
|
|
1429
|
+
await expect(app.forge([])).rejects.toThrow();
|
|
1430
|
+
expect(prompted).not.toContain('name');
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
it('should use prompt callback to resolve config', async () => {
|
|
1434
|
+
const prompted: string[] = [];
|
|
1435
|
+
const provider: PromptProvider = {
|
|
1436
|
+
prompt: async (option) => {
|
|
1437
|
+
prompted.push(option.name);
|
|
1438
|
+
return 'value';
|
|
1439
|
+
},
|
|
1440
|
+
};
|
|
1441
|
+
|
|
1442
|
+
const app = cli('test', {
|
|
1443
|
+
handler: () => {},
|
|
1444
|
+
})
|
|
1445
|
+
.option('token', {
|
|
1446
|
+
type: 'string',
|
|
1447
|
+
prompt: (args: any) => (args.authFile ? false : 'Enter token'),
|
|
1448
|
+
})
|
|
1449
|
+
.withPromptProvider(provider);
|
|
1450
|
+
|
|
1451
|
+
await app.forge([]);
|
|
1452
|
+
expect(prompted).toContain('token');
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1455
|
+
it('should throw when prompting needed but no provider registered', async () => {
|
|
1456
|
+
const app = cli('test', {
|
|
1457
|
+
handler: () => {},
|
|
1458
|
+
})
|
|
1459
|
+
.option('name', { type: 'string', prompt: true });
|
|
1460
|
+
|
|
1461
|
+
await expect(app.forge([])).rejects.toThrow(/no prompt provider/i);
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
it('should use filtered providers before fallback providers', async () => {
|
|
1465
|
+
const calls: Array<{ provider: string; option: string }> = [];
|
|
1466
|
+
const filteredProvider: PromptProvider = {
|
|
1467
|
+
filter: (name) => name === 'secret',
|
|
1468
|
+
prompt: async (option) => {
|
|
1469
|
+
calls.push({ provider: 'filtered', option: option.name });
|
|
1470
|
+
return 'secret-value';
|
|
1471
|
+
},
|
|
1472
|
+
};
|
|
1473
|
+
const fallbackProvider: PromptProvider = {
|
|
1474
|
+
prompt: async (option) => {
|
|
1475
|
+
calls.push({ provider: 'fallback', option: option.name });
|
|
1476
|
+
return 'fallback-value';
|
|
1477
|
+
},
|
|
1478
|
+
};
|
|
1479
|
+
|
|
1480
|
+
const app = cli('test', {
|
|
1481
|
+
handler: () => {},
|
|
1482
|
+
})
|
|
1483
|
+
.option('name', { type: 'string', prompt: true })
|
|
1484
|
+
.option('secret', { type: 'string', prompt: true })
|
|
1485
|
+
.withPromptProvider(filteredProvider)
|
|
1486
|
+
.withPromptProvider(fallbackProvider);
|
|
1487
|
+
|
|
1488
|
+
await app.forge([]);
|
|
1489
|
+
expect(calls).toContainEqual({ provider: 'filtered', option: 'secret' });
|
|
1490
|
+
expect(calls).toContainEqual({ provider: 'fallback', option: 'name' });
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
it('should prefer promptBatch over prompt when available', async () => {
|
|
1494
|
+
let batchCalled = false;
|
|
1495
|
+
const provider: PromptProvider = {
|
|
1496
|
+
promptBatch: async (options) => {
|
|
1497
|
+
batchCalled = true;
|
|
1498
|
+
const result: Record<string, unknown> = {};
|
|
1499
|
+
for (const opt of options) {
|
|
1500
|
+
result[opt.name] = 'batch-value';
|
|
1501
|
+
}
|
|
1502
|
+
return result;
|
|
1503
|
+
},
|
|
1504
|
+
prompt: async () => {
|
|
1505
|
+
throw new Error('Should not be called when promptBatch exists');
|
|
1506
|
+
},
|
|
1507
|
+
};
|
|
1508
|
+
|
|
1509
|
+
const app = cli('test', {
|
|
1510
|
+
handler: () => {},
|
|
1511
|
+
})
|
|
1512
|
+
.option('a', { type: 'string', prompt: true })
|
|
1513
|
+
.option('b', { type: 'string', prompt: true })
|
|
1514
|
+
.withPromptProvider(provider);
|
|
1515
|
+
|
|
1516
|
+
await app.forge([]);
|
|
1517
|
+
expect(batchCalled).toBe(true);
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
it('should prompt, inject values, and pass validation', async () => {
|
|
1521
|
+
let handlerArgs: any;
|
|
1522
|
+
const provider: PromptProvider = {
|
|
1523
|
+
promptBatch: async (options) => {
|
|
1524
|
+
const results: Record<string, unknown> = {};
|
|
1525
|
+
for (const opt of options) {
|
|
1526
|
+
if (opt.config.type === 'number') {
|
|
1527
|
+
results[opt.name] = 42;
|
|
1528
|
+
} else {
|
|
1529
|
+
results[opt.name] = 'prompted-' + opt.name;
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
return results;
|
|
1533
|
+
},
|
|
1534
|
+
};
|
|
1535
|
+
|
|
1536
|
+
const app = cli('test', {
|
|
1537
|
+
handler: (args) => {
|
|
1538
|
+
handlerArgs = args;
|
|
1539
|
+
},
|
|
1540
|
+
})
|
|
1541
|
+
.option('name', { type: 'string', required: true })
|
|
1542
|
+
.option('port', { type: 'number', prompt: 'Which port?' })
|
|
1543
|
+
.option('verbose', { type: 'boolean', default: false })
|
|
1544
|
+
.withPromptProvider(provider);
|
|
1545
|
+
|
|
1546
|
+
await app.forge([]);
|
|
1547
|
+
|
|
1548
|
+
expect(handlerArgs.name).toBe('prompted-name');
|
|
1549
|
+
expect(handlerArgs.port).toBe(42);
|
|
1550
|
+
expect(handlerArgs.verbose).toBe(false); // default, not prompted
|
|
1551
|
+
});
|
|
1552
|
+
});
|
|
1253
1553
|
});
|
package/src/lib/internal-cli.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
|
ArgvParser,
|
|
4
4
|
EnvOptionConfig,
|
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
ErrorHandler,
|
|
23
23
|
SDKCommand,
|
|
24
24
|
} from './public-api';
|
|
25
|
+
import type { PromptProvider, PromptOptionConfig } from './prompt-types';
|
|
26
|
+
import { resolvePrompts } from './resolve-prompts';
|
|
25
27
|
import { getCallingFile, getParentPackageJson } from './utils';
|
|
26
28
|
|
|
27
29
|
/**
|
|
@@ -43,14 +45,43 @@ import { getCallingFile, getParentPackageJson } from './utils';
|
|
|
43
45
|
* }).forge();
|
|
44
46
|
* ```
|
|
45
47
|
*/
|
|
48
|
+
/**
|
|
49
|
+
* Cross-realm brand symbol used to identify InternalCLI instances across
|
|
50
|
+
* different copies of the cli-forge package (e.g. when pnpm resolves
|
|
51
|
+
* multiple copies due to differing peer dependencies). `Symbol.for()`
|
|
52
|
+
* returns the same symbol globally, so the brand check works even when
|
|
53
|
+
* `instanceof` would fail.
|
|
54
|
+
*/
|
|
55
|
+
const CLI_FORGE_BRAND = Symbol.for('cli-forge:InternalCLI');
|
|
56
|
+
|
|
46
57
|
export class InternalCLI<
|
|
47
58
|
TArgs extends ParsedArgs = ParsedArgs,
|
|
48
59
|
THandlerReturn = void,
|
|
49
|
-
// eslint-disable-next-line @typescript-eslint/
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
50
61
|
TChildren = {},
|
|
51
62
|
TParent = undefined
|
|
52
63
|
> implements CLI<TArgs, THandlerReturn, TChildren, TParent>
|
|
53
64
|
{
|
|
65
|
+
/**
|
|
66
|
+
* Cross-realm brand for identifying InternalCLI instances across
|
|
67
|
+
* different package copies. See {@link CLI_FORGE_BRAND}.
|
|
68
|
+
*/
|
|
69
|
+
readonly [CLI_FORGE_BRAND] = true;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check whether `obj` is an InternalCLI instance, even when it was
|
|
73
|
+
* created by a different copy of the cli-forge package.
|
|
74
|
+
*/
|
|
75
|
+
static isInternalCLI(
|
|
76
|
+
obj: unknown
|
|
77
|
+
): obj is InternalCLI<any, any, any, any> {
|
|
78
|
+
return (
|
|
79
|
+
obj != null &&
|
|
80
|
+
typeof obj === 'object' &&
|
|
81
|
+
CLI_FORGE_BRAND in obj
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
54
85
|
/**
|
|
55
86
|
* For internal use only. Stick to properties available on {@link CLI}.
|
|
56
87
|
*/
|
|
@@ -93,6 +124,14 @@ export class InternalCLI<
|
|
|
93
124
|
(cli: any, args: TArgs) => Promise<void> | void
|
|
94
125
|
> = [];
|
|
95
126
|
|
|
127
|
+
registeredPromptProviders: PromptProvider[] = [];
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Stores prompt config for each option, keyed by option name.
|
|
131
|
+
* Set when .option() is called with a `prompt` property.
|
|
132
|
+
*/
|
|
133
|
+
promptConfigs: Map<string, PromptOptionConfig<any>> = new Map();
|
|
134
|
+
|
|
96
135
|
/**
|
|
97
136
|
* Set when a `$0` alias replaces the root builder via `.command()`.
|
|
98
137
|
* The $0 builder should only run if no explicit subcommand is given,
|
|
@@ -258,7 +297,7 @@ export class InternalCLI<
|
|
|
258
297
|
CLI<TArgs, THandlerReturn, TChildren, TParent>
|
|
259
298
|
>;
|
|
260
299
|
}
|
|
261
|
-
: // eslint-disable-next-line @typescript-eslint/
|
|
300
|
+
: // eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
262
301
|
{}),
|
|
263
302
|
TParent
|
|
264
303
|
> {
|
|
@@ -304,10 +343,16 @@ export class InternalCLI<
|
|
|
304
343
|
this.registeredCommands[alias] = cmd;
|
|
305
344
|
}
|
|
306
345
|
}
|
|
307
|
-
} else if (keyOrCommand
|
|
308
|
-
const cmd = keyOrCommand
|
|
346
|
+
} else if (InternalCLI.isInternalCLI(keyOrCommand)) {
|
|
347
|
+
const cmd = keyOrCommand as InternalCLI<any, any, any, any>;
|
|
309
348
|
if (cmd.name === '$0') {
|
|
310
349
|
this.withRootCommandConfiguration(cmd.configuration as any);
|
|
350
|
+
// Copy any commands registered on the $0 instance (e.g. subcommands
|
|
351
|
+
// added via `.commands()` after the $0 CLI was created).
|
|
352
|
+
for (const [key, subcmd] of Object.entries(cmd.registeredCommands)) {
|
|
353
|
+
subcmd._parent = this;
|
|
354
|
+
this.registeredCommands[key] = subcmd;
|
|
355
|
+
}
|
|
311
356
|
return this as any;
|
|
312
357
|
}
|
|
313
358
|
cmd._parent = this;
|
|
@@ -337,8 +382,12 @@ export class InternalCLI<
|
|
|
337
382
|
option<
|
|
338
383
|
TOption extends string,
|
|
339
384
|
const TOptionConfig extends OptionConfig<any, any, any>
|
|
340
|
-
>(name: TOption, config: TOptionConfig) {
|
|
341
|
-
|
|
385
|
+
>(name: TOption, config: TOptionConfig & { prompt?: PromptOptionConfig<TArgs> }) {
|
|
386
|
+
const { prompt, ...parserConfig } = config;
|
|
387
|
+
if (prompt !== undefined) {
|
|
388
|
+
this.promptConfigs.set(name, prompt);
|
|
389
|
+
}
|
|
390
|
+
this.parser.option(name, parserConfig as TOptionConfig);
|
|
342
391
|
// Interface modifies the return type to reflect new params, cast is necessay.... I think 🤔
|
|
343
392
|
return this as any;
|
|
344
393
|
}
|
|
@@ -346,8 +395,12 @@ export class InternalCLI<
|
|
|
346
395
|
positional<
|
|
347
396
|
TOption extends string,
|
|
348
397
|
const TOptionConfig extends OptionConfig<any, any, any>
|
|
349
|
-
>(name: TOption, config: TOptionConfig) {
|
|
350
|
-
|
|
398
|
+
>(name: TOption, config: TOptionConfig & { prompt?: PromptOptionConfig<TArgs> }) {
|
|
399
|
+
const { prompt, ...parserConfig } = config;
|
|
400
|
+
if (prompt !== undefined) {
|
|
401
|
+
this.promptConfigs.set(name, prompt);
|
|
402
|
+
}
|
|
403
|
+
this.parser.positional(name, parserConfig as TOptionConfig);
|
|
351
404
|
// Interface modifies the return type to reflect new params, cast is necessay.... I think 🤔
|
|
352
405
|
return this as any;
|
|
353
406
|
}
|
|
@@ -794,6 +847,18 @@ export class InternalCLI<
|
|
|
794
847
|
return this as unknown as CLI<TArgs, THandlerReturn, TChildren, TParent>;
|
|
795
848
|
}
|
|
796
849
|
|
|
850
|
+
withPromptProvider(
|
|
851
|
+
provider: PromptProvider
|
|
852
|
+
): CLI<TArgs, THandlerReturn, TChildren, TParent> {
|
|
853
|
+
if (!provider.prompt && !provider.promptBatch) {
|
|
854
|
+
throw new Error(
|
|
855
|
+
"Prompt provider must implement at least one of 'prompt' or 'promptBatch'"
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
this.registeredPromptProviders.push(provider);
|
|
859
|
+
return this as unknown as CLI<TArgs, THandlerReturn, TChildren, TParent>;
|
|
860
|
+
}
|
|
861
|
+
|
|
797
862
|
group(
|
|
798
863
|
labelOrConfigObject:
|
|
799
864
|
| string
|
|
@@ -990,6 +1055,47 @@ export class InternalCLI<
|
|
|
990
1055
|
// seeded with the accumulated values from the discovery loop.
|
|
991
1056
|
// The alreadyParsed values ensure proper required-option
|
|
992
1057
|
// validation and prevent positional re-consumption.
|
|
1058
|
+
|
|
1059
|
+
// Prompt for missing option values before final validation.
|
|
1060
|
+
// Collect prompt providers from the full command chain.
|
|
1061
|
+
const allPromptProviders: PromptProvider[] = [
|
|
1062
|
+
...this.registeredPromptProviders,
|
|
1063
|
+
];
|
|
1064
|
+
const allPromptConfigs = new Map(this.promptConfigs);
|
|
1065
|
+
{
|
|
1066
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
1067
|
+
let walkCmd: InternalCLI<any, any, any, any> = this;
|
|
1068
|
+
for (const command of this.commandChain) {
|
|
1069
|
+
walkCmd = walkCmd.registeredCommands[command];
|
|
1070
|
+
for (const p of walkCmd.registeredPromptProviders) {
|
|
1071
|
+
allPromptProviders.push(p);
|
|
1072
|
+
}
|
|
1073
|
+
for (const [k, v] of walkCmd.promptConfigs) {
|
|
1074
|
+
allPromptConfigs.set(k, v);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (allPromptProviders.length > 0 || allPromptConfigs.size > 0) {
|
|
1080
|
+
const promptedValues = await resolvePrompts({
|
|
1081
|
+
configuredOptions: this.parser.configuredOptions as Record<
|
|
1082
|
+
string,
|
|
1083
|
+
any
|
|
1084
|
+
>,
|
|
1085
|
+
configuredImplies: this.parser.configuredImplies,
|
|
1086
|
+
promptConfigs: allPromptConfigs,
|
|
1087
|
+
providers: allPromptProviders,
|
|
1088
|
+
currentArgs: mergedArgs,
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
// Inject prompted values into accumulated args
|
|
1092
|
+
for (const [key, value] of Object.entries(promptedValues)) {
|
|
1093
|
+
if (value !== undefined) {
|
|
1094
|
+
mergedArgs[key] = value;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
993
1099
|
try {
|
|
994
1100
|
argv = this.parser
|
|
995
1101
|
.clone({
|
|
@@ -1044,6 +1150,8 @@ export class InternalCLI<
|
|
|
1044
1150
|
}
|
|
1045
1151
|
clone.commandChain = [...this.commandChain];
|
|
1046
1152
|
clone.requiresCommand = this.requiresCommand;
|
|
1153
|
+
clone.registeredPromptProviders = [...this.registeredPromptProviders];
|
|
1154
|
+
clone.promptConfigs = new Map(this.promptConfigs);
|
|
1047
1155
|
return clone;
|
|
1048
1156
|
}
|
|
1049
1157
|
}
|
|
@@ -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
|
+
}
|