padrone 1.1.0 → 1.2.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/LICENSE +1 -1
  3. package/README.md +60 -30
  4. package/dist/args-CKNh7Dm9.mjs +175 -0
  5. package/dist/args-CKNh7Dm9.mjs.map +1 -0
  6. package/dist/chunk-y_GBKt04.mjs +5 -0
  7. package/dist/codegen/index.d.mts +305 -0
  8. package/dist/codegen/index.d.mts.map +1 -0
  9. package/dist/codegen/index.mjs +1348 -0
  10. package/dist/codegen/index.mjs.map +1 -0
  11. package/dist/completion.d.mts +64 -0
  12. package/dist/completion.d.mts.map +1 -0
  13. package/dist/completion.mjs +417 -0
  14. package/dist/completion.mjs.map +1 -0
  15. package/dist/docs/index.d.mts +34 -0
  16. package/dist/docs/index.d.mts.map +1 -0
  17. package/dist/docs/index.mjs +404 -0
  18. package/dist/docs/index.mjs.map +1 -0
  19. package/dist/formatter-Dvx7jFXr.d.mts +82 -0
  20. package/dist/formatter-Dvx7jFXr.d.mts.map +1 -0
  21. package/dist/help-mUIX0T0V.mjs +1195 -0
  22. package/dist/help-mUIX0T0V.mjs.map +1 -0
  23. package/dist/index.d.mts +120 -546
  24. package/dist/index.d.mts.map +1 -1
  25. package/dist/index.mjs +1180 -1197
  26. package/dist/index.mjs.map +1 -1
  27. package/dist/test.d.mts +112 -0
  28. package/dist/test.d.mts.map +1 -0
  29. package/dist/test.mjs +138 -0
  30. package/dist/test.mjs.map +1 -0
  31. package/dist/types-qrtt0135.d.mts +1037 -0
  32. package/dist/types-qrtt0135.d.mts.map +1 -0
  33. package/dist/update-check-EbNDkzyV.mjs +146 -0
  34. package/dist/update-check-EbNDkzyV.mjs.map +1 -0
  35. package/package.json +61 -21
  36. package/src/args.ts +365 -0
  37. package/src/cli/completions.ts +29 -0
  38. package/src/cli/docs.ts +86 -0
  39. package/src/cli/doctor.ts +312 -0
  40. package/src/cli/index.ts +159 -0
  41. package/src/cli/init.ts +135 -0
  42. package/src/cli/link.ts +320 -0
  43. package/src/cli/wrap.ts +152 -0
  44. package/src/codegen/README.md +118 -0
  45. package/src/codegen/code-builder.ts +226 -0
  46. package/src/codegen/discovery.ts +232 -0
  47. package/src/codegen/file-emitter.ts +73 -0
  48. package/src/codegen/generators/barrel-file.ts +16 -0
  49. package/src/codegen/generators/command-file.ts +184 -0
  50. package/src/codegen/generators/command-tree.ts +124 -0
  51. package/src/codegen/index.ts +33 -0
  52. package/src/codegen/parsers/fish.ts +163 -0
  53. package/src/codegen/parsers/help.ts +378 -0
  54. package/src/codegen/parsers/merge.ts +158 -0
  55. package/src/codegen/parsers/zsh.ts +221 -0
  56. package/src/codegen/schema-to-code.ts +199 -0
  57. package/src/codegen/template.ts +69 -0
  58. package/src/codegen/types.ts +143 -0
  59. package/src/colorizer.ts +2 -2
  60. package/src/command-utils.ts +501 -0
  61. package/src/completion.ts +110 -97
  62. package/src/create.ts +1036 -305
  63. package/src/docs/index.ts +607 -0
  64. package/src/errors.ts +131 -0
  65. package/src/formatter.ts +149 -63
  66. package/src/help.ts +151 -55
  67. package/src/index.ts +12 -15
  68. package/src/interactive.ts +169 -0
  69. package/src/parse.ts +31 -16
  70. package/src/repl-loop.ts +317 -0
  71. package/src/runtime.ts +304 -0
  72. package/src/shell-utils.ts +83 -0
  73. package/src/test.ts +285 -0
  74. package/src/type-helpers.ts +10 -10
  75. package/src/type-utils.ts +124 -14
  76. package/src/types.ts +752 -154
  77. package/src/update-check.ts +244 -0
  78. package/src/wrap.ts +44 -40
  79. package/src/zod.d.ts +2 -2
  80. package/src/options.ts +0 -180
