@sylphx/flow 3.16.0 → 3.17.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.
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * Centralized logging utility for Sylphx Flow
3
- * Provides structured logging with different levels and output formats
3
+ * Provides structured logging with Pino backend and pretty printing
4
4
  */
5
5
 
6
- import { randomUUID } from 'node:crypto';
7
- import chalk from 'chalk';
6
+ import pino from 'pino';
8
7
 
9
8
  export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
10
9
 
@@ -33,27 +32,6 @@ export interface LoggerConfig {
33
32
  module?: string;
34
33
  }
35
34
 
36
- const LEVEL_PRIORITY: Record<LogLevel, number> = {
37
- debug: 0,
38
- info: 1,
39
- warn: 2,
40
- error: 3,
41
- };
42
-
43
- const LEVEL_COLORS: Record<LogLevel, (text: string) => string> = {
44
- debug: chalk.gray,
45
- info: chalk.blue,
46
- warn: chalk.yellow,
47
- error: chalk.red,
48
- };
49
-
50
- const LEVEL_SYMBOLS: Record<LogLevel, string> = {
51
- debug: '🔍',
52
- info: 'ℹ',
53
- warn: '⚠',
54
- error: '✗',
55
- };
56
-
57
35
  /**
58
36
  * Logger interface for dependency injection and testing
59
37
  */
