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/CHANGELOG.md +16 -0
- package/README.md +1 -1
- package/dist/{index-C2n3k4e8.d.mts → index-D-Dpz7l_.d.mts} +20 -5
- package/dist/index-D-Dpz7l_.d.mts.map +1 -0
- package/dist/index.d.mts +8 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +200 -70
- package/dist/index.mjs.map +1 -1
- package/dist/test.d.mts +1 -1
- package/dist/zod.d.mts +1 -1
- package/package.json +1 -1
- package/src/cli/link.ts +51 -19
- package/src/core/exec.ts +93 -46
- package/src/core/interceptors.ts +101 -4
- package/src/extension/auto-output.ts +28 -3
- package/src/extension/progress-renderer.ts +3 -0
- package/src/extension/progress.ts +35 -1
- package/src/index.ts +1 -0
- package/src/types/index.ts +2 -0
- package/src/types/interceptor.ts +21 -0
- package/dist/index-C2n3k4e8.d.mts.map +0 -1
package/dist/test.d.mts
CHANGED
package/dist/zod.d.mts
CHANGED
package/package.json
CHANGED
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
|
|
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
|
-
|
|
166
|
-
|
|
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 '
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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:
|
|
182
|
-
const
|
|
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
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
withDrain({
|
|
230
|
-
command: command as any,
|
|
233
|
+
const executeCtx: InterceptorExecuteContext = {
|
|
234
|
+
...validateCtx,
|
|
231
235
|
args: v.args,
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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(
|
|
288
|
+
return thenMaybe(routedOrPromise, continueAfterRoute) as any;
|
|
242
289
|
};
|
|
243
290
|
|
|
244
291
|
return thenMaybe(parsedOrPromise, continueAfterParse) as any;
|
package/src/core/interceptors.ts
CHANGED
|
@@ -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
|
-
...
|
|
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
|
-
...
|
|
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 {
|
|
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
|
-
|
|
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
package/src/types/index.ts
CHANGED