@jlcpcb/cli 0.1.1 → 0.3.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.
@@ -11,6 +11,11 @@ import { renderApp } from '../app/App.js';
11
11
  const componentService = createComponentService();
12
12
  const libraryService = createLibraryService();
13
13
 
14
+ // LCSC IDs match pattern: C followed by digits (e.g., C2040, C5446)
15
+ function isLcscId(id: string): boolean {
16
+ return /^C\d+$/i.test(id);
17
+ }
18
+
14
19
  interface InstallOptions {
15
20
  projectPath?: string;
16
21
  include3d?: boolean;
@@ -18,6 +23,13 @@ interface InstallOptions {
18
23
  }
19
24
 
20
25
  export async function installCommand(id: string | undefined, options: InstallOptions): Promise<void> {
26
+ // Check if ID looks like an EasyEDA UUID (not an LCSC ID)
27
+ if (id && !isLcscId(id)) {
28
+ p.log.error(`"${id}" is not an LCSC part number (e.g., C2040).`);
29
+ p.log.info(`For EasyEDA community components, use: ${chalk.cyan(`jlc easyeda install ${id}`)}`);
30
+ process.exit(1);
31
+ }
32
+
21
33
  // If ID provided with --force, do direct install (non-interactive)
22
34
  if (id && options.force) {
23
35
  const spinner = p.spinner();
@@ -56,18 +68,15 @@ export async function installCommand(id: string | undefined, options: InstallOpt
56
68
 
57
69
  // If ID provided (without --force), fetch component and launch TUI for install
58
70
  if (id) {
59
- const spinner = p.spinner();
60
- spinner.start(`Fetching component ${id}...`);
71
+ console.log(`Fetching component ${id}...`);
61
72
 
62
73
  try {
63
74
  const details = await componentService.getDetails(id);
64
- spinner.stop('Component found');
65
-
66
- // Launch TUI at info screen (user can navigate to install from there)
67
- 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 });
68
78
  } catch (error) {
69
- spinner.stop('Failed to fetch component');
70
- 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'}`);
71
80
  process.exit(1);
72
81
  }
73
82
  return;
@@ -88,8 +97,7 @@ export async function installCommand(id: string | undefined, options: InstallOpt
88
97
  process.exit(0);
89
98
  }
90
99
 
91
- const spinner = p.spinner();
92
- spinner.start(`Searching for "${query}"...`);
100
+ console.log(`Searching for "${query}"...`);
93
101
 
94
102
  const searchOptions: SearchOptions = { limit: 20 };
95
103
  let results = await componentService.search(query as string, searchOptions);
@@ -101,13 +109,12 @@ export async function installCommand(id: string | undefined, options: InstallOpt
101
109
  return 0;
102
110
  });
103
111
 
104
- spinner.stop(`Found ${results.length} results`);
105
-
106
112
  if (results.length === 0) {
107
- p.log.warn('No components found. Try a different search term.');
113
+ console.log('No components found. Try a different search term.');
108
114
  return;
109
115
  }
110
116
 
111
- // Launch TUI for selection and install
112
- 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 });
113
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,7 +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
+ import { easyedaSearchCommand, easyedaInstallCommand } from './commands/easyeda.js';
14
+ import { validateCommand } from './commands/validate.js';
13
15
 
14
16
  const program = new Command();
15
17
 
@@ -57,9 +59,14 @@ program
57
59
  });
58
60
  });
59
61
 
60
- program
62
+ // Library subcommand group
63
+ const library = program
61
64
  .command('library')
62
- .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')
63
70
  .option('--json', 'Output as JSON')
64
71
  .action(async (options) => {
65
72
  await libraryCommand({
@@ -67,4 +74,66 @@ program
67
74
  });
68
75
  });
69
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
+
109
+ // EasyEDA subcommand group
110
+ const easyeda = program
111
+ .command('easyeda')
112
+ .description('EasyEDA community component browser');
113
+
114
+ easyeda
115
+ .command('search <query...>')
116
+ .description('Open browser-based component search')
117
+ .option('-p, --port <number>', 'HTTP server port', '3847')
118
+ .action(async (queryParts: string[], options) => {
119
+ const query = queryParts.join(' ');
120
+ await easyedaSearchCommand(query, {
121
+ port: options.port ? parseInt(options.port, 10) : undefined,
122
+ });
123
+ });
124
+
125
+ easyeda
126
+ .command('install [uuid]')
127
+ .description('Install EasyEDA community component to KiCad libraries')
128
+ .option('-p, --project <path>', 'Install to project-local library')
129
+ .option('--with-3d', 'Include 3D model')
130
+ .option('-f, --force', 'Force reinstall (regenerate symbol and footprint)')
131
+ .action(async (uuid, options) => {
132
+ await easyedaInstallCommand(uuid, {
133
+ projectPath: options.project,
134
+ include3d: options.with3d,
135
+ force: options.force,
136
+ });
137
+ });
138
+
70
139
  program.parse();