@jlcpcb/cli 0.3.2 → 0.4.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +23 -5
  3. package/dist/assets/search.html +32 -31
  4. package/dist/index.js +7053 -6459
  5. package/package.json +1 -1
  6. package/src/app/App.tsx +1 -1
  7. package/src/app/__fixtures__/interactive-install-scenarios.tsx +149 -0
  8. package/src/app/__fixtures__/navigation-scenarios.tsx +103 -0
  9. package/src/app/__fixtures__/terminal.tsx +57 -0
  10. package/src/app/components/DetailView.tsx +8 -5
  11. package/src/app/components/ListView.tsx +1 -1
  12. package/src/app/components/list-view-format.test.ts +2 -0
  13. package/src/app/navigation/NavigationContext.tsx +1 -1
  14. package/src/app/navigation/types.ts +8 -1
  15. package/src/app/navigation.test.ts +24 -0
  16. package/src/app/screens/EasyEDAInfoScreen.tsx +7 -9
  17. package/src/app/screens/InfoScreen.tsx +37 -19
  18. package/src/app/screens/InstallScreen.tsx +3 -5
  19. package/src/app/screens/InstalledScreen.tsx +3 -3
  20. package/src/app/screens/LibrarySetupScreen.tsx +12 -5
  21. package/src/app/screens/SearchScreen.tsx +22 -18
  22. package/src/app/state/AppStateContext.tsx +0 -23
  23. package/src/commands/__fixtures__/failure-scenarios.ts +67 -0
  24. package/src/commands/agent-json.test.ts +194 -0
  25. package/src/commands/easyeda.ts +67 -14
  26. package/src/commands/failures.test.ts +105 -0
  27. package/src/commands/info.ts +6 -9
  28. package/src/commands/install.ts +43 -12
  29. package/src/commands/interactive-install.test.ts +29 -0
  30. package/src/commands/library.ts +14 -23
  31. package/src/commands/search.ts +36 -4
  32. package/src/commands/validate.ts +114 -53
  33. package/src/index.ts +38 -6
  34. package/src/utils/agent-output.ts +35 -0
  35. package/src/utils/search-result-output.ts +20 -0