@@ -0,0 +1,83 @@
1
+ export type ShellType = 'bash' | 'zsh' | 'fish' | 'powershell';
2
+
3
+ /**
4
+ * Detects the current shell from environment variables and process info.
5
+ * @returns The detected shell type, or undefined if unknown
6
+ */
7
+ export function detectShell(): ShellType | undefined {
8
+ if (typeof process === 'undefined') return undefined;
9
+
10
+ // Method 1: Check SHELL environment variable (most common)
11
+ const shellEnv = process.env.SHELL || '';
12
+ if (shellEnv.includes('zsh')) return 'zsh';
13
+ if (shellEnv.includes('bash')) return 'bash';
14
+ if (shellEnv.includes('fish')) return 'fish';
15
+
16
+ // Method 2: Check Windows-specific shells
17
+ if (process.env.PSModulePath || process.env.POWERSHELL_DISTRIBUTION_CHANNEL) {
18
+ return 'powershell';
19
+ }
20
+
21
+ // Method 3: Check parent process on Unix-like systems
22
+ try {
23
+ const ppid = process.ppid;
24
+ if (ppid) {
25
+ const { execSync } = require('node:child_process') as typeof import('node:child_process');
26
+ const processName = execSync(`ps -p ${ppid} -o comm=`, {
27
+ encoding: 'utf-8',
28
+ stdio: ['pipe', 'pipe', 'ignore'],
29
+ }).trim();
30
+
31
+ if (processName.includes('zsh')) return 'zsh';
32
+ if (processName.includes('bash')) return 'bash';
33
+ if (processName.includes('fish')) return 'fish';
34
+ }
35
+ } catch {
36
+ // Ignore errors (e.g., ps not available)
37
+ }
38
+
39
+ return undefined;
40
+ }
41
+
42
+ export function getRcFile(shell: ShellType, home?: string): string | null {
43
+ const { homedir } = require('node:os') as typeof import('node:os');
44
+ const { join } = require('node:path') as typeof import('node:path');
45
+ const h = home ?? homedir();
46
+ switch (shell) {
47
+ case 'bash':
48
+ return join(h, '.bashrc');
49
+ case 'zsh':
50
+ return join(h, '.zshrc');
51
+ case 'fish':
52
+ return join(h, '.config', 'fish', 'config.fish');
53
+ case 'powershell':
54
+ return process.env.PROFILE || join(h, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1');
55
+ default:
56
+ return null;
57
+ }
58
+ }
59
+
60
+ export function escapeRegExp(str: string): string {
61
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
62
+ }
63
+
64
+ /**
65
+ * Writes a snippet to a shell config file using begin/end markers for idempotency.
66
+ * If a block with the same begin marker exists, it is replaced. Otherwise the snippet is appended.
67
+ */
68
+ export function writeToRcFile(rcFile: string, snippet: string, beginMarker: string, endMarker: string): { file: string; updated: boolean } {
69
+ const { existsSync, mkdirSync, readFileSync, writeFileSync } = require('node:fs') as typeof import('node:fs');
70
+ const { dirname } = require('node:path') as typeof import('node:path');
71
+ const existing = existsSync(rcFile) ? readFileSync(rcFile, 'utf-8') : '';
72
+
73
+ if (existing.includes(beginMarker)) {
74
+ const pattern = new RegExp(`${escapeRegExp(beginMarker)}[\\s\\S]*?${escapeRegExp(endMarker)}`);
75
+ writeFileSync(rcFile, existing.replace(pattern, snippet));
76
+ return { file: rcFile, updated: true };
77
+ }
78
+
79
+ mkdirSync(dirname(rcFile), { recursive: true });
80
+ const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
81
+ writeFileSync(rcFile, `${existing}${separator}\n${snippet}\n`);
82
+ return { file: rcFile, updated: false };
83
+ }
package/src/test.ts ADDED
@@ -0,0 +1,285 @@
1
+ import type { InteractivePromptConfig, PadroneRuntime } from './runtime.ts';
2
+ import type { AnyPadroneCommand, PadroneCommandResult } from './types.ts';
3
+
4
+ /**
5
+ * Result from a single command execution in test mode.
6
+ * Extends the standard PadroneCommandResult with captured I/O.
7
+ */
8
+ export type TestCliResult = {
9
+ /** The matched command. */
10
+ command: AnyPadroneCommand;
11
+ /** Validated arguments (undefined if validation failed). */
12
+ args: unknown;
13
+ /** Action handler return value (undefined if validation failed or no action). */
14
+ result: unknown;
15
+ /** Validation issues, if any. */
16
+ issues: { message: string; path?: PropertyKey[] }[] | undefined;
17
+ /** All values passed to `runtime.output()`. */
18
+ stdout: unknown[];
19
+ /** All strings passed to `runtime.error()`. */
20
+ stderr: string[];
21
+ /** The thrown error, if the command threw (routing error, action error, etc.). */
22
+ error?: unknown;
23
+ };
24
+
25
+ /**
26
+ * Result from a REPL test session.
27
+ */
28
+ export type TestReplResult = {
29
+ /** One entry per successfully executed command (validation errors are captured in stderr, not here). */
30
+ results: Omit<TestCliResult, 'stdout' | 'stderr'>[];
31
+ /** All output from the entire REPL session. */
32
+ stdout: unknown[];
33
+ /** All errors from the entire REPL session. */
34
+ stderr: string[];
35
+ };
36
+
37
+ /**
38
+ * Fluent builder for setting up CLI test scenarios.
39
+ */
40
+ export type TestCliBuilder = {
41
+ /** Set the CLI input string (e.g. `'deploy --env production'`). */
42
+ args(input: string): TestCliBuilder;
43
+ /** Set environment variables visible to the command. */
44
+ env(vars: Record<string, string | undefined>): TestCliBuilder;
45
+ /** Provide mock answers for interactive prompts. Keys are field names. */
46
+ prompt(answers: Record<string, unknown>): TestCliBuilder;
47
+ /** Provide mock config files. Keys are file paths, values are parsed config objects. */
48
+ config(files: Record<string, Record<string, unknown>>): TestCliBuilder;
49
+ /** Provide mock stdin data (simulates piped input). */
50
+ stdin(data: string): TestCliBuilder;
51
+ /**
52
+ * Execute a single command via `eval()` and return the result with captured I/O.
53
+ * @param input - Optional CLI input string. Overrides `.args()` if provided.
54
+ */
55
+ run(input?: string): Promise<TestCliResult>;
56
+ /**
57
+ * Run a REPL session with the given sequence of inputs.
58
+ * Each string in the array is fed as one line of input.
59
+ * The session ends after all inputs are consumed (EOF).
60
+ */
61
+ repl(inputs: string[]): Promise<TestReplResult>;
62
+ };
63
+
64
+ /**
65
+ * Creates a fluent test builder for a Padrone program.
66
+ * Captures all I/O and provides a clean interface for assertions.
67
+ *
68
+ * Works with any test framework (bun:test, vitest, jest, node:test, etc.).
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * import { testCli } from 'padrone/test'
73
+ *
74
+ * const result = await testCli(myProgram)
75
+ * .args('deploy --env production')
76
+ * .env({ API_KEY: 'xxx' })
77
+ * .run()
78
+ *
79
+ * expect(result.result).toBe('Deployed')
80
+ * expect(result.stdout).toContain('Deploying...')
81
+ * ```
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * // Shorthand: pass input directly to run()
86
+ * const result = await testCli(myProgram).run('deploy --env production')
87
+ * ```
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * // Test interactive prompts
92
+ * const result = await testCli(myProgram)
93
+ * .args('init')
94
+ * .prompt({ name: 'myapp', template: 'react' })
95
+ * .run()
96
+ *
97
+ * expect(result.args).toEqual({ name: 'myapp', template: 'react' })
98
+ * ```
99
+ *
100
+ * @example
101
+ * ```ts
102
+ * // Test REPL sessions
103
+ * const { results } = await testCli(myProgram)
104
+ * .repl(['greet World', 'add --a=2 --b=3'])
105
+ *
106
+ * expect(results[0].result).toBe('Hello, World!')
107
+ * expect(results[1].result).toBe(5)
108
+ * ```
109
+ */
110
+ /**
111
+ * Any program-like object that has `eval`, `runtime`, and `repl` methods.
112
+ * Avoids strict variance issues with `AnyPadroneProgram`.
113
+ */
114
+ type TestableProgram = {
115
+ eval: (input: string, prefs?: { autoOutput?: boolean }) => any;
116
+ runtime: (runtime: PadroneRuntime) => TestableProgram;
117
+ repl: (options?: { greeting?: false; hint?: false }) => AsyncIterable<any>;
118
+ };
119
+
120
+ export function testCli(program: TestableProgram): TestCliBuilder {
121
+ let input: string | undefined;
122
+ let envVars: Record<string, string | undefined> | undefined;
123
+ let promptAnswers: Record<string, unknown> | undefined;
124
+ let configFiles: Record<string, Record<string, unknown>> | undefined;
125
+ let stdinData: string | undefined;
126
+
127
+ const builder: TestCliBuilder = {
128
+ args(args: string) {
129
+ input = args;
130
+ return builder;
131
+ },
132
+ env(vars) {
133
+ envVars = vars;
134
+ return builder;
135
+ },
136
+ prompt(answers) {
137
+ promptAnswers = answers;
138
+ return builder;
139
+ },
140
+ config(files) {
141
+ configFiles = files;
142
+ return builder;
143
+ },
144
+ stdin(data: string) {
145
+ stdinData = data;
146
+ return builder;
147
+ },
148
+
149
+ async run(runInput?: string) {
150
+ const stdout: unknown[] = [];
151
+ const stderr: string[] = [];
152
+
153
+ const runtime = buildRuntime(stdout, stderr, { envVars, promptAnswers, configFiles, stdinData });
154
+ const testProgram = program.runtime(runtime);
155
+
156
+ try {
157
+ const evalResult = await testProgram.eval(runInput ?? input ?? '', { autoOutput: false });
158
+ return toTestResult(evalResult, stdout, stderr);
159
+ } catch (err) {
160
+ stderr.push(err instanceof Error ? err.message : String(err));
161
+ return {
162
+ command: undefined as unknown as AnyPadroneCommand,
163
+ args: undefined,
164
+ result: undefined,
165
+ issues: undefined,
166
+ stdout,
167
+ stderr,
168
+ error: err,
169
+ };
170
+ }
171
+ },
172
+
173
+ async repl(inputs: string[]) {
174
+ const stdout: unknown[] = [];
175
+ const stderr: string[] = [];
176
+
177
+ const runtime = buildRuntime(stdout, stderr, {
178
+ envVars,
179
+ promptAnswers,
180
+ configFiles,
181
+ readLine: createMockReadLine(inputs),
182
+ });
183
+
184
+ const testProgram = program.runtime(runtime);
185
+ const results: Omit<TestCliResult, 'stdout' | 'stderr'>[] = [];
186
+
187
+ for await (const r of testProgram.repl({ greeting: false, hint: false })) {
188
+ results.push({
189
+ command: r.command,
190
+ args: r.args,
191
+ result: r.result,
192
+ issues: r.argsResult?.issues as TestCliResult['issues'],
193
+ });
194
+ }
195
+
196
+ return { results, stdout, stderr };
197
+ },
198
+ };
199
+
200
+ return builder;
201
+ }
202
+
203
+ function toTestResult(evalResult: PadroneCommandResult, stdout: unknown[], stderr: string[]): TestCliResult {
204
+ return {
205
+ command: evalResult.command,
206
+ args: evalResult.args,
207
+ result: evalResult.result,
208
+ issues: evalResult.argsResult?.issues as TestCliResult['issues'],
209
+ stdout,
210
+ stderr,
211
+ };
212
+ }
213
+
214
+ function buildRuntime(
215
+ stdout: unknown[],
216
+ stderr: string[],
217
+ opts: {
218
+ envVars?: Record<string, string | undefined>;
219
+ promptAnswers?: Record<string, unknown>;
220
+ configFiles?: Record<string, Record<string, unknown>>;
221
+ readLine?: (prompt: string) => Promise<string | null>;
222
+ stdinData?: string;
223
+ },
224
+ ): PadroneRuntime {
225
+ const runtime: PadroneRuntime = {
226
+ output: (...args: unknown[]) => stdout.push(...args),
227
+ error: (text: string) => stderr.push(text),
228
+ };
229
+
230
+ if (opts.envVars) {
231
+ runtime.env = () => opts.envVars!;
232
+ }
233
+
234
+ if (opts.promptAnswers) {
235
+ runtime.interactive = 'supported';
236
+ runtime.prompt = async (config: InteractivePromptConfig) => opts.promptAnswers![config.name];
237
+ }
238
+
239
+ if (opts.configFiles) {
240
+ runtime.loadConfigFile = (path: string) => opts.configFiles![path];
241
+ runtime.findFile = (names: string[]) => names.find((n) => n in opts.configFiles!);
242
+ }
243
+
244
+ if (opts.readLine) {
245
+ runtime.readLine = opts.readLine;
246
+ }
247
+
248
+ if (opts.stdinData !== undefined) {
249
+ runtime.stdin = {
250
+ isTTY: false,
251
+ async text() {
252
+ return opts.stdinData!;
253
+ },
254
+ async *lines() {
255
+ const lines = opts.stdinData!.split('\n');
256
+ // Remove trailing empty line from final newline (matches readline behavior)
257
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
258
+ for (const line of lines) {
259
+ yield line;
260
+ }
261
+ },
262
+ };
263
+ } else {
264
+ // No stdin data: simulate a TTY (no piped input) to avoid reading from process.stdin
265
+ runtime.stdin = {
266
+ isTTY: true,
267
+ async text() {
268
+ return '';
269
+ },
270
+ async *lines() {
271
+ // no lines
272
+ },
273
+ };
274
+ }
275
+
276
+ return runtime;
277
+ }
278
+
279
+ function createMockReadLine(inputs: string[]): (prompt: string) => Promise<string | null> {
280
+ let index = 0;
281
+ return async (_prompt: string): Promise<string | null> => {
282
+ if (index >= inputs.length) return null;
283
+ return inputs[index++] ?? null;
284
+ };
285
+ }
@@ -2,22 +2,22 @@ import type { PickCommandByName, PossibleCommands } from './type-utils.ts';
2
2
  import type { AnyPadroneCommand, AnyPadroneProgram, PadroneCommand, PadroneSchema } from './types.ts';
3
3
 
4
4
  /**
5
- * Extracts the input type of the options schema from a command.
5
+ * Extracts the input type of the arguments schema from a command.
6
6
  * @example
7
7
  * ```ts
8
- * type Options = InferOptionsInput<typeof myCommand>;
8
+ * type Args = InferArgsInput<typeof myCommand>;
9
9
  * ```
10
10
  */
11
- export type InferOptionsInput<T extends AnyPadroneCommand> = T['~types']['optionsInput'];
11
+ export type InferArgsInput<T extends AnyPadroneCommand> = T['~types']['argsInput'];
12
12
 
13
13
  /**
14
- * Extracts the output type of the options schema from a command.
14
+ * Extracts the output type of the arguments schema from a command.
15
15
  * @example
16
16
  * ```ts
17
- * type Options = InferOptionsOutput<typeof myCommand>;
17
+ * type Args = InferArgsOutput<typeof myCommand>;
18
18
  * ```
19
19
  */
20
- export type InferOptionsOutput<T extends AnyPadroneCommand> = T['~types']['optionsOutput'];
20
+ export type InferArgsOutput<T extends AnyPadroneCommand> = T['~types']['argsOutput'];
21
21
 
22
22
  /**
23
23
  * Extracts the input type of the config schema from a command.
@@ -26,17 +26,17 @@ export type InferOptionsOutput<T extends AnyPadroneCommand> = T['~types']['optio
26
26
  * type Config = InferConfigInput<typeof myCommand>;
27
27
  * ```
28
28
  */
29
- export type InferConfigInput<T extends AnyPadroneCommand> = T['config'] extends PadroneSchema<infer I, any> ? I : never;
29
+ export type InferConfigInput<T extends AnyPadroneCommand> = T['configSchema'] extends PadroneSchema<infer I, any> ? I : never;
30
30
 
31
31
  /**
32
32
  * Extracts the output type of the config schema from a command.
33
- * This is the type after transformation, which should match the options shape.
33
+ * This is the type after transformation, which should match the arguments shape.
34
34
  * @example
35
35
  * ```ts
36
36
  * type ConfigOutput = InferConfigOutput<typeof myCommand>;
37
37
  * ```
38
38
  */
39
- export type InferConfigOutput<T extends AnyPadroneCommand> = T['config'] extends PadroneSchema<any, infer O> ? O : never;
39
+ export type InferConfigOutput<T extends AnyPadroneCommand> = T['configSchema'] extends PadroneSchema<any, infer O> ? O : never;
40
40
 
41
41
  /**
42
42
  * Extracts the input type of the env schema from a command.
@@ -50,7 +50,7 @@ export type InferEnvInput<T extends AnyPadroneCommand> = T['envSchema'] extends
50
50
 
51
51
  /**
52
52
  * Extracts the output type of the env schema from a command.
53
- * This is the type after transformation, which should match the options shape.
53
+ * This is the type after transformation, which should match the arguments shape.
54
54
  * @example
55
55
  * ```ts
56
56
  * type EnvOutput = InferEnvOutput<typeof myCommand>;
package/src/type-utils.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AnyPadroneCommand } from './types.ts';
1
+ import type { AnyPadroneCommand, PadroneCommand } from './types.ts';
2
2
 
3
3
  /**
4
4
  * Use this type instead of `any` when you intend to fix it later
@@ -13,6 +13,54 @@ type IsNever<T> = [T] extends [never] ? true : false;
13
13
 
14
14
  export type IsGeneric<T> = IsAny<T> extends true ? true : IsUnknown<T> extends true ? true : IsNever<T> extends true ? true : false;
15
15
 
16
+ /**
17
+ * Detects whether a schema has been branded as async via the `'~async'` property.
18
+ * Standard Schema V1's `validate()` always types its return as `Result | Promise<Result>`
19
+ * regardless of whether the schema is actually async, so we rely on an explicit brand instead.
20
+ *
21
+ * Use `asyncSchema(schema)` to brand a schema, or check for the `{ '~async': true }` property.
22
+ */
23
+ export type IsAsyncSchema<T> = IsAny<T> extends true ? false : T extends { '~async': true } ? true : false;
24
+
25
+ /**
26
+ * Computes the new TAsync flag when a schema is added to a builder.
27
+ * Once TAsync is `true`, it stays `true`. Otherwise, checks if the new schema is branded async.
28
+ */
29
+ export type OrAsync<TExisting extends boolean, TSchema> = TExisting extends true
30
+ ? true
31
+ : IsAsyncSchema<TSchema> extends true
32
+ ? true
33
+ : false;
34
+
35
+ /**
36
+ * Detects whether argument meta contains interactive or optionalInteractive configuration.
37
+ * When either is `true` or a `string[]`, the command requires async execution for prompting.
38
+ */
39
+ export type HasInteractive<TMeta> = TMeta extends { interactive: true | string[] }
40
+ ? true
41
+ : TMeta extends { optionalInteractive: true | string[] }
42
+ ? true
43
+ : false;
44
+
45
+ /**
46
+ * Combines schema-level async detection with meta-level interactive detection.
47
+ * Returns `true` if the existing async flag is set, the schema is branded async, or the meta has interactive fields.
48
+ */
49
+ export type OrAsyncMeta<TExisting extends boolean, TMeta> = TExisting extends true
50
+ ? true
51
+ : HasInteractive<TMeta> extends true
52
+ ? true
53
+ : false;
54
+
55
+ /**
56
+ * Conditionally wraps a type in Promise based on the TAsync flag.
57
+ * - `true` → `Promise<T>`
58
+ * - `false` → `T`
59
+ * - `boolean` (union of true|false) → `Promise<T>` (safe default when async-ness is uncertain)
60
+ * - `any` → `T` (for generic/any typed commands like AnyPadroneCommand)
61
+ */
62
+ export type MaybePromise<T, TAsync> = IsAny<TAsync> extends true ? T : true extends TAsync ? Promise<T> : T;
63
+
16
64
  type SplitString<TName extends string, TSplitBy extends string = ' '> = TName extends `${infer FirstPart}${TSplitBy}${infer RestParts}`
17
65
  ? [FirstPart, ...SplitString<RestParts, TSplitBy>]
18
66
  : [TName];
@@ -62,6 +110,44 @@ type GetCommandPathsAndAliases<TCommand extends AnyPadroneCommand> = TCommand['~
62
110
  : Path
63
111
  : never;
64
112
 
113
+ /**
114
+ * Find a direct child command in a tuple by name.
115
+ * Unlike PickCommandByName, this does NOT flatten — it only checks direct children by their `name` field.
116
+ */
117
+ export type FindDirectChild<TCommands extends AnyPadroneCommand[], TName extends string> = TCommands extends [
118
+ infer First extends AnyPadroneCommand,
119
+ ...infer Rest extends AnyPadroneCommand[],
120
+ ]
121
+ ? First['~types']['name'] extends TName
122
+ ? First
123
+ : FindDirectChild<Rest, TName>
124
+ : never;
125
+
126
+ /**
127
+ * Replace a command in a tuple by name, or append if not found.
128
+ * Used by `.command()` override semantics: re-registering a name replaces that entry.
129
+ */
130
+ export type ReplaceOrAppendCommand<TCommands extends [...AnyPadroneCommand[]], TName extends string, TNew extends AnyPadroneCommand> =
131
+ HasDirectChild<TCommands, TName> extends true ? ReplaceInTuple<TCommands, TName, TNew> : [...TCommands, TNew];
132
+
133
+ type HasDirectChild<TCommands extends AnyPadroneCommand[], TName extends string> = TCommands extends [
134
+ infer First extends AnyPadroneCommand,
135
+ ...infer Rest extends AnyPadroneCommand[],
136
+ ]
137
+ ? First['~types']['name'] extends TName
138
+ ? true
139
+ : HasDirectChild<Rest, TName>
140
+ : false;
141
+
142
+ type ReplaceInTuple<TCommands extends AnyPadroneCommand[], TName extends string, TNew extends AnyPadroneCommand> = TCommands extends [
143
+ infer First extends AnyPadroneCommand,
144
+ ...infer Rest extends AnyPadroneCommand[],
145
+ ]
146
+ ? First['~types']['name'] extends TName
147
+ ? [TNew, ...Rest]
148
+ : [First, ...ReplaceInTuple<Rest, TName, TNew>]
149
+ : [];
150
+
65
151
  export type PickCommandByName<
66
152
  TCommands extends AnyPadroneCommand[],
67
153
  TName extends string | AnyPadroneCommand,
@@ -130,19 +216,43 @@ type CommandIsUnknownable<TCommand> =
130
216
  * This is done by recursively splitting the string by the last space, and then checking if the prefix is a valid command name or alias.
131
217
  * This is needed to avoid matching the top-level command when there are nested commands.
132
218
  */
219
+ /**
220
+ * Recursively re-paths a command's children under a new parent path.
221
+ * Used by `mount()` to update all nested command paths when a program is mounted as a subcommand.
222
+ */
223
+ export type RepathCommands<TCommands extends [...AnyPadroneCommand[]], TNewParentPath extends string> = TCommands extends [
224
+ infer First extends AnyPadroneCommand,
225
+ ...infer Rest extends AnyPadroneCommand[],
226
+ ]
227
+ ? [RepathCommand<First, TNewParentPath>, ...RepathCommands<Rest, TNewParentPath>]
228
+ : [];
229
+
230
+ type RepathCommand<TCommand extends AnyPadroneCommand, TNewParentName extends string> = PadroneCommand<
231
+ TCommand['~types']['name'],
232
+ TNewParentName,
233
+ TCommand['~types']['argsSchema'],
234
+ TCommand['~types']['result'],
235
+ RepathCommands<TCommand['~types']['commands'], FullCommandName<TCommand['~types']['name'], TNewParentName>>,
236
+ TCommand['~types']['aliases'],
237
+ TCommand['~types']['configSchema'],
238
+ TCommand['~types']['envSchema'],
239
+ TCommand['~types']['async']
240
+ >;
241
+
133
242
  export type PickCommandByPossibleCommands<
134
243
  TCommands extends AnyPadroneCommand[],
135
244
  TCommand extends PossibleCommands<TCommands, true, true> | SafeString,
136
- > = CommandIsUnknownable<TCommand> extends true
137
- ? FlattenCommands<TCommands>
138
- : TCommand extends AnyPadroneCommand
139
- ? TCommand
140
- : TCommand extends string
141
- ? TCommand extends GetCommandPathsOrAliases<TCommands>
142
- ? PickCommandByName<TCommands, TCommand>
143
- : SplitLastSpace<TCommand> extends [infer Prefix extends string, infer Rest]
144
- ? IsNever<Rest> extends true
145
- ? PickCommandByName<TCommands, Prefix>
146
- : PickCommandByPossibleCommands<TCommands, Prefix>
147
- : never
148
- : never;
245
+ > =
246
+ CommandIsUnknownable<TCommand> extends true
247
+ ? FlattenCommands<TCommands>
248
+ : TCommand extends AnyPadroneCommand
249
+ ? TCommand
250
+ : TCommand extends string
251
+ ? TCommand extends GetCommandPathsOrAliases<TCommands>
252
+ ? PickCommandByName<TCommands, TCommand>
253
+ : SplitLastSpace<TCommand> extends [infer Prefix extends string, infer Rest]
254
+ ? IsNever<Rest> extends true
255
+ ? PickCommandByName<TCommands, Prefix>
256
+ : PickCommandByPossibleCommands<TCommands, Prefix>
257
+ : never
258
+ : never;