padrone 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +108 -283
  3. package/dist/args-Cnq0nwSM.mjs +272 -0
  4. package/dist/args-Cnq0nwSM.mjs.map +1 -0
  5. package/dist/codegen/index.d.mts +28 -3
  6. package/dist/codegen/index.d.mts.map +1 -1
  7. package/dist/codegen/index.mjs +169 -19
  8. package/dist/codegen/index.mjs.map +1 -1
  9. package/dist/commands-B_gufyR9.mjs +514 -0
  10. package/dist/commands-B_gufyR9.mjs.map +1 -0
  11. package/dist/{completion.mjs → completion-BEuflbDO.mjs} +86 -108
  12. package/dist/completion-BEuflbDO.mjs.map +1 -0
  13. package/dist/docs/index.d.mts +22 -2
  14. package/dist/docs/index.d.mts.map +1 -1
  15. package/dist/docs/index.mjs +92 -7
  16. package/dist/docs/index.mjs.map +1 -1
  17. package/dist/errors-CL63UOzt.mjs +137 -0
  18. package/dist/errors-CL63UOzt.mjs.map +1 -0
  19. package/dist/{formatter-ClUK5hcQ.d.mts → formatter-DrvhDMrq.d.mts} +35 -6
  20. package/dist/formatter-DrvhDMrq.d.mts.map +1 -0
  21. package/dist/help-B5Kk83of.mjs +849 -0
  22. package/dist/help-B5Kk83of.mjs.map +1 -0
  23. package/dist/index-BaU3X6dY.d.mts +1178 -0
  24. package/dist/index-BaU3X6dY.d.mts.map +1 -0
  25. package/dist/index.d.mts +763 -36
  26. package/dist/index.d.mts.map +1 -1
  27. package/dist/index.mjs +3608 -1534
  28. package/dist/index.mjs.map +1 -1
  29. package/dist/mcp-BM-d0nZi.mjs +377 -0
  30. package/dist/mcp-BM-d0nZi.mjs.map +1 -0
  31. package/dist/serve-Bk0JUlCj.mjs +402 -0
  32. package/dist/serve-Bk0JUlCj.mjs.map +1 -0
  33. package/dist/stream-DC4H8YTx.mjs +77 -0
  34. package/dist/stream-DC4H8YTx.mjs.map +1 -0
  35. package/dist/test.d.mts +5 -8
  36. package/dist/test.d.mts.map +1 -1
  37. package/dist/test.mjs +5 -27
  38. package/dist/test.mjs.map +1 -1
  39. package/dist/{update-check-EbNDkzyV.mjs → update-check-CZ2VqjnV.mjs} +16 -17
  40. package/dist/update-check-CZ2VqjnV.mjs.map +1 -0
  41. package/dist/zod.d.mts +32 -0
  42. package/dist/zod.d.mts.map +1 -0
  43. package/dist/zod.mjs +50 -0
  44. package/dist/zod.mjs.map +1 -0
  45. package/package.json +20 -9
  46. package/src/cli/completions.ts +14 -11
  47. package/src/cli/docs.ts +13 -16
  48. package/src/cli/doctor.ts +213 -24
  49. package/src/cli/index.ts +28 -82
  50. package/src/cli/init.ts +12 -10
  51. package/src/cli/link.ts +22 -18
  52. package/src/cli/wrap.ts +14 -11
  53. package/src/codegen/discovery.ts +80 -28
  54. package/src/codegen/index.ts +2 -1
  55. package/src/codegen/parsers/bash.ts +179 -0
  56. package/src/codegen/schema-to-code.ts +2 -1
  57. package/src/core/args.ts +296 -0
  58. package/src/core/commands.ts +373 -0
  59. package/src/core/create.ts +268 -0
  60. package/src/{runtime.ts → core/default-runtime.ts} +70 -135
  61. package/src/{errors.ts → core/errors.ts} +22 -0
  62. package/src/core/exec.ts +259 -0
  63. package/src/core/interceptors.ts +302 -0
  64. package/src/{parse.ts → core/parse.ts} +36 -89
  65. package/src/core/program-methods.ts +301 -0
  66. package/src/core/results.ts +229 -0
  67. package/src/core/runtime.ts +246 -0
  68. package/src/core/validate.ts +247 -0
  69. package/src/docs/index.ts +124 -11
  70. package/src/extension/auto-output.ts +95 -0
  71. package/src/extension/color.ts +38 -0
  72. package/src/extension/completion.ts +49 -0
  73. package/src/extension/config.ts +262 -0
  74. package/src/extension/env.ts +101 -0
  75. package/src/extension/help.ts +192 -0
  76. package/src/extension/index.ts +43 -0
  77. package/src/extension/ink.ts +93 -0
  78. package/src/extension/interactive.ts +106 -0
  79. package/src/extension/logger.ts +214 -0
  80. package/src/extension/man.ts +51 -0
  81. package/src/extension/mcp.ts +52 -0
  82. package/src/extension/progress-renderer.ts +338 -0
  83. package/src/extension/progress.ts +299 -0
  84. package/src/extension/repl.ts +94 -0
  85. package/src/extension/serve.ts +48 -0
  86. package/src/extension/signal.ts +87 -0
  87. package/src/extension/stdin.ts +62 -0
  88. package/src/extension/suggestions.ts +114 -0
  89. package/src/extension/timing.ts +81 -0
  90. package/src/extension/tracing.ts +175 -0
  91. package/src/extension/update-check.ts +77 -0
  92. package/src/extension/utils.ts +51 -0
  93. package/src/extension/version.ts +63 -0
  94. package/src/{completion.ts → feature/completion.ts} +130 -57
  95. package/src/{interactive.ts → feature/interactive.ts} +47 -6
  96. package/src/feature/mcp.ts +387 -0
  97. package/src/{repl-loop.ts → feature/repl-loop.ts} +26 -16
  98. package/src/feature/serve.ts +438 -0
  99. package/src/feature/test.ts +262 -0
  100. package/src/{update-check.ts → feature/update-check.ts} +16 -16
  101. package/src/{wrap.ts → feature/wrap.ts} +27 -27
  102. package/src/index.ts +120 -11
  103. package/src/output/colorizer.ts +154 -0
  104. package/src/{formatter.ts → output/formatter.ts} +281 -135
  105. package/src/{help.ts → output/help.ts} +62 -15
  106. package/src/{zod.d.ts → schema/zod.d.ts} +1 -1
  107. package/src/schema/zod.ts +50 -0
  108. package/src/test.ts +2 -285
  109. package/src/types/args-meta.ts +151 -0
  110. package/src/types/builder.ts +697 -0
  111. package/src/types/command.ts +157 -0
  112. package/src/types/index.ts +59 -0
  113. package/src/types/interceptor.ts +296 -0
  114. package/src/types/preferences.ts +83 -0
  115. package/src/types/result.ts +71 -0
  116. package/src/types/schema.ts +19 -0
  117. package/src/util/dotenv.ts +244 -0
  118. package/src/{shell-utils.ts → util/shell-utils.ts} +26 -9
  119. package/src/util/stream.ts +101 -0
  120. package/src/{type-helpers.ts → util/type-helpers.ts} +23 -16
  121. package/src/{type-utils.ts → util/type-utils.ts} +99 -37
  122. package/src/util/utils.ts +51 -0
  123. package/src/zod.ts +1 -0
  124. package/dist/args-CVDbyyzG.mjs +0 -199
  125. package/dist/args-CVDbyyzG.mjs.map +0 -1
  126. package/dist/chunk-y_GBKt04.mjs +0 -5
  127. package/dist/completion.d.mts +0 -64
  128. package/dist/completion.d.mts.map +0 -1
  129. package/dist/completion.mjs.map +0 -1
  130. package/dist/formatter-ClUK5hcQ.d.mts.map +0 -1
  131. package/dist/help-CcBe91bV.mjs +0 -1254
  132. package/dist/help-CcBe91bV.mjs.map +0 -1
  133. package/dist/types-DjIdJN5G.d.mts +0 -1059
  134. package/dist/types-DjIdJN5G.d.mts.map +0 -1
  135. package/dist/update-check-EbNDkzyV.mjs.map +0 -1
  136. package/src/args.ts +0 -461
  137. package/src/colorizer.ts +0 -41
  138. package/src/command-utils.ts +0 -532
  139. package/src/create.ts +0 -1477
  140. package/src/types.ts +0 -1109
  141. package/src/utils.ts +0 -140
