@portel/photon-core 1.0.0 → 1.0.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.
@@ -0,0 +1,579 @@
1
+ /**
2
+ * CLI Output Formatter
3
+ *
4
+ * Shared formatting utilities for beautiful CLI output.
5
+ * Used by photon CLI, lumina, ncp, and other projects.
6
+ *
7
+ * Structural formats:
8
+ * - primitive: Single values (string, number, boolean)
9
+ * - table: Flat objects or arrays of flat objects (bordered tables)
10
+ * - tree: Nested/hierarchical structures (indented)
11
+ * - list: Arrays of primitives (bullet points)
12
+ * - none: No data to display
13
+ *
14
+ * Content formats:
15
+ * - json: Pretty-printed JSON
16
+ * - markdown: Rendered markdown
17
+ * - yaml: YAML content
18
+ * - code / code:<lang>: Syntax highlighted code
19
+ */
20
+
21
+ import { OutputFormat } from './types.js';
22
+ import { highlight } from 'cli-highlight';
23
+ import chalk from 'chalk';
24
+
25
+ /**
26
+ * Format and output data with optional format hint
27
+ */
28
+ export function formatOutput(data: any, hint?: OutputFormat): void {
29
+ const format = hint || detectFormat(data);
30
+
31
+ // Handle content formats (for string data)
32
+ if (typeof data === 'string' && isContentFormat(format)) {
33
+ renderContent(data, format);
34
+ return;
35
+ }
36
+
37
+ // Handle structural formats
38
+ formatDataWithHint(data, format as StructuralFormat);
39
+ }
40
+
41
+ type StructuralFormat = 'primitive' | 'table' | 'tree' | 'list' | 'none';
42
+
43
+ /**
44
+ * Check if format is a content type format
45
+ */
46
+ function isContentFormat(format: OutputFormat): boolean {
47
+ return ['json', 'markdown', 'yaml', 'xml', 'html'].includes(format) ||
48
+ format === 'code' ||
49
+ format.startsWith('code:');
50
+ }
51
+
52
+ /**
53
+ * Render content with appropriate formatting
54
+ */
55
+ function renderContent(content: string, format: OutputFormat): void {
56
+ switch (format) {
57
+ case 'json':
58
+ renderJson(content);
59
+ break;
60
+ case 'markdown':
61
+ renderMarkdown(content);
62
+ break;
63
+ case 'yaml':
64
+ renderYaml(content);
65
+ break;
66
+ case 'xml':
67
+ case 'html':
68
+ renderXml(content);
69
+ break;
70
+ default:
71
+ if (format === 'code' || format.startsWith('code:')) {
72
+ const lang = format === 'code' ? undefined : format.split(':')[1];
73
+ renderCode(content, lang);
74
+ } else {
75
+ console.log(content);
76
+ }
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Render JSON with syntax highlighting
82
+ */
83
+ function renderJson(content: string): void {
84
+ try {
85
+ const parsed = typeof content === 'string' ? JSON.parse(content) : content;
86
+ const formatted = JSON.stringify(parsed, null, 2);
87
+ console.log(highlight(formatted, { language: 'json', ignoreIllegals: true }));
88
+ } catch {
89
+ console.log(content);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Render markdown with colored terminal output
95
+ */
96
+ function renderMarkdown(content: string): void {
97
+ // Process markdown with colors for terminal
98
+ // Order matters: process block elements first, then inline elements
99
+ let rendered = content;
100
+
101
+ // 1. Code blocks - highlight with language if specified (must be first)
102
+ rendered = rendered.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => {
103
+ const trimmedCode = code.trim();
104
+ if (lang && lang !== '') {
105
+ try {
106
+ return '\n' + highlight(trimmedCode, { language: lang, ignoreIllegals: true }) + '\n';
107
+ } catch {
108
+ return '\n' + chalk.gray(trimmedCode) + '\n';
109
+ }
110
+ }
111
+ return '\n' + chalk.gray(trimmedCode) + '\n';
112
+ });
113
+
114
+ // 2. Links - convert to plain format first (before other inline processing)
115
+ // This prevents markdown link brackets from interfering with other processing
116
+ rendered = rendered.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, text, url) =>
117
+ chalk.blue.underline(text) + chalk.dim(` (${url})`)
118
+ );
119
+
120
+ // 3. Headers with colors
121
+ rendered = rendered
122
+ .replace(/^### (.+)$/gm, (_m, h) => '\n' + chalk.cyan(' ' + h) + '\n ' + chalk.dim('-'.repeat(20)))
123
+ .replace(/^## (.+)$/gm, (_m, h) => '\n' + chalk.yellow.bold(' ' + h) + '\n ' + chalk.dim('='.repeat(30)))
124
+ .replace(/^# (.+)$/gm, (_m, h) => '\n' + chalk.magenta.bold(h) + '\n' + chalk.dim('='.repeat(40)));
125
+
126
+ // 4. Blockquotes
127
+ rendered = rendered.replace(/^> (.+)$/gm, (_m, quote) => chalk.dim('│ ') + chalk.italic(quote));
128
+
129
+ // 5. Horizontal rules
130
+ rendered = rendered.replace(/^---+$/gm, chalk.dim('─'.repeat(40)));
131
+
132
+ // 6. Lists
133
+ rendered = rendered.replace(/^- /gm, chalk.dim(' • '));
134
+ rendered = rendered.replace(/^(\d+)\. /gm, (_m, num) => chalk.dim(` ${num}. `));
135
+
136
+ // 7. Bold (before italic to handle **text** before *text*)
137
+ rendered = rendered.replace(/\*\*(.+?)\*\*/g, (_m, text) => chalk.bold(text));
138
+
139
+ // 8. Italic
140
+ rendered = rendered.replace(/\*(.+?)\*/g, (_m, text) => chalk.italic(text));
141
+ rendered = rendered.replace(/_(.+?)_/g, (_m, text) => chalk.italic(text));
142
+
143
+ // 9. Inline code (last to avoid matching code in other elements)
144
+ rendered = rendered.replace(/`([^`]+)`/g, (_m, code) => chalk.cyan(code));
145
+
146
+ console.log(rendered);
147
+ }
148
+
149
+ /**
150
+ * Render YAML with syntax highlighting
151
+ */
152
+ function renderYaml(content: string): void {
153
+ try {
154
+ console.log(highlight(content, { language: 'yaml', ignoreIllegals: true }));
155
+ } catch {
156
+ console.log(content);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Render XML/HTML with syntax highlighting
162
+ */
163
+ function renderXml(content: string): void {
164
+ try {
165
+ console.log(highlight(content, { language: 'xml', ignoreIllegals: true }));
166
+ } catch {
167
+ console.log(content);
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Render code with syntax highlighting
173
+ */
174
+ function renderCode(content: string, lang?: string): void {
175
+ try {
176
+ if (lang) {
177
+ console.log(highlight(content, { language: lang, ignoreIllegals: true }));
178
+ } else {
179
+ // Auto-detect language
180
+ console.log(highlight(content, { ignoreIllegals: true }));
181
+ }
182
+ } catch {
183
+ console.log(content);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Format data using specified or detected format
189
+ */
190
+ function formatDataWithHint(data: any, format: StructuralFormat): void {
191
+ switch (format) {
192
+ case 'primitive':
193
+ renderPrimitive(data);
194
+ break;
195
+ case 'list':
196
+ renderList(Array.isArray(data) ? data : [data]);
197
+ break;
198
+ case 'table':
199
+ renderTable(data);
200
+ break;
201
+ case 'tree':
202
+ renderTree(data);
203
+ break;
204
+ case 'none':
205
+ renderNone();
206
+ break;
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Detect format type from data structure
212
+ */
213
+ export function detectFormat(data: any): OutputFormat {
214
+ // null/undefined = none
215
+ if (data === null || data === undefined) {
216
+ return 'none';
217
+ }
218
+
219
+ // Primitive types
220
+ if (
221
+ typeof data === 'string' ||
222
+ typeof data === 'number' ||
223
+ typeof data === 'boolean'
224
+ ) {
225
+ return 'primitive';
226
+ }
227
+
228
+ // Array handling
229
+ if (Array.isArray(data)) {
230
+ if (data.length === 0) {
231
+ return 'list';
232
+ }
233
+
234
+ // Check first element to determine list vs table
235
+ const firstItem = data[0];
236
+
237
+ // Array of primitives = list
238
+ if (typeof firstItem !== 'object' || firstItem === null) {
239
+ return 'list';
240
+ }
241
+
242
+ // Array of flat objects = table
243
+ if (isFlatObject(firstItem)) {
244
+ return 'table';
245
+ }
246
+
247
+ // Array of nested objects = tree
248
+ return 'tree';
249
+ }
250
+
251
+ // Single object
252
+ if (typeof data === 'object') {
253
+ // Flat object = table
254
+ if (isFlatObject(data)) {
255
+ return 'table';
256
+ }
257
+
258
+ // Nested object = tree
259
+ return 'tree';
260
+ }
261
+
262
+ // Default fallback
263
+ return 'none';
264
+ }
265
+
266
+ /**
267
+ * Check if an object is flat (no nested objects or arrays)
268
+ */
269
+ function isFlatObject(obj: any): boolean {
270
+ if (typeof obj !== 'object' || obj === null) {
271
+ return false;
272
+ }
273
+
274
+ for (const value of Object.values(obj)) {
275
+ if (typeof value === 'object' && value !== null) {
276
+ return false;
277
+ }
278
+ }
279
+
280
+ return true;
281
+ }
282
+
283
+ /**
284
+ * Render primitive value
285
+ */
286
+ export function renderPrimitive(value: any): void {
287
+ if (typeof value === 'boolean') {
288
+ console.log(value ? 'yes' : 'no');
289
+ } else {
290
+ console.log(value);
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Render list (array of primitives)
296
+ */
297
+ export function renderList(data: any[]): void {
298
+ if (data.length === 0) {
299
+ console.log('(empty)');
300
+ return;
301
+ }
302
+
303
+ data.forEach(item => {
304
+ console.log(` * ${item}`);
305
+ });
306
+ }
307
+
308
+ /**
309
+ * Render table (flat object or array of flat objects)
310
+ */
311
+ export function renderTable(data: any): void {
312
+ // Single flat object - show as bordered key-value table
313
+ if (!Array.isArray(data)) {
314
+ const entries = Object.entries(data).filter(
315
+ ([key, value]) => !(key === 'returnValue' && value === true)
316
+ );
317
+
318
+ if (entries.length === 0) {
319
+ console.log('(empty)');
320
+ return;
321
+ }
322
+
323
+ const maxKeyLength = Math.max(...entries.map(([k]) => formatKey(k).length));
324
+ const maxValueLength = Math.max(
325
+ ...entries.map(([_, v]) => String(formatValue(v)).length)
326
+ );
327
+
328
+ // Top border
329
+ console.log(
330
+ `┌─${'─'.repeat(maxKeyLength)}─┬─${'─'.repeat(maxValueLength)}─┐`
331
+ );
332
+
333
+ // Rows
334
+ for (let i = 0; i < entries.length; i++) {
335
+ const [key, value] = entries[i];
336
+ const formattedKey = formatKey(key);
337
+ const formattedValue = String(formatValue(value));
338
+ const keyPadding = ' '.repeat(maxKeyLength - formattedKey.length);
339
+ const valuePadding = ' '.repeat(maxValueLength - formattedValue.length);
340
+
341
+ console.log(
342
+ `| ${formattedKey}${keyPadding} | ${formattedValue}${valuePadding} |`
343
+ );
344
+
345
+ // Add separator between rows (not after last row)
346
+ if (i < entries.length - 1) {
347
+ console.log(
348
+ `├─${'─'.repeat(maxKeyLength)}─┼─${'─'.repeat(maxValueLength)}─┤`
349
+ );
350
+ }
351
+ }
352
+
353
+ // Bottom border
354
+ console.log(
355
+ `└─${'─'.repeat(maxKeyLength)}─┴─${'─'.repeat(maxValueLength)}─┘`
356
+ );
357
+ return;
358
+ }
359
+
360
+ // Array of flat objects - show as bordered table
361
+ if (data.length === 0) {
362
+ console.log('(empty)');
363
+ return;
364
+ }
365
+
366
+ // Get all unique keys across all objects
367
+ const allKeys = Array.from(
368
+ new Set(data.flatMap(obj => Object.keys(obj)))
369
+ ).filter(k => k !== 'returnValue');
370
+
371
+ if (allKeys.length === 0) {
372
+ console.log('(no data)');
373
+ return;
374
+ }
375
+
376
+ // Calculate column widths
377
+ const columnWidths = new Map<string, number>();
378
+ for (const key of allKeys) {
379
+ const headerWidth = formatKey(key).length;
380
+ const maxValueWidth = Math.max(
381
+ ...data.map(obj => String(formatValue(obj[key] ?? '')).length)
382
+ );
383
+ columnWidths.set(key, Math.max(headerWidth, maxValueWidth));
384
+ }
385
+
386
+ // Top border
387
+ const topBorderParts = allKeys.map(
388
+ key => '─'.repeat(columnWidths.get(key)! + 2)
389
+ );
390
+ console.log('┌' + topBorderParts.join('┬') + '┐');
391
+
392
+ // Header
393
+ const headerParts = allKeys.map(key => {
394
+ const formattedKey = formatKey(key);
395
+ const width = columnWidths.get(key)!;
396
+ return ' ' + formattedKey.padEnd(width) + ' ';
397
+ });
398
+ console.log('│' + headerParts.join('│') + '│');
399
+
400
+ // Header separator
401
+ const separatorParts = allKeys.map(
402
+ key => '─'.repeat(columnWidths.get(key)! + 2)
403
+ );
404
+ console.log('├' + separatorParts.join('┼') + '┤');
405
+
406
+ // Rows
407
+ for (const row of data) {
408
+ const rowParts = allKeys.map(key => {
409
+ const value = formatValue(row[key] ?? '');
410
+ const width = columnWidths.get(key)!;
411
+ return ' ' + String(value).padEnd(width) + ' ';
412
+ });
413
+ console.log('│' + rowParts.join('│') + '│');
414
+ }
415
+
416
+ // Bottom border
417
+ const bottomBorderParts = allKeys.map(
418
+ key => '─'.repeat(columnWidths.get(key)! + 2)
419
+ );
420
+ console.log('└' + bottomBorderParts.join('┴') + '┘');
421
+ }
422
+
423
+ /**
424
+ * Render tree (nested/hierarchical structure)
425
+ */
426
+ export function renderTree(data: any, indent: string = ''): void {
427
+ // Array of objects
428
+ if (Array.isArray(data)) {
429
+ if (data.length === 0) {
430
+ console.log(`${indent}(empty)`);
431
+ return;
432
+ }
433
+
434
+ data.forEach((item, index) => {
435
+ if (typeof item === 'object' && item !== null) {
436
+ console.log(`${indent}[${index}]`);
437
+ renderTree(item, indent + ' ');
438
+ } else {
439
+ console.log(`${indent}* ${item}`);
440
+ }
441
+ });
442
+ return;
443
+ }
444
+
445
+ // Object
446
+ if (typeof data === 'object' && data !== null) {
447
+ const entries = Object.entries(data).filter(
448
+ ([key, value]) => !(key === 'returnValue' && value === true)
449
+ );
450
+
451
+ for (const [key, value] of entries) {
452
+ const formattedKey = formatKey(key);
453
+
454
+ if (value === null || value === undefined) {
455
+ console.log(`${indent}${formattedKey}: (none)`);
456
+ } else if (Array.isArray(value)) {
457
+ console.log(`${indent}${formattedKey}:`);
458
+ renderTree(value, indent + ' ');
459
+ } else if (typeof value === 'object') {
460
+ console.log(`${indent}${formattedKey}:`);
461
+ renderTree(value, indent + ' ');
462
+ } else {
463
+ console.log(`${indent}${formattedKey}: ${formatValue(value)}`);
464
+ }
465
+ }
466
+ return;
467
+ }
468
+
469
+ // Primitive (shouldn't happen but handle it)
470
+ console.log(`${indent}${formatValue(data)}`);
471
+ }
472
+
473
+ /**
474
+ * Render none format (operation with no data)
475
+ */
476
+ export function renderNone(): void {
477
+ console.log('Done');
478
+ }
479
+
480
+ /**
481
+ * Format a key for display (camelCase to Title Case)
482
+ */
483
+ export function formatKey(key: string): string {
484
+ return key
485
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
486
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
487
+ .replace(/_/g, ' ')
488
+ .replace(/^./, str => str.toUpperCase())
489
+ .trim();
490
+ }
491
+
492
+ /**
493
+ * Format a value for display
494
+ */
495
+ export function formatValue(value: any): string | number | boolean {
496
+ if (value === null || value === undefined) {
497
+ return '-';
498
+ }
499
+ if (typeof value === 'boolean') {
500
+ return value ? 'yes' : 'no';
501
+ }
502
+ return value;
503
+ }
504
+
505
+ /**
506
+ * Status indicators for tables (ASCII only for proper alignment)
507
+ */
508
+ export const STATUS = {
509
+ OK: 'ok',
510
+ UPDATE: 'update',
511
+ WARN: 'warn',
512
+ ERROR: '!',
513
+ OFF: 'off',
514
+ UNKNOWN: '?',
515
+ } as const;
516
+
517
+ /**
518
+ * Convert output format to MIME type (for MCP responses)
519
+ */
520
+ export function formatToMimeType(format: OutputFormat): string | undefined {
521
+ const mimeTypes: Record<string, string> = {
522
+ json: 'application/json',
523
+ markdown: 'text/markdown',
524
+ yaml: 'text/yaml',
525
+ xml: 'application/xml',
526
+ html: 'text/html',
527
+ };
528
+
529
+ if (mimeTypes[format]) {
530
+ return mimeTypes[format];
531
+ }
532
+
533
+ if (format === 'code') {
534
+ return 'text/plain';
535
+ }
536
+
537
+ if (format.startsWith('code:')) {
538
+ const lang = format.split(':')[1];
539
+ return `text/x-${lang}`;
540
+ }
541
+
542
+ return undefined;
543
+ }
544
+
545
+ /**
546
+ * Print a success message
547
+ */
548
+ export function printSuccess(message: string): void {
549
+ console.error(`✓ ${message}`);
550
+ }
551
+
552
+ /**
553
+ * Print an error message
554
+ */
555
+ export function printError(message: string): void {
556
+ console.error(`✗ ${message}`);
557
+ }
558
+
559
+ /**
560
+ * Print an info message
561
+ */
562
+ export function printInfo(message: string): void {
563
+ console.error(`${message}`);
564
+ }
565
+
566
+ /**
567
+ * Print a warning message
568
+ */
569
+ export function printWarning(message: string): void {
570
+ console.error(`! ${message}`);
571
+ }
572
+
573
+ /**
574
+ * Print a section header
575
+ */
576
+ export function printHeader(title: string): void {
577
+ console.error(`\n${title}`);
578
+ console.error('─'.repeat(title.length));
579
+ }
@@ -40,7 +40,7 @@ export class DependencyManager {
40
40
 
41
41
  // Match @dependencies tags in JSDoc comments
42
42
  // Regex: @dependencies package@version, package2@version2
43
- const dependencyRegex = /@dependencies\s+([\w@^~.,\s-]+)/g;
43
+ const dependencyRegex = /@dependencies\s+([\w@^~.,\s/-]+)/g;
44
44
 
45
45
  let match;
46
46
  while ((match = dependencyRegex.exec(content)) !== null) {
@@ -138,6 +138,12 @@ export class DependencyManager {
138
138
  await fs.readFile(packageJsonPath, 'utf-8')
139
139
  );
140
140
 
141
+ // Check if dependency count matches (catches added/removed dependencies)
142
+ const installedCount = Object.keys(packageJson.dependencies || {}).length;
143
+ if (installedCount !== dependencies.length) {
144
+ return false;
145
+ }
146
+
141
147
  // Check if all dependencies match
142
148
  for (const dep of dependencies) {
143
149
  if (packageJson.dependencies?.[dep.name] !== dep.version) {
package/src/index.ts CHANGED
@@ -42,5 +42,37 @@ export { DependencyManager } from './dependency-manager.js';
42
42
  // Schema extraction
43
43
  export { SchemaExtractor } from './schema-extractor.js';
44
44
 
45
+ // CLI formatting
46
+ export {
47
+ formatOutput,
48
+ detectFormat,
49
+ renderPrimitive,
50
+ renderList,
51
+ renderTable,
52
+ renderTree,
53
+ renderNone,
54
+ formatKey,
55
+ formatValue,
56
+ formatToMimeType,
57
+ printSuccess,
58
+ printError,
59
+ printInfo,
60
+ printWarning,
61
+ printHeader,
62
+ STATUS,
63
+ } from './cli-formatter.js';
64
+
65
+ // Path resolution
66
+ export {
67
+ resolvePath,
68
+ listFiles,
69
+ ensureDir,
70
+ resolvePhotonPath,
71
+ listPhotonFiles,
72
+ ensurePhotonDir,
73
+ DEFAULT_PHOTON_DIR,
74
+ type ResolverOptions,
75
+ } from './path-resolver.js';
76
+
45
77
  // Types
46
78
  export * from './types.js';