padrone 1.8.0 → 1.8.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/dist/test.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { p as AnyPadroneCommand, yt as PadroneRuntime } from "./index-C2n3k4e8.mjs";
1
+ import { bt as PadroneRuntime, p as AnyPadroneCommand } from "./index-D-Dpz7l_.mjs";
2
2
 
3
3
  //#region src/feature/test.d.ts
4
4
  /**
package/dist/zod.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { lt as PadroneSchema } from "./index-C2n3k4e8.mjs";
1
+ import { ut as PadroneSchema } from "./index-D-Dpz7l_.mjs";
2
2
  import * as z from "zod/v4";
3
3
 
4
4
  //#region src/schema/zod.d.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "padrone",
3
- "version": "1.8.0",
3
+ "version": "1.8.2",
4
4
  "description": "Create type-safe, interactive CLI apps with Zod schemas",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli/link.ts CHANGED
@@ -8,6 +8,8 @@ import { detectShell, getRcFile, type ShellType, writeToRcFile } from '../util/s
8
8
  export const linkSchema = z.object({
9
9
  entry: z.string().optional().describe('Entry file (auto-detected from package.json bin field)'),
10
10
  name: z.string().optional().describe('Command name (auto-detected from package.json)'),
11
+ script: z.string().optional().describe('Use a package.json script instead of bin entry (e.g. "start", "dev")'),
12
+ pm: z.enum(['bun', 'npm', 'pnpm', 'yarn']).optional().describe('Package manager to use (auto-detected from lockfile)'),
11
13
  list: z.boolean().optional().default(false).describe('List all linked programs'),
12
14
  setup: z.boolean().optional().default(false).describe('Add ~/.padrone/bin to PATH in shell config'),
13
15
  });
@@ -58,6 +60,8 @@ export interface DetectedEntry {
58
60
  name: string;
59
61
  /** Full run command prefix parsed from scripts (e.g. "bun --conditions=padrone@dev") */
60
62
  runPrefix?: string;
63
+ /** When set, the shim should run this script via the package manager instead of the entry directly */
64
+ scriptCommand?: string;
61
65
  }
62
66
 
63
67
  function parseRunPrefix(script: string, entryRelative: string, dir: string): string | undefined {
@@ -74,12 +78,21 @@ function parseRunPrefix(script: string, entryRelative: string, dir: string): str
74
78
  return undefined;
75
79
  }
76
80
 
