@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
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useState } from 'react';
1
+ import React, { useEffect } from 'react';
2
2
  import { Box, Text } from 'ink';
3
3
  import { createLibraryService, type InstallResult } from '@jlcpcb/core';
4
4
  import { useNavigation, useCurrentScreen } from '../navigation/NavigationContext.js';
@@ -10,14 +10,12 @@ export function InstallScreen() {
10
10
  const { replace } = useNavigation();
11
11
  const { params } = useCurrentScreen() as { screen: 'install'; params: InstallParams };
12
12
 
13
- const [status, setStatus] = useState<'installing' | 'done'>('installing');
14
-
15
13
  useEffect(() => {
16
14
  let mounted = true;
17
15
 
18
16
  async function install() {
19
17
  try {
20
- const result: InstallResult = await libraryService.install(params.componentId, {});
18
+ const result: InstallResult = await libraryService.install(params.componentId, params.installOptions);
21
19
  if (mounted) {
22
20
  replace('installed', {
23
21
  componentId: params.componentId,
@@ -41,7 +39,7 @@ export function InstallScreen() {
41
39
  return () => {
42
40
  mounted = false;
43
41
  };
44
- }, [params.componentId, params.component, replace]);
42
+ }, [params.componentId, params.component, params.installOptions, replace]);
45
43
 
46
44
  return (
47
45
  <Box flexDirection="column">
@@ -10,8 +10,8 @@ export function InstalledScreen() {
10
10
  const { params } = useCurrentScreen() as { screen: 'installed'; params: InstalledParams };
11
11
  const { columns: terminalWidth } = useTerminalSize();
12
12
 
13
- useInput(() => {
14
- pop();
13
+ useInput((_input, key) => {
14
+ if (!key.escape) pop();
15
15
  });
16
16
 
17
17
  const result = params.result
@@ -29,7 +29,7 @@ export function InstalledScreen() {
29
29
  </Text>
30
30
  </Box>
31
31
  <InstalledView
32
- component={params.component}
32
+ component={{ ...params.component, lcscId: params.componentId }}
33
33
  result={result}
34
34
  error={params.error || null}
35
35
  terminalWidth={terminalWidth}
@@ -1,4 +1,4 @@
1
- import React, { useState } from 'react';
1
+ import React, { useEffect, useRef, useState } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import { createLibraryService } from '@jlcpcb/core';
4
4
  import { useNavigation, useCurrentScreen } from '../navigation/NavigationContext.js';
@@ -9,13 +9,19 @@ import { Divider } from '../components/Divider.js';
9
9
  const libraryService = createLibraryService();
10
10
 
11
11
  export function LibrarySetupScreen() {
12
- const { push, pop } = useNavigation();
12
+ const { replace, pop } = useNavigation();
13
13
  const { params } = useCurrentScreen() as { screen: 'library-setup'; params: LibrarySetupParams };
14
14
  const { columns: terminalWidth } = useTerminalSize();
15
15
 
16
16
  const [selectedOption, setSelectedOption] = useState<'install' | 'cancel'>('install');
17
17
  const [isInstalling, setIsInstalling] = useState(false);
18
18
  const [error, setError] = useState<string | null>(null);
19
+ const activeRef = useRef(true);
20
+
21
+ useEffect(() => {
22
+ activeRef.current = true;
23
+ return () => { activeRef.current = false; };
24
+ }, []);
19
25
 
20
26
  useInput((input, key) => {
21
27
  if (isInstalling) return;
@@ -31,19 +37,20 @@ export function LibrarySetupScreen() {
31
37
  libraryService
32
38
  .ensureGlobalTables()
33
39
  .then(() => {
40
+ if (!activeRef.current) return;
34
41
  // Success - continue to install the component
35
- push('install', {
42
+ replace('install', {
36
43
  componentId: params.componentId,
37
44
  component: params.component,
45
+ installOptions: params.installOptions,
38
46
  });
39
47
  })
40
48
  .catch((err) => {
49
+ if (!activeRef.current) return;
41
50
  setError(err instanceof Error ? err.message : 'Unknown error');
42
51
  setIsInstalling(false);
43
52
  });
44
53
  }
45
- } else if (key.escape) {
46
- pop();
47
54
  }
48
55
  });
49
56
 
@@ -1,51 +1,55 @@
1
1
  import React, { useState } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
- import { createComponentService, type ComponentSearchResult } from '@jlcpcb/core';
3
+ import { createComponentService } from '@jlcpcb/core';
4
4
  import { useNavigation, useCurrentScreen } from '../navigation/NavigationContext.js';
5
5
  import type { SearchParams } from '../navigation/types.js';
6
- import { useAppState } from '../state/AppStateContext.js';
7
6
  import { useTerminalSize } from '../hooks/useTerminalSize.js';
8
7
  import { ListView } from '../components/ListView.js';
9
8
 
10
9
  const componentService = createComponentService();
11
10
 
12
11
  export function SearchScreen() {
13
- const { push } = useNavigation();
12
+ const { push, replace } = useNavigation();
14
13
  const { params } = useCurrentScreen() as { screen: 'search'; params: SearchParams };
15
- const { selectedIndex, setSelectedIndex, isFiltered, setIsFiltered, resetSelection } = useAppState();
16
14
  const { columns: terminalWidth } = useTerminalSize();
17
15
 
18
- const [results, setResults] = useState<ComponentSearchResult[]>(params.results);
16
+ const { results, selectedIndex = 0 } = params;
17
+ const isFiltered = params.searchOptions?.basicOnly ?? false;
19
18
  const [isSearching, setIsSearching] = useState(false);
20
19
 
21
20
  useInput(async (input, key) => {
22
21
  if (isSearching) return;
23
22
 
24
23
  if (key.upArrow) {
25
- setSelectedIndex(Math.max(0, selectedIndex - 1));
24
+ replace('search', { ...params, selectedIndex: Math.max(0, selectedIndex - 1) });
26
25
  } else if (key.downArrow) {
27
- setSelectedIndex(Math.min(results.length - 1, selectedIndex + 1));
26
+ replace('search', { ...params, selectedIndex: Math.max(0, Math.min(results.length - 1, selectedIndex + 1)) });
28
27
  } else if (key.return && results[selectedIndex]) {
29
- push('info', {
30
- componentId: results[selectedIndex].lcscId,
31
- component: results[selectedIndex],
32
- });
28
+ const selected = results[selectedIndex];
29
+ if (selected.idType === 'easyeda_uuid') {
30
+ push('easyeda-info', {
31
+ uuid: selected.easyedaUuid ?? selected.id,
32
+ installOptions: params.installOptions,
33
+ });
34
+ } else {
35
+ push('info', {
36
+ componentId: selected.lcscId ?? selected.id,
37
+ component: selected,
38
+ installOptions: params.installOptions,
39
+ });
40
+ }
33
41
  } else if (key.tab) {
34
42
  setIsSearching(true);
35
43
  const newFiltered = !isFiltered;
36
44
  try {
37
- let newResults = await componentService.search(params.query, {
38
- limit: 20,
39
- basicOnly: newFiltered,
40
- });
45
+ const searchOptions = { ...params.searchOptions, basicOnly: newFiltered };
46
+ let newResults = await componentService.search(params.query, searchOptions);
41
47
  newResults = newResults.sort((a, b) => {
42
48
  if (a.libraryType === 'basic' && b.libraryType !== 'basic') return -1;
43
49
  if (a.libraryType !== 'basic' && b.libraryType === 'basic') return 1;
44
50
  return 0;
45
51
  });
46
- setResults(newResults);
47
- resetSelection();
48
- setIsFiltered(newFiltered);
52
+ replace('search', { ...params, results: newResults, searchOptions, selectedIndex: 0 });
49
53
  } catch {
50
54
  // Keep existing results on error
51
55
  }
@@ -2,15 +2,10 @@ import React, { createContext, useContext, useState, useCallback, type ReactNode
2
2
 
3
3
  export interface AppState {
4
4
  selectedIndex: number;
5
- isFiltered: boolean;
6
- isLoading: boolean;
7
5
  }
8
6
 
9
7
  export interface AppStateActions {
10
8
  setSelectedIndex: (index: number) => void;
11
- setIsFiltered: (filtered: boolean) => void;
12
- setIsLoading: (loading: boolean) => void;
13
- resetSelection: () => void;
14
9
  }
15
10
 
16
11
  export type AppStateContextValue = AppState & AppStateActions;
@@ -23,33 +18,15 @@ interface AppStateProviderProps {
23
18
 
24
19
  export function AppStateProvider({ children }: AppStateProviderProps) {
25
20
  const [selectedIndex, setSelectedIndexState] = useState(0);
26
- const [isFiltered, setIsFilteredState] = useState(false);
27
- const [isLoading, setIsLoadingState] = useState(false);
28
21
 
29
22
  const setSelectedIndex = useCallback((index: number) => {
30
23
  setSelectedIndexState(index);
31
24
  }, []);
32
25
 
33
- const setIsFiltered = useCallback((filtered: boolean) => {
34
- setIsFilteredState(filtered);
35
- }, []);
36
-
37
- const setIsLoading = useCallback((loading: boolean) => {
38
- setIsLoadingState(loading);
39
- }, []);
40
-
41
- const resetSelection = useCallback(() => {
42
- setSelectedIndexState(0);
43
- }, []);
44
26
 
45
27
  const value: AppStateContextValue = {
46
28
  selectedIndex,
47
- isFiltered,
48
- isLoading,
49
29
  setSelectedIndex,
50
- setIsFiltered,
51
- setIsLoading,
52
- resetSelection,
53
30
  };
54
31
 
55
32
  return <AppStateContext.Provider value={value}>{children}</AppStateContext.Provider>;
@@ -0,0 +1,67 @@
1
+ import { mock } from 'bun:test';
2
+ import * as core from '@jlcpcb/core';
3
+
4
+ const scenario = process.argv[2];
5
+ const manufacturing = scenario.startsWith('footprint-');
6
+ const symbolScenario = scenario === 'disabled-footprint' || scenario.startsWith('symbol-');
7
+ const symbolReference = `<svg>
8
+ <g c_partid="part_pin" c_spicepin="1" c_origin="-10,0" c_elec="1" ${scenario === 'symbol-unverified' ? '' : 'c_rotation="180"'}>
9
+ ${scenario === 'symbol-unverified' ? '' : '<path d="M-10,0h5"/>'}<text>IN</text><text>1</text>
10
+ </g>
11
+ <g c_partid="part_pin" c_spicepin="2" c_origin="10,0" c_elec="2" c_rotation="0">
12
+ <path d="M10,0h-5"/><text>OUT</text><text>2</text>
13
+ </g>
14
+ </svg>`;
15
+ const reference = manufacturing
16
+ ? {
17
+ footprintSvg: `<svg><g c_partid="part_pad" number="1" c_origin="0,0" ${scenario === 'footprint-unverified' ? '' : 'layerid="1"'}>
18
+ <rect x="-2" y="-1" width="4" height="2" />
19
+ <circle c_padhole="1" cx="0" cy="0" r="0" />
20
+ </g></svg>`,
21
+ symbolSvg: null,
22
+ }
23
+ : symbolScenario
24
+ ? { footprintSvg: null, symbolSvg: symbolReference }
25
+ : { footprintSvg: null, symbolSvg: null };
26
+
27
+ mock.module('@jlcpcb/core', () => ({
28
+ ...core,
29
+ fetchReferenceSVG: async () => reference,
30
+ easyedaClient: { getComponentData: async () => ({
31
+ info: { name: scenario === 'footprint-identity-mpn' ? 'DifferentPart' : 'Part' },
32
+ footprint: { name: scenario === 'footprint-identity-package' ? 'DifferentPackage' : 'PKG' },
33
+ }) },
34
+ footprintConverter: {
35
+ convert: () => `(footprint "Part" (pad "1" smd rect (at ${scenario === 'footprint-moved' ? 4 : 0} 0) (size 1.016 0.508) (layers "F.Cu")))`,
36
+ },
37
+ symbolConverter: {
38
+ convert: () => `(symbol "Part" (symbol "Part_1_1"
39
+ (pin input line (at -2.54 0 0) (length 1.27) (name "IN") (number "1"))
40
+ (pin output line (at ${scenario === 'symbol-moved' ? 4.54 : 2.54} 0 180) (length 1.27) (name "OUT") (number "2"))))`,
41
+ },
42
+ getAllTestComponents: () => [{ lcsc: 'C123', name: 'Part', expectedMpn: 'Part', expectedPackage: 'PKG' }],
43
+ createLibraryService: () => ({
44
+ listInstalled: async () => [{ lcscId: 'C123', name: 'Part' }],
45
+ regenerate: async () => ({
46
+ total: 1,
47
+ success: 0,
48
+ failed: 1,
49
+ components: [{ id: 'C123', name: 'Part', status: 'failed', error: 'conversion failed' }],
50
+ }),
51
+ }),
52
+ }));
53
+
54
+ // Commands must load after the subprocess-specific dependency mocks.
55
+ if (scenario === 'regenerate') {
56
+ const { regenerateCommand } = await import('../library.js');
57
+ await regenerateCommand({});
58
+ } else {
59
+ const { validateCommand } = await import('../validate.js');
60
+ const batch = scenario === 'batch-missing' || scenario.startsWith('footprint-identity-');
61
+ await validateCommand(batch ? undefined : 'C123', {
62
+ json: scenario !== 'text-missing',
63
+ all: batch,
64
+ symbolOnly: symbolScenario,
65
+ footprintOnly: manufacturing,
66
+ });
67
+ }
@@ -0,0 +1,194 @@
1
+ import { describe, expect, it, mock, spyOn } from 'bun:test';
2
+
3
+ const mockDetails = {
4
+ lcscId: 'C123',
5
+ name: 'Part',
6
+ manufacturer: 'Maker',
7
+ description: 'A part',
8
+ category: 'ICs',
9
+ package: 'SOT-23',
10
+ pinCount: 3,
11
+ padCount: 3,
12
+ has3DModel: false,
13
+ };
14
+
15
+ const mockSearchResults = [{
16
+ id: 'C123',
17
+ idType: 'lcsc' as const,
18
+ lcscId: 'C123',
19
+ name: 'Part',
20
+ manufacturer: 'Maker',
21
+ description: 'A part',
22
+ package: 'SOT-23',
23
+ stock: 10,
24
+ }];
25
+
26
+ const mockInstallResult = {
27
+ success: true,
28
+ id: 'C123',
29
+ source: 'lcsc' as const,
30
+ storageMode: 'global' as const,
31
+ category: 'ICs',
32
+ symbolName: 'Part',
33
+ symbolRef: 'JLC-MCP-ICs:Part',
34
+ footprintRef: 'Package:SOT-23',
35
+ footprintType: 'reference' as const,
36
+ files: {
37
+ symbolLibrary: '/tmp/symbols/JLC-MCP-ICs.kicad_sym',
38
+ },
39
+ symbolAction: 'exists' as const,
40
+ validationData: {
41
+ component: { name: 'Part' },
42
+ symbol: { pin_count: 3, pins: [] },
43
+ footprint: {
44
+ type: 'smd',
45
+ pad_count: 3,
46
+ pads: null,
47
+ is_kicad_standard: true,
48
+ kicad_ref: 'Package:SOT-23',
49
+ },
50
+ checks: {
51
+ pin_pad_count_match: true,
52
+ has_power_pins: false,
53
+ has_ground_pins: false,
54
+ },
55
+ },
56
+ };
57
+
58
+ mock.module('@jlcpcb/core', () => ({
59
+ createComponentService: () => ({
60
+ getDetails: async () => mockDetails,
61
+ search: async () => mockSearchResults,
62
+ }),
63
+ createLibraryService: () => ({
64
+ ensureGlobalTables: async () => undefined,
65
+ install: async () => mockInstallResult,
66
+ getStatus: async () => ({
67
+ installed: true,
68
+ linked: true,
69
+ version: '9.0',
70
+ componentCount: 1,
71
+ paths: {
72
+ symbolsDir: '/tmp/symbols',
73
+ footprintsDir: '/tmp/footprints',
74
+ models3dDir: '/tmp/models',
75
+ symLibTable: '/tmp/sym-lib-table',
76
+ fpLibTable: '/tmp/fp-lib-table',
77
+ },
78
+ }),
79
+ listInstalled: async () => [{
80
+ lcscId: 'C123',
81
+ name: 'Part',
82
+ category: 'ICs',
83
+ symbolRef: 'JLC-MCP-ICs:Part',
84
+ footprintRef: 'Package:SOT-23',
85
+ library: 'JLC-MCP-ICs',
86
+ has3dModel: false,
87
+ }],
88
+ }),
89
+ startHttpServer: () => 3847,
90
+ stopHttpServer: () => undefined,
91
+ }));
92
+
93
+ describe('agent JSON CLI output', () => {
94
+ it('info --json writes only a JSON object', async () => {
95
+ const write = spyOn(process.stdout, 'write').mockImplementation(() => true);
96
+ const { infoCommand } = await import('./info.js');
97
+
98
+ await infoCommand('C123', { json: true });
99
+
100
+ expect(write).toHaveBeenCalledTimes(1);
101
+ expect(JSON.parse(String(write.mock.calls[0][0]))).toEqual({
102
+ success: true,
103
+ component: mockDetails,
104
+ });
105
+ write.mockRestore();
106
+ });
107
+
108
+ it('search --json writes only a JSON object and does not require the TUI', async () => {
109
+ const write = spyOn(process.stdout, 'write').mockImplementation(() => true);
110
+ const { searchCommand } = await import('./search.js');
111
+
112
+ await searchCommand('part', { json: true, limit: 1, source: 'lcsc' });
113
+
114
+ const payload = JSON.parse(String(write.mock.calls[0][0]));
115
+ expect(payload).toMatchObject({
116
+ success: true,
117
+ query: 'part',
118
+ count: 1,
119
+ results: [{
120
+ id: 'C123',
121
+ id_type: 'lcsc',
122
+ lcsc_id: 'C123',
123
+ name: 'Part',
124
+ }],
125
+ });
126
+ expect(write).toHaveBeenCalledTimes(1);
127
+ write.mockRestore();
128
+ });
129
+
130
+ it('library status --json writes only a JSON object', async () => {
131
+ const write = spyOn(process.stdout, 'write').mockImplementation(() => true);
132
+ const { libraryCommand } = await import('./library.js');
133
+
134
+ await libraryCommand({ json: true });
135
+
136
+ const payload = JSON.parse(String(write.mock.calls[0][0]));
137
+ expect(payload.success).toBe(true);
138
+ expect(payload.status.installed).toBe(true);
139
+ expect(payload.components).toHaveLength(1);
140
+ expect(write).toHaveBeenCalledTimes(1);
141
+ write.mockRestore();
142
+ });
143
+
144
+ it('install --json writes only a JSON object and does not require --force', async () => {
145
+ const write = spyOn(process.stdout, 'write').mockImplementation(() => true);
146
+ const { installCommand } = await import('./install.js');
147
+
148
+ await installCommand('C123', { json: true });
149
+
150
+ const payload = JSON.parse(String(write.mock.calls[0][0]));
151
+ expect(payload).toMatchObject({
152
+ success: true,
153
+ result: {
154
+ id: 'C123',
155
+ symbolAction: 'exists',
156
+ },
157
+ });
158
+ expect(write).toHaveBeenCalledTimes(1);
159
+ write.mockRestore();
160
+ });
161
+
162
+ it('easyeda search --json writes normalized search results', async () => {
163
+ const write = spyOn(process.stdout, 'write').mockImplementation(() => true);
164
+ const { easyedaSearchCommand } = await import('./easyeda.js');
165
+
166
+ await easyedaSearchCommand('part', { json: true });
167
+
168
+ const payload = JSON.parse(String(write.mock.calls[0][0]));
169
+ expect(payload.results[0]).toMatchObject({
170
+ id: 'C123',
171
+ id_type: 'lcsc',
172
+ });
173
+ expect(write).toHaveBeenCalledTimes(1);
174
+ write.mockRestore();
175
+ });
176
+
177
+ it('easyeda install --json writes only a JSON object and does not require --force', async () => {
178
+ const write = spyOn(process.stdout, 'write').mockImplementation(() => true);
179
+ const { easyedaInstallCommand } = await import('./easyeda.js');
180
+
181
+ await easyedaInstallCommand('8007c710c0b9406db963b55df6990340', { json: true });
182
+
183
+ const payload = JSON.parse(String(write.mock.calls[0][0]));
184
+ expect(payload).toMatchObject({
185
+ success: true,
186
+ result: {
187
+ symbolRef: 'JLC-MCP-ICs:Part',
188
+ },
189
+ });
190
+ expect(write).toHaveBeenCalledTimes(1);
191
+ write.mockRestore();
192
+ });
193
+
194
+ });
@@ -14,12 +14,17 @@ import {
14
14
  type SearchOptions,
15
15
  } from '@jlcpcb/core'
16
16
  import { renderApp } from '../app/App.js'
17
+ import { printJson, printJsonError, getErrorMessage } from '../utils/agent-output.js'
18
+ import { formatSearchResultForJson } from '../utils/search-result-output.js'
17
19
 
18
20
  const componentService = createComponentService()
19
21
  const libraryService = createLibraryService()
20
22
 
21
23
  interface EasyedaSearchOptions {
22
24
  port?: number
25
+ json?: boolean
26
+ open?: boolean
27
+ once?: boolean
23
28
  }
24
29
 
25
30
  /**
@@ -29,6 +34,25 @@ export async function easyedaSearchCommand(
29
34
  query: string,
30
35
  options: EasyedaSearchOptions
31
36
  ): Promise<void> {
37
+ if (options.json) {
38
+ try {
39
+ const results = await componentService.search(query, {
40
+ limit: 20,
41
+ source: 'easyeda-community',
42
+ })
43
+ printJson({
44
+ success: true,
45
+ query,
46
+ count: results.length,
47
+ results: results.map(formatSearchResultForJson),
48
+ })
49
+ } catch (error) {
50
+ printJsonError('easyeda_search_failed', getErrorMessage(error), { retryable: true })
51
+ process.exit(1)
52
+ }
53
+ return
54
+ }
55
+
32
56
  const port = options.port ?? 3847
33
57
 
34
58
  console.log('Starting component browser...')
@@ -41,8 +65,14 @@ export async function easyedaSearchCommand(
41
65
 
42
66
  console.log(`Browser opened at ${searchUrl}`)
43
67
 
44
- // Open browser
45
- await open(searchUrl)
68
+ if (options.open !== false) {
69
+ await open(searchUrl)
70
+ }
71
+
72
+ if (options.once) {
73
+ stopHttpServer()
74
+ process.exit(0)
75
+ }
46
76
 
47
77
  console.log('Press Ctrl+C to stop the server and exit')
48
78
  }
@@ -62,6 +92,8 @@ export async function easyedaSearchCommand(
62
92
  interface EasyedaInstallOptions {
63
93
  projectPath?: string
64
94
  include3d?: boolean
95
+ yes?: boolean
96
+ json?: boolean
65
97
  force?: boolean
66
98
  }
67
99
 
@@ -72,23 +104,40 @@ export async function easyedaInstallCommand(
72
104
  uuid: string | undefined,
73
105
  options: EasyedaInstallOptions
74
106
  ): Promise<void> {
75
- // If UUID provided with --force, do direct install (non-interactive)
76
- if (uuid && options.force) {
77
- const spinner = p.spinner()
78
- spinner.start(`Installing EasyEDA component ${uuid}...`)
107
+ if (!uuid && (options.yes || options.json)) {
108
+ if (options.json) {
109
+ printJsonError('missing_uuid', 'Direct EasyEDA install requires a component UUID')
110
+ } else {
111
+ p.log.error('Direct EasyEDA install requires a component UUID.')
112
+ }
113
+ process.exit(1)
114
+ }
115
+
116
+ // If UUID provided with --yes or --json, do direct install (non-interactive)
117
+ if (uuid && (options.yes || options.json)) {
118
+ const spinner = options.json ? null : p.spinner()
119
+ spinner?.start(`Installing EasyEDA component ${uuid}...`)
79
120
 
80
121
  try {
81
122
  // Ensure libraries are set up
82
- await libraryService.ensureGlobalTables()
123
+ if (!options.projectPath) await libraryService.ensureGlobalTables()
83
124
 
84
125
  // Install the component
85
126
  const result = await libraryService.install(uuid, {
86
127
  projectPath: options.projectPath,
87
128
  include3d: options.include3d,
88
- force: true,
129
+ force: options.force,
89
130
  })
90
131
 
91
- spinner.stop(chalk.green('✓ Component installed'))
132
+ spinner?.stop(chalk.green('✓ Component installed'))
133
+
134
+ if (options.json) {
135
+ printJson({
136
+ success: true,
137
+ result,
138
+ })
139
+ return
140
+ }
92
141
 
93
142
  // Display result
94
143
  console.log()
@@ -101,17 +150,21 @@ export async function easyedaInstallCommand(
101
150
  console.log()
102
151
  console.log(chalk.dim(`Library: ${result.files.symbolLibrary}`))
103
152
  } catch (error) {
104
- spinner.stop(chalk.red('✗ Installation failed'))
105
- p.log.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`)
153
+ spinner?.stop(chalk.red('✗ Installation failed'))
154
+ if (options.json) {
155
+ printJsonError('easyeda_install_failed', getErrorMessage(error), { retryable: true })
156
+ } else {
157
+ p.log.error(`Error: ${getErrorMessage(error)}`)
158
+ }
106
159
  process.exit(1)
107
160
  }
108
161
  return
109
162
  }
110
163
 
111
- // If UUID provided (without --force), launch TUI to fetch and display
164
+ // If UUID provided, launch TUI to fetch and display.
112
165
  if (uuid) {
113
166
  // Launch TUI at EasyEDA info screen - let it fetch the component
114
- await renderApp('easyeda-info', { uuid })
167
+ await renderApp('easyeda-info', { uuid, installOptions: options })
115
168
  return
116
169
  }
117
170
 
@@ -145,5 +198,5 @@ export async function easyedaInstallCommand(
145
198
 
146
199
  // Clear the "Searching..." line and launch interactive UI
147
200
  process.stdout.write('\x1b[1A\x1b[2K')
148
- await renderApp('search', { query: query as string, results })
201
+ await renderApp('search', { query: query as string, results, searchOptions, installOptions: options })
149
202
  }