@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
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { join } from 'node:path';
4
+
5
+ function run(scenario: string) {
6
+ // Isolate module mocks and process.exit from other CLI/core tests.
7
+ return spawnSync(process.execPath, [join(import.meta.dir, '__fixtures__/failure-scenarios.ts'), scenario], {
8
+ encoding: 'utf8',
9
+ timeout: 15_000,
10
+ });
11
+ }
12
+
13
+ describe('command failure exit status', () => {
14
+ it('reports failed regenerations and exits unsuccessfully', () => {
15
+ const result = run('regenerate');
16
+ expect(result.status).toBe(1);
17
+ expect(result.stdout).toContain('C123');
18
+ expect(result.stdout).toContain('conversion failed');
19
+ });
20
+
21
+ it('does not pass absent requested reference SVGs in JSON', () => {
22
+ const result = run('missing');
23
+ expect(result.status).toBe(1);
24
+ expect(JSON.parse(result.stdout)).toMatchObject({
25
+ success: false,
26
+ result: { passed: false, error: expect.stringContaining('footprint, symbol') },
27
+ });
28
+ });
29
+
30
+ it('reports absent requested references in text', () => {
31
+ const result = run('text-missing');
32
+ expect(result.status).toBe(1);
33
+ expect(result.stdout).toContain('Missing reference SVG: footprint, symbol');
34
+ });
35
+
36
+ it('counts missing references as batch errors instead of passes', () => {
37
+ const result = run('batch-missing');
38
+ expect(result.status).toBe(1);
39
+ expect(JSON.parse(result.stdout)).toMatchObject({
40
+ success: false,
41
+ summary: { total: 1, passed: 0, failed: 0, errors: 1 },
42
+ });
43
+ });
44
+
45
+ it('allows an absent footprint reference when its check was disabled', () => {
46
+ const result = run('disabled-footprint');
47
+ expect(result.status).toBe(0);
48
+ expect(JSON.parse(result.stdout)).toMatchObject({
49
+ success: true,
50
+ result: { passed: true, footprint: null, symbol: { passed: true } },
51
+ });
52
+ });
53
+
54
+ it('rejects symbol pin displacement through the real comparator', () => {
55
+ const result = run('symbol-moved');
56
+ expect(result.status).toBe(1);
57
+ const output = JSON.parse(result.stdout);
58
+ expect(output.success).toBe(false);
59
+ expect(output.result.symbol.errors.some((e: { field: string }) => e.field === 'position')).toBe(true);
60
+ });
61
+
62
+ it('does not pass symbol pins with unknown reference direction', () => {
63
+ const result = run('symbol-unverified');
64
+ expect(result.status).toBe(1);
65
+ const output = JSON.parse(result.stdout);
66
+ expect(output.success).toBe(false);
67
+ expect(output.result.symbol.errors.some((e: { field: string }) => e.field === 'coverage')).toBe(true);
68
+ });
69
+
70
+ it('rejects a manufacturing position mismatch through the real comparator', () => {
71
+ const result = run('footprint-moved');
72
+ expect(result.status).toBe(1);
73
+ const output = JSON.parse(result.stdout);
74
+ expect(output.success).toBe(false);
75
+ expect(output.result.footprint.errors.some((e: { field: string }) => e.field === 'position')).toBe(true);
76
+ });
77
+
78
+ it('does not claim success for an unverified reference copper layer', () => {
79
+ const result = run('footprint-unverified');
80
+ expect(result.status).toBe(1);
81
+ const output = JSON.parse(result.stdout);
82
+ expect(output.success).toBe(false);
83
+ expect(output.result.footprint.errors.some((e: { field: string }) => e.field === 'coverage')).toBe(true);
84
+ });
85
+
86
+ it('validates matching, fully described manufacturing geometry', () => {
87
+ const result = run('footprint-matching');
88
+ expect(result.status).toBe(0);
89
+ expect(JSON.parse(result.stdout)).toMatchObject({
90
+ success: true,
91
+ result: { passed: true, footprint: { passed: true, errors: [] } },
92
+ });
93
+ });
94
+
95
+ it.each(['mpn', 'package'])('rejects a changed fixture %s during batch validation', (field) => {
96
+ const result = run(`footprint-identity-${field}`);
97
+ expect(result.status).toBe(1);
98
+ const output = JSON.parse(result.stdout);
99
+ expect(output).toMatchObject({
100
+ success: false,
101
+ summary: { total: 1, passed: 0, failed: 0, errors: 1 },
102
+ });
103
+ expect(output.results[0].error).toMatch(field === 'mpn' ? /Fixture MPN differs/ : /Fixture package differs/);
104
+ });
105
+ });
@@ -3,10 +3,9 @@
3
3
  * Display component details
