@jlcpcb/cli 0.2.0 → 0.3.1

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.
@@ -6,15 +6,15 @@ import type { LibraryParams } from '../navigation/types.js';
6
6
  import { useAppState } from '../state/AppStateContext.js';
7
7
  import { useTerminalSize } from '../hooks/useTerminalSize.js';
8
8
  import { Divider } from '../components/Divider.js';
9
+ import {
10
+ formatLibraryHeader,
11
+ formatLibraryRow,
12
+ getLibraryTableWidths,
13
+ } from '../components/library-table-format.js';
9
14
 
10
15
  const libraryService = createLibraryService();
11
16
  const componentService = createComponentService();
12
17
 
13
- function truncate(str: string, len: number): string {
14
- if (!str) return '';
15
- return str.length > len ? str.slice(0, len - 1) + '…' : str;
16
- }
17
-
18
18
  function StatusBadge({ installed, linked }: { installed: boolean; linked: boolean }) {
19
19
  if (installed && linked) {
20
20
  return <Text color="green">Installed & Linked</Text>;
@@ -193,10 +193,7 @@ export function LibraryScreen() {
193
193
  // Components table - full width responsive layout
194
194
  // Fixed columns: selector(2) + name + category + status columns(15)
195
195
  // Description takes remaining space
196
- const nameWidth = 20;
197
- const categoryWidth = 12;
198
- const statusWidth = 15; // Sym(5) + FP(5) + 3D(5)
199
- const descWidth = Math.max(terminalWidth - 2 - nameWidth - categoryWidth - statusWidth, 15);
196
+ const widths = getLibraryTableWidths(terminalWidth);
200
197
 
201
198
  return (
202
199
  <Box flexDirection="column" width="100%">
@@ -216,36 +213,36 @@ export function LibraryScreen() {
216
213
  </Box>
217
214
  <Divider width={terminalWidth} />
218
215
  <Box marginBottom={1} marginTop={1}>
219
- <Text bold dimColor>
220
- {' '}
221
- {'Name'.padEnd(nameWidth)}
222
- {'Category'.padEnd(categoryWidth)}
223
- {'Description'.padEnd(descWidth)}
224
- {'Sym'.padEnd(5)}
225
- {'FP'.padEnd(5)}
226
- {'3D'}
227
- </Text>
216
+ <Text bold dimColor>{formatLibraryHeader(widths)}</Text>
228
217
  </Box>
229
218
  {installed.map((item, i) => {
230
219
  const isSelected = i === selectedIndex;
231
220
  const desc = descriptions[item.lcscId] || '';
232
- // Check if footprint is standard KiCad (not JLC-MCP custom)
233
- const isStandardFp = item.footprintRef && !item.footprintRef.startsWith('JLC-MCP:');
234
- const fpLabel = !item.footprintRef ? 'N' : isStandardFp ? 'S' : 'Y';
235
- const fpColor = !item.footprintRef ? 'red' : isStandardFp ? 'cyan' : 'green';
221
+ const row = formatLibraryRow(item, desc, widths);
236
222
  return (
237
223
  <Box key={`${item.lcscId}-${i}`}>
238
224
  <Text color={isSelected ? 'cyan' : undefined}>
239
225
  {isSelected ? '> ' : ' '}
240
226
  </Text>
241
- <Text inverse={isSelected}>
242
- <Text color="cyan">{truncate(item.name, nameWidth - 1).padEnd(nameWidth)}</Text>
243
- {truncate(item.category, categoryWidth - 1).padEnd(categoryWidth)}
244
- <Text dimColor>{truncate(desc, descWidth - 1).padEnd(descWidth)}</Text>
245
- <Text color="green">{'Y'.padEnd(5)}</Text>
246
- <Text color={fpColor}>{fpLabel.padEnd(5)}</Text>
247
- <Text color={item.has3dModel ? 'green' : 'red'}>{item.has3dModel ? 'Y' : 'N'}</Text>
248
- </Text>
227
+ {isSelected ? (
228
+ <Text inverse>
229
+ {row.name}
230
+ {row.category}
231
+ {row.description}
232
+ {row.sym}
233
+ {row.fp}
234
+ {row.model}
235
+ </Text>
236
+ ) : (
237
+ <Text>
238
+ <Text color="cyan">{row.name}</Text>
239
+ {row.category}
240
+ <Text dimColor>{row.description}</Text>
241
+ <Text color="green">{row.sym}</Text>
242
+ <Text color={row.fpColor}>{row.fp}</Text>
243
+ <Text color={row.modelColor}>{row.model}</Text>
244
+ </Text>
245
+ )}
249
246
  </Box>
250
247
  );
251
248
  })}
@@ -111,7 +111,7 @@ export async function easyedaInstallCommand(
111
111
  // If UUID provided (without --force), launch TUI to fetch and display
112
112
  if (uuid) {
113
113
  // Launch TUI at EasyEDA info screen - let it fetch the component
114
- renderApp('easyeda-info', { uuid })
114
+ await renderApp('easyeda-info', { uuid })
115
115
  return
116
116
  }
117
117
 
@@ -130,8 +130,7 @@ export async function easyedaInstallCommand(
130
130
  process.exit(0)
131
131
  }
132
132
 
133
- const spinner = p.spinner()
134
- spinner.start(`Searching EasyEDA community for "${query}"...`)
133
+ console.log(`Searching EasyEDA community for "${query}"...`)
135
134
 
136
135
  const searchOptions: SearchOptions = {
137
136
  limit: 20,
@@ -139,13 +138,12 @@ export async function easyedaInstallCommand(
139
138
  }
140
139
  const results = await componentService.search(query as string, searchOptions)
141
140
 
142
- spinner.stop(`Found ${results.length} results`)
143
-
144
141
  if (results.length === 0) {
145
- p.log.warn('No components found. Try a different search term.')
142
+ console.log('No components found. Try a different search term.')
146
143
  return
147
144
  }
148
145
 
149
- // Launch TUI for selection and install
150
- renderApp('search', { query: query as string, results })
146
+ // Clear the "Searching..." line and launch interactive UI
147
+ process.stdout.write('\x1b[1A\x1b[2K')
148
+ await renderApp('search', { query: query as string, results })
151
149
  }
@@ -33,5 +33,5 @@ export async function infoCommand(id: string, options: InfoOptions): Promise<voi
33
33
  }
34
34
 
35
35
  // Interactive mode - launch TUI
36
- renderApp('info', { componentId: id });
36
+ await renderApp('info', { componentId: id });
37
37
  }
@@ -68,18 +68,15 @@ export async function installCommand(id: string | undefined, options: InstallOpt
68
68
 
69
69
  // If ID provided (without --force), fetch component and launch TUI for install
70
70
  if (id) {
71
- const spinner = p.spinner();
72
- spinner.start(`Fetching component ${id}...`);
71
+ console.log(`Fetching component ${id}...`);
73
72
 
74
73
  try {
75
74
  const details = await componentService.getDetails(id);
76
- spinner.stop('Component found');
77
-
78
- // Launch TUI at info screen (user can navigate to install from there)
79
- renderApp('info', { componentId: id, component: details as any });
75
+ // Clear the "Fetching..." line and launch interactive UI
76
+ process.stdout.write('\x1b[1A\x1b[2K');
77
+ await renderApp('info', { componentId: id, component: details as any });
80
78
  } catch (error) {
81
- spinner.stop('Failed to fetch component');
82
- p.log.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
79
+ console.error(`Failed to fetch component: ${error instanceof Error ? error.message : 'Unknown error'}`);
83
80
  process.exit(1);
84
81
  }
85
82
  return;
@@ -100,8 +97,7 @@ export async function installCommand(id: string | undefined, options: InstallOpt
100
97
  process.exit(0);
101
98
  }
102
99
 
103
- const spinner = p.spinner();
104
- spinner.start(`Searching for "${query}"...`);
100
+ console.log(`Searching for "${query}"...`);
105
101
 
106
102
  const searchOptions: SearchOptions = { limit: 20 };
107
103
  let results = await componentService.search(query as string, searchOptions);
@@ -113,13 +109,12 @@ export async function installCommand(id: string | undefined, options: InstallOpt
113
109
  return 0;
114
110
  });
115
111
 
116
- spinner.stop(`Found ${results.length} results`);
117
-
118
112
  if (results.length === 0) {
119
- p.log.warn('No components found. Try a different search term.');
113
+ console.log('No components found. Try a different search term.');
120
114
  return;
121
115
  }
122
116
 
123
- // Launch TUI for selection and install
124
- renderApp('search', { query: query as string, results });
117
+ // Clear the "Searching..." line and launch interactive UI
118
+ process.stdout.write('\x1b[1A\x1b[2K');
119
+ await renderApp('search', { query: query as string, results });
125
120
  }
@@ -4,7 +4,8 @@
4
4
  */
5
5
 
6
6
  import * as p from '@clack/prompts';
7
- import { createLibraryService } from '@jlcpcb/core';
7
+ import chalk from 'chalk';
8
+ import { createLibraryService, type InstalledComponent } from '@jlcpcb/core';
8
9
  import { renderApp } from '../app/App.js';
9
10
 
10
11
  const libraryService = createLibraryService();
@@ -52,5 +53,53 @@ export async function libraryCommand(options: LibraryOptions): Promise<void> {
52
53
  }
53
54
 
54
55
  // Interactive mode - launch TUI
55
- renderApp('library', {});
56
+ await renderApp('library', {});
57
+ }
58
+
59
+ interface RegenerateOptions {
60
+ projectPath?: string;
61
+ include3d?: boolean;
62
+ }
63
+
64
+ export async function regenerateCommand(options: RegenerateOptions): Promise<void> {
65
+ const include3d = options.include3d ?? true;
66
+
67
+ // First get the count
68
+ const components = await libraryService.listInstalled({ projectPath: options.projectPath });
69
+
70
+ if (components.length === 0) {
71
+ p.log.warn('No installed components found to regenerate.');
72
+ return;
73
+ }
74
+
75
+ p.log.info(`Found ${chalk.cyan(components.length)} installed components to regenerate.`);
76
+ console.log();
77
+
78
+ let currentSpinner: ReturnType<typeof p.spinner> | null = null;
79
+
80
+ const result = await libraryService.regenerate({
81
+ projectPath: options.projectPath,
82
+ include3d,
83
+ onProgress: (current: number, total: number, component: InstalledComponent, status: 'start' | 'success' | 'error', error?: string) => {
84
+ if (status === 'start') {
85
+ currentSpinner = p.spinner();
86
+ currentSpinner.start(`[${current}/${total}] Regenerating ${chalk.cyan(component.name)} (${component.lcscId})...`);
87
+ } else if (status === 'success') {
88
+ currentSpinner?.stop(chalk.green(`✓ [${current}/${total}] ${component.name} (${component.lcscId})`));
89
+ } else if (status === 'error') {
90
+ currentSpinner?.stop(chalk.red(`✗ [${current}/${total}] ${component.name} (${component.lcscId}): ${error}`));
91
+ }
92
+ },
93
+ });
94
+
95
+ console.log();
96
+ p.log.success(`Regeneration complete: ${chalk.green(result.success)} succeeded, ${chalk.red(result.failed)} failed`);
97
+
98
+ if (result.failed > 0) {
99
+ console.log();
100
+ p.log.warn('Failed components:');
101
+ for (const comp of result.components.filter(c => c.status === 'failed')) {
102
+ console.log(chalk.red(` • ${comp.name} (${comp.id}): ${comp.error}`));
103
+ }
104
+ }
56
105
  }
@@ -30,7 +30,7 @@ export async function searchCommand(query: string, options: SearchOptions): Prom
30
30
 
31
31
  // Clear the "Searching..." line and launch interactive UI
32
32
  process.stdout.write('\x1b[1A\x1b[2K');
33
- renderApp('search', { query, results });
33
+ await renderApp('search', { query, results });
34
34
  } catch (error) {
35
35
  console.error(`Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
36
36
  process.exit(1);
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Validate command
3
+ * Compare generated footprints/symbols against JLCPCB reference
4
+ */
5
+
6
+ import * as p from '@clack/prompts';
7
+ import chalk from 'chalk';
8
+ import { writeFileSync } from 'fs';
9
+ import {
10
+ fetchReferenceSVG,
11
+ extractFromReferenceSVG,
12
+ extractFromKiCadFootprint,
13
+ compareFootprints,
14
+ formatComparisonResult,
15
+ extractSymbolFromReferenceSVG,
16
+ extractFromKiCadSymbol,
17
+ compareSymbols,
18
+ formatSymbolComparisonResult,
19
+ generateValidationReport,
20
+ generateBatchReport,
21
+ TEST_CATEGORIES,
22
+ getAllTestComponents,
23
+ getTestComponentsByCategory,
24
+ getCategoryNames,
25
+ renderFootprintSvg,
26
+ renderSymbolSvg,
27
+ type ValidationResult,
28
+ type ReportSvgs,
29
+ easyedaClient,
30
+ footprintConverter,
31
+ symbolConverter,
32
+ } from '@jlcpcb/core';
33
+
34
+ interface ValidateOptions {
35
+ footprintOnly?: boolean;
36
+ symbolOnly?: boolean;
37
+ category?: string;
38
+ all?: boolean;
39
+ html?: string;
40
+ json?: boolean;
41
+ }
42
+
43
+ /**
44
+ * Validate a single component
45
+ */
46
+ async function validateComponent(
47
+ lcscCode: string,
48
+ options: { footprint: boolean; symbol: boolean }
49
+ ): Promise<ValidationResult> {
50
+ const startTime = Date.now();
51
+ const normalizedCode = lcscCode.replace(/^C/i, '');
52
+ const fullCode = `C${normalizedCode}`;
53
+
54
+ try {
55
+ // Fetch reference SVG
56
+ const reference = await fetchReferenceSVG(fullCode);
57
+
58
+ // Fetch component data
59
+ const componentData = await easyedaClient.getComponentData(fullCode);
60
+ if (!componentData) {
61
+ return {
62
+ lcscCode: fullCode,
63
+ componentName: fullCode,
64
+ passed: false,
65
+ footprint: null,
66
+ symbol: null,
67
+ timestamp: new Date(),
68
+ durationMs: Date.now() - startTime,
69
+ error: 'Failed to fetch component data from EasyEDA',
70
+ };
71
+ }
72
+
73
+ let footprintResult = null;
74
+ let symbolResult = null;
75
+
76
+ // Validate footprint
77
+ if (options.footprint && reference.footprintSvg) {
78
+ const kicadContent = footprintConverter.convert(componentData, {});
79
+
80
+ const refData = extractFromReferenceSVG(reference.footprintSvg);
81
+ const genData = extractFromKiCadFootprint(kicadContent);
82
+
83
+ footprintResult = compareFootprints(refData, genData, {
84
+ sizeWarningsOnly: true,
85
+ positionTolerance: 50,
86
+ });
87
+ }
88
+
89
+ // Validate symbol
90
+ if (options.symbol && reference.symbolSvg) {
91
+ const kicadContent = symbolConverter.convert(componentData, {
92
+ symbolName: componentData.info.name,
93
+ });
94
+
95
+ const refData = extractSymbolFromReferenceSVG(reference.symbolSvg);
96
+ const genData = extractFromKiCadSymbol(kicadContent);
97
+
98
+ symbolResult = compareSymbols(refData, genData, {
99
+ positionTolerance: 50,
100
+ });
101
+ }
102
+
103
+ const passed =
104
+ (!footprintResult || footprintResult.passed) && (!symbolResult || symbolResult.passed);
105
+
106
+ return {
107
+ lcscCode: fullCode,
108
+ componentName: componentData.info.name || fullCode,
109
+ passed,
110
+ footprint: footprintResult,
111
+ symbol: symbolResult,
112
+ timestamp: new Date(),
113
+ durationMs: Date.now() - startTime,
114
+ };
115
+ } catch (error) {
116
+ return {
117
+ lcscCode: fullCode,
118
+ componentName: fullCode,
119
+ passed: false,
120
+ footprint: null,
121
+ symbol: null,
122
+ timestamp: new Date(),
123
+ durationMs: Date.now() - startTime,
124
+ error: error instanceof Error ? error.message : String(error),
125
+ };
126
+ }
127
+ }
128
+
129
+ export async function validateCommand(
130
+ id: string | undefined,
131
+ options: ValidateOptions
132
+ ): Promise<void> {
133
+ const validateFootprint = !options.symbolOnly;
134
+ const validateSymbol = !options.footprintOnly;
135
+
136
+ // Single component validation
137
+ if (id && !options.category && !options.all) {
138
+ const spinner = p.spinner();
139
+ spinner.start(`Validating ${id}...`);
140
+
141
+ const result = await validateComponent(id, {
142
+ footprint: validateFootprint,
143
+ symbol: validateSymbol,
144
+ });
145
+
146
+ spinner.stop(result.passed ? chalk.green('✓ Validation passed') : chalk.red('✗ Validation failed'));
147
+
148
+ // Display results
149
+ console.log();
150
+ console.log(chalk.bold(`${result.componentName} (${result.lcscCode})`));
151
+ console.log(chalk.dim(`Duration: ${result.durationMs}ms`));
152
+ console.log();
153
+
154
+ if (result.error) {
155
+ console.log(chalk.red(`Error: ${result.error}`));
156
+ process.exit(1);
157
+ }
158
+
159
+ if (result.footprint) {
160
+ console.log(formatComparisonResult(result.footprint));
161
+ }
162
+
163
+ if (result.symbol) {
164
+ console.log(formatSymbolComparisonResult(result.symbol));
165
+ }
166
+
167
+ // JSON output
168
+ if (options.json) {
169
+ console.log(JSON.stringify(result, null, 2));
170
+ }
171
+
172
+ // HTML output
173
+ if (options.html) {
174
+ const reference = await fetchReferenceSVG(id);
175
+ const componentData = await easyedaClient.getComponentData(id);
176
+
177
+ const svgs: ReportSvgs = {
178
+ footprintRef: reference.footprintSvg || undefined,
179
+ symbolRef: reference.symbolSvg || undefined,
180
+ };
181
+
182
+ // Generate our KiCad output and render to SVG
183
+ if (componentData) {
184
+ if (validateFootprint) {
185
+ const kicadFootprint = footprintConverter.convert(componentData, {});
186
+ svgs.footprintGen = renderFootprintSvg(kicadFootprint);
187
+ }
188
+ if (validateSymbol) {
189
+ const kicadSymbol = symbolConverter.convert(componentData, {
190
+ symbolName: componentData.info.name,
191
+ });
192
+ svgs.symbolGen = renderSymbolSvg(kicadSymbol);
193
+ }
194
+ }
195
+
196
+ const html = generateValidationReport(result, svgs);
197
+ writeFileSync(options.html, html);
198
+ console.log(chalk.dim(`HTML report: ${options.html}`));
199
+ }
200
+
201
+ process.exit(result.passed ? 0 : 1);
202
+ }
203
+
204
+ // Batch validation
205
+ let components = options.all
206
+ ? getAllTestComponents()
207
+ : options.category
208
+ ? getTestComponentsByCategory(options.category)
209
+ : [];
210
+
211
+ if (components.length === 0 && !id) {
212
+ // Interactive category selection
213
+ const categories = getCategoryNames();
214
+ const selected = await p.select({
215
+ message: 'Select a category to validate:',
216
+ options: [
217
+ { value: 'all', label: 'All categories' },
218
+ ...categories.map((c) => ({ value: c, label: c })),
219
+ ],
220
+ });
221
+
222
+ if (p.isCancel(selected)) {
223
+ p.cancel('Validation cancelled');
224
+ process.exit(0);
225
+ }
226
+
227
+ components =
228
+ selected === 'all' ? getAllTestComponents() : getTestComponentsByCategory(selected as string);
229
+ }
230
+
231
+ // Filter out skipped components
232
+ components = components.filter((c) => !c.skip);
233
+
234
+ if (components.length === 0) {
235
+ p.log.warn('No components to validate');
236
+ process.exit(0);
237
+ }
238
+
239
+ console.log(chalk.bold(`\nValidating ${components.length} components...\n`));
240
+
241
+ const results: ValidationResult[] = [];
242
+ let passed = 0;
243
+ let failed = 0;
244
+ let errors = 0;
245
+
246
+ for (const component of components) {
247
+ process.stdout.write(` ${component.lcsc.padEnd(12)} ${component.name.slice(0, 40).padEnd(42)} `);
248
+
249
+ const result = await validateComponent(component.lcsc, {
250
+ footprint: validateFootprint,
251
+ symbol: validateSymbol,
252
+ });
253
+
254
+ results.push(result);
255
+
256
+ if (result.error) {
257
+ errors++;
258
+ console.log(chalk.yellow('⚠ ERROR'));
259
+ } else if (result.passed) {
260
+ passed++;
261
+ console.log(chalk.green('✓ PASS'));
262
+ } else {
263
+ failed++;
264
+ console.log(chalk.red('✗ FAIL'));
265
+ }
266
+ }
267
+
268
+ // Summary
269
+ console.log();
270
+ console.log(chalk.bold('Summary:'));
271
+ console.log(` ${chalk.green(`${passed} passed`)} ${chalk.red(`${failed} failed`)} ${chalk.yellow(`${errors} errors`)}`);
272
+ console.log();
273
+
274
+ // JSON output
275
+ if (options.json) {
276
+ console.log(JSON.stringify(results, null, 2));
277
+ }
278
+
279
+ // HTML output
280
+ if (options.html) {
281
+ const html = generateBatchReport(results, 'JLC-CLI Validation Report');
282
+ writeFileSync(options.html, html);
283
+ console.log(chalk.dim(`HTML report: ${options.html}`));
284
+ }
285
+
286
+ process.exit(failed > 0 || errors > 0 ? 1 : 0);
287
+ }
package/src/index.ts CHANGED
@@ -9,8 +9,9 @@ import { Command } from 'commander';
9
9
  import { searchCommand } from './commands/search.js';
10
10
  import { infoCommand } from './commands/info.js';
11
11
  import { installCommand } from './commands/install.js';
12
- import { libraryCommand } from './commands/library.js';
12
+ import { libraryCommand, regenerateCommand } from './commands/library.js';
13
13
  import { easyedaSearchCommand, easyedaInstallCommand } from './commands/easyeda.js';
14
+ import { validateCommand } from './commands/validate.js';
14
15
 
15
16
  const program = new Command();
16
17
 
@@ -58,9 +59,14 @@ program
58
59
  });
59
60
  });
60
61
 
61
- program
62
+ // Library subcommand group
63
+ const library = program
62
64
  .command('library')
63
- .description('View JLC-MCP library status and installed components')
65
+ .description('JLC-MCP library management');
66
+
67
+ library
68
+ .command('status', { isDefault: true })
69
+ .description('View library status and installed components')
64
70
  .option('--json', 'Output as JSON')
65
71
  .action(async (options) => {
66
72
  await libraryCommand({
@@ -68,6 +74,38 @@ program
68
74
  });
69
75
  });
70
76
 
77
+ library
78
+ .command('regenerate')
79
+ .description('Regenerate all installed components (refetch and reconvert symbols, footprints, 3D models)')
80
+ .option('-p, --project <path>', 'Regenerate project-local library')
81
+ .option('--no-3d', 'Skip 3D model download')
82
+ .action(async (options) => {
83
+ await regenerateCommand({
84
+ projectPath: options.project,
85
+ include3d: options['3d'],
86
+ });
87
+ });
88
+
89
+ program
90
+ .command('validate [id]')
91
+ .description('Validate footprint/symbol generation against JLCPCB reference')
92
+ .option('--footprint-only', 'Only validate footprint')
93
+ .option('--symbol-only', 'Only validate symbol')
94
+ .option('-c, --category <name>', 'Validate all components in category')
95
+ .option('-a, --all', 'Validate all test fixtures')
96
+ .option('--html <path>', 'Output HTML report to file')
97
+ .option('--json', 'Output as JSON')
98
+ .action(async (id, options) => {
99
+ await validateCommand(id, {
100
+ footprintOnly: options.footprintOnly,
101
+ symbolOnly: options.symbolOnly,
102
+ category: options.category,
103
+ all: options.all,
104
+ html: options.html,
105
+ json: options.json,
106
+ });
107
+ });
108
+
71
109
  // EasyEDA subcommand group
72
110
  const easyeda = program
73
111
  .command('easyeda')