@@ -76,6 +54,7 @@ export interface Logger {
76
54
  interface LoggerState {
77
55
  config: LoggerConfig;
78
56
  context?: Record<string, unknown>;
57
+ pinoInstance: pino.Logger;
79
58
  }
80
59
 
81
60
  /**
@@ -86,6 +65,49 @@ interface CreateLoggerOptions {
86
65
  context?: Record<string, unknown>;
87
66
  }
88
67
 
68
+ /**
69
+ * Map our log levels to Pino levels
70
+ */
71
+ const LEVEL_MAP: Record<LogLevel, string> = {
72
+ debug: 'debug',
73
+ info: 'info',
74
+ warn: 'warn',
75
+ error: 'error',
76
+ };
77
+
78
+ /**
79
+ * Create a Pino instance based on configuration
80
+ */
81
+ function createPinoInstance(config: LoggerConfig, context?: Record<string, unknown>): pino.Logger {
82
+ const isProduction = process.env.NODE_ENV === 'production';
83
+ const usePretty = config.format === 'pretty' && !isProduction;
84
+
85
+ const pinoConfig: pino.LoggerOptions = {
86
+ level: config.level,
87
+ base: context ? { ...context } : undefined,
88
+ timestamp: config.includeTimestamp ? pino.stdTimeFunctions.isoTime : false,
89
+ };
90
+
91
+ if (usePretty) {
92
+ // Use pino-pretty for development
93
+ return pino({
94
+ ...pinoConfig,
95
+ transport: {
96
+ target: 'pino-pretty',
97
+ options: {
98
+ colorize: config.colors,
99
+ translateTime: 'HH:MM:ss',
100
+ ignore: 'pid,hostname',
101
+ messageFormat: '{msg}',
102
+ },
103
+ },
104
+ });
105
+ }
106
+
107
+ // JSON output for production or when format is 'json'
108
+ return pino(pinoConfig);
109
+ }
110
+
89
111
  /**
90
112
  * Create a logger instance with the specified configuration and context
91
113
  */
@@ -97,152 +119,19 @@ export function createLogger(options: Partial<LoggerConfig> | CreateLoggerOption
97
119
  : (options as Partial<LoggerConfig>);
98
120
  const initialContext = isOptionsStyle ? (options as CreateLoggerOptions).context : undefined;
99
121
 
100
- const state: LoggerState = {
101
- config: {
102
- level: 'info',
103
- format: 'pretty',
104
- includeTimestamp: true,
105
- includeContext: true,
106
- colors: true,
107
- ...config,
108
- },
109
- context: initialContext,
110
- };
111
-
112
- /**
113
- * Check if a log level should be output
114
- */
115
- const shouldLog = (level: LogLevel): boolean => {
116
- return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[state.config.level];
117
- };
118
-
119
- /**
120
- * Create a log entry
121
- */
122
- const createLogEntry = (
123
- level: LogLevel,
124
- message: string,
125
- error?: Error,
126
- additionalContext?: Record<string, unknown>
127
- ): LogEntry => {
128
- const entry: LogEntry = {
129
- id: randomUUID(),
130
- timestamp: new Date().toISOString(),
131
- level,
132
- message,
133
- module: state.context?.module,
134
- function: state.context?.function,
135
- };
136
-
137
- // Merge contexts
138
- if (state.config.includeContext) {
139
- entry.context = { ...state.context, ...additionalContext };
140
- }
141
-
142
- // Add error information if provided
143
- if (error) {
144
- entry.error = {
145
- name: error.name,
146
- message: error.message,
147
- stack: error.stack,
148
- };
149
-
150
- // Add error code if it's a CLIError
151
- if ('code' in error && typeof error.code === 'string') {
152
- entry.error.code = error.code;
153
- }
154
- }
155
-
156
- return entry;
157
- };
158
-
159
- /**
160
- * Format a log entry for output
161
- */
162
- const formatEntry = (entry: LogEntry): string => {
163
- switch (state.config.format) {
164
- case 'json':
165
- return JSON.stringify(entry);
166
-
167
- case 'simple': {
168
- const levelStr = entry.level.toUpperCase().padEnd(5);
169
- const moduleStr = entry.module ? `[${entry.module}] ` : '';
170
- return `${levelStr} ${moduleStr}${entry.message}`;
171
- }
172
- default: {
173
- const parts: string[] = [];
174
-
175
- // Timestamp
176
- if (state.config.includeTimestamp) {
177
- const time = new Date(entry.timestamp).toLocaleTimeString();
178
- parts.push(chalk.gray(time));
179
- }
180
-
181
- // Level symbol and name
182
- const colorFn = state.config.colors ? LEVEL_COLORS[entry.level] : (s: string) => s;
183
- parts.push(
184
- `${colorFn(LEVEL_SYMBOLS[entry.level])} ${colorFn(entry.level.toUpperCase().padEnd(5))}`
185
- );
186
-
187
- // Module
188
- if (entry.module) {
189
- parts.push(chalk.cyan(`[${entry.module}]`));
190
- }
191
-
192
- // Function
193
- if (entry.function) {
194
- parts.push(chalk.gray(`${entry.function}()`));
195
- }
196
-
197
- // Message
198
- parts.push(entry.message);
199
-
200
- let result = parts.join(' ');
201
-
202
- // Context
203
- if (entry.context && Object.keys(entry.context).length > 0) {
204
- const contextStr = JSON.stringify(entry.context, null, 2);
205
- result += `\n${chalk.gray(' Context: ')}${chalk.gray(contextStr)}`;
206
- }
207
-
208
- // Error details
209
- if (entry.error) {
210
- result += `\n${chalk.red(' Error: ')}${chalk.red(entry.error.message)}`;
211
- if (entry.error.code) {
212
- result += `\n${chalk.red(' Code: ')}${chalk.red(entry.error.code)}`;
213
- }
214
- if (entry.error.stack) {
215
- result += `\n${chalk.gray(entry.error.stack)}`;
216
- }
217
- }
218
-
219
- return result;
220
- }
221
- }
122
+ const defaultConfig: LoggerConfig = {
123
+ level: 'info',
124
+ format: 'pretty',
125
+ includeTimestamp: true,
126
+ includeContext: true,
127
+ colors: true,
128
+ ...config,
222
129
  };
223
130
 
224
- /**
225
- * Internal logging method
226
- */
227
- const logInternal = (
228
- level: LogLevel,
229
- message: string,
230
- error?: Error,
231
- additionalContext?: Record<string, any>
232
- ): void => {
233
- if (!shouldLog(level)) {
234
- return;
235
- }
236
-
237
- const entry = createLogEntry(level, message, error, additionalContext);
238
- const formatted = formatEntry(entry);
239
-
240
- // Output to appropriate stream
241
- if (level === 'error') {
242
- console.error(formatted);
243
- } else {
244
- console.log(formatted);
245
- }
131
+ const state: LoggerState = {
132
+ config: defaultConfig,
133
+ context: initialContext,
134
+ pinoInstance: createPinoInstance(defaultConfig, initialContext),
246
135
  };
247
136
 
248
137
  /**
@@ -267,41 +156,75 @@ export function createLogger(options: Partial<LoggerConfig> | CreateLoggerOption
267
156
  */
268
157
  const setLevel = (level: LogLevel): void => {
269
158
  state.config.level = level;
159
+ state.pinoInstance.level = level;
270
160
  };
271
161
 
272
162
  /**
273
163
  * Update logger configuration
164
+ * Note: Recreates Pino instance when configuration changes
274
165
  */
275
166
  const updateConfig = (config: Partial<LoggerConfig>): void => {
276
167
  state.config = { ...state.config, ...config };
168
+ state.pinoInstance = createPinoInstance(state.config, state.context);
277
169
  };
278
170
 
279
171
  /**
280
172
  * Debug level logging
281
173
  */
282
174
  const debug = (message: string, context?: Record<string, unknown>): void => {
283
- logInternal('debug', message, undefined, context);
175
+ if (context && state.config.includeContext) {
176
+ state.pinoInstance.debug(context, message);
177
+ } else {
178
+ state.pinoInstance.debug(message);
179
+ }
284
180
  };
285
181
 
286
182
  /**
287
183
  * Info level logging
288
184
  */
289
185
  const info = (message: string, context?: Record<string, unknown>): void => {
290
- logInternal('info', message, undefined, context);
186
+ if (context && state.config.includeContext) {
187
+ state.pinoInstance.info(context, message);
188
+ } else {
189
+ state.pinoInstance.info(message);
190
+ }
291
191
  };
292
192
 
293
193
  /**
294
194
  * Warning level logging
295
195
  */
296
196
  const warn = (message: string, context?: Record<string, unknown>): void => {
297
- logInternal('warn', message, undefined, context);
197
+ if (context && state.config.includeContext) {
198
+ state.pinoInstance.warn(context, message);
199
+ } else {
200
+ state.pinoInstance.warn(message);
201
+ }
298
202
  };
299
203
 
300
204
  /**
301
205
  * Error level logging
302
206
  */
303
207
  const error = (message: string, errorObj?: Error, context?: Record<string, unknown>): void => {
304
- logInternal('error', message, errorObj, context);
208
+ const errorContext: Record<string, unknown> = { ...context };
209
+
210
+ if (errorObj) {
211
+ errorContext.err = {
212
+ name: errorObj.name,
213
+ message: errorObj.message,
214
+ stack: errorObj.stack,
215
+ };
216
+
217
+ // Add error code if it's a CLIError
218
+ if ('code' in errorObj && typeof errorObj.code === 'string') {
219
+ (errorContext.err as Record<string, unknown>).code = errorObj.code;
220
+ }
221
+ }
222
+
223
+ if (Object.keys(errorContext).length > 0 && state.config.includeContext) {
224
+ state.pinoInstance.error(errorContext, message);
225
+ } else {
226
+ state.pinoInstance.error(message);
227
+ }
305
228
  };
306
229
 
307
230
  /**
@@ -1,16 +1,27 @@
1
1
  /**
2
2
  * Prompt Helpers
3
3
  * Utilities for handling user prompts and error handling
4
+ *
5
+ * This module provides backward-compatible error handling that works with
6
+ * both legacy inquirer errors and the new Clack cancellation pattern.
4
7
  */
5
8
 
9
+ import { isCancel } from '@clack/prompts';
6
10
  import { UserCancelledError } from './errors.js';
7
11
 
8
12
  /**
9
13
  * Check if error is a user cancellation (Ctrl+C or force closed)
10
- * @param error - Error to check
14
+ * Supports both legacy inquirer errors and Clack cancellation symbols
15
+ * @param error - Error or symbol to check
11
16
  * @returns True if error represents user cancellation
12
17
  */
13
18
  export function isUserCancellation(error: unknown): boolean {
19
+ // Handle Clack cancellation symbol
20
+ if (isCancel(error)) {
21
+ return true;
22
+ }
23
+
24
+ // Handle legacy inquirer errors
14
25
  if (error === null || typeof error !== 'object') {
15
26
  return false;
16
27
  }
@@ -19,9 +30,9 @@ export function isUserCancellation(error: unknown): boolean {
19
30
  }
20
31
 
21
32
  /**
22
- * Handle inquirer prompt errors with consistent error handling
33
+ * Handle inquirer/clack prompt errors with consistent error handling
23
34
  * Throws UserCancelledError for user cancellations, re-throws other errors
24
- * @param error - Error from inquirer prompt
35
+ * @param error - Error from prompt
25
36
  * @param message - Custom cancellation message
26
37
  * @throws {UserCancelledError} If user cancelled the prompt
27
38
  * @throws Original error if not a cancellation
@@ -34,8 +45,8 @@ export function handlePromptError(error: unknown, message: string): never {
34
45
  }
35
46
 
36
47
  /**
37
- * Wrap an inquirer prompt with consistent error handling
38
- * @param promptFn - Function that returns a promise from inquirer
48
+ * Wrap a prompt with consistent error handling
49
+ * @param promptFn - Function that returns a promise from a prompt library
39
50
  * @param errorMessage - Message to use if user cancels
40
51
  * @returns Promise with the prompt result
41
52
  * @throws {UserCancelledError} If user cancels the prompt
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Centralized Clack Prompts Wrapper
3
+ * Unified prompt utilities with consistent error handling and cancellation support
4
+ */
5
+
6
+ import * as clack from '@clack/prompts';
7
+ import chalk from 'chalk';
8
+ import { UserCancelledError } from '../errors.js';
9
+
10
+ // Re-export clack utilities for convenience
11
+ export { cancel, group, intro, isCancel, log, note, outro } from '@clack/prompts';
12
+
13
+ // ============================================================================
14
+ // Types
15
+ // ============================================================================
16
+
17
+ export interface SelectOption<T> {
18
+ label: string;
19
+ value: T;
20
+ hint?: string;
21
+ }
22
+
23
+ export interface MultiselectOption<T> {
24
+ label: string;
25
+ value: T;
26
+ hint?: string;
27
+ }
28
+
29
+ export interface SpinnerInstance {
30
+ start: (message?: string) => void;
31
+ stop: (message?: string) => void;
32
+ message: (message: string) => void;
33
+ }
34
+
35
+ // ============================================================================
36
+ // Cancellation Handling
37
+ // ============================================================================
38
+
39
+ /**
40
+ * Handle cancellation by checking if result is a cancel symbol
41
+ * Throws UserCancelledError if cancelled
42
+ */
43
+ export function handleCancellation<T>(
44
+ result: T | symbol,
45
+ message = 'Operation cancelled'
46
+ ): asserts result is T {
47
+ if (clack.isCancel(result)) {
48
+ clack.cancel(message);
49
+ throw new UserCancelledError(message);
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Wrap a prompt result with automatic cancellation handling
55
+ */
56
+ export function withCancellation<T>(result: T | symbol, message = 'Operation cancelled'): T {
57
+ handleCancellation(result, message);
58
+ return result;
59
+ }
60
+
61
+ // ============================================================================
62
+ // Prompt Wrappers with Cancellation Handling
63
+ // ============================================================================
64
+
65
+ /**
66
+ * Select prompt with automatic cancellation handling
67
+ */
68
+ export async function promptSelect<T extends string | number | boolean>(options: {
69
+ message: string;
70
+ options: SelectOption<T>[];
71
+ initialValue?: T;
72
+ }): Promise<T> {
73
+ const result = await clack.select({
74
+ message: options.message,
75
+ options: options.options,
76
+ initialValue: options.initialValue,
77
+ });
78
+
79
+ handleCancellation(result, 'Selection cancelled');
80
+ return result as T;
81
+ }
82
+
83
+ /**
84
+ * Multiselect prompt with automatic cancellation handling
85
+ */
86
+ export async function promptMultiselect<T extends string | number | boolean>(options: {
87
+ message: string;
88
+ options: MultiselectOption<T>[];
89
+ initialValues?: T[];
90
+ required?: boolean;
91
+ }): Promise<T[]> {
92
+ const result = await clack.multiselect({
93
+ message: options.message,
94
+ options: options.options,
95
+ initialValues: options.initialValues,
96
+ required: options.required ?? false,
97
+ });
98
+
99
+ handleCancellation(result, 'Selection cancelled');
100
+ return result as T[];
101
+ }
102
+
103
+ /**
104
+ * Confirm prompt with automatic cancellation handling
105
+ */
106
+ export async function promptConfirm(options: {
107
+ message: string;
108
+ initialValue?: boolean;
109
+ }): Promise<boolean> {
110
+ const result = await clack.confirm({
111
+ message: options.message,
112
+ initialValue: options.initialValue ?? true,
113
+ });
114
+
115
+ handleCancellation(result, 'Confirmation cancelled');
116
+ return result;
117
+ }
118
+
119
+ /**
120
+ * Text input prompt with automatic cancellation handling
121
+ */
122
+ export async function promptText(options: {
123
+ message: string;
124
+ placeholder?: string;
125
+ defaultValue?: string;
126
+ validate?: (value: string) => string | undefined;
127
+ }): Promise<string> {
128
+ const result = await clack.text({
129
+ message: options.message,
130
+ placeholder: options.placeholder,
131
+ defaultValue: options.defaultValue,
132
+ validate: options.validate,
133
+ });
134
+
135
+ handleCancellation(result, 'Input cancelled');
136
+ return result;
137
+ }
138
+
139
+ /**
140
+ * Password input prompt with automatic cancellation handling
141
+ */
142
+ export async function promptPassword(options: {
143
+ message: string;
144
+ mask?: string;
145
+ validate?: (value: string) => string | undefined;
146
+ }): Promise<string> {
147
+ const result = await clack.password({
148
+ message: options.message,
149
+ mask: options.mask ?? '*',
150
+ validate: options.validate,
151
+ });
152
+
153
+ handleCancellation(result, 'Password input cancelled');
154
+ return result;
155
+ }
156
+
157
+ // ============================================================================
158
+ // Spinner Wrapper
159
+ // ============================================================================
160
+
161
+ /**
162
+ * Create a spinner instance with consistent API
163
+ * Wraps Clack's spinner with additional convenience methods
164
+ */
165
+ export function createSpinner(): SpinnerInstance {
166
+ const s = clack.spinner();
167
+
168
+ return {
169
+ start: (message?: string) => s.start(message),
170
+ stop: (message?: string) => s.stop(message),
171
+ message: (message: string) => s.message(message),
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Run an async operation with a spinner
177
+ */
178
+ export async function withSpinner<T>(
179
+ message: string,
180
+ fn: () => Promise<T>,
181
+ options?: {
182
+ successMessage?: string | ((result: T) => string);
183
+ errorMessage?: string | ((error: Error) => string);
184
+ }
185
+ ): Promise<T> {
186
+ const s = clack.spinner();
187
+ s.start(message);
188
+
189
+ try {
190
+ const result = await fn();
191
+ const successMsg =
192
+ typeof options?.successMessage === 'function'
193
+ ? options.successMessage(result)
194
+ : (options?.successMessage ?? message);
195
+ s.stop(chalk.green(`✓ ${successMsg}`));
196
+ return result;
197
+ } catch (error) {
198
+ const errorMsg =
199
+ typeof options?.errorMessage === 'function'
200
+ ? options.errorMessage(error as Error)
201
+ : (options?.errorMessage ?? `Failed: ${message}`);
202
+ s.stop(chalk.red(`✗ ${errorMsg}`));
203
+ throw error;
204
+ }
205
+ }
206
+
207
+ // ============================================================================
208
+ // Group Prompts with Cancellation
209
+ // ============================================================================
210
+
211
+ /**
212
+ * Group multiple prompts together with shared cancellation handling
213
+ * If any prompt is cancelled, the entire group is cancelled
214
+ */
215
+ export async function promptGroup<T extends Record<string, unknown>>(
216
+ prompts: {
217
+ [K in keyof T]: () => Promise<T[K] | symbol>;
218
+ },
219
+ options?: {
220
+ onCancel?: () => void;
221
+ }
222
+ ): Promise<T> {
223
+ const result = await clack.group(prompts, {
224
+ onCancel: () => {
225
+ options?.onCancel?.();
226
+ clack.cancel('Operation cancelled');
227
+ throw new UserCancelledError('Operation cancelled');
228
+ },
229
+ });
230
+
231
+ return result as T;
232
+ }