@@ -0,0 +1,95 @@
1
+ import { defineInterceptor } from '../core/interceptors.ts';
2
+ import { isAsyncIterator, isIterator } from '../core/results.ts';
3
+ import type { AnyPadroneBuilder, CommandTypesBase, InterceptorExecuteResult } from '../types/index.ts';
4
+
5
+ // ── Helpers ─────────────────────────────────────────────────────────────
6
+
7
+ /**
8
+ * Outputs each value and collects into a result.
9
+ * For iterators: outputs each yielded value, returns collected array.
10
+ * For promises: awaits, then recurses.
11
+ * For other values: outputs directly, returns as-is.
12
+ */
13
+ function outputAndCollect(value: unknown, output: (...args: unknown[]) => void): unknown {
14
+ if (value == null) return value;
15
+
16
+ if (isAsyncIterator(value)) {
17
+ return (async () => {
18
+ const items: unknown[] = [];
19
+ const iter = (value as any)[Symbol.asyncIterator]();
20
+ while (true) {
21
+ const { done, value: item } = await iter.next();
22
+ if (done) break;
23
+ items.push(item);
24
+ if (item != null) output(item);
25
+ }
26
+ return items;
27
+ })();
28
+ }
29
+
30
+ if (typeof value !== 'string' && !Array.isArray(value) && isIterator(value)) {
31
+ const items: unknown[] = [];
32
+ const iter = (value as any)[Symbol.iterator]();
33
+ while (true) {
34
+ const { done, value: item } = iter.next();
35
+ if (done) break;
36
+ items.push(item);
37
+ if (item != null) output(item);
38
+ }
39
+ return items;
40
+ }
41
+
42
+ if (value instanceof Promise) {
43
+ return value.then((resolved) => outputAndCollect(resolved, output));
44
+ }
45
+
46
+ output(value);
47
+ return value;
48
+ }
49
+
50
+ // ── Interceptor ─────────────────────────────────────────────────────────
51
+
52
+ const autoOutputMeta = { id: 'padrone:auto-output', name: 'padrone:auto-output', order: -1100 } as const;
53
+
54
+ const autoOutputInterceptor = defineInterceptor(autoOutputMeta, () => ({
55
+ execute(ctx, next) {
56
+ const handleResult = (e: InterceptorExecuteResult): InterceptorExecuteResult | Promise<InterceptorExecuteResult> => {
57
+ if (e.result instanceof Promise) {
58
+ return { result: e.result.then((value: unknown) => outputAndCollect(value, ctx.runtime.output)) };
59
+ }
60
+
61
+ const collected = outputAndCollect(e.result, ctx.runtime.output);
62
+ if (collected instanceof Promise) return collected.then((v) => ({ result: v }));
63
+ return { result: collected };
64
+ };
65
+
66
+ const executedOrPromise = next();
67
+ if (executedOrPromise instanceof Promise) return executedOrPromise.then(handleResult);
68
+ return handleResult(executedOrPromise);
69
+ },
70
+ }));
71
+
72
+ // ── Extension ───────────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Extension that automatically writes a command's return value to output after execution.
76
+ *
77
+ * - Values are passed directly to the runtime's `output` function (no stringification).
78
+ * - Promises are awaited before output.
79
+ * - Iterators and async iterators are consumed, outputting each yielded value as it arrives.
80
+ * The result is replaced with the collected array so `drain()` still works.
81
+ * - `undefined` and `null` results produce no output.
82
+ *
83
+ * Included in the default extensions. Can also be applied per-command:
84
+ * ```ts
85
+ * createPadrone('my-cli')
86
+ * .command('greet', (c) =>
87
+ * c.extend(padroneAutoOutput())
88
+ * .action(() => 'hello')
89
+ * )
90
+ * ```
91
+ */
92
+ export function padroneAutoOutput(options?: { disabled?: boolean }): <T extends CommandTypesBase>(builder: T) => T {
93
+ const interceptor = options?.disabled ? defineInterceptor({ ...autoOutputMeta, disabled: true }, () => ({})) : autoOutputInterceptor;
94
+ return ((builder: AnyPadroneBuilder) => builder.intercept(interceptor)) as any;
95
+ }
@@ -0,0 +1,38 @@
1
+ import { thenMaybe } from '#src/core/results.ts';
2
+ import { defineInterceptor } from '../core/interceptors.ts';
3
+ import type { AnyPadroneBuilder, CommandTypesBase } from '../types/index.ts';
4
+
5
+ // ── Interceptor ─────────────────────────────────────────────────────────
6
+
7
+ const colorInterceptor = defineInterceptor({ id: 'padrone:color', name: 'padrone:color', order: -1001 }, () => ({
8
+ parse(ctx, next) {
9
+ return thenMaybe(next(), (res) => {
10
+ if ('color' in res.rawArgs) {
11
+ const color = res.rawArgs.color;
12
+ delete res.rawArgs.color;
13
+
14
+ ctx.runtime.theme = color as any;
15
+ }
16
+ return res;
17
+ });
18
+ },
19
+ }));
20
+
21
+ // ── Extension ────────────────────────────────────────────────────────────
22
+
23
+ /**
24
+ * Extension that handles `--color` / `--no-color` flags:
25
+ * - `--color` or `--color=true` → use default theme
26
+ * - `--color=false` or `--no-color` → disable colors (text format)
27
+ * - `--color=<theme>` → use the named theme
28
+ *
29
+ * Modifies the runtime's format and theme accordingly.
30
+ *
31
+ * Usage:
32
+ * ```ts
33
+ * createPadrone('my-cli').extend(padroneColor())
34
+ * ```
35
+ */
36
+ export function padroneColor(): <T extends CommandTypesBase>(builder: T) => T {
37
+ return ((builder: AnyPadroneBuilder) => builder.intercept(colorInterceptor)) as any;
38
+ }
@@ -0,0 +1,49 @@
1
+ import type { ShellType } from '#src/util/shell-utils.ts';
2
+ import { resolveAllCommands } from '../core/commands.ts';
3
+ import type { AnyPadroneBuilder, CommandTypesBase, PadroneCommand } from '../types/index.ts';
4
+ import type { PadroneSchema } from '../types/schema.ts';
5
+ import type { WithCommand } from '../util/type-utils.ts';
6
+ import { getRootCommand } from '../util/utils.ts';
7
+ import { passthroughSchema } from './utils.ts';
8
+
9
+ // ── Types ────────────────────────────────────────────────────────────────
10
+
11
+ type CompletionArgs = { shell?: string; setup?: boolean };
12
+
13
+ type CompletionCommand = PadroneCommand<'completion', '', PadroneSchema<CompletionArgs>, string, [], [], true>;
14
+
15
+ export type WithCompletion<T> = WithCommand<T, 'completion', CompletionCommand>;
16
+
17
+ // ── Extension ────────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Extension that adds the `completion` command for shell completion script generation.
21
+ *
22
+ * Usage:
23
+ * ```ts
24
+ * createPadrone('my-cli').extend(padroneCompletion())
25
+ * ```
26
+ */
27
+ export function padroneCompletion(): <T extends CommandTypesBase>(builder: T) => WithCompletion<T> {
28
+ return ((builder: AnyPadroneBuilder) =>
29
+ builder.command('completion', (c) =>
30
+ c
31
+ .configure({ description: 'Generate shell completion scripts', hidden: true })
32
+ .arguments(passthroughSchema({ shell: 'string', setup: 'boolean' }), { positional: ['shell'] })
33
+ .async()
34
+ .action(async (args, ctx) => {
35
+ const rootCommand = getRootCommand(ctx.command);
36
+ resolveAllCommands(rootCommand);
37
+ const { detectShell, generateCompletionOutput, setupCompletions } = await import('../feature/completion.ts');
38
+ const shell = args.shell as ShellType;
39
+ const setup = args.setup;
40
+ if (setup) {
41
+ const resolvedShell = shell ?? (await detectShell());
42
+ if (!resolvedShell) throw new Error('Could not detect shell. Specify one: completion bash --setup');
43
+ const setupResult = await setupCompletions(rootCommand.name, resolvedShell);
44
+ return `${setupResult.updated ? 'Updated' : 'Added'} ${rootCommand.name} completions in ${setupResult.file}`;
45
+ }
46
+ return generateCompletionOutput(rootCommand, shell);
47
+ }),
48
+ )) as any;
49
+ }
@@ -0,0 +1,262 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
2
+ import { applyValues } from '../core/args.ts';
3
+ import { ConfigError } from '../core/errors.ts';
4
+ import { defineInterceptor } from '../core/interceptors.ts';
5
+ import { thenMaybe } from '../core/results.ts';
6
+ import type { AnyPadroneBuilder, CommandTypesBase, InterceptorValidateContext } from '../types/index.ts';
7
+ import { getRootCommand } from '../util/utils.ts';
8
+
9
+ // ── Types ────────────────────────────────────────────────────────────────
10
+
11
+ export type PadroneConfigOptions = {
12
+ /** Config file names to auto-detect (e.g. `['config.json', '.myapprc']`). First found is used. */
13
+ files?: string | string[];
14
+ /** Schema to validate and transform config file data into the args shape. */
15
+ schema?: StandardSchemaV1;
16
+ /** Disable this extension. */
17
+ disabled?: boolean;
18
+ /** Whether to add `--config` / `-c` flag support. Defaults to `true`. */
19
+ flag?: boolean;
20
+ /** Whether subcommands inherit this interceptor. Defaults to `true`. */
21
+ inherit?: boolean;
22
+ /**
23
+ * Search for config files in the user's platform-specific config directory.
24
+ * - `true` — use the program name as the subdirectory (e.g. program `'myapp'` → `~/.config/myapp/`).
25
+ * - `string` — use a custom app name as the subdirectory.
26
+ * - `false` — disable (default).
27
+ *
28
+ * Directories searched (after cwd):
29
+ * - **Linux**: `$XDG_CONFIG_HOME/<app>` or `~/.config/<app>`
30
+ * - **macOS**: `~/Library/Application Support/<app>` (or `$XDG_CONFIG_HOME/<app>` when set)
31
+ * - **Windows**: `%APPDATA%\<app>`
32
+ *
33
+ * Config files found in cwd always take precedence over XDG paths.
34
+ */
35
+ xdg?: string | boolean;
36
+ /**
37
+ * Custom config loader. When provided, replaces the built-in file system loader.
38
+ * Useful for testing or non-CLI environments.
39
+ */
40
+ loadConfig?: (
41
+ files: string | string[],
42
+ xdgAppName?: string,
43
+ ) => Record<string, unknown> | undefined | Promise<Record<string, unknown> | undefined>;
44
+ };
45
+
46
+ // ── File system config loader ───────────────────────────────────────────
47
+
48
+ // Lazily resolved Node.js modules — cached after first import to keep loadConfig sync after initialization.
49
+ let _fs: typeof import('node:fs') | undefined;
50
+ let _path: typeof import('node:path') | undefined;
51
+
52
+ async function initNodeModules(): Promise<void> {
53
+ if (_fs && _path) return;
54
+ _fs = await import('node:fs');
55
+ _path = await import('node:path');
56
+ }
57
+
58
+ // Eagerly start caching node modules so loadConfig is sync by the time it's called.
59
+ try {
60
+ if (typeof process !== 'undefined') initNodeModules();
61
+ } catch {
62
+ // Non-CLI environments (browser, edge) — ignore
63
+ }
64
+
65
+ function getUserConfigDir(path: typeof import('node:path'), appName: string): string | undefined {
66
+ const platform = process.platform;
67
+
68
+ // Respect XDG_CONFIG_HOME on all platforms when explicitly set
69
+ const xdgHome = process.env.XDG_CONFIG_HOME;
70
+ if (xdgHome) return path.join(xdgHome, appName);
71
+
72
+ const home = process.env.HOME || process.env.USERPROFILE;
73
+ if (!home) return undefined;
74
+
75
+ if (platform === 'win32') {
76
+ const appData = process.env.APPDATA;
77
+ return appData ? path.join(appData, appName) : path.join(home, 'AppData', 'Roaming', appName);
78
+ }
79
+ if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', appName);
80
+
81
+ // Linux and other Unix — default XDG path
82
+ return path.join(home, '.config', appName);
83
+ }
84
+
85
+ function resolveConfigPath(fs: any, path: any, cwd: string, files: string | string[], xdgAppName?: string): string | undefined {
86
+ if (typeof files === 'string') {
87
+ const abs = path.isAbsolute(files) ? files : path.resolve(cwd, files);
88
+ if (!fs.existsSync(abs)) {
89
+ console.error(`Config file not found: ${abs}`);
90
+ return undefined;
91
+ }
92
+ return abs;
93
+ }
94
+
95
+ // Search in cwd first
96
+ for (const candidate of files) {
97
+ const abs = path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
98
+ if (fs.existsSync(abs)) return abs;
99
+ }
100
+
101
+ // Then search in the user config directory (XDG / platform-specific)
102
+ if (xdgAppName) {
103
+ const configDir = getUserConfigDir(path, xdgAppName);
104
+ if (configDir) {
105
+ for (const candidate of files) {
106
+ const abs = path.join(configDir, candidate);
107
+ if (fs.existsSync(abs)) return abs;
108
+ }
109
+ }
110
+ }
111
+
112
+ return undefined;
113
+ }
114
+
115
+ function loadConfigSync(
116
+ fs: typeof import('node:fs'),
117
+ path: typeof import('node:path'),
118
+ files: string | string[],
119
+ xdgAppName?: string,
120
+ ): Record<string, unknown> | undefined | Promise<Record<string, unknown> | undefined> {
121
+ const cwd = process.cwd();
122
+ const absolutePath = resolveConfigPath(fs, path, cwd, files, xdgAppName);
123
+ if (!absolutePath) return undefined;
124
+
125
+ const getContent = () => fs.readFileSync(absolutePath, 'utf-8');
126
+ const ext = path.extname(absolutePath).toLowerCase();
127
+
128
+ if (ext === '.yaml' || ext === '.yml') return Bun.YAML.parse(getContent()) as any;
129
+ if (ext === '.toml') return Bun.TOML.parse(getContent()) as any;
130
+ if (ext === '.jsonc') return Bun.JSONC.parse(getContent()) as any;
131
+ if (ext === '.json') {
132
+ if (Bun.JSONC) return Bun.JSONC.parse(getContent()) as any;
133
+ try {
134
+ return JSON.parse(getContent());
135
+ } catch {
136
+ return Bun.JSONC.parse(getContent()) as any;
137
+ }
138
+ }
139
+ if (ext === '.js' || ext === '.cjs' || ext === '.mjs' || ext === '.ts' || ext === '.cts' || ext === '.mts') {
140
+ return import(absolutePath).then((mod) => mod.default ?? mod);
141
+ }
142
+
143
+ // Unknown extension — try JSON
144
+ try {
145
+ return JSON.parse(getContent());
146
+ } catch {
147
+ console.error(`Unable to parse config file: ${absolutePath}`);
148
+ return undefined;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Built-in config file loader. Directly accesses the file system.
154
+ * Returns `undefined` in non-CLI environments where `node:fs` is unavailable.
155
+ */
156
+ function loadConfig(
157
+ files: string | string[],
158
+ xdgAppName?: string,
159
+ ): Record<string, unknown> | undefined | Promise<Record<string, unknown> | undefined> {
160
+ if (typeof process === 'undefined') return undefined;
161
+
162
+ try {
163
+ if (_fs && _path) return loadConfigSync(_fs, _path, files, xdgAppName);
164
+ return initNodeModules().then(() => loadConfigSync(_fs!, _path!, files, xdgAppName));
165
+ } catch {
166
+ return undefined;
167
+ }
168
+ }
169
+
170
+ // ── Extension ────────────────────────────────────────────────────────────
171
+
172
+ /**
173
+ * Extension that handles config file loading, validation, and merging into command arguments.
174
+ *
175
+ * Features:
176
+ * - `--config` / `-c` flag for explicit config file path (can be disabled via `flag: false`)
177
+ * - Auto-detection of config files from a list of candidate names
178
+ * - Optional schema validation and transformation of config data
179
+ * - Directly accesses the file system (gracefully no-ops in non-CLI environments)
180
+ *
181
+ * Config values have the lowest precedence (CLI > stdin > env > config).
182
+ *
183
+ * Not included in the default built-in extensions — must be explicitly added:
184
+ * ```ts
185
+ * createPadrone('my-cli')
186
+ * .extend(padroneConfig({
187
+ * files: ['config.json', '.myapprc'],
188
+ * schema: z.object({ port: z.number(), host: z.string() }),
189
+ * }))
190
+ * ```
191
+ */
192
+ export function padroneConfig(options?: PadroneConfigOptions): <T extends CommandTypesBase>(builder: T) => T {
193
+ if (options?.disabled) {
194
+ const disabled = defineInterceptor({ id: 'padrone:config', name: 'padrone:config', order: -999, disabled: true }, () => ({}));
195
+ return ((builder: AnyPadroneBuilder) => builder.intercept(disabled)) as any;
196
+ }
197
+
198
+ const configFiles = options?.files ? (Array.isArray(options.files) ? options.files : [options.files]) : undefined;
199
+ const configSchema = options?.schema;
200
+ const flagEnabled = options?.flag !== false;
201
+ const inherit = options?.inherit;
202
+ const xdgOption = options?.xdg;
203
+ const configLoader = options?.loadConfig ?? loadConfig;
204
+
205
+ const interceptor = defineInterceptor(
206
+ { id: 'padrone:config', name: 'padrone:config', order: -999, ...(inherit === false && { inherit: false }) },
207
+ () => ({
208
+ validate(ctx: InterceptorValidateContext, next) {
209
+ // Extract --config / -c from rawArgs
210
+ let explicitConfigPath: string | undefined;
211
+ if (flagEnabled) {
212
+ explicitConfigPath = (ctx.rawArgs.config ?? ctx.rawArgs.c) as string | undefined;
213
+ if (typeof explicitConfigPath === 'string') {
214
+ delete ctx.rawArgs.config;
215
+ delete ctx.rawArgs.c;
216
+ }
217
+ }
218
+
219
+ // Skip entirely when there's nothing to load
220
+ if (!explicitConfigPath && !configFiles) return next();
221
+
222
+ // Resolve XDG app name: true → derive from root command name, string → use as-is
223
+ let xdgAppName: string | undefined;
224
+ if (typeof xdgOption === 'string') xdgAppName = xdgOption;
225
+ else if (xdgOption === true) xdgAppName = getRootCommand(ctx.command).name;
226
+
227
+ // Load config data: explicit --config flag takes priority, then auto-detect
228
+ const configDataOrPromise = configLoader(explicitConfigPath ?? configFiles ?? [], xdgAppName);
229
+
230
+ const applyConfig = (configData: Record<string, unknown> | undefined) => {
231
+ if (!configData) return next();
232
+
233
+ // Validate against schema if provided
234
+ if (configSchema) {
235
+ const validated = configSchema['~standard'].validate(configData);
236
+ return thenMaybe(validated, (result) => {
237
+ if (result.issues) {
238
+ const issueMessages = result.issues
239
+ .map((i: StandardSchemaV1.Issue) => ` - ${i.path?.join('.') || 'root'}: ${i.message}`)
240
+ .join('\n');
241
+ throw new ConfigError(`Invalid config file:\n${issueMessages}`, {
242
+ command: ctx.command.path || ctx.command.name,
243
+ });
244
+ }
245
+ const validatedData = result.value as Record<string, unknown>;
246
+ const mergedRawArgs = applyValues(ctx.rawArgs, validatedData);
247
+ return next({ rawArgs: mergedRawArgs });
248
+ });
249
+ }
250
+
251
+ // No schema — pass through as-is
252
+ const mergedRawArgs = applyValues(ctx.rawArgs, configData);
253
+ return next({ rawArgs: mergedRawArgs });
254
+ };
255
+
256
+ return thenMaybe(configDataOrPromise, applyConfig);
257
+ },
258
+ }),
259
+ );
260
+
261
+ return ((builder: AnyPadroneBuilder) => builder.intercept(interceptor)) as any;
262
+ }
@@ -0,0 +1,101 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
2
+ import { applyValues } from '../core/args.ts';
3
+ import { defineInterceptor } from '../core/interceptors.ts';
4
+ import { thenMaybe } from '../core/results.ts';
5
+ import type { AnyPadroneBuilder, CommandTypesBase, InterceptorValidateContext } from '../types/index.ts';
6
+ import type { LoadEnvFilesOptions } from '../util/dotenv.ts';
7
+ import { loadEnvFiles } from '../util/dotenv.ts';
8
+
9
+ // ── Types ────────────────────────────────────────────────────────────────
10
+
11
+ export type PadroneEnvOptions = {
12
+ /** Env modes to load (e.g. `['production']`). Loads `.env.{mode}` files. */
13
+ modes?: string[];
14
+ /** Whether to load `.env.local` and `.env.{mode}.local` files. @default true */
15
+ local?: boolean;
16
+ /** Directory to search for `.env` files. @default process.cwd() */
17
+ dir?: string;
18
+ /** When `true`, file values override `process.env` values. @default false */
19
+ override?: boolean;
20
+ /** When `false`, the base `.env` (and `.env.local`) files are not loaded. @default true */
21
+ base?: boolean;
22
+ };
23
+
24
+ // ── Helpers ──────────────────────────────────────────────────────────────
25
+
26
+ function isSchema(value: unknown): value is StandardSchemaV1 {
27
+ return value != null && typeof value === 'object' && '~standard' in value;
28
+ }
29
+
30
+ // ── Extension ────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Extension that reads environment variables, validates them against a schema,
34
+ * and merges the transformed values into command arguments.
35
+ *
36
+ * Supports loading `.env` files with mode-based overrides and variable expansion.
37
+ *
38
+ * ```ts
39
+ * // Schema only (reads process.env)
40
+ * .extend(padroneEnv(
41
+ * z.object({ PORT: z.string() }).transform(e => ({ port: Number(e.PORT) }))
42
+ * ))
43
+ *
44
+ * // Schema + .env file loading
45
+ * .extend(padroneEnv(
46
+ * z.object({ PORT: z.string() }).transform(e => ({ port: Number(e.PORT) })),
47
+ * { modes: ['production'] }
48
+ * ))
49
+ *
50
+ * // .env file loading only (no schema validation)
51
+ * .extend(padroneEnv({ modes: ['production'] }))
52
+ * ```
53
+ *
54
+ * Env values have lower precedence than CLI args and stdin, but higher than config files.
55
+ */
56
+ export function padroneEnv(schema: StandardSchemaV1): <T extends CommandTypesBase>(builder: T) => T;
57
+ export function padroneEnv(schema: StandardSchemaV1, options: PadroneEnvOptions): <T extends CommandTypesBase>(builder: T) => T;
58
+ export function padroneEnv(options: PadroneEnvOptions): <T extends CommandTypesBase>(builder: T) => T;
59
+ export function padroneEnv(
60
+ schemaOrOptions: StandardSchemaV1 | PadroneEnvOptions,
61
+ maybeOptions?: PadroneEnvOptions,
62
+ ): <T extends CommandTypesBase>(builder: T) => T {
63
+ const schema = isSchema(schemaOrOptions) ? schemaOrOptions : undefined;
64
+ const options = isSchema(schemaOrOptions) ? maybeOptions : schemaOrOptions;
65
+ const hasFiles = options?.modes !== undefined;
66
+ const fileOptions: LoadEnvFilesOptions | undefined = hasFiles ? options : undefined;
67
+ const override = options?.override ?? false;
68
+
69
+ const interceptor = defineInterceptor({ id: 'padrone:env', name: 'padrone:env', order: -1000 }, () => ({
70
+ validate(ctx: InterceptorValidateContext, next) {
71
+ const processEnv = ctx.runtime.env();
72
+
73
+ const applyEnv = (envFromFiles: Record<string, string>) => {
74
+ const rawEnv = override ? { ...processEnv, ...envFromFiles } : { ...envFromFiles, ...processEnv };
75
+
76
+ if (schema) {
77
+ const envValidated = schema['~standard'].validate(rawEnv);
78
+ return thenMaybe(envValidated, (result) => {
79
+ if (result.issues || !result.value) return next();
80
+ return next({ rawArgs: applyValues(ctx.rawArgs, result.value as Record<string, unknown>) });
81
+ });
82
+ }
83
+
84
+ // No schema — merge file env values directly into rawArgs
85
+ if (Object.keys(envFromFiles).length > 0) {
86
+ return next({ rawArgs: applyValues(ctx.rawArgs, envFromFiles) });
87
+ }
88
+ return next();
89
+ };
90
+
91
+ if (hasFiles) {
92
+ const loaded = loadEnvFiles(fileOptions!, processEnv);
93
+ return thenMaybe(loaded, applyEnv);
94
+ }
95
+
96
+ return applyEnv({});
97
+ },
98
+ }));
99
+
100
+ return ((builder: AnyPadroneBuilder) => builder.intercept(interceptor)) as any;
101
+ }