padrone 1.0.0-beta.2

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/src/help.ts ADDED
@@ -0,0 +1,227 @@
1
+ import type { StandardJSONSchemaV1 } from '@standard-schema/spec';
2
+ import { createFormatter, type HelpArgumentInfo, type HelpDetail, type HelpFormat, type HelpInfo, type HelpOptionInfo } from './formatter';
3
+ import { extractSchemaMetadata, type PadroneMeta, parsePositionalConfig } from './options';
4
+ import type { AnyPadroneCommand } from './types';
5
+ import { getRootCommand } from './utils';
6
+
7
+ export type HelpOptions = {
8
+ format?: HelpFormat | 'auto';
9
+ detail?: HelpDetail;
10
+ };
11
+
12
+ /**
13
+ * Extract positional arguments info from schema based on meta.positional config.
14
+ */
15
+ function extractPositionalArgsInfo(
16
+ schema: StandardJSONSchemaV1,
17
+ meta?: PadroneMeta,
18
+ ): { args: HelpArgumentInfo[]; positionalNames: Set<string> } {
19
+ const args: HelpArgumentInfo[] = [];
20
+ const positionalNames = new Set<string>();
21
+
22
+ if (!schema || !meta?.positional || meta.positional.length === 0) {
23
+ return { args, positionalNames };
24
+ }
25
+
26
+ const positionalConfig = parsePositionalConfig(meta.positional);
27
+
28
+ try {
29
+ const jsonSchema = schema['~standard'].jsonSchema.input({ target: 'draft-2020-12' }) as Record<string, any>;
30
+
31
+ if (jsonSchema.type === 'object' && jsonSchema.properties) {
32
+ const properties = jsonSchema.properties as Record<string, any>;
33
+ const required = (jsonSchema.required as string[]) || [];
34
+
35
+ for (const { name, variadic } of positionalConfig) {
36
+ const prop = properties[name];
37
+ if (!prop) continue;
38
+
39
+ positionalNames.add(name);
40
+ const optMeta = meta.options?.[name];
41
+
42
+ args.push({
43
+ name: variadic ? `...${name}` : name,
44
+ description: optMeta?.description ?? prop.description,
45
+ optional: !required.includes(name),
46
+ default: prop.default,
47
+ type: variadic ? `array<${prop.items?.type || 'string'}>` : prop.type,
48
+ });
49
+ }
50
+ }
51
+ } catch {
52
+ // Fallback to empty result if toJSONSchema fails
53
+ }
54
+
55
+ return { args, positionalNames };
56
+ }
57
+
58
+ function extractOptionsInfo(schema: StandardJSONSchemaV1, meta?: PadroneMeta, positionalNames?: Set<string>) {
59
+ const result: HelpOptionInfo[] = [];
60
+ if (!schema) return result;
61
+
62
+ const vendor = schema['~standard'].vendor;
63
+ if (!vendor.includes('zod')) return result;
64
+
65
+ const optionsMeta = meta?.options;
66
+
67
+ try {
68
+ const jsonSchema = schema['~standard'].jsonSchema.input({ target: 'draft-2020-12' }) as Record<string, any>;
69
+
70
+ // Handle object: z.object({ key: z.string(), ... })
71
+ if (jsonSchema.type === 'object' && jsonSchema.properties) {
72
+ const properties = jsonSchema.properties as Record<string, any>;
73
+ const required = (jsonSchema.required as string[]) || [];
74
+ const propertyNames = new Set(Object.keys(properties));
75
+
76
+ // Helper to check if a negated version of an option exists
77
+ const hasExplicitNegation = (key: string): boolean => {
78
+ // Check for noVerbose style (camelCase)
79
+ const camelNegated = `no${key.charAt(0).toUpperCase()}${key.slice(1)}`;
80
+ if (propertyNames.has(camelNegated)) return true;
81
+ // Check for no-verbose style (kebab-case, though rare in JS)
82
+ const kebabNegated = `no-${key}`;
83
+ if (propertyNames.has(kebabNegated)) return true;
84
+ return false;
85
+ };
86
+
87
+ // Helper to check if this option is itself a negation of another option
88
+ const isNegationOf = (key: string): boolean => {
89
+ // Check for noVerbose -> verbose (camelCase)
90
+ if (key.startsWith('no') && key.length > 2 && key[2] === key[2]?.toUpperCase()) {
91
+ const positiveKey = key.charAt(2).toLowerCase() + key.slice(3);
92
+ if (propertyNames.has(positiveKey)) return true;
93
+ }
94
+ // Check for no-verbose -> verbose (kebab-case)
95
+ if (key.startsWith('no-')) {
96
+ const positiveKey = key.slice(3);
97
+ if (propertyNames.has(positiveKey)) return true;
98
+ }
99
+ return false;
100
+ };
101
+
102
+ for (const [key, prop] of Object.entries(properties)) {
103
+ // Skip positional arguments - they are shown in arguments section
104
+ if (positionalNames?.has(key)) continue;
105
+
106
+ const isOptional = !required.includes(key);
107
+ const enumValues = prop.enum as string[] | undefined;
108
+ const optMeta = optionsMeta?.[key];
109
+ const propType = prop.type as string;
110
+
111
+ // Booleans are negatable unless there's an explicit noOption property
112
+ // or this option is itself a negation of another option
113
+ const isNegatable = propType === 'boolean' && !hasExplicitNegation(key) && !isNegationOf(key);
114
+
115
+ result.push({
116
+ name: key,
117
+ description: optMeta?.description ?? prop.description,
118
+ optional: isOptional,
119
+ default: prop.default,
120
+ type: propType,
121
+ enum: enumValues,
122
+ deprecated: optMeta?.deprecated ?? prop?.deprecated,
123
+ hidden: optMeta?.hidden ?? prop?.hidden,
124
+ examples: optMeta?.examples ?? prop?.examples,
125
+ env: optMeta?.env ?? prop?.env,
126
+ variadic: propType === 'array', // Arrays are always variadic
127
+ negatable: isNegatable,
128
+ configKey: optMeta?.configKey ?? prop?.configKey,
129
+ });
130
+ }
131
+ }
132
+ } catch {
133
+ // Fallback to empty result if toJSONSchema fails
134
+ }
135
+
136
+ return result;
137
+ }
138
+
139
+ // ============================================================================
140
+ // Core Help Info Builder
141
+ // ============================================================================
142
+
143
+ /**
144
+ * Builds a comprehensive HelpInfo structure from a command.
145
+ * This is the single source of truth that all formatters use.
146
+ * @param cmd - The command to build help info for
147
+ * @param detail - The level of detail ('minimal', 'standard', or 'full')
148
+ */
149
+ function getHelpInfo(cmd: AnyPadroneCommand, detail: HelpOptions['detail'] = 'standard'): HelpInfo {
150
+ const rootCmd = getRootCommand(cmd);
151
+ const commandName = cmd.path || cmd.name || 'program';
152
+
153
+ // Extract positional args from options schema based on meta.positional
154
+ const { args: positionalArgs, positionalNames } = cmd.options
155
+ ? extractPositionalArgsInfo(cmd.options, cmd.meta)
156
+ : { args: [], positionalNames: new Set<string>() };
157
+
158
+ const hasArguments = positionalArgs.length > 0;
159
+
160
+ const helpInfo: HelpInfo = {
161
+ name: commandName,
162
+ title: cmd.title,
163
+ description: cmd.description,
164
+ deprecated: cmd.deprecated,
165
+ hidden: cmd.hidden,
166
+ usage: {
167
+ command: rootCmd === cmd ? commandName : `${rootCmd.name} ${commandName}`,
168
+ hasSubcommands: !!(cmd.commands && cmd.commands.length > 0),
169
+ hasArguments,
170
+ hasOptions: !!cmd.options,
171
+ },
172
+ };
173
+
174
+ // Build subcommands info (filter out hidden commands unless showing full detail)
175
+ if (cmd.commands && cmd.commands.length > 0) {
176
+ const visibleCommands = detail === 'full' ? cmd.commands : cmd.commands.filter((c) => !c.hidden);
177
+ helpInfo.subcommands = visibleCommands.map((c) => ({
178
+ name: c.name,
179
+ title: c.title,
180
+ description: c.description,
181
+ deprecated: c.deprecated,
182
+ hidden: c.hidden,
183
+ }));
184
+
185
+ // In 'full' detail mode, recursively build help for all nested commands
186
+ if (detail === 'full') {
187
+ helpInfo.nestedCommands = visibleCommands.map((c) => getHelpInfo(c, 'full'));
188
+ }
189
+ }
190
+
191
+ // Build arguments info from positional options
192
+ if (hasArguments) {
193
+ helpInfo.arguments = positionalArgs;
194
+ }
195
+
196
+ // Build options info with aliases (excluding positional args)
197
+ if (cmd.options) {
198
+ const optionsInfo = extractOptionsInfo(cmd.options, cmd.meta, positionalNames);
199
+ const optMap: Record<string, HelpOptionInfo> = Object.fromEntries(optionsInfo.map((opt) => [opt.name, opt]));
200
+
201
+ // Merge aliases into options
202
+ const { aliases } = extractSchemaMetadata(cmd.options, cmd.meta?.options);
203
+ for (const [alias, name] of Object.entries(aliases)) {
204
+ const opt = optMap[name];
205
+ if (!opt) continue;
206
+ opt.aliases = [...(opt.aliases || []), alias];
207
+ }
208
+
209
+ // Filter out hidden options
210
+ const visibleOptions = optionsInfo.filter((opt) => !opt.hidden);
211
+ if (visibleOptions.length > 0) {
212
+ helpInfo.options = visibleOptions;
213
+ }
214
+ }
215
+
216
+ return helpInfo;
217
+ }
218
+
219
+ // ============================================================================
220
+ // Main Entry Point
221
+ // ============================================================================
222
+
223
+ export function generateHelp(rootCommand: AnyPadroneCommand, commandObj: AnyPadroneCommand = rootCommand, options?: HelpOptions): string {
224
+ const helpInfo = getHelpInfo(commandObj, options?.detail);
225
+ const formatter = createFormatter(options?.format ?? 'auto', options?.detail);
226
+ return formatter.format(helpInfo);
227
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { createPadroneCommandBuilder } from './create';
2
+ import type { PadroneCommand, PadroneProgram } from './types';
3
+
4
+ export function createPadrone(name: string): PadroneProgram {
5
+ return createPadroneCommandBuilder({ name, path: '', commands: [] } as PadroneCommand) as PadroneProgram;
6
+ }
7
+
8
+ export type { HelpArgumentInfo, HelpFormat, HelpInfo, HelpOptionInfo, HelpSubcommandInfo } from './formatter';
9
+ export type { HelpOptions } from './help';
10
+ export type { PadroneOptionsMeta } from './options';
11
+ // Re-export types for consumers
12
+ export type {
13
+ AnyPadroneCommand,
14
+ AnyPadroneProgram,
15
+ PadroneCommand,
16
+ PadroneCommandBuilder,
17
+ PadroneCommandConfig,
18
+ PadroneCommandResult,
19
+ PadroneParseOptions,
20
+ PadroneParseResult,
21
+ PadroneProgram,
22
+ } from './types';
package/src/options.ts ADDED
@@ -0,0 +1,290 @@
1
+ import type { StandardJSONSchemaV1 } from '@standard-schema/spec';
2
+
3
+ export interface PadroneOptionsMeta {
4
+ description?: string;
5
+ alias?: string[] | string;
6
+ deprecated?: boolean | string;
7
+ hidden?: boolean;
8
+ examples?: unknown[];
9
+ /**
10
+ * Environment variable name(s) to bind this option to.
11
+ * Can be a single string or array of env var names (checked in order).
12
+ */
13
+ env?: string | string[];
14
+ /**
15
+ * Key path in config file that maps to this option.
16
+ * Supports dot notation for nested keys (e.g., 'server.port').
17
+ */
18
+ configKey?: string;
19
+ }
20
+
21
+ type PositionalArgs<TObj> =
22
+ TObj extends Record<string, any>
23
+ ? {
24
+ [K in keyof TObj]: TObj[K] extends Array<any> ? `...${K & string}` : K & string;
25
+ }[keyof TObj]
26
+ : string;
27
+
28
+ /**
29
+ * Meta configuration for options including positional arguments.
30
+ * The `positional` array defines which options are positional arguments and their order.
31
+ * Use '...name' prefix to indicate variadic (rest) arguments, matching JS/TS rest syntax.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * .options(schema, {
36
+ * positional: ['source', '...files', 'dest'], // '...files' is variadic
37
+ * })
38
+ * ```
39
+ */
40
+ export interface PadroneMeta<TObj = Record<string, any>> {
41
+ /**
42
+ * Array of option names that should be treated as positional arguments.
43
+ * Order in array determines position. Use '...name' prefix for variadic args.
44
+ * @example ['source', '...files', 'dest'] - 'files' captures multiple values
45
+ */
46
+ positional?: PositionalArgs<TObj>[];
47
+ /**
48
+ * Per-option metadata.
49
+ */
50
+ options?: { [K in keyof TObj]?: PadroneOptionsMeta };
51
+ }
52
+
53
+ /**
54
+ * Parse positional configuration to extract names and variadic info.
55
+ */
56
+ export function parsePositionalConfig(positional: string[]): { name: string; variadic: boolean }[] {
57
+ return positional.map((p) => {
58
+ const isVariadic = p.startsWith('...');
59
+ const name = isVariadic ? p.slice(3) : p;
60
+ return { name, variadic: isVariadic };
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Result type for extractSchemaMetadata function.
66
+ */
67
+ interface SchemaMetadataResult {
68
+ aliases: Record<string, string>;
69
+ envBindings: Record<string, string[]>;
70
+ configKeys: Record<string, string>;
71
+ }
72
+
73
+ /**
74
+ * Extract all option metadata from schema and meta in a single pass.
75
+ * This consolidates aliases, env bindings, and config keys extraction.
76
+ */
77
+ export function extractSchemaMetadata(
78
+ schema: StandardJSONSchemaV1,
79
+ meta?: Record<string, PadroneOptionsMeta | undefined>,
80
+ ): SchemaMetadataResult {
81
+ const aliases: Record<string, string> = {};
82
+ const envBindings: Record<string, string[]> = {};
83
+ const configKeys: Record<string, string> = {};
84
+
85
+ // Extract from meta object
86
+ if (meta) {
87
+ for (const [key, value] of Object.entries(meta)) {
88
+ if (!value) continue;
89
+
90
+ // Extract aliases
91
+ if (value.alias) {
92
+ const list = typeof value.alias === 'string' ? [value.alias] : value.alias;
93
+ for (const aliasKey of list) {
94
+ if (typeof aliasKey === 'string' && aliasKey && aliasKey !== key) {
95
+ aliases[aliasKey] = key;
96
+ }
97
+ }
98
+ }
99
+
100
+ // Extract env bindings
101
+ if (value.env) {
102
+ envBindings[key] = typeof value.env === 'string' ? [value.env] : value.env;
103
+ }
104
+
105
+ // Extract config keys
106
+ if (value.configKey) {
107
+ configKeys[key] = value.configKey;
108
+ }
109
+ }
110
+ }
111
+
112
+ // Extract from JSON schema properties
113
+ try {
114
+ const jsonSchema = schema['~standard'].jsonSchema.input({ target: 'draft-2020-12' }) as Record<string, any>;
115
+ if (jsonSchema.type === 'object' && jsonSchema.properties) {
116
+ for (const [propertyName, propertySchema] of Object.entries(jsonSchema.properties as Record<string, any>)) {
117
+ if (!propertySchema) continue;
118
+
119
+ // Extract aliases from schema
120
+ const propAlias = propertySchema.alias;
121
+ if (propAlias) {
122
+ const list = typeof propAlias === 'string' ? [propAlias] : propAlias;
123
+ if (Array.isArray(list)) {
124
+ for (const aliasKey of list) {
125
+ if (typeof aliasKey === 'string' && aliasKey && aliasKey !== propertyName && !(aliasKey in aliases)) {
126
+ aliases[aliasKey] = propertyName;
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ // Extract env bindings from schema
133
+ if (propertySchema.env && !(propertyName in envBindings)) {
134
+ const envVars = typeof propertySchema.env === 'string' ? [propertySchema.env] : propertySchema.env;
135
+ if (Array.isArray(envVars)) {
136
+ envBindings[propertyName] = envVars;
137
+ }
138
+ }
139
+
140
+ // Extract config keys from schema
141
+ if (propertySchema.configKey && !(propertyName in configKeys)) {
142
+ configKeys[propertyName] = propertySchema.configKey;
143
+ }
144
+ }
145
+ }
146
+ } catch {
147
+ // Ignore errors from JSON schema generation
148
+ }
149
+
150
+ return { aliases, envBindings, configKeys };
151
+ }
152
+
153
+ function preprocessAliases(data: Record<string, unknown>, aliases: Record<string, string>): Record<string, unknown> {
154
+ const result = { ...data };
155
+
156
+ for (const [aliasKey, fullOptionName] of Object.entries(aliases)) {
157
+ if (aliasKey in data && aliasKey !== fullOptionName) {
158
+ const aliasValue = data[aliasKey];
159
+ // Prefer full option name if it exists
160
+ if (!(fullOptionName in result)) result[fullOptionName] = aliasValue;
161
+ delete result[aliasKey];
162
+ }
163
+ }
164
+
165
+ return result;
166
+ }
167
+
168
+ /**
169
+ * Apply environment variable values to options.
170
+ * CLI values take precedence over environment variables.
171
+ */
172
+ function applyEnvBindings(
173
+ data: Record<string, unknown>,
174
+ envBindings: Record<string, string[]>,
175
+ env: Record<string, string | undefined> = typeof process !== 'undefined' ? process.env : {},
176
+ ): Record<string, unknown> {
177
+ const result = { ...data };
178
+
179
+ for (const [optionName, envVars] of Object.entries(envBindings)) {
180
+ // Only apply env var if option wasn't already set
181
+ if (optionName in result && result[optionName] !== undefined) continue;
182
+
183
+ for (const envVar of envVars) {
184
+ const envValue = env[envVar];
185
+ if (envValue !== undefined) {
186
+ // Try to parse the value intelligently
187
+ result[optionName] = parseEnvValue(envValue);
188
+ break;
189
+ }
190
+ }
191
+ }
192
+
193
+ return result;
194
+ }
195
+
196
+ /**
197
+ * Parse an environment variable value, attempting to convert to appropriate types.
198
+ */
199
+ function parseEnvValue(value: string): unknown {
200
+ // Handle boolean-like values
201
+ const lowerValue = value.toLowerCase();
202
+ if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes') return true;
203
+ if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'no') return false;
204
+
205
+ // Handle numeric values
206
+ if (/^-?\d+$/.test(value)) return Number.parseInt(value, 10);
207
+ if (/^-?\d+\.\d+$/.test(value)) return Number.parseFloat(value);
208
+
209
+ // Handle arrays (comma-separated)
210
+ if (value.includes(',')) {
211
+ return value.split(',').map((v) => parseEnvValue(v.trim()));
212
+ }
213
+
214
+ return value;
215
+ }
216
+
217
+ /**
218
+ * Get a nested value from an object using dot notation.
219
+ */
220
+ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
221
+ const parts = path.split('.');
222
+ let current: unknown = obj;
223
+
224
+ for (const part of parts) {
225
+ if (current === null || current === undefined) return undefined;
226
+ if (typeof current !== 'object') return undefined;
227
+ current = (current as Record<string, unknown>)[part];
228
+ }
229
+
230
+ return current;
231
+ }
232
+
233
+ /**
234
+ * Apply config file values to options.
235
+ * CLI values and env values take precedence over config file values.
236
+ */
237
+ function applyConfigValues(
238
+ data: Record<string, unknown>,
239
+ configKeys: Record<string, string>,
240
+ configData: Record<string, unknown>,
241
+ ): Record<string, unknown> {
242
+ const result = { ...data };
243
+
244
+ for (const [optionName, configKey] of Object.entries(configKeys)) {
245
+ // Only apply config value if option wasn't already set
246
+ if (optionName in result && result[optionName] !== undefined) continue;
247
+
248
+ const configValue = getNestedValue(configData, configKey);
249
+ if (configValue !== undefined) {
250
+ result[optionName] = configValue;
251
+ }
252
+ }
253
+
254
+ return result;
255
+ }
256
+
257
+ interface ParseOptionsContext {
258
+ aliases?: Record<string, string>;
259
+ envBindings?: Record<string, string[]>;
260
+ configKeys?: Record<string, string>;
261
+ configData?: Record<string, unknown>;
262
+ env?: Record<string, string | undefined>;
263
+ }
264
+
265
+ /**
266
+ * Combined preprocessing of options with all features.
267
+ * Precedence order (highest to lowest): CLI args > env vars > config file
268
+ */
269
+ export function preprocessOptions(data: Record<string, unknown>, ctx: ParseOptionsContext): Record<string, unknown> {
270
+ let result = { ...data };
271
+
272
+ // 1. Apply aliases first
273
+ if (ctx.aliases && Object.keys(ctx.aliases).length > 0) {
274
+ result = preprocessAliases(result, ctx.aliases);
275
+ }
276
+
277
+ // 2. Apply environment variables (higher precedence than config)
278
+ // These only apply if CLI didn't set the option
279
+ if (ctx.envBindings && Object.keys(ctx.envBindings).length > 0) {
280
+ result = applyEnvBindings(result, ctx.envBindings, ctx.env);
281
+ }
282
+
283
+ // 3. Apply config file values (lowest precedence)
284
+ // These only apply if neither CLI nor env set the option
285
+ if (ctx.configKeys && ctx.configData) {
286
+ result = applyConfigValues(result, ctx.configKeys, ctx.configData);
287
+ }
288
+
289
+ return result;
290
+ }