@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.
- package/CHANGELOG.md +26 -0
- package/README.md +23 -5
- package/dist/assets/search.html +32 -31
- package/dist/index.js +7053 -6459
- package/package.json +1 -1
- package/src/app/App.tsx +1 -1
- package/src/app/__fixtures__/interactive-install-scenarios.tsx +149 -0
- package/src/app/__fixtures__/navigation-scenarios.tsx +103 -0
- package/src/app/__fixtures__/terminal.tsx +57 -0
- package/src/app/components/DetailView.tsx +8 -5
- package/src/app/components/ListView.tsx +1 -1
- package/src/app/components/list-view-format.test.ts +2 -0
- package/src/app/navigation/NavigationContext.tsx +1 -1
- package/src/app/navigation/types.ts +8 -1
- package/src/app/navigation.test.ts +24 -0
- package/src/app/screens/EasyEDAInfoScreen.tsx +7 -9
- package/src/app/screens/InfoScreen.tsx +37 -19
- package/src/app/screens/InstallScreen.tsx +3 -5
- package/src/app/screens/InstalledScreen.tsx +3 -3
- package/src/app/screens/LibrarySetupScreen.tsx +12 -5
- package/src/app/screens/SearchScreen.tsx +22 -18
- package/src/app/state/AppStateContext.tsx +0 -23
- package/src/commands/__fixtures__/failure-scenarios.ts +67 -0
- package/src/commands/agent-json.test.ts +194 -0
- package/src/commands/easyeda.ts +67 -14
- package/src/commands/failures.test.ts +105 -0
- package/src/commands/info.ts +6 -9
- package/src/commands/install.ts +43 -12
- package/src/commands/interactive-install.test.ts +29 -0
- package/src/commands/library.ts +14 -23
- package/src/commands/search.ts +36 -4
- package/src/commands/validate.ts +114 -53
- package/src/index.ts +38 -6
- package/src/utils/agent-output.ts +35 -0
- package/src/utils/search-result-output.ts +20 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useEffect
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
24
|
+
replace('search', { ...params, selectedIndex: Math.max(0, selectedIndex - 1) });
|
|
26
25
|
} else if (key.downArrow) {
|
|
27
|
-
|
|
26
|
+
replace('search', { ...params, selectedIndex: Math.max(0, Math.min(results.length - 1, selectedIndex + 1)) });
|
|
28
27
|
} else if (key.return && results[selectedIndex]) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/commands/easyeda.ts
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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:
|
|
129
|
+
force: options.force,
|
|
89
130
|
})
|
|
90
131
|
|
|
91
|
-
spinner
|
|
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
|
|
105
|
-
|
|
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
|
|
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
|
}
|