77
- export function detectEntry(dir: string): DetectedEntry | undefined {
81
+ export function detectEntry(dir: string, options?: { script?: string }): DetectedEntry | undefined {
78
82
  const pkgPath = resolve(dir, 'package.json');
79
83
  if (!existsSync(pkgPath)) return undefined;
80
84
 
81
85
  try {
82
86
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
87
+ const scripts = pkg.scripts as Record<string, string> | undefined;
88
+
89
+ // When --script is specified, use the package.json script directly
90
+ if (options?.script) {
91
+ const scriptName = options.script;
92
+ if (!scripts?.[scriptName]) return undefined;
93
+ const name = pkg.name || basename(dir);
94
+ return { entry: resolve(dir, 'package.json'), name, scriptCommand: scriptName };
95
+ }
83
96
 
84
97
  let entryRelative: string | undefined;
85
98
  let name: string | undefined;
@@ -110,7 +123,6 @@ export function detectEntry(dir: string): DetectedEntry | undefined {
110
123
 
111
124
  // Check start/dev scripts for runtime flags
112
125
  let runPrefix: string | undefined;
113
- const scripts = pkg.scripts as Record<string, string> | undefined;
114
126
  if (scripts) {
115
127
  for (const key of ['start', 'dev']) {
116
128
  if (scripts[key]) {
@@ -157,30 +169,40 @@ async function setupPath(shell: ShellType): Promise<{ file: string; updated: boo
157
169
  return writeToRcFile(rcFile, snippet, PATH_BEGIN_MARKER, PATH_END_MARKER);
158
170
  }
159
171
 
160
- function detectRuntime(dir: string): string {
172
+ function detectPackageManager(dir: string): string {
161
173
  let current = dir;
162
174
  while (true) {
163
175
  if (existsSync(resolve(current, 'bun.lock')) || existsSync(resolve(current, 'bun.lockb'))) return 'bun';
164
- if (
165
- existsSync(resolve(current, 'package-lock.json')) ||
166
- existsSync(resolve(current, 'yarn.lock')) ||
167
- existsSync(resolve(current, 'pnpm-lock.yaml'))
168
- )
169
- return 'node';
176
+ if (existsSync(resolve(current, 'pnpm-lock.yaml'))) return 'pnpm';
177
+ if (existsSync(resolve(current, 'yarn.lock'))) return 'yarn';
178
+ if (existsSync(resolve(current, 'package-lock.json'))) return 'npm';
170
179
  const parent = dirname(current);
171
180
  if (parent === current) break;
172
181
  current = parent;
173
182
  }
174
- return 'node';
183
+ return 'npm';
184
+ }
185
+
186
+ function detectRuntime(dir: string): string {
187
+ const pm = detectPackageManager(dir);
188
+ return pm === 'bun' ? 'bun' : 'node';
175
189
  }
176
190
 
177
- function createShim(name: string, entry: string, dir: string, runPrefix?: string) {
191
+ function createShim(name: string, entry: string, dir: string, runPrefix?: string, scriptCommand?: string, pm?: string) {
178
192
  mkdirSync(BIN_DIR, { recursive: true });
179
193
  const shimPath = resolve(BIN_DIR, sanitizeBinName(name));
180
194
 
181
- const prefix = runPrefix ?? detectRuntime(dir);
182
-
183
- const shim = ['#!/usr/bin/env sh', `# Linked by padrone — do not edit`, `${prefix} "${entry}" "$@"`, ''].join('\n');
195
+ let shim: string;
196
+ if (scriptCommand) {
197
+ const resolvedPm = pm ?? detectPackageManager(dir);
198
+ const cwdFlag = resolvedPm === 'npm' ? `--prefix="${dir}"` : resolvedPm === 'pnpm' ? `--dir="${dir}"` : `--cwd="${dir}"`;
199
+ shim = ['#!/usr/bin/env sh', `# Linked by padrone — do not edit`, `${resolvedPm} ${cwdFlag} run ${scriptCommand} -- "$@"`, ''].join(
200
+ '\n',
201
+ );
202
+ } else {
203
+ const prefix = runPrefix ?? detectRuntime(dir);
204
+ shim = ['#!/usr/bin/env sh', `# Linked by padrone — do not edit`, `${prefix} "${entry}" "$@"`, ''].join('\n');
205
+ }
184
206
 
185
207
  writeFileSync(shimPath, shim);
186
208
  chmodSync(shimPath, 0o755);
@@ -215,6 +237,7 @@ export async function runLink(args: LinkArgs, ctx: PadroneActionContext) {
215
237
  let entry: string;
216
238
  let name: string;
217
239
  let runPrefix: string | undefined;
240
+ let scriptCommand: string | undefined;
218
241
 
219
242
  const resolvedArg = args.entry ? resolve(dir, args.entry) : undefined;
220
243
 
@@ -230,15 +253,24 @@ export async function runLink(args: LinkArgs, ctx: PadroneActionContext) {
230
253
 
231
254
  if (targetDir || !resolvedArg) {
232
255
  // Detect entry from the target directory's package.json
233
- const detected = detectEntry(targetDir ?? dir);
256
+ const detected = detectEntry(targetDir ?? dir, { script: args.script });
234
257
  if (!detected) {
235
- error('Could not detect entry point. Provide an entry file or add a "bin" field to package.json.');
258
+ if (args.script) {
259
+ error(`Script "${args.script}" not found in package.json.`);
260
+ } else {
261
+ error('Could not detect entry point. Provide an entry file or add a "bin" field to package.json.');
262
+ }
236
263
  process.exit(1);
237
264
  }
238
265
  entry = detected.entry;
239
266
  name = sanitizeBinName(args.name || detected.name);
240
267
  runPrefix = detected.runPrefix;
268
+ scriptCommand = detected.scriptCommand;
241
269
  } else {
270
+ if (args.script) {
271
+ error('--script cannot be used with an explicit entry file.');
272
+ process.exit(1);
273
+ }
242
274
  // Explicit file path
243
275
  entry = resolvedArg;
244
276
  name = sanitizeBinName(args.name || basename(entry).replace(/\.[cm]?[jt]sx?$/, ''));
@@ -250,14 +282,14 @@ export async function runLink(args: LinkArgs, ctx: PadroneActionContext) {
250
282
  process.exit(1);
251
283
  }
252
284
 
253
- if (!existsSync(entry)) {
285
+ if (!scriptCommand && !existsSync(entry)) {
254
286
  error(`Entry file not found: ${entry}`);
255
287
  process.exit(1);
256
288
  }
257
289
 
258
290
  const entryDir = targetDir ?? (existsSync(resolve(dirname(entry), 'package.json')) ? dirname(entry) : dir);
259
291
 
260
- createShim(name, entry, entryDir, runPrefix);
292
+ createShim(name, entry, entryDir, runPrefix, scriptCommand, args.pm);
261
293
 
262
294
  const links = readLinks();
263
295
  links[name] = {
@@ -268,7 +300,7 @@ export async function runLink(args: LinkArgs, ctx: PadroneActionContext) {
268
300
  };
269
301
  writeLinks(links);
270
302
 
271
- output(`Linked ${name} → ${entry}`);
303
+ output(`Linked ${name} → ${scriptCommand ? `${scriptCommand} (script)` : entry}`);
272
304
 
273
305
  if (!isInPath(BIN_DIR)) {
274
306
  if (args.setup) {
package/src/core/exec.ts CHANGED
@@ -6,6 +6,8 @@ import type {
6
6
  InterceptorExecuteResult,
7
7
  InterceptorParseContext,
8
8
  InterceptorParseResult,
9
+ InterceptorPipelinePhase,
10
+ InterceptorRouteContext,
9
11
  InterceptorValidateContext,
10
12
  InterceptorValidateResult,
11
13
  PadroneActionContext,
@@ -15,7 +17,7 @@ import type {
15
17
  } from '../types/index.ts';
16
18
  import { getCommandRuntime } from './commands.ts';
17
19
  import { RoutingError, SignalError, ValidationError } from './errors.ts';
18
- import { resolveRegisteredInterceptors, runInterceptorChain, wrapWithLifecycle } from './interceptors.ts';
20
+ import { resolveRegisteredInterceptors, runInterceptorChain, wrapWithCommandLifecycle, wrapWithLifecycle } from './interceptors.ts';
19
21
  import { errorResult, noop, thenMaybe, warnIfUnexpectedAsync, withDrain } from './results.ts';
20
22
  import { buildCommandArgs, formatIssueMessages, validateCommandArgs } from './validate.ts';
21
23
 
@@ -143,7 +145,9 @@ export function execCommand(
143
145
  const inertSignal = new AbortController().signal;
144
146
 
145
147
  // Pipeline state accumulated as phases complete — propagated to error/shutdown contexts.
146
- const pipelineState: { rawArgs?: Record<string, unknown>; positionalArgs?: string[]; args?: unknown } = {};
148
+ const pipelineState: { phase: InterceptorPipelinePhase; rawArgs?: Record<string, unknown>; positionalArgs?: string[]; args?: unknown } = {
149
+ phase: 'start',
150
+ };
147
151
 
148
152
  const initialContext = evalOptions?.context;
149
153
 
@@ -152,6 +156,7 @@ export function execCommand(
152
156
  const factoryCache = new Map<RegisteredInterceptor, ResolvedInterceptor>();
153
157
  const rootRegistered = rootCommand.interceptors ?? [];
154
158
  const rootInterceptors = resolveRegisteredInterceptors(rootRegistered, factoryCache);
159
+ const rootInterceptorSet = new Set(rootInterceptors);
155
160
 
156
161
  const runPipeline = (signal: AbortSignal, pipelineContext: unknown) => {
157
162
  // ── Phase 1: Parse ──────────────────────────────────────────────────
@@ -173,72 +178,114 @@ export function execCommand(
173
178
  // ── Phases 2 & 3 chained after parse ────────────────────────────────
174
179
  const continueAfterParse = (parsed: InterceptorParseResult) => {
175
180
  const { command } = parsed;
181
+ pipelineState.phase = 'parse';
176
182
  pipelineState.rawArgs = parsed.rawArgs;
177
183
  pipelineState.positionalArgs = parsed.positionalArgs;
178
184
  const commandInterceptors = resolveRegisteredInterceptors(collectInterceptorsFn(command), factoryCache);
185
+ const commandOnlyInterceptors = commandInterceptors.filter((i) => !rootInterceptorSet.has(i));
179
186
  const context = resolveContext(command, pipelineContext);
180
187
 
181
- // ── Phase 2: Validate ───────────────────────────────────────────
182
- const validateCtx: InterceptorValidateContext = {
188
+ // ── Phase 2: Route ──────────────────────────────────────────────
189
+ const routeCtx: InterceptorRouteContext = {
183
190
  ...parseCtx,
184
191
  command,
185
192
  rawArgs: parsed.rawArgs,
186
193
  positionalArgs: parsed.positionalArgs,
187
194
  context: context as object,
188
- evalInteractive: evalOptions?.interactive,
189
- };
190
-
191
- const coreValidate = (validateCtx: InterceptorValidateContext): InterceptorValidateResult | Promise<InterceptorValidateResult> => {
192
- const { args: preprocessedArgs, issues } = buildCommandArgs(validateCtx.command, validateCtx.rawArgs, validateCtx.positionalArgs);
193
- if (issues) return { args: undefined, argsResult: { issues } as any };
194
- const validated = validateCommandArgs(validateCtx.command, preprocessedArgs);
195
- return thenMaybe(validated, (v) => v as InterceptorValidateResult);
196
195
  };
197
196
 
198
- const validatedOrPromise = runInterceptorChain('validate', commandInterceptors, validateCtx, coreValidate);
199
-
200
- // ── Phase 3: Execute (or handle validation errors) ──────────────
201
- const continueAfterValidate = (v: InterceptorValidateResult) => {
202
- pipelineState.args = v.args;
203
- if (v.argsResult?.issues) return handleValidationIssues(v.argsResult as StandardSchemaV1.FailureResult, command, errorMode);
197
+ const routedOrPromise = runInterceptorChain('route', commandInterceptors, routeCtx, () => {});
204
198
 
205
- const executeCtx: InterceptorExecuteContext = {
206
- ...validateCtx,
207
- args: v.args,
208
- };
199
+ const continueAfterRoute = () => {
200
+ pipelineState.phase = 'route';
201
+ const runValidateAndExecute = () => {
202
+ // ── Phase 3: Validate ───────────────────────────────────────────
203
+ const validateCtx: InterceptorValidateContext = {
204
+ ...parseCtx,
205
+ command,
206
+ rawArgs: parsed.rawArgs,
207
+ positionalArgs: parsed.positionalArgs,
208
+ context: context as object,
209
+ evalInteractive: evalOptions?.interactive,
210
+ };
209
211
 
210
- const coreExecute = (executeCtx: InterceptorExecuteContext): InterceptorExecuteResult => {
211
- const handler = command.action ?? noop;
212
- const effectiveRuntime = executeCtx.runtime;
213
- const actionCtx: PadroneActionContext = {
214
- runtime: effectiveRuntime,
215
- command: executeCtx.command,
216
- program: ctx.builder as any,
217
- signal: executeCtx.signal,
218
- context: executeCtx.context,
219
- caller,
212
+ const coreValidate = (
213
+ validateCtx: InterceptorValidateContext,
214
+ ): InterceptorValidateResult | Promise<InterceptorValidateResult> => {
215
+ const { args: preprocessedArgs, issues } = buildCommandArgs(
216
+ validateCtx.command,
217
+ validateCtx.rawArgs,
218
+ validateCtx.positionalArgs,
219
+ );
220
+ if (issues) return { args: undefined, argsResult: { issues } as any };
221
+ const validated = validateCommandArgs(validateCtx.command, preprocessedArgs);
222
+ return thenMaybe(validated, (v) => v as InterceptorValidateResult);
220
223
  };
221
- const result = handler(executeCtx.args as any, actionCtx);
222
- return { result };
223
- };
224
224
 
225
- const executedOrPromise = runInterceptorChain('execute', commandInterceptors, executeCtx, coreExecute);
225
+ const validatedOrPromise = runInterceptorChain('validate', commandInterceptors, validateCtx, coreValidate);
226
+
227
+ // ── Phase 3: Execute (or handle validation errors) ──────────────
228
+ const continueAfterValidate = (v: InterceptorValidateResult) => {
229
+ pipelineState.phase = 'validate';
230
+ pipelineState.args = v.args;
231
+ if (v.argsResult?.issues) return handleValidationIssues(v.argsResult as StandardSchemaV1.FailureResult, command, errorMode);
226
232
 
227
- return thenMaybe(executedOrPromise, (e) => {
228
- const finalize = (result: unknown) =>
229
- withDrain({
230
- command: command as any,
233
+ const executeCtx: InterceptorExecuteContext = {
234
+ ...validateCtx,
231
235
  args: v.args,
232
- argsResult: v.argsResult,
233
- result,
236
+ };
237
+
238
+ const coreExecute = (executeCtx: InterceptorExecuteContext): InterceptorExecuteResult => {
239
+ const handler = command.action ?? noop;
240
+ const effectiveRuntime = executeCtx.runtime;
241
+ const actionCtx: PadroneActionContext = {
242
+ runtime: effectiveRuntime,
243
+ command: executeCtx.command,
244
+ program: ctx.builder as any,
245
+ signal: executeCtx.signal,
246
+ context: executeCtx.context,
247
+ caller,
248
+ };
249
+ const result = handler(executeCtx.args as any, actionCtx);
250
+ return { result };
251
+ };
252
+
253
+ pipelineState.phase = 'execute';
254
+ const executedOrPromise = runInterceptorChain('execute', commandInterceptors, executeCtx, coreExecute);
255
+
256
+ return thenMaybe(executedOrPromise, (e) => {
257
+ const finalize = (result: unknown) =>
258
+ withDrain({
259
+ command: command as any,
260
+ args: v.args,
261
+ argsResult: v.argsResult,
262
+ result,
263
+ });
264
+
265
+ if (e.result instanceof Promise) return e.result.then(finalize);
266
+ return finalize(e.result);
234
267
  });
268
+ };
269
+
270
+ return thenMaybe(warnIfUnexpectedAsync(validatedOrPromise, command), continueAfterValidate) as any;
271
+ };
235
272
 
236
- if (e.result instanceof Promise) return e.result.then(finalize);
237
- return finalize(e.result);
238
- });
273
+ return wrapWithCommandLifecycle(
274
+ commandOnlyInterceptors,
275
+ command,
276
+ resolvedInput,
277
+ runValidateAndExecute,
278
+ (result) => withDrain({ command: command as any, args: undefined, argsResult: undefined, result }),
279
+ signal,
280
+ context,
281
+ runtime,
282
+ ctx.builder,
283
+ caller,
284
+ pipelineState,
285
+ );
239
286
  };
240
287
 
241
- return thenMaybe(warnIfUnexpectedAsync(validatedOrPromise, command), continueAfterValidate) as any;
288
+ return thenMaybe(routedOrPromise, continueAfterRoute) as any;
242
289
  };
243
290
 
244
291
  return thenMaybe(parsedOrPromise, continueAfterParse) as any;
@@ -6,6 +6,7 @@ import type {
6
6
  InterceptorErrorResult,
7
7
  InterceptorFactory,
8
8
  InterceptorMeta,
9
+ InterceptorPipelinePhase,
9
10
  InterceptorShutdownContext,
10
11
  InterceptorStartContext,
11
12
  PadroneInterceptorFn,
@@ -151,7 +152,7 @@ function deduplicateInterceptors(interceptors: ResolvedInterceptor[]): ResolvedI
151
152
  * into the context before passing to the next interceptor or core function.
152
153
  */
153
154
  export function runInterceptorChain<TCtx extends object, TResult>(
154
- phase: 'start' | 'parse' | 'validate' | 'execute' | 'error' | 'shutdown',
155
+ phase: 'start' | 'parse' | 'route' | 'validate' | 'execute' | 'error' | 'shutdown',
155
156
  interceptors: ResolvedInterceptor[],
156
157
  ctx: TCtx,
157
158
  core: (ctx: TCtx) => TResult | Promise<TResult>,
@@ -204,13 +205,15 @@ export function wrapWithLifecycle<T>(
204
205
  runtime?: ResolvedPadroneRuntime,
205
206
  program?: AnyPadroneProgram,
206
207
  caller: 'cli' | 'eval' | 'run' | 'repl' | 'serve' | 'mcp' | 'tool' = 'eval',
207
- pipelineState?: { rawArgs?: Record<string, unknown>; positionalArgs?: string[]; args?: unknown },
208
+ pipelineState?: { phase: InterceptorPipelinePhase; rawArgs?: Record<string, unknown>; positionalArgs?: string[]; args?: unknown },
208
209
  ): T | Promise<T> {
209
210
  const defaultSignal = typeof AbortSignal !== 'undefined' ? AbortSignal.abort() : (undefined as unknown as AbortSignal);
210
211
  const hasStart = interceptors.some((p) => p.start);
211
212
  const hasError = interceptors.some((p) => p.error);
212
213
  const hasShutdown = interceptors.some((p) => p.shutdown);
213
214
 
215
+ const effectivePipelineState = pipelineState ?? { phase: 'start' as const };
216
+
214
217
  // Fast path: no lifecycle interceptors
215
218
  if (!hasStart && !hasError && !hasShutdown) return pipeline(signal ?? defaultSignal, context);
216
219
  // Mutable refs: start-phase interceptors can override signal and context (e.g., signal extension, auth),
@@ -230,7 +233,7 @@ export function wrapWithLifecycle<T>(
230
233
  runtime: runtime!,
231
234
  program: program!,
232
235
  caller,
233
- ...pipelineState,
236
+ ...effectivePipelineState,
234
237
  };
235
238
  return runInterceptorChain('shutdown', interceptors, ctx, () => {});
236
239
  };
@@ -253,7 +256,7 @@ export function wrapWithLifecycle<T>(
253
256
  runtime: runtime!,
254
257
  program: program!,
255
258
  caller,
256
- ...pipelineState,
259
+ ...effectivePipelineState,
257
260
  };
258
261
  const errorResult = runInterceptorChain('error', interceptors, ctx, (): InterceptorErrorResult => ({ error }));
259
262
  return thenMaybe(errorResult, (er) => {
@@ -306,3 +309,97 @@ export function wrapWithLifecycle<T>(
306
309
 
307
310
  return handleSuccess(result);
308
311
  }
312
+
313
+ /**
314
+ * Wraps a command-level pipeline (validate + execute) with error → shutdown lifecycle hooks.
315
+ * Unlike `wrapWithLifecycle`, this has no `start` phase and uses the resolved command context.
316
+ * Only interceptors exclusive to the command chain (not in root) should be passed here.
317
+ */
318
+ export function wrapWithCommandLifecycle<T>(
319
+ interceptors: ResolvedInterceptor[],
320
+ command: AnyPadroneCommand,
321
+ input: string | undefined,
322
+ pipeline: () => T | Promise<T>,
323
+ wrapErrorResult: ((result: unknown) => T) | undefined,
324
+ signal: AbortSignal,
325
+ context: unknown,
326
+ runtime: ResolvedPadroneRuntime,
327
+ program: AnyPadroneProgram,
328
+ caller: 'cli' | 'eval' | 'run' | 'repl' | 'serve' | 'mcp' | 'tool',
329
+ pipelineState: { phase: InterceptorPipelinePhase; rawArgs?: Record<string, unknown>; positionalArgs?: string[]; args?: unknown },
330
+ ): T | Promise<T> {
331
+ const hasError = interceptors.some((p) => p.error);
332
+ const hasShutdown = interceptors.some((p) => p.shutdown);
333
+
334
+ if (!hasError && !hasShutdown) return pipeline();
335
+
336
+ const runShutdown = (error?: unknown, result?: unknown) => {
337
+ if (!hasShutdown) return;
338
+ const ctx: InterceptorShutdownContext = {
339
+ command,
340
+ input,
341
+ error,
342
+ result,
343
+ signal,
344
+ context: context as object,
345
+ runtime,
346
+ program,
347
+ caller,
348
+ ...pipelineState,
349
+ };
350
+ return runInterceptorChain('shutdown', interceptors, ctx, () => {});
351
+ };
352
+
353
+ const runError = (error: unknown): T | Promise<T> => {
354
+ if (!hasError) {
355
+ const s = runShutdown(error);
356
+ if (s instanceof Promise)
357
+ return s.then(() => {
358
+ throw error;
359
+ });
360
+ throw error;
361
+ }
362
+ const ctx: InterceptorErrorContext = {
363
+ command,
364
+ input,
365
+ error,
366
+ signal,
367
+ context: context as object,
368
+ runtime,
369
+ program,
370
+ caller,
371
+ ...pipelineState,
372
+ };
373
+ const errorResult = runInterceptorChain('error', interceptors, ctx, (): InterceptorErrorResult => ({ error }));
374
+ return thenMaybe(errorResult, (er) => {
375
+ if (er.error !== undefined) {
376
+ const s = runShutdown(er.error);
377
+ return thenMaybe(s as void | Promise<void>, () => {
378
+ throw er.error;
379
+ });
380
+ }
381
+ const wrapped = wrapErrorResult ? wrapErrorResult(er.result) : (er.result as T);
382
+ const s = runShutdown(undefined, wrapped);
383
+ return thenMaybe(s as void | Promise<void>, () => wrapped);
384
+ });
385
+ };
386
+
387
+ const handleSuccess = (result: T): T | Promise<T> => {
388
+ const s = runShutdown(undefined, result);
389
+ if (s instanceof Promise) return s.then(() => result);
390
+ return result;
391
+ };
392
+
393
+ let result: T | Promise<T>;
394
+ try {
395
+ result = pipeline();
396
+ } catch (e) {
397
+ return runError(e);
398
+ }
399
+
400
+ if (result instanceof Promise) {
401
+ return result.then(handleSuccess, runError);
402
+ }
403
+
404
+ return handleSuccess(result);
405
+ }
@@ -3,7 +3,14 @@ import { isAsyncIterator, isIterator } from '../core/results.ts';
3
3
  import type { OutputConfig } from '../output/output-indicator.ts';
4
4
  import { createOutputIndicator, formatDeclarativeOutput } from '../output/output-indicator.ts';
5
5
  import { resolveOutputFormat } from '../output/styling.ts';
6
- import type { AnyPadroneBuilder, CommandTypesBase, InterceptorExecuteContext, InterceptorExecuteResult } from '../types/index.ts';
6
+ import type {
7
+ AnyPadroneBuilder,
8
+ CommandTypesBase,
9
+ InterceptorErrorContext,
10
+ InterceptorErrorResult,
11
+ InterceptorExecuteContext,
12
+ InterceptorExecuteResult,
13
+ } from '../types/index.ts';
7
14
 
8
15
  // ── Helpers ─────────────────────────────────────────────────────────────
9
16
 
@@ -54,8 +61,20 @@ function outputAndCollect(value: unknown, output: (...args: unknown[]) => void):
54
61
 
55
62
  const autoOutputMeta = { id: 'padrone:auto-output', name: 'padrone:auto-output', order: -1100 } as const;
56
63
 
57
- function createAutoOutputInterceptor(outputConfig?: OutputConfig) {
64
+ function createAutoOutputInterceptor(outputConfig?: OutputConfig, errorOutput?: boolean) {
58
65
  return defineInterceptor(autoOutputMeta, () => ({
66
+ error(ctx: InterceptorErrorContext, next: () => InterceptorErrorResult | Promise<InterceptorErrorResult>) {
67
+ const handleResult = (er: InterceptorErrorResult): InterceptorErrorResult => {
68
+ if (!er.error || errorOutput === false || ctx.caller !== 'cli' || ctx.phase !== 'execute') return er;
69
+ const message = er.error instanceof Error ? er.error.message : String(er.error);
70
+ ctx.runtime.error(message);
71
+ return er;
72
+ };
73
+
74
+ const result = next();
75
+ if (result instanceof Promise) return result.then(handleResult);
76
+ return handleResult(result);
77
+ },
59
78
  execute(ctx: InterceptorExecuteContext, next) {
60
79
  const outputCtx = resolveOutputFormat(ctx.runtime, ctx.caller);
61
80
  const indicator = createOutputIndicator(ctx.runtime.output, outputCtx);
@@ -115,6 +134,12 @@ export type PadroneAutoOutputOptions = {
115
134
  * ```
116
135
  */
117
136
  output?: OutputConfig;
137
+ /**
138
+ * Automatically print unhandled errors to stderr in CLI mode.
139
+ * Skips errors already handled by other extensions (routing, validation, signal).
140
+ * @default true
141
+ */
142
+ errorOutput?: boolean;
118
143
  };
119
144
 
120
145
  /**
@@ -141,6 +166,6 @@ export type PadroneAutoOutputOptions = {
141
166
  export function padroneAutoOutput(options?: PadroneAutoOutputOptions): <T extends CommandTypesBase>(builder: T) => T {
142
167
  const interceptor = options?.disabled
143
168
  ? defineInterceptor({ ...autoOutputMeta, disabled: true }, () => ({}))
144
- : createAutoOutputInterceptor(options?.output);
169
+ : createAutoOutputInterceptor(options?.output, options?.errorOutput);
145
170
  return ((builder: AnyPadroneBuilder) => builder.intercept(interceptor)) as any;
146
171
  }
@@ -265,6 +265,9 @@ export function createTerminalProgress(message: string, options?: PadroneProgres
265
265
  }, tickInterval)
266
266
  : undefined;
267
267
 
268
+ // Prevent the spinner timer from keeping the process alive on uncaught errors
269
+ if (timer && typeof timer === 'object' && 'unref' in timer) (timer as NodeJS.Timeout).unref();
270
+
268
271
  render();
269
272
 
270
273
  const clear = () => {
@@ -198,7 +198,32 @@ function progressInterceptor(config: string | PadroneProgressConfig) {
198
198
  ctx.runtime.error = originalError;
199
199
  };
200
200
 
201
- return next();
201
+ const onValidationFailure = (error: unknown) => {
202
+ if (indicator) {
203
+ cleanup(indicator, msgs!.success, msgs!.error, error, undefined, true);
204
+ teardown();
205
+ }
206
+ };
207
+
208
+ const checkResult = (result: any) => {
209
+ if (result.argsResult?.issues) onValidationFailure(new Error('Validation failed'));
210
+ return result;
211
+ };
212
+
213
+ let result: any;
214
+ try {
215
+ result = next();
216
+ } catch (err) {
217
+ onValidationFailure(err);
218
+ throw err;
219
+ }
220
+ if (result instanceof Promise) {
221
+ return result.then(checkResult, (err: unknown) => {
222
+ onValidationFailure(err);
223
+ throw err;
224
+ });
225
+ }
226
+ return checkResult(result);
202
227
  },
203
228
 
204
229
  execute(_ctx, next) {
@@ -259,6 +284,15 @@ function progressInterceptor(config: string | PadroneProgressConfig) {
259
284
  onSuccess(result!.result);
260
285
  return result;
261
286
  },
287
+
288
+ shutdown(ctx) {
289
+ // Safety net: if validate/execute cleanup paths were bypassed (e.g., outer interceptor
290
+ // threw during execute before reaching this interceptor's execute handler), stop the indicator.
291
+ if (indicator) {
292
+ cleanup(indicator, msgs!.success, msgs!.error, ctx.error, ctx.result, !!ctx.error);
293
+ teardown();
294
+ }
295
+ },
262
296
  };
263
297
  })
264
298
  .provides<{ progress: PadroneProgressIndicator }>();
package/src/index.ts CHANGED
@@ -107,6 +107,7 @@ export type {
107
107
  InterceptorParseContext,
108
108
  InterceptorParseResult,
109
109
  InterceptorPhases,
110
+ InterceptorRouteContext,
110
111
  InterceptorShutdownContext,
111
112
  InterceptorStartContext,
112
113
  InterceptorValidateContext,
@@ -34,6 +34,8 @@ export type {
34
34
  InterceptorParseContext,
35
35
  InterceptorParseResult,
36
36
  InterceptorPhases,
37
+ InterceptorPipelinePhase,
38
+ InterceptorRouteContext,
37
39
  InterceptorShutdownContext,
38
40
  InterceptorStartContext,
39
41
  InterceptorValidateContext,