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,317 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
2
+ import { buildReplCompleter, findCommandByName, getCommandRuntime } from './command-utils.ts';
3
+ import { createTerminalReplSession, REPL_SIGINT, type ReplSessionConfig } from './runtime.ts';
4
+ import type { AnyPadroneCommand, PadroneEvalPreferences, PadroneReplPreferences } from './types.ts';
5
+ import { getVersion } from './utils.ts';
6
+
7
+ export type ReplDeps = {
8
+ existingCommand: AnyPadroneCommand;
9
+ evalCommand: (input: string, prefs?: PadroneEvalPreferences) => any;
10
+ replActiveRef: { value: boolean };
11
+ };
12
+
13
+ /**
14
+ * Creates a REPL async iterable for running commands interactively.
15
+ */
16
+ export function createReplIterator(deps: ReplDeps, options?: PadroneReplPreferences): AsyncIterable<any> {
17
+ const { existingCommand, evalCommand, replActiveRef } = deps;
18
+
19
+ if (replActiveRef.value) {
20
+ const runtime = getCommandRuntime(existingCommand);
21
+ runtime.error('REPL is already running. Nested REPL sessions are not supported.');
22
+ return (async function* () {})() as any;
23
+ }
24
+
25
+ const runtime = getCommandRuntime(existingCommand);
26
+
27
+ const programName = existingCommand.name || 'padrone';
28
+ const useAnsi =
29
+ runtime.format === 'ansi' ||
30
+ (runtime.format === 'auto' && typeof process !== 'undefined' && !process.env.NO_COLOR && !process.env.CI && process.stdout?.isTTY);
31
+
32
+ // Track command history for .history built-in
33
+ const commandHistory: string[] = [];
34
+
35
+ // Resolve the initial scope command from options.scope (command path like 'db' or 'db migrate')
36
+ const resolveScope = (scope: string): AnyPadroneCommand[] => {
37
+ const parts = scope.split(/\s+/);
38
+ const stack: AnyPadroneCommand[] = [];
39
+ let current = existingCommand;
40
+ for (const part of parts) {
41
+ const found = findCommandByName(part, current.commands);
42
+ if (!found) break;
43
+ stack.push(found);
44
+ current = found;
45
+ }
46
+ return stack;
47
+ };
48
+
49
+ async function* replIterator() {
50
+ replActiveRef.value = true;
51
+ const showGreeting = options?.greeting !== false;
52
+ const showHint = options?.hint !== false;
53
+
54
+ // Empty line before greeting/hint block
55
+ if (showGreeting || showHint) runtime.output('');
56
+
57
+ // Greeting: default shows program title (or name) + version, like "Welcome to My App v1.0.0"
58
+ if (showGreeting) {
59
+ if (options?.greeting) {
60
+ runtime.output(options.greeting);
61
+ } else {
62
+ const displayName = existingCommand.title || programName;
63
+ const version = existingCommand.version ? getVersion(existingCommand.version) : undefined;
64
+ const greeting = version ? `Welcome to ${displayName} v${version}` : `Welcome to ${displayName}`;
65
+ runtime.output(greeting);
66
+ }
67
+ }
68
+
69
+ // Hint: dimmed text below greeting
70
+ if (showHint) {
71
+ const hintText =
72
+ (typeof options?.hint === 'string' ? options.hint : undefined) ?? 'Type ".help" for more information, ".exit" to quit.';
73
+ runtime.output(useAnsi ? `\x1b[2m${hintText}\x1b[0m` : hintText);
74
+ }
75
+
76
+ // Empty line after greeting/hint block
77
+ if (showGreeting || showHint) runtime.output('');
78
+
79
+ // Scope stack for nested/contextual REPLs.
80
+ // `cd <subcommand>` pushes, `cd ..`/`..` pops. The scope path is prepended to all eval input.
81
+ const scopeStack: AnyPadroneCommand[] = options?.scope ? resolveScope(options.scope) : [];
82
+
83
+ const getScopeCommand = () => (scopeStack.length ? scopeStack[scopeStack.length - 1]! : existingCommand);
84
+ const getScopePath = () => scopeStack.map((c) => c.name).join(' ');
85
+
86
+ const buildPrompt = () => {
87
+ if (options?.prompt) return typeof options.prompt === 'function' ? options.prompt() : options.prompt;
88
+ const scopePath = getScopePath();
89
+ const label = scopePath ? `${programName}/${scopePath.replace(/ /g, '/')}` : programName;
90
+ return useAnsi ? `\x1b[1m${label}\x1b[0m ❯ ` : `${label} ❯ `;
91
+ };
92
+
93
+ // Build completer scoped to the current command
94
+ const buildScopedCompleter = () => {
95
+ const scopeCmd = getScopeCommand();
96
+ const inScope = scopeStack.length > 0;
97
+ return buildReplCompleter(scopeCmd, { inScope });
98
+ };
99
+
100
+ // Build session config with completer
101
+ const sessionConfig: ReplSessionConfig = { history: options?.history };
102
+ if (options?.completion !== false) {
103
+ sessionConfig.completer = buildScopedCompleter();
104
+ }
105
+
106
+ // If the runtime provides a custom readLine, use it (stateless, no history/completion).
107
+ // Otherwise, create a persistent terminal session with history + tab completion.
108
+ const session = runtime.readLine ? undefined : createTerminalReplSession(sessionConfig);
109
+ const questionFn = session ? (prompt: string) => session.question(prompt) : runtime.readLine!;
110
+
111
+ // Update the session's completer when scope changes
112
+ const updateCompleter = () => {
113
+ if (options?.completion === false) return;
114
+ const completer = buildScopedCompleter();
115
+ if (session) session.completer = completer;
116
+ sessionConfig.completer = completer;
117
+ };
118
+
119
+ // Track last SIGINT time for double Ctrl+C to exit
120
+ let lastSigintTime = 0;
121
+
122
+ try {
123
+ while (true) {
124
+ const promptStr = buildPrompt();
125
+ const input = await questionFn(promptStr);
126
+
127
+ // EOF (Ctrl+D, closed connection)
128
+ if (input === null) break;
129
+
130
+ // Handle Ctrl+C (SIGINT sentinel from terminal session)
131
+ if (input === REPL_SIGINT) {
132
+ const now = Date.now();
133
+ if (now - lastSigintTime < 2000) break; // Double Ctrl+C within 2s → exit
134
+ lastSigintTime = now;
135
+ runtime.output('(press Ctrl+C again to exit, or Ctrl+D)');
136
+ continue;
137
+ }
138
+
139
+ const trimmed = input.trim();
140
+ if (!trimmed) continue;
141
+
142
+ // Reset SIGINT timer on any real input
143
+ lastSigintTime = 0;
144
+
145
+ // Track command history for .history
146
+ commandHistory.push(trimmed);
147
+
148
+ // Dot-prefixed built-in REPL commands
149
+ if (trimmed === '.exit' || trimmed === '.quit') break;
150
+ if (trimmed === '.clear') {
151
+ runtime.output('\x1B[2J\x1B[H');
152
+ continue;
153
+ }
154
+ if (trimmed === '.help') {
155
+ const lines = [
156
+ 'REPL Commands:',
157
+ ' . Execute the current scoped command',
158
+ ' .help Print this help message',
159
+ ' .exit Exit the REPL',
160
+ ' .clear Clear the screen',
161
+ ' .history Show command history',
162
+ ' .scope <cmd> Scope into a subcommand',
163
+ ' .scope .. Go up one scope level',
164
+ ];
165
+ lines.push(
166
+ '',
167
+ 'Keybindings:',
168
+ ' Ctrl+C Cancel current line (press twice to exit)',
169
+ ' Ctrl+D Exit the REPL',
170
+ ' Up/Down Navigate history',
171
+ ' Tab Auto-complete',
172
+ '',
173
+ 'Type "help" to see available commands.',
174
+ );
175
+ runtime.output(lines.join('\n'));
176
+ continue;
177
+ }
178
+ if (trimmed === '.history') {
179
+ // Show all previous entries (excluding the .history command itself)
180
+ const entries = commandHistory.slice(0, -1);
181
+ if (entries.length === 0) {
182
+ runtime.output('No history.');
183
+ } else {
184
+ runtime.output(entries.map((entry, i) => `${i + 1} ${entry}`).join('\n'));
185
+ }
186
+ continue;
187
+ }
188
+
189
+ // `.scope <subcommand>` — scope the REPL to a command subtree
190
+ // `.scope ..` or `..` — go up one scope level
191
+ if (trimmed.startsWith('.scope ') || trimmed === '.scope') {
192
+ const target = trimmed.slice(6).trim();
193
+ if (target === '..' || target === '') {
194
+ if (scopeStack.length > 0) {
195
+ scopeStack.pop();
196
+ updateCompleter();
197
+ }
198
+ } else {
199
+ const scopeCmd = getScopeCommand();
200
+ const found = findCommandByName(target, scopeCmd.commands);
201
+ if (found) {
202
+ if (found.commands?.length) {
203
+ scopeStack.push(found);
204
+ updateCompleter();
205
+ } else {
206
+ runtime.error(`"${target}" has no subcommands to scope into.`);
207
+ }
208
+ } else {
209
+ runtime.error(`Unknown command: ${target}`);
210
+ }
211
+ }
212
+ continue;
213
+ }
214
+
215
+ // `..` shorthand for `.scope ..`
216
+ if (trimmed === '..') {
217
+ if (scopeStack.length > 0) {
218
+ scopeStack.pop();
219
+ updateCompleter();
220
+ }
221
+ continue;
222
+ }
223
+
224
+ // `.` (bare dot) — execute the current command (scoped or root)
225
+ let evalInput = trimmed;
226
+ if (trimmed === '.') {
227
+ evalInput = '';
228
+ }
229
+
230
+ const prefix = options?.outputPrefix;
231
+ const prefixLines = prefix
232
+ ? (text: string) =>
233
+ text
234
+ .split('\n')
235
+ .map((l) => prefix + l)
236
+ .join('\n')
237
+ : undefined;
238
+
239
+ // Temporarily patch runtime on all commands so handler output gets prefixed.
240
+ // Commands store parent refs from build time, so we patch each command directly.
241
+ const savedRuntimes: { cmd: AnyPadroneCommand; runtime: typeof existingCommand.runtime }[] = [];
242
+ if (prefixLines) {
243
+ const prefixedRuntime = {
244
+ ...existingCommand.runtime,
245
+ output: (...args: unknown[]) => {
246
+ const first = args[0];
247
+ runtime.output(typeof first === 'string' ? prefixLines(first) : first, ...args.slice(1));
248
+ },
249
+ error: (text: string) => runtime.error(prefixLines(text)),
250
+ };
251
+ const patchAll = (cmd: AnyPadroneCommand) => {
252
+ savedRuntimes.push({ cmd, runtime: cmd.runtime });
253
+ cmd.runtime = prefixedRuntime;
254
+ cmd.commands?.forEach(patchAll);
255
+ };
256
+ patchAll(existingCommand);
257
+ }
258
+
259
+ // Resolve before/after spacing from the shorthand or object form
260
+ const sp = options?.spacing;
261
+ const isSpacingObject = typeof sp === 'object' && sp !== null && !Array.isArray(sp);
262
+ const spacingBefore = isSpacingObject ? sp.before : sp;
263
+ const spacingAfter = isSpacingObject ? sp.after : sp;
264
+
265
+ const emitSpacingLine = (value: boolean | string) => {
266
+ if (typeof value === 'string') {
267
+ const sep =
268
+ value.length === 1
269
+ ? value.repeat(typeof process !== 'undefined' && process.stdout?.columns ? process.stdout.columns : 80)
270
+ : value;
271
+ runtime.output(sep);
272
+ } else if (value) {
273
+ runtime.output('');
274
+ }
275
+ };
276
+ const emitSpacing = (value: typeof spacingBefore) => {
277
+ if (!value) return;
278
+ if (Array.isArray(value)) {
279
+ for (const line of value) emitSpacingLine(line);
280
+ } else {
281
+ emitSpacingLine(value);
282
+ }
283
+ };
284
+
285
+ emitSpacing(spacingBefore);
286
+
287
+ // Prepend scope path so evalCommand resolves relative to root
288
+ const scopePath = getScopePath();
289
+ const scopedInput = scopePath ? (evalInput ? `${scopePath} ${evalInput}` : scopePath) : evalInput;
290
+
291
+ try {
292
+ const replEvalPrefs: PadroneEvalPreferences | undefined = options?.autoOutput === false ? { autoOutput: false } : undefined;
293
+ const result = await evalCommand(scopedInput, replEvalPrefs);
294
+ if (result.argsResult?.issues) {
295
+ const issueMessages = result.argsResult.issues
296
+ .map((i: StandardSchemaV1.Issue) => ` - ${i.path?.join('.') || 'root'}: ${i.message}`)
297
+ .join('\n');
298
+ const msg = `Validation error:\n${issueMessages}`;
299
+ runtime.error(prefixLines ? prefixLines(msg) : msg);
300
+ }
301
+ yield result as any;
302
+ } catch (err) {
303
+ const msg = err instanceof Error ? err.message : String(err);
304
+ runtime.error(prefixLines ? prefixLines(msg) : msg);
305
+ } finally {
306
+ for (const { cmd, runtime: saved } of savedRuntimes) cmd.runtime = saved;
307
+ emitSpacing(spacingAfter);
308
+ }
309
+ }
310
+ } finally {
311
+ replActiveRef.value = false;
312
+ session?.close();
313
+ }
314
+ }
315
+
316
+ return replIterator() as any;
317
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,304 @@
1
+ import type { HelpFormat } from './formatter.ts';
2
+ import { findConfigFile, loadConfigFile } from './utils.ts';
3
+
4
+ /**
5
+ * Controls interactive prompting capability and default behavior at the runtime level.
6
+ * - `'supported'` — capable; caller decides.
7
+ * - `'unsupported'` — hard veto; nothing can override.
8
+ * - `'forced'` — capable and forces prompts by default.
9
+ * - `'disabled'` — capable but suppresses prompts by default.
10
+ */
11
+ export type InteractiveMode = 'supported' | 'unsupported' | 'forced' | 'disabled';
12
+
13
+ /**
14
+ * Configuration passed to the runtime's `prompt` function for interactive field prompting.
15
+ * The prompt type and choices are auto-detected from the field's JSON schema.
16
+ */
17
+ export type InteractivePromptConfig = {
18
+ /** The field name being prompted. */
19
+ name: string;
20
+ /** Human-readable message/label for the prompt, derived from the field's description or name. */
21
+ message: string;
22
+ /** The prompt type, auto-detected from the JSON schema. */
23
+ type: 'input' | 'confirm' | 'select' | 'multiselect' | 'password';
24
+ /** Available choices for select/multiselect prompts. */
25
+ choices?: { label: string; value: unknown }[];
26
+ /** Default value from the schema. */
27
+ default?: unknown;
28
+ };
29
+
30
+ /**
31
+ * Defines the execution context for a Padrone program.
32
+ * Abstracts all environment-dependent I/O so the CLI framework
33
+ * can run outside of a terminal (e.g., web UIs, chat interfaces, testing).
34
+ *
35
+ * All fields are optional — unspecified fields fall back to the Node.js/Bun defaults.
36
+ */
37
+ export type PadroneRuntime = {
38
+ /** Write normal output (replaces console.log). Receives the raw value — runtime handles formatting. */
39
+ output?: (...args: unknown[]) => void;
40
+ /** Write error output (replaces console.error). */
41
+ error?: (text: string) => void;
42
+ /** Return the raw CLI arguments (replaces process.argv.slice(2)). */
43
+ argv?: () => string[];
44
+ /** Return environment variables (replaces process.env). */
45
+ env?: () => Record<string, string | undefined>;
46
+ /** Default help output format. */
47
+ format?: HelpFormat | 'auto';
48
+ /** Load and parse a config file by path. Return undefined if not found or unparsable. */
49
+ loadConfigFile?: (path: string) => Record<string, unknown> | undefined;
50
+ /** Find the first existing file from a list of candidate names. */
51
+ findFile?: (names: string[]) => string | undefined;
52
+ /**
53
+ * Standard input abstraction. Provides methods to read piped data from stdin.
54
+ * When not provided, defaults to reading from `process.stdin`.
55
+ *
56
+ * Used by commands that declare a `stdin` field in their arguments meta.
57
+ * The framework reads stdin automatically during the validate phase and
58
+ * injects the data into the specified argument field.
59
+ */
60
+ stdin?: {
61
+ /** Whether stdin is a TTY (interactive terminal) vs a pipe/file. */
62
+ isTTY?: boolean;
63
+ /** Read all of stdin as a string. */
64
+ text: () => Promise<string>;
65
+ /** Async iterable of lines for streaming. */
66
+ lines: () => AsyncIterable<string>;
67
+ };
68
+ /**
69
+ * Controls interactive prompting capability and default behavior.
70
+ * - `'supported'` — runtime can handle prompts; caller (flag/pref) decides whether to prompt. This is the default when `prompt` is provided.
71
+ * - `'unsupported'` — runtime cannot handle prompts; hard veto that nothing can override.
72
+ * - `'forced'` — runtime supports prompts and forces them by default (prompts even for provided values).
73
+ * - `'disabled'` — runtime supports prompts but suppresses them by default.
74
+ *
75
+ * `'unsupported'` is the only immutable state. For the others, the `--interactive`/`-i` flag
76
+ * and `cli()` preferences can override the default behavior.
77
+ */
78
+ interactive?: InteractiveMode;
79
+ /**
80
+ * Prompt the user for input. Called during `cli()` for fields marked as interactive.
81
+ * When `interactive` is `true` and this is not provided, defaults to an Enquirer-based terminal prompt.
82
+ */
83
+ prompt?: (config: InteractivePromptConfig) => Promise<unknown>;
84
+ /**
85
+ * Read a line of input from the user. Used by `repl()` for custom runtimes
86
+ * (web UIs, chat interfaces, testing).
87
+ * Returns the input string, `null` on EOF (e.g. Ctrl+D, closed connection),
88
+ * or `REPL_SIGINT` when the user presses Ctrl+C.
89
+ *
90
+ * When not provided, `repl()` uses a built-in Node.js readline session
91
+ * with command history (up/down arrows) and tab completion.
92
+ */
93
+ readLine?: (prompt: string) => Promise<string | typeof REPL_SIGINT | null>;
94
+ };
95
+
96
+ /**
97
+ * Internal resolved runtime where all fields are guaranteed to be present.
98
+ * The `prompt`, `interactive`, and `readLine` fields remain optional since not all runtimes provide them.
99
+ */
100
+ export type ResolvedPadroneRuntime = Required<Omit<PadroneRuntime, 'prompt' | 'interactive' | 'readLine' | 'stdin'>> &
101
+ Pick<PadroneRuntime, 'prompt' | 'interactive' | 'readLine' | 'stdin'>;
102
+
103
+ /**
104
+ * Default terminal prompt implementation powered by Enquirer.
105
+ * Lazily imported to avoid loading Enquirer when not needed.
106
+ */
107
+ async function defaultTerminalPrompt(config: InteractivePromptConfig): Promise<unknown> {
108
+ const Enquirer = (await import('enquirer')).default;
109
+
110
+ const question: Record<string, unknown> = {
111
+ type: config.type,
112
+ name: config.name,
113
+ message: config.message,
114
+ };
115
+
116
+ if (config.default !== undefined) {
117
+ question.initial = config.default;
118
+ }
119
+
120
+ if (config.choices) {
121
+ question.choices = config.choices.map((c) => ({
122
+ name: String(c.value),
123
+ message: c.label,
124
+ }));
125
+ }
126
+
127
+ const response = (await Enquirer.prompt(question as any)) as Record<string, unknown>;
128
+ return response[config.name];
129
+ }
130
+
131
+ /**
132
+ * Internal session config for the REPL's persistent readline interface.
133
+ */
134
+ export type ReplSessionConfig = {
135
+ completer?: (line: string) => [string[], string];
136
+ history?: string[];
137
+ };
138
+
139
+ /**
140
+ * Creates a persistent Node.js readline session for the REPL.
141
+ * Enables up/down arrow history navigation and tab completion.
142
+ * Used internally by `repl()` when no custom `readLine` is provided.
143
+ */
144
+ /**
145
+ * Sentinel value returned by the terminal REPL session when Ctrl+C is pressed.
146
+ * Distinguished from empty string (user pressed enter) and null (EOF/Ctrl+D).
147
+ */
148
+ export const REPL_SIGINT = Symbol('REPL_SIGINT');
149
+
150
+ export function createTerminalReplSession(config: ReplSessionConfig) {
151
+ // History accumulates across per-call interfaces, giving us
152
+ // up/down arrow navigation without a persistent stdin listener
153
+ // that would conflict with Enquirer or other stdin consumers.
154
+ let history: string[] = config.history ? [...config.history] : [];
155
+ let currentCompleter = config.completer;
156
+
157
+ return {
158
+ /** Update the tab completer (e.g. when REPL scope changes). Takes effect on the next question. */
159
+ set completer(fn: ((line: string) => [string[], string]) | undefined) {
160
+ currentCompleter = fn;
161
+ },
162
+ async question(prompt: string): Promise<string | typeof REPL_SIGINT | null> {
163
+ const { createInterface } = await import('node:readline');
164
+ const opts: Record<string, unknown> = {
165
+ input: process.stdin,
166
+ output: process.stdout,
167
+ terminal: true,
168
+ history: [...history],
169
+ historySize: Math.max(history.length, 1000),
170
+ };
171
+ if (currentCompleter) {
172
+ opts.completer = currentCompleter;
173
+ }
174
+ const rl = createInterface(opts as any);
175
+
176
+ return new Promise((resolve) => {
177
+ let resolved = false;
178
+ const settle = (value: string | typeof REPL_SIGINT | null) => {
179
+ if (resolved) return;
180
+ resolved = true;
181
+ rl.close();
182
+ resolve(value);
183
+ };
184
+
185
+ rl.question(prompt, (answer) => {
186
+ // Grab updated history (includes the new entry) before closing.
187
+ if (Array.isArray((rl as any).history)) history = [...(rl as any).history];
188
+ settle(answer);
189
+ });
190
+ // Ctrl+C: cancel current line, print newline, resolve SIGINT sentinel.
191
+ rl.once('SIGINT', () => {
192
+ process.stdout.write('\n');
193
+ settle(REPL_SIGINT);
194
+ });
195
+ // EOF (Ctrl+D) fires close without the question callback.
196
+ rl.once('close', () => {
197
+ // Write newline so zsh doesn't show '%' (partial-line indicator).
198
+ process.stdout.write('\n');
199
+ settle(null);
200
+ });
201
+ });
202
+ },
203
+ close() {
204
+ // No persistent interface to clean up.
205
+ },
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Auto-detect interactive mode when not explicitly set.
211
+ * Returns 'disabled' in CI environments or non-TTY contexts, 'supported' otherwise.
212
+ */
213
+ function detectInteractiveMode(): InteractiveMode {
214
+ if (typeof process === 'undefined') return 'disabled';
215
+ if (process.env.CI || process.env.CONTINUOUS_INTEGRATION) return 'disabled';
216
+ if (!process.stdout?.isTTY) return 'disabled';
217
+ return 'supported';
218
+ }
219
+
220
+ /**
221
+ * Creates the default Node.js/Bun runtime.
222
+ */
223
+ /**
224
+ * Creates a default stdin reader from `process.stdin`.
225
+ * Only created when a command actually declares a `stdin` meta field.
226
+ */
227
+ function createDefaultStdin(): NonNullable<PadroneRuntime['stdin']> {
228
+ return {
229
+ get isTTY() {
230
+ // process.stdin.isTTY is `true` when interactive terminal, `undefined` when piped/redirected.
231
+ // Node.js never sets it to `false` — it's either `true` or absent.
232
+ if (typeof process === 'undefined') return true;
233
+ return process.stdin?.isTTY === true;
234
+ },
235
+ async text() {
236
+ if (typeof process === 'undefined') return '';
237
+ const chunks: Buffer[] = [];
238
+ for await (const chunk of process.stdin) {
239
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
240
+ }
241
+ return Buffer.concat(chunks).toString('utf-8');
242
+ },
243
+ async *lines() {
244
+ if (typeof process === 'undefined') return;
245
+ const { createInterface } = await import('node:readline');
246
+ const rl = createInterface({ input: process.stdin });
247
+ try {
248
+ for await (const line of rl) {
249
+ yield line;
250
+ }
251
+ } finally {
252
+ rl.close();
253
+ }
254
+ },
255
+ };
256
+ }
257
+
258
+ export function createDefaultRuntime(): ResolvedPadroneRuntime {
259
+ return {
260
+ output: (...args) => console.log(...args),
261
+ error: (text) => console.error(text),
262
+ argv: () => (typeof process !== 'undefined' ? process.argv.slice(2) : []),
263
+ env: () => (typeof process !== 'undefined' ? (process.env as Record<string, string | undefined>) : {}),
264
+ format: 'auto',
265
+ loadConfigFile,
266
+ findFile: findConfigFile,
267
+ prompt: defaultTerminalPrompt,
268
+ interactive: detectInteractiveMode(),
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Merges a partial runtime with the default runtime.
274
+ */
275
+ /**
276
+ * Returns the stdin abstraction: custom runtime stdin > default process.stdin.
277
+ * Returns `undefined` when no custom stdin is provided and process.stdin is not piped.
278
+ */
279
+ export function resolveStdin(partial?: PadroneRuntime): NonNullable<PadroneRuntime['stdin']> | undefined {
280
+ if (partial?.stdin) return partial.stdin;
281
+ const defaultStdin = createDefaultStdin();
282
+ // Only use default stdin if it's actually piped (isTTY === false).
283
+ // This avoids accidentally blocking on stdin in tests/CI.
284
+ if (defaultStdin.isTTY) return undefined;
285
+ return defaultStdin;
286
+ }
287
+
288
+ export function resolveRuntime(partial?: PadroneRuntime): ResolvedPadroneRuntime {
289
+ const defaults = createDefaultRuntime();
290
+ if (!partial) return defaults;
291
+ return {
292
+ output: partial.output ?? defaults.output,
293
+ error: partial.error ?? defaults.error,
294
+ argv: partial.argv ?? defaults.argv,
295
+ env: partial.env ?? defaults.env,
296
+ format: partial.format ?? defaults.format,
297
+ loadConfigFile: partial.loadConfigFile ?? defaults.loadConfigFile,
298
+ findFile: partial.findFile ?? defaults.findFile,
299
+ interactive: partial.interactive ?? defaults.interactive,
300
+ prompt: partial.prompt ?? defaults.prompt,
301
+ readLine: partial.readLine ?? defaults.readLine,
302
+ stdin: partial.stdin,
303
+ };
304
+ }