4
4
  */
5
5
 
6
- import * as p from '@clack/prompts';
7
- import chalk from 'chalk';
8
6
  import { createComponentService } from '@jlcpcb/core';
9
7
  import { renderApp } from '../app/App.js';
8
+ import { printJson, printJsonError, getErrorMessage } from '../utils/agent-output.js';
10
9
 
11
10
  const componentService = createComponentService();
12
11
 
@@ -17,16 +16,14 @@ interface InfoOptions {
17
16
  export async function infoCommand(id: string, options: InfoOptions): Promise<void> {
18
17
  // JSON mode - non-interactive output for scripting
19
18
  if (options.json) {
20
- const spinner = p.spinner();
21
- spinner.start(`Fetching component ${id}...`);
22
-
23
19
  try {
24
20
  const details = await componentService.getDetails(id);
25
- spinner.stop('Component found');
26
- console.log(JSON.stringify(details, null, 2));
21
+ printJson({
22
+ success: true,
23
+ component: details,
24
+ });
27
25
  } catch (error) {
28
- spinner.stop('Failed to fetch component');
29
- p.log.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
26
+ printJsonError('component_fetch_failed', getErrorMessage(error), { retryable: true });
30
27
  process.exit(1);
31
28
  }
32
29
  return;
@@ -7,6 +7,7 @@ import * as p from '@clack/prompts';
7
7
  import chalk from 'chalk';
8
8
  import { createComponentService, createLibraryService, type SearchOptions } from '@jlcpcb/core';
9
9
  import { renderApp } from '../app/App.js';
10
+ import { printJson, printJsonError, getErrorMessage } from '../utils/agent-output.js';
10
11
 
11
12
  const componentService = createComponentService();
12
13
  const libraryService = createLibraryService();
@@ -19,34 +20,60 @@ function isLcscId(id: string): boolean {
19
20
  interface InstallOptions {
20
21
  projectPath?: string;
21
22
  include3d?: boolean;
23
+ yes?: boolean;
24
+ json?: boolean;
22
25
  force?: boolean;
23
26
  }
24
27
 
25
28
  export async function installCommand(id: string | undefined, options: InstallOptions): Promise<void> {
29
+ if (!id && (options.yes || options.json)) {
30
+ if (options.json) {
31
+ printJsonError('missing_id', 'Direct install requires an LCSC part number');
32
+ } else {
33
+ p.log.error('Direct install requires an LCSC part number.');
34
+ }
35
+ process.exit(1);
36
+ }
37
+
26
38
  // Check if ID looks like an EasyEDA UUID (not an LCSC ID)
27
39
  if (id && !isLcscId(id)) {
40
+ if (options.json) {
41
+ printJsonError('invalid_lcsc_id', `"${id}" is not an LCSC part number`, {
42
+ details: { hint: `Use jlc easyeda install ${id}` },
43
+ });
44
+ process.exit(1);
45
+ }
46
+
28
47
  p.log.error(`"${id}" is not an LCSC part number (e.g., C2040).`);
29
48
  p.log.info(`For EasyEDA community components, use: ${chalk.cyan(`jlc easyeda install ${id}`)}`);
30
49
  process.exit(1);
31
50
  }
32
51
 
33
- // If ID provided with --force, do direct install (non-interactive)
34
- if (id && options.force) {
35
- const spinner = p.spinner();
36
- spinner.start(`Installing component ${id}...`);
52
+ // If ID provided with --yes or --json, do direct install (non-interactive).
53
+ if (id && (options.yes || options.json)) {
54
+ const spinner = options.json ? null : p.spinner();
55
+ spinner?.start(`Installing component ${id}...`);
37
56
 
38
57
  try {
39
58
  // Ensure libraries are set up
40
- await libraryService.ensureGlobalTables();
59
+ if (!options.projectPath) await libraryService.ensureGlobalTables();
41
60
 
42
61
  // Install the component
43
62
  const result = await libraryService.install(id, {
44
63
  projectPath: options.projectPath,
45
64
  include3d: options.include3d,
46
- force: true,
65
+ force: options.force,
47
66
  });
48
67
 
49
- spinner.stop(chalk.green('✓ Component installed'));
68
+ spinner?.stop(chalk.green('✓ Component installed'));
69
+
70
+ if (options.json) {
71
+ printJson({
72
+ success: true,
73
+ result,
74
+ });
75
+ return;
76
+ }
50
77
 
51
78
  // Display result
52
79
  console.log();
@@ -59,14 +86,18 @@ export async function installCommand(id: string | undefined, options: InstallOpt
59
86
  console.log();
60
87
  console.log(chalk.dim(`Library: ${result.files.symbolLibrary}`));
61
88
  } catch (error) {
62
- spinner.stop(chalk.red('✗ Installation failed'));
63
- p.log.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
89
+ spinner?.stop(chalk.red('✗ Installation failed'));
90
+ if (options.json) {
91
+ printJsonError('install_failed', getErrorMessage(error), { retryable: true });
92
+ } else {
93
+ p.log.error(`Error: ${getErrorMessage(error)}`);
94
+ }
64
95
  process.exit(1);
65
96
  }
66
97
  return;
67
98
  }
68
99
 
69
- // If ID provided (without --force), fetch component and launch TUI for install
100
+ // If ID provided, fetch component and launch TUI for install.
70
101
  if (id) {
71
102
  console.log(`Fetching component ${id}...`);
72
103
 
@@ -74,7 +105,7 @@ export async function installCommand(id: string | undefined, options: InstallOpt
74
105
  const details = await componentService.getDetails(id);
75
106
  // Clear the "Fetching..." line and launch interactive UI
76
107
  process.stdout.write('\x1b[1A\x1b[2K');
77
- await renderApp('info', { componentId: id, component: details as any });
108
+ await renderApp('info', { componentId: id, component: details, installOptions: options });
78
109
  } catch (error) {
79
110
  console.error(`Failed to fetch component: ${error instanceof Error ? error.message : 'Unknown error'}`);
80
111
  process.exit(1);
@@ -116,5 +147,5 @@ export async function installCommand(id: string | undefined, options: InstallOpt
116
147
 
117
148
  // Clear the "Searching..." line and launch interactive UI
118
149
  process.stdout.write('\x1b[1A\x1b[2K');
119
- await renderApp('search', { query: query as string, results });
150
+ await renderApp('search', { query: query as string, results, searchOptions, installOptions: options });
120
151
  }
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ describe('interactive install artifacts', () => {
8
+ for (const [scenario, behavior] of [
9
+ ['lcsc', 'reinstalls the selected LCSC project artifact with downloaded 3D bytes through no-ID search'],
10
+ ['community', 'ignores global community installation status and installs/reinstalls locally without 3D through no-ID search'],
11
+ ]) {
12
+ it(behavior, () => {
13
+ // Core services and Ink are real; only upstream clients and terminal/prompt IO are substituted.
14
+ const root = mkdtempSync(join(tmpdir(), 'jlc-interactive-install-'));
15
+ try {
16
+ const result = spawnSync(process.execPath, [
17
+ join(import.meta.dir, '../app/__fixtures__/interactive-install-scenarios.tsx'), scenario, root,
18
+ ], {
19
+ encoding: 'utf8',
20
+ timeout: 15_000,
21
+ env: { ...process.env, HOME: join(root, 'home'), USERPROFILE: join(root, 'home'), XDG_CONFIG_HOME: join(root, 'config') },
22
+ });
23
+ expect({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: '' });
24
+ } finally {
25
+ rmSync(root, { recursive: true, force: true });
26
+ }
27
+ }, 20_000);
28
+ }
29
+ });
@@ -7,6 +7,7 @@ import * as p from '@clack/prompts';
7
7
  import chalk from 'chalk';
8
8
  import { createLibraryService, type InstalledComponent } from '@jlcpcb/core';
9
9
  import { renderApp } from '../app/App.js';
10
+ import { printJson, printJsonError, getErrorMessage } from '../utils/agent-output.js';
10
11
 
11
12
  const libraryService = createLibraryService();
12
13
 
@@ -17,36 +18,25 @@ interface LibraryOptions {
17
18
  export async function libraryCommand(options: LibraryOptions): Promise<void> {
18
19
  // JSON mode - non-interactive output for scripting
19
20
  if (options.json) {
20
- const spinner = p.spinner();
21
- spinner.start('Loading library status...');
22
-
23
21
  try {
24
22
  const [status, components] = await Promise.all([
25
23
  libraryService.getStatus(),
26
24
  libraryService.listInstalled({}),
27
25
  ]);
28
26
 
29
- spinner.stop(`Found ${components.length} installed components`);
30
-
31
- console.log(
32
- JSON.stringify(
33
- {
34
- status: {
35
- installed: status.installed,
36
- linked: status.linked,
37
- version: status.version,
38
- componentCount: status.componentCount,
39
- paths: status.paths,
40
- },
41
- components,
42
- },
43
- null,
44
- 2
45
- )
46
- );
27
+ printJson({
28
+ success: true,
29
+ status: {
30
+ installed: status.installed,
31
+ linked: status.linked,
32
+ version: status.version,
33
+ componentCount: status.componentCount,
34
+ paths: status.paths,
35
+ },
36
+ components,
37
+ });
47
38
  } catch (error) {
48
- spinner.stop('Failed to get library status');
49
- p.log.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
39
+ printJsonError('library_status_failed', getErrorMessage(error), { retryable: false });
50
40
  process.exit(1);
51
41
  }
52
42
  return;
@@ -101,5 +91,6 @@ export async function regenerateCommand(options: RegenerateOptions): Promise<voi
101
91
  for (const comp of result.components.filter(c => c.status === 'failed')) {
102
92
  console.log(chalk.red(` • ${comp.name} (${comp.id}): ${comp.error}`));
103
93
  }
94
+ process.exitCode = 1;
104
95
  }
105
96
  }
@@ -5,11 +5,19 @@
5
5
 
6
6
  import { createComponentService, type SearchOptions } from '@jlcpcb/core';
7
7
  import { renderApp } from '../app/App.js';
8
+ import { printJson, printJsonError, getErrorMessage } from '../utils/agent-output.js';
9
+ import { formatSearchResultForJson } from '../utils/search-result-output.js';
8
10
 
9
11
  const componentService = createComponentService();
10
12
 
11
- export async function searchCommand(query: string, options: SearchOptions): Promise<void> {
12
- console.log(`Searching for "${query}"...`);
13
+ interface SearchCommandOptions extends SearchOptions {
14
+ json?: boolean;
15
+ }
16
+
17
+ export async function searchCommand(query: string, options: SearchCommandOptions): Promise<void> {
18
+ if (!options.json) {
19
+ console.log(`Searching for "${query}"...`);
20
+ }
13
21
 
14
22
  try {
15
23
  let results = await componentService.search(query, options);
@@ -24,15 +32,39 @@ export async function searchCommand(query: string, options: SearchOptions): Prom
24
32
  }
25
33
 
26
34
  if (results.length === 0) {
35
+ if (options.json) {
36
+ printJson({
37
+ success: true,
38
+ query,
39
+ count: 0,
40
+ results: [],
41
+ });
42
+ return;
43
+ }
44
+
27
45
  console.log('No components found. Try a different search term.');
28
46
  return;
29
47
  }
30
48
 
49
+ if (options.json) {
50
+ printJson({
51
+ success: true,
52
+ query,
53
+ count: results.length,
54
+ results: results.map(formatSearchResultForJson),
55
+ });
56
+ return;
57
+ }
58
+
31
59
  // Clear the "Searching..." line and launch interactive UI
32
60
  process.stdout.write('\x1b[1A\x1b[2K');
33
- await renderApp('search', { query, results });
61
+ await renderApp('search', { query, results, searchOptions: options });
34
62
  } catch (error) {
35
- console.error(`Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
63
+ if (options.json) {
64
+ printJsonError('search_failed', getErrorMessage(error), { retryable: true });
65
+ } else {
66
+ console.error(`Search failed: ${getErrorMessage(error)}`);
67
+ }
36
68
  process.exit(1);
37
69
  }
38
70
  }