@@ -18,7 +18,6 @@ import {
18
18
  formatSymbolComparisonResult,
19
19
  generateValidationReport,
20
20
  generateBatchReport,
21
- TEST_CATEGORIES,
22
21
  getAllTestComponents,
23
22
  getTestComponentsByCategory,
24
23
  getCategoryNames,
@@ -26,10 +25,12 @@ import {
26
25
  renderSymbolSvg,
27
26
  type ValidationResult,
28
27
  type ReportSvgs,
28
+ type TestComponent,
29
29
  easyedaClient,
30
30
  footprintConverter,
31
31
  symbolConverter,
32
32
  } from '@jlcpcb/core';
33
+ import { printJson, printJsonError } from '../utils/agent-output.js';
33
34
 
34
35
  interface ValidateOptions {
35
36
  footprintOnly?: boolean;
@@ -45,7 +46,8 @@ interface ValidateOptions {
45
46
  */
46
47
  async function validateComponent(
47
48
  lcscCode: string,
48
- options: { footprint: boolean; symbol: boolean }
49
+ options: { footprint: boolean; symbol: boolean },
50
+ identity?: Pick<TestComponent, 'expectedMpn' | 'expectedPackage'>
49
51
  ): Promise<ValidationResult> {
50
52
  const startTime = Date.now();
51
53
  const normalizedCode = lcscCode.replace(/^C/i, '');
@@ -69,9 +71,18 @@ async function validateComponent(
69
71
  error: 'Failed to fetch component data from EasyEDA',
70
72
  };
71
73
  }
74
+ if (identity?.expectedMpn !== undefined && componentData.info.name !== identity.expectedMpn) {
75
+ throw new Error(`Fixture MPN differs: expected ${identity.expectedMpn}, got ${componentData.info.name}`);
76
+ }
77
+ if (identity?.expectedPackage !== undefined && componentData.footprint.name !== identity.expectedPackage) {
78
+ throw new Error(`Fixture package differs: expected ${identity.expectedPackage}, got ${componentData.footprint.name}`);
79
+ }
72
80
 
73
81
  let footprintResult = null;
74
82
  let symbolResult = null;
83
+ const missingReferences: string[] = [];
84
+ if (options.footprint && !reference.footprintSvg) missingReferences.push('footprint');
85
+ if (options.symbol && !reference.symbolSvg) missingReferences.push('symbol');
75
86
 
76
87
  // Validate footprint
77
88
  if (options.footprint && reference.footprintSvg) {
@@ -80,10 +91,7 @@ async function validateComponent(
80
91
  const refData = extractFromReferenceSVG(reference.footprintSvg);
81
92
  const genData = extractFromKiCadFootprint(kicadContent);
82
93
 
83
- footprintResult = compareFootprints(refData, genData, {
84
- sizeWarningsOnly: true,
85
- positionTolerance: 50,
86
- });
94
+ footprintResult = compareFootprints(refData, genData);
87
95
  }
88
96
 
89
97
  // Validate symbol
@@ -95,12 +103,11 @@ async function validateComponent(
95
103
  const refData = extractSymbolFromReferenceSVG(reference.symbolSvg);
96
104
  const genData = extractFromKiCadSymbol(kicadContent);
97
105
 
98
- symbolResult = compareSymbols(refData, genData, {
99
- positionTolerance: 50,
100
- });
106
+ symbolResult = compareSymbols(refData, genData);
101
107
  }
102
108
 
103
109
  const passed =
110
+ missingReferences.length === 0 &&
104
111
  (!footprintResult || footprintResult.passed) && (!symbolResult || symbolResult.passed);
105
112
 
106
113
  return {
@@ -111,6 +118,9 @@ async function validateComponent(
111
118
  symbol: symbolResult,
112
119
  timestamp: new Date(),
113
120
  durationMs: Date.now() - startTime,
121
+ ...(missingReferences.length > 0
122
+ ? { error: `Missing reference SVG: ${missingReferences.join(', ')}` }
123
+ : {}),
114
124
  };
115
125
  } catch (error) {
116
126
  return {
@@ -130,46 +140,32 @@ export async function validateCommand(
130
140
  id: string | undefined,
131
141
  options: ValidateOptions
132
142
  ): Promise<void> {
143
+ if (options.footprintOnly && options.symbolOnly) {
144
+ if (options.json) {
145
+ printJsonError('invalid_options', '--footprint-only and --symbol-only are mutually exclusive');
146
+ } else {
147
+ p.log.error('--footprint-only and --symbol-only are mutually exclusive');
148
+ }
149
+ process.exit(1);
150
+ }
151
+
133
152
  const validateFootprint = !options.symbolOnly;
134
153
  const validateSymbol = !options.footprintOnly;
135
154
 
136
155
  // Single component validation
137
156
  if (id && !options.category && !options.all) {
138
- const spinner = p.spinner();
139
- spinner.start(`Validating ${id}...`);
157
+ const spinner = options.json ? null : p.spinner();
158
+ spinner?.start(`Validating ${id}...`);
140
159
 
141
160
  const result = await validateComponent(id, {
142
161
  footprint: validateFootprint,
143
162
  symbol: validateSymbol,
144
163
  });
145
164
 
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
- }
165
+ spinner?.stop(result.passed ? chalk.green('✓ Validation passed') : chalk.red('✗ Validation failed'));
162
166
 
163
- if (result.symbol) {
164
- console.log(formatSymbolComparisonResult(result.symbol));
165
- }
167
+ let htmlPath: string | undefined;
166
168
 
167
- // JSON output
168
- if (options.json) {
169
- console.log(JSON.stringify(result, null, 2));
170
- }
171
-
172
- // HTML output
173
169
  if (options.html) {
174
170
  const reference = await fetchReferenceSVG(id);
175
171
  const componentData = await easyedaClient.getComponentData(id);
@@ -195,9 +191,42 @@ export async function validateCommand(
195
191
 
196
192
  const html = generateValidationReport(result, svgs);
197
193
  writeFileSync(options.html, html);
198
- console.log(chalk.dim(`HTML report: ${options.html}`));
194
+ htmlPath = options.html;
199
195
  }
200
196
 
197
+ if (options.json) {
198
+ printJson({
199
+ success: result.passed && !result.error,
200
+ result,
201
+ html: htmlPath,
202
+ });
203
+ process.exit(result.passed && !result.error ? 0 : 1);
204
+ }
205
+
206
+ // Display results
207
+ console.log();
208
+ console.log(chalk.bold(`${result.componentName} (${result.lcscCode})`));
209
+ console.log(chalk.dim(`Duration: ${result.durationMs}ms`));
210
+ console.log();
211
+
212
+ if (result.error) {
213
+ console.log(chalk.red(`Error: ${result.error}`));
214
+ process.exit(1);
215
+ }
216
+
217
+ if (result.footprint) {
218
+ console.log(formatComparisonResult(result.footprint));
219
+ }
220
+
221
+ if (result.symbol) {
222
+ console.log(formatSymbolComparisonResult(result.symbol));
223
+ }
224
+
225
+ if (htmlPath) {
226
+ console.log(chalk.dim(`HTML report: ${htmlPath}`));
227
+ }
228
+
229
+
201
230
  process.exit(result.passed ? 0 : 1);
202
231
  }
203
232
 
@@ -209,6 +238,11 @@ export async function validateCommand(
209
238
  : [];
210
239
 
211
240
  if (components.length === 0 && !id) {
241
+ if (options.json) {
242
+ printJsonError('missing_validation_target', 'JSON validation requires an id, --category, or --all');
243
+ process.exit(1);
244
+ }
245
+
212
246
  // Interactive category selection
213
247
  const categories = getCategoryNames();
214
248
  const selected = await p.select({
@@ -232,11 +266,22 @@ export async function validateCommand(
232
266
  components = components.filter((c) => !c.skip);
233
267
 
234
268
  if (components.length === 0) {
269
+ if (options.json) {
270
+ printJson({
271
+ success: true,
272
+ summary: { total: 0, passed: 0, failed: 0, errors: 0 },
273
+ results: [],
274
+ });
275
+ process.exit(0);
276
+ }
277
+
235
278
  p.log.warn('No components to validate');
236
279
  process.exit(0);
237
280
  }
238
281
 
239
- console.log(chalk.bold(`\nValidating ${components.length} components...\n`));
282
+ if (!options.json) {
283
+ console.log(chalk.bold(`\nValidating ${components.length} components...\n`));
284
+ }
240
285
 
241
286
  const results: ValidationResult[] = [];
242
287
  let passed = 0;
@@ -244,43 +289,59 @@ export async function validateCommand(
244
289
  let errors = 0;
245
290
 
246
291
  for (const component of components) {
247
- process.stdout.write(` ${component.lcsc.padEnd(12)} ${component.name.slice(0, 40).padEnd(42)} `);
292
+ if (!options.json) {
293
+ process.stdout.write(` ${component.lcsc.padEnd(12)} ${component.name.slice(0, 40).padEnd(42)} `);
294
+ }
248
295
 
249
296
  const result = await validateComponent(component.lcsc, {
250
297
  footprint: validateFootprint,
251
298
  symbol: validateSymbol,
252
- });
299
+ }, component);
253
300
 
254
301
  results.push(result);
255
302
 
256
303
  if (result.error) {
257
304
  errors++;
258
- console.log(chalk.yellow('⚠ ERROR'));
305
+ if (!options.json) console.log(chalk.yellow(`ERROR: ${result.error}`));
259
306
  } else if (result.passed) {
260
307
  passed++;
261
- console.log(chalk.green('✓ PASS'));
308
+ if (!options.json) console.log(chalk.green('✓ PASS'));
262
309
  } else {
263
310
  failed++;
264
- console.log(chalk.red('✗ FAIL'));
311
+ if (!options.json) console.log(chalk.red('✗ FAIL'));
265
312
  }
266
313
  }
267
314
 
315
+ let htmlPath: string | undefined;
316
+ if (options.html) {
317
+ const html = generateBatchReport(results, 'JLC-CLI Validation Report');
318
+ writeFileSync(options.html, html);
319
+ htmlPath = options.html;
320
+ }
321
+
322
+ if (options.json) {
323
+ printJson({
324
+ success: failed === 0 && errors === 0,
325
+ summary: {
326
+ total: results.length,
327
+ passed,
328
+ failed,
329
+ errors,
330
+ },
331
+ results,
332
+ html: htmlPath,
333
+ });
334
+ process.exit(failed > 0 || errors > 0 ? 1 : 0);
335
+ }
336
+
268
337
  // Summary
269
338
  console.log();
270
339
  console.log(chalk.bold('Summary:'));
271
340
  console.log(` ${chalk.green(`${passed} passed`)} ${chalk.red(`${failed} failed`)} ${chalk.yellow(`${errors} errors`)}`);
272
341
  console.log();
273
342
 
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}`));
343
+ if (htmlPath) {
344
+ console.log(chalk.dim(`HTML report: ${htmlPath}`));
284
345
  }
285
346
 
286
347
  process.exit(failed > 0 || errors > 0 ? 1 : 0);
package/src/index.ts CHANGED
@@ -6,34 +6,49 @@
6
6
  */
7
7
 
8
8
  import { Command } from 'commander';
9
+ import { createRequire } from 'module';
9
10
  import { searchCommand } from './commands/search.js';
10
11
  import { infoCommand } from './commands/info.js';
11
12
  import { installCommand } from './commands/install.js';
12
13
  import { libraryCommand, regenerateCommand } from './commands/library.js';
13
14
  import { easyedaSearchCommand, easyedaInstallCommand } from './commands/easyeda.js';
14
15
  import { validateCommand } from './commands/validate.js';
16
+ import { getErrorMessage } from './utils/agent-output.js';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const { version } = require('../package.json');
15
20
 
16
21
  const program = new Command();
17
22
 
23
+ function parsePositiveInt(value: string): number {
24
+ const parsed = Number.parseInt(value, 10);
25
+ if (!Number.isInteger(parsed) || parsed <= 0) {
26
+ throw new Error(`Expected a positive integer, got "${value}"`);
27
+ }
28
+ return parsed;
29
+ }
30
+
18
31
  program
19
32
  .name('jlc')
20
33
  .description('JLC/EasyEDA component sourcing and KiCad library management')
21
- .version('0.1.0');
34
+ .version(version);
22
35
 
23
36
  program
24
37
  .command('search <query...>')
25
38
  .description('Search for components (basic parts sorted first)')
26
- .option('-l, --limit <number>', 'Maximum results', '20')
39
+ .option('-l, --limit <number>', 'Maximum results', parsePositiveInt, 20)
27
40
  .option('--in-stock', 'Only show in-stock components')
28
41
  .option('--basic-only', 'Only show basic parts (no extended)')
29
42
  .option('--community', 'Search EasyEDA community library')
43
+ .option('--json', 'Output clean JSON and do not launch the TUI')
30
44
  .action(async (queryParts: string[], options) => {
31
45
  const query = queryParts.join(' ');
32
46
  await searchCommand(query, {
33
- limit: parseInt(options.limit, 10),
47
+ limit: options.limit,
34
48
  inStock: options.inStock,
35
49
  basicOnly: options.basicOnly || false,
36
50
  source: options.community ? 'easyeda-community' : 'lcsc',
51
+ json: options.json,
37
52
  });
38
53
  });
39
54
 
@@ -50,11 +65,15 @@ program
50
65
  .description('Install component to KiCad libraries')
51
66
  .option('-p, --project <path>', 'Install to project-local library')
52
67
  .option('--with-3d', 'Include 3D model')
68
+ .option('-y, --yes', 'Install directly without launching the TUI')
69
+ .option('--json', 'Output clean JSON and install directly')
53
70
  .option('-f, --force', 'Force reinstall (regenerate symbol and footprint)')
54
71
  .action(async (id, options) => {
55
72
  await installCommand(id, {
56
73
  projectPath: options.project,
57
74
  include3d: options.with3d,
75
+ yes: options.yes,
76
+ json: options.json,
58
77
  force: options.force,
59
78
  });
60
79
  });
@@ -114,11 +133,17 @@ const easyeda = program
114
133
  easyeda
115
134
  .command('search <query...>')
116
135
  .description('Open browser-based component search')
117
- .option('-p, --port <number>', 'HTTP server port', '3847')
136
+ .option('-p, --port <number>', 'HTTP server port', parsePositiveInt, 3847)
137
+ .option('--json', 'Output clean JSON search results and do not open a browser')
138
+ .option('--no-open', 'Start the browser server without opening a browser')
139
+ .option('--once', 'Print the browser URL and exit after the server is ready')
118
140
  .action(async (queryParts: string[], options) => {
119
141
  const query = queryParts.join(' ');
120
142
  await easyedaSearchCommand(query, {
121
- port: options.port ? parseInt(options.port, 10) : undefined,
143
+ port: options.port,
144
+ json: options.json,
145
+ open: options.open,
146
+ once: options.once,
122
147
  });
123
148
  });
124
149
 
@@ -127,13 +152,20 @@ easyeda
127
152
  .description('Install EasyEDA community component to KiCad libraries')
128
153
  .option('-p, --project <path>', 'Install to project-local library')
129
154
  .option('--with-3d', 'Include 3D model')
155
+ .option('-y, --yes', 'Install directly without launching the TUI')
156
+ .option('--json', 'Output clean JSON and install directly')
130
157
  .option('-f, --force', 'Force reinstall (regenerate symbol and footprint)')
131
158
  .action(async (uuid, options) => {
132
159
  await easyedaInstallCommand(uuid, {
133
160
  projectPath: options.project,
134
161
  include3d: options.with3d,
162
+ yes: options.yes,
163
+ json: options.json,
135
164
  force: options.force,
136
165
  });
137
166
  });
138
167
 
139
- program.parse();
168
+ program.parseAsync().catch((error) => {
169
+ console.error(`Error: ${getErrorMessage(error)}`);
170
+ process.exit(1);
171
+ });
@@ -0,0 +1,35 @@
1
+ export interface JsonError {
2
+ success: false;
3
+ error: {
4
+ code: string;
5
+ message: string;
6
+ retryable: boolean;
7
+ details?: unknown;
8
+ };
9
+ }
10
+
11
+ export function printJson(value: unknown): void {
12
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
13
+ }
14
+
15
+ export function printJsonError(
16
+ code: string,
17
+ message: string,
18
+ options: { retryable?: boolean; details?: unknown } = {}
19
+ ): void {
20
+ const payload: JsonError = {
21
+ success: false,
22
+ error: {
23
+ code,
24
+ message,
25
+ retryable: options.retryable ?? false,
26
+ details: options.details,
27
+ },
28
+ };
29
+
30
+ printJson(payload);
31
+ }
32
+
33
+ export function getErrorMessage(error: unknown): string {
34
+ return error instanceof Error ? error.message : String(error);
35
+ }
@@ -0,0 +1,20 @@
1
+ import type { ComponentSearchResult } from '@jlcpcb/core';
2
+
3
+ export function formatSearchResultForJson(result: ComponentSearchResult) {
4
+ return {
5
+ id: result.id,
6
+ id_type: result.idType,
7
+ lcsc_id: result.idType === 'lcsc' ? result.lcscId : undefined,
8
+ easyeda_uuid: result.idType === 'easyeda_uuid' ? result.easyedaUuid ?? result.id : undefined,
9
+ name: result.name,
10
+ manufacturer: result.manufacturer,
11
+ description: result.description,
12
+ package: result.package,
13
+ datasheet: result.datasheetPdf,
14
+ stock: result.stock,
15
+ price: result.price,
16
+ library_type: result.libraryType,
17
+ category: result.category,
18
+ attributes: result.attributes,
19
+ };
20
+ }