@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
package/package.json
CHANGED
package/src/app/App.tsx
CHANGED
|
@@ -36,7 +36,7 @@ function ScreenRouter() {
|
|
|
36
36
|
|
|
37
37
|
function AppContent() {
|
|
38
38
|
const { exit } = useApp();
|
|
39
|
-
const { pop, currentIndex
|
|
39
|
+
const { pop, currentIndex } = useNavigation();
|
|
40
40
|
const { screen } = useCurrentScreen();
|
|
41
41
|
|
|
42
42
|
useInput((input, key) => {
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { mock, spyOn } from 'bun:test';
|
|
6
|
+
import React from 'react';
|
|
7
|
+
import {
|
|
8
|
+
createLibraryService, getLibraryPaths, easyedaClient, easyedaCommunityClient, jlcClient,
|
|
9
|
+
type EasyEDAComponentData, type EasyEDACommunityComponent,
|
|
10
|
+
} from '@jlcpcb/core';
|
|
11
|
+
import type { ScreenName, ScreenParams } from '../navigation/types.js';
|
|
12
|
+
import { createTerminal } from './terminal.js';
|
|
13
|
+
|
|
14
|
+
const scenario = process.argv[2];
|
|
15
|
+
const root = process.argv[3];
|
|
16
|
+
const projectPath = join(root, 'selected-project');
|
|
17
|
+
await mkdir(projectPath, { recursive: true });
|
|
18
|
+
// Any forgotten upstream dependency must fail rather than reaching the network.
|
|
19
|
+
spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Unexpected network request in interactive install fixture'));
|
|
20
|
+
const component: EasyEDAComponentData = {
|
|
21
|
+
info: { name: 'Interactive Part', prefix: 'U', lcscId: 'C400', package: 'Custom_Interactive_Package' },
|
|
22
|
+
symbol: {
|
|
23
|
+
pins: [], rectangles: [], circles: [], ellipses: [], arcs: [], polylines: [],
|
|
24
|
+
polygons: [], paths: [], texts: [], origin: { x: 0, y: 0 },
|
|
25
|
+
},
|
|
26
|
+
footprint: {
|
|
27
|
+
name: 'Custom_Interactive_Package', type: 'smd', pads: [], tracks: [], holes: [],
|
|
28
|
+
circles: [], arcs: [], rects: [], texts: [], vias: [], solidRegions: [],
|
|
29
|
+
origin: { x: 0, y: 0 },
|
|
30
|
+
},
|
|
31
|
+
model3d: {
|
|
32
|
+
name: 'Interactive Part', uuid: 'interactive-model',
|
|
33
|
+
translation: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0 },
|
|
34
|
+
},
|
|
35
|
+
rawData: {},
|
|
36
|
+
};
|
|
37
|
+
const community: EasyEDACommunityComponent = {
|
|
38
|
+
uuid: '8007c710c0b9406db963b55df6990340', title: 'Community Part',
|
|
39
|
+
description: 'Interactive community component', tags: [],
|
|
40
|
+
owner: { uuid: 'owner', username: 'owner', nickname: 'Community' },
|
|
41
|
+
updateTime: 0, docType: 2, verify: true,
|
|
42
|
+
symbol: { ...component.symbol, head: {} },
|
|
43
|
+
footprint: { ...component.footprint, uuid: 'footprint', head: {} },
|
|
44
|
+
model3d: component.model3d, rawData: {},
|
|
45
|
+
};
|
|
46
|
+
const model = Buffer.from('ISO-10303-21;\nEND-ISO-10303-21;');
|
|
47
|
+
spyOn(easyedaClient, 'getComponentData').mockImplementation(async () => structuredClone(component));
|
|
48
|
+
spyOn(easyedaClient, 'get3DModel').mockResolvedValue(model);
|
|
49
|
+
spyOn(jlcClient, 'getComponentDetails').mockResolvedValue(null);
|
|
50
|
+
spyOn(jlcClient, 'search').mockResolvedValue([{
|
|
51
|
+
id: 'C400', idType: 'lcsc', lcscId: 'C400', name: 'Interactive Part',
|
|
52
|
+
manufacturer: 'Maker', description: 'Interactive component', package: component.footprint.name,
|
|
53
|
+
stock: 10, libraryType: 'basic',
|
|
54
|
+
}]);
|
|
55
|
+
spyOn(easyedaCommunityClient, 'getComponent').mockImplementation(async () => structuredClone(community));
|
|
56
|
+
spyOn(easyedaCommunityClient, 'get3DModel').mockResolvedValue(model);
|
|
57
|
+
spyOn(easyedaCommunityClient, 'search').mockResolvedValue([{
|
|
58
|
+
uuid: community.uuid, title: community.title, description: community.description,
|
|
59
|
+
owner: community.owner, thumb: '', tags: [], package: community.footprint.name,
|
|
60
|
+
has3DModel: true, docType: 2,
|
|
61
|
+
}]);
|
|
62
|
+
mock.module('@clack/prompts', () => ({ text: async () => 'interactive', isCancel: () => false }));
|
|
63
|
+
|
|
64
|
+
const terminal = createTerminal();
|
|
65
|
+
const { see, key, output } = terminal;
|
|
66
|
+
// Dynamic imports keep application/command initialization behind this subprocess's mock boundaries.
|
|
67
|
+
const app = await import('../App.js');
|
|
68
|
+
// Preserve the real App, screens, navigation and core services; only supply isolated terminal streams.
|
|
69
|
+
mock.module('../App.js', () => ({
|
|
70
|
+
...app,
|
|
71
|
+
renderApp: async (screen: ScreenName, params: ScreenParams[ScreenName]) => {
|
|
72
|
+
const instance = terminal.render(<app.App initialScreen={screen} initialParams={params} />);
|
|
73
|
+
await instance.waitUntilExit();
|
|
74
|
+
},
|
|
75
|
+
}));
|
|
76
|
+
const service = createLibraryService();
|
|
77
|
+
try {
|
|
78
|
+
if (scenario === 'lcsc') {
|
|
79
|
+
// A pre-existing symbol exercises force (without it the customized footprint stays untouched).
|
|
80
|
+
const global = await service.install('C400', { include3d: false });
|
|
81
|
+
const local = await service.install('C400', { projectPath, include3d: false });
|
|
82
|
+
const globalSymbol = await readFile(global.files.symbolLibrary, 'utf8');
|
|
83
|
+
const globalFootprint = await readFile(global.files.footprint!, 'utf8');
|
|
84
|
+
await writeFile(local.files.footprint!, '(footprint "User customization")');
|
|
85
|
+
const { installCommand } = await import('../../commands/install.js');
|
|
86
|
+
const command = installCommand(undefined, { projectPath, force: true, include3d: true });
|
|
87
|
+
await see('Search:');
|
|
88
|
+
key('\r');
|
|
89
|
+
await see('Enter Install');
|
|
90
|
+
key('\r');
|
|
91
|
+
await see('Installed C400');
|
|
92
|
+
|
|
93
|
+
const footprint = await readFile(local.files.footprint!, 'utf8');
|
|
94
|
+
assert.ok(!footprint.includes('User customization'));
|
|
95
|
+
const modelReference = footprint.match(/\(model\s+"([^"]+)"/)?.[1];
|
|
96
|
+
assert.ok(modelReference?.startsWith('${KIPRJMOD}/libraries/3dmodels/'));
|
|
97
|
+
assert.deepEqual(await readFile(modelReference!.replace('${KIPRJMOD}', projectPath)), model);
|
|
98
|
+
assert.equal(await readFile(global.files.symbolLibrary, 'utf8'), globalSymbol);
|
|
99
|
+
assert.equal(await readFile(global.files.footprint!, 'utf8'), globalFootprint);
|
|
100
|
+
assert.equal(existsSync(getLibraryPaths().models3dFullDir), false);
|
|
101
|
+
const installed = await service.listInstalled({ projectPath });
|
|
102
|
+
assert.equal(installed.find(part => part.lcscId === 'C400')?.symbolRef, local.symbolRef);
|
|
103
|
+
|
|
104
|
+
key('\u001b');
|
|
105
|
+
await see('Enter Install');
|
|
106
|
+
key('\u001b');
|
|
107
|
+
await see('Search:');
|
|
108
|
+
key('q');
|
|
109
|
+
await command;
|
|
110
|
+
} else {
|
|
111
|
+
const global = await service.install(community.uuid, { include3d: true });
|
|
112
|
+
const globalSymbol = await readFile(global.files.symbolLibrary, 'utf8');
|
|
113
|
+
const globalFootprint = await readFile(global.files.footprint!, 'utf8');
|
|
114
|
+
assert.equal(await service.isEasyEDAInstalled(community.title), true);
|
|
115
|
+
assert.equal(await service.isEasyEDAInstalled(community.title, projectPath), false);
|
|
116
|
+
const { easyedaInstallCommand } = await import('../../commands/easyeda.js');
|
|
117
|
+
const command = easyedaInstallCommand(undefined, { projectPath, force: true, include3d: false });
|
|
118
|
+
await see('Search:');
|
|
119
|
+
key('\r');
|
|
120
|
+
// The global installation must not hide the selected project's install affordance.
|
|
121
|
+
await see('Enter Install');
|
|
122
|
+
assert.ok(!output().includes('R Regenerate'));
|
|
123
|
+
key('\r');
|
|
124
|
+
await see('Installed: EasyEDA-local:Community_Part');
|
|
125
|
+
const paths = getLibraryPaths(projectPath);
|
|
126
|
+
const symbolPath = join(paths.symbolsDir, 'EasyEDA-local.kicad_sym');
|
|
127
|
+
const footprintPath = join(paths.footprintsDir, 'EasyEDA-local.pretty', 'Community_Part.kicad_mod');
|
|
128
|
+
assert.ok((await readFile(symbolPath, 'utf8')).includes('(symbol "Community_Part"'));
|
|
129
|
+
const initialFootprint = await readFile(footprintPath, 'utf8');
|
|
130
|
+
assert.ok(!initialFootprint.includes('(model '));
|
|
131
|
+
assert.equal(existsSync(paths.models3dDir), false);
|
|
132
|
+
|
|
133
|
+
await writeFile(footprintPath, '(footprint "User customization")');
|
|
134
|
+
key('\r');
|
|
135
|
+
await see('Installed: EasyEDA-local:Community_Part');
|
|
136
|
+
assert.equal(await readFile(footprintPath, 'utf8'), initialFootprint);
|
|
137
|
+
assert.equal(existsSync(paths.models3dDir), false);
|
|
138
|
+
assert.equal(await readFile(global.files.symbolLibrary, 'utf8'), globalSymbol);
|
|
139
|
+
assert.equal(await readFile(global.files.footprint!, 'utf8'), globalFootprint);
|
|
140
|
+
assert.deepEqual(await readFile(global.files.model3d!), model);
|
|
141
|
+
assert.ok((await readFile(join(projectPath, 'sym-lib-table'), 'utf8')).includes('EasyEDA-local'));
|
|
142
|
+
key('\u001b');
|
|
143
|
+
await see('Search:');
|
|
144
|
+
key('q');
|
|
145
|
+
await command;
|
|
146
|
+
}
|
|
147
|
+
} finally {
|
|
148
|
+
terminal.cleanup();
|
|
149
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
import { mock } from 'bun:test';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { createTerminal } from './terminal.js';
|
|
6
|
+
import * as core from '@jlcpcb/core';
|
|
7
|
+
import type { SearchOptions } from '@jlcpcb/core';
|
|
8
|
+
|
|
9
|
+
const scenario = process.argv[2];
|
|
10
|
+
const part = {
|
|
11
|
+
id: 'C123', idType: 'lcsc' as const, lcscId: 'C123', name: 'Original Part',
|
|
12
|
+
manufacturer: 'Maker', description: 'A component', package: 'SOT-23',
|
|
13
|
+
stock: 10, libraryType: 'extended' as const,
|
|
14
|
+
};
|
|
15
|
+
const basic = { ...part, id: 'C124', lcscId: 'C124', name: 'Filtered Part', libraryType: 'basic' as const };
|
|
16
|
+
let librariesReady = !scenario.startsWith('setup-');
|
|
17
|
+
let finishSetup: () => void;
|
|
18
|
+
const pendingSetup = new Promise<void>((resolve) => { finishSetup = resolve; });
|
|
19
|
+
mock.module('@jlcpcb/core', () => ({
|
|
20
|
+
...core,
|
|
21
|
+
createComponentService: () => ({
|
|
22
|
+
search: async (query: string, options: SearchOptions) => {
|
|
23
|
+
if (query.startsWith('C')) return [query === 'C124' ? basic : part];
|
|
24
|
+
// Changing source, stock or page size changes visible backend results.
|
|
25
|
+
if (options.source !== 'lcsc' || !options.inStock || options.limit !== 7) return [part];
|
|
26
|
+
return options.basicOnly ? [basic] : [part, basic];
|
|
27
|
+
},
|
|
28
|
+
getDetails: async () => part,
|
|
29
|
+
}),
|
|
30
|
+
createLibraryService: () => ({
|
|
31
|
+
getStatus: async () => ({ installed: librariesReady, linked: librariesReady }),
|
|
32
|
+
listInstalled: async () => [],
|
|
33
|
+
ensureGlobalTables: async () => {
|
|
34
|
+
if (scenario === 'setup-pending-back') await pendingSetup;
|
|
35
|
+
librariesReady = true;
|
|
36
|
+
},
|
|
37
|
+
install: async () => ({ symbolRef: 'JLC:Original_Part', footprintRef: 'JLC:SOT-23' }),
|
|
38
|
+
}),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
// Load the application only after installing this subprocess's service mocks.
|
|
42
|
+
const { App } = await import('../App.js');
|
|
43
|
+
const terminal = createTerminal();
|
|
44
|
+
const { see, key, output } = terminal;
|
|
45
|
+
terminal.render(
|
|
46
|
+
<App initialScreen="search" initialParams={{
|
|
47
|
+
query: 'parts', results: [part, basic],
|
|
48
|
+
searchOptions: { source: 'lcsc', inStock: true, limit: 7 },
|
|
49
|
+
}} />,
|
|
50
|
+
);
|
|
51
|
+
try {
|
|
52
|
+
await see('Search:');
|
|
53
|
+
if (scenario === 'filter-back') {
|
|
54
|
+
key('\t');
|
|
55
|
+
await see('1 results - Basic/Preferred only');
|
|
56
|
+
assert.ok(output().includes('Filtered Part'));
|
|
57
|
+
key('\r');
|
|
58
|
+
await see('Component:');
|
|
59
|
+
await see('Enter Install');
|
|
60
|
+
key('\u001b');
|
|
61
|
+
await see('1 results - Basic/Preferred only');
|
|
62
|
+
assert.ok(output().includes('Filtered Part'));
|
|
63
|
+
assert.ok(!output().includes('Original Part'));
|
|
64
|
+
key('\t');
|
|
65
|
+
await see('2 results');
|
|
66
|
+
assert.ok(output().includes('Original Part'));
|
|
67
|
+
} else {
|
|
68
|
+
key('\r');
|
|
69
|
+
await see('Component:');
|
|
70
|
+
await see('Enter Install');
|
|
71
|
+
key('\r');
|
|
72
|
+
if (scenario.startsWith('setup-')) {
|
|
73
|
+
await see('Library Setup Required');
|
|
74
|
+
if (scenario === 'setup-escape') {
|
|
75
|
+
key('\u001b');
|
|
76
|
+
} else {
|
|
77
|
+
key('\r');
|
|
78
|
+
if (scenario === 'setup-pending-back') {
|
|
79
|
+
await see('Setting Up Libraries');
|
|
80
|
+
key('\u001b');
|
|
81
|
+
await see('Component:');
|
|
82
|
+
// Settle the abandoned promise while details are active.
|
|
83
|
+
finishSetup!();
|
|
84
|
+
await delay(100);
|
|
85
|
+
assert.ok(!output().includes('Installing') && !output().includes('Installed'));
|
|
86
|
+
} else {
|
|
87
|
+
await see('Installed C123');
|
|
88
|
+
key(scenario === 'setup-complete-key' ? 'x' : '\u001b');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
await see('Installed C123');
|
|
93
|
+
key(scenario === 'installed-key' ? 'x' : '\u001b');
|
|
94
|
+
}
|
|
95
|
+
await see('Component:');
|
|
96
|
+
await see('Enter Install');
|
|
97
|
+
assert.ok(!output().includes('Search:'));
|
|
98
|
+
key('\u001b');
|
|
99
|
+
await see('Search:');
|
|
100
|
+
}
|
|
101
|
+
} finally {
|
|
102
|
+
terminal.cleanup();
|
|
103
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { PassThrough, Writable } from 'node:stream';
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
5
|
+
import { render, type Instance } from 'ink';
|
|
6
|
+
import type { ReactNode } from 'react';
|
|
7
|
+
|
|
8
|
+
export function createTerminal() {
|
|
9
|
+
class Input extends PassThrough {
|
|
10
|
+
isTTY = true;
|
|
11
|
+
setRawMode() { return this; }
|
|
12
|
+
ref() { return this; }
|
|
13
|
+
unref() { return this; }
|
|
14
|
+
}
|
|
15
|
+
const stdin = new Input();
|
|
16
|
+
let output = '';
|
|
17
|
+
let instance: Instance | undefined;
|
|
18
|
+
const stdout = new Writable({
|
|
19
|
+
write(chunk, _encoding, callback) {
|
|
20
|
+
output += stripVTControlCharacters(String(chunk));
|
|
21
|
+
callback();
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
Object.assign(stdout, { columns: 120, rows: 40, isTTY: true });
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
render(node: ReactNode) {
|
|
28
|
+
instance = render(node, {
|
|
29
|
+
stdin: stdin as unknown as NodeJS.ReadStream,
|
|
30
|
+
stdout: stdout as NodeJS.WriteStream,
|
|
31
|
+
stderr: stdout as NodeJS.WriteStream,
|
|
32
|
+
debug: true,
|
|
33
|
+
patchConsole: false,
|
|
34
|
+
exitOnCtrlC: false,
|
|
35
|
+
});
|
|
36
|
+
return instance;
|
|
37
|
+
},
|
|
38
|
+
async see(text: string) {
|
|
39
|
+
const deadline = Date.now() + 3_000;
|
|
40
|
+
while (!output.includes(text) && Date.now() < deadline) await delay(10);
|
|
41
|
+
assert.ok(output.includes(text), `Expected ${JSON.stringify(text)} in terminal output:\n${output}`);
|
|
42
|
+
// Let Ink commit its input subscriptions before sending the next key.
|
|
43
|
+
await delay(30);
|
|
44
|
+
},
|
|
45
|
+
key(value: string) {
|
|
46
|
+
output = '';
|
|
47
|
+
stdin.write(value);
|
|
48
|
+
},
|
|
49
|
+
output: () => output,
|
|
50
|
+
cleanup() {
|
|
51
|
+
instance?.unmount();
|
|
52
|
+
instance?.cleanup();
|
|
53
|
+
stdin.destroy();
|
|
54
|
+
stdout.destroy();
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -22,6 +22,7 @@ interface DetailViewProps {
|
|
|
22
22
|
component: DetailViewComponent;
|
|
23
23
|
terminalWidth: number;
|
|
24
24
|
isInstalled?: boolean;
|
|
25
|
+
canInstall?: boolean;
|
|
25
26
|
installedInfo?: InstalledComponent | null;
|
|
26
27
|
statusMessage?: string | null;
|
|
27
28
|
}
|
|
@@ -36,7 +37,7 @@ function truncate(str: string, len: number): string {
|
|
|
36
37
|
return str.length > len ? str.slice(0, len - 1) + '…' : str;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
export function DetailView({ component, terminalWidth, isInstalled, installedInfo, statusMessage }: DetailViewProps) {
|
|
40
|
+
export function DetailView({ component, terminalWidth, isInstalled, canInstall = !isInstalled, installedInfo, statusMessage }: DetailViewProps) {
|
|
40
41
|
const isWide = terminalWidth >= 80;
|
|
41
42
|
const labelWidth = 16;
|
|
42
43
|
// In wide mode, split into two columns with gap; otherwise full width
|
|
@@ -140,10 +141,12 @@ export function DetailView({ component, terminalWidth, isInstalled, installedInf
|
|
|
140
141
|
</Box>
|
|
141
142
|
) : null;
|
|
142
143
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
? 'R Regenerate
|
|
146
|
-
|
|
144
|
+
const footerText = [
|
|
145
|
+
...(canInstall ? ['Enter Install'] : []),
|
|
146
|
+
...(isInstalled ? ['R Regenerate', 'D Delete'] : []),
|
|
147
|
+
'O Datasheet',
|
|
148
|
+
'Esc Back',
|
|
149
|
+
].join(' • ');
|
|
147
150
|
|
|
148
151
|
return (
|
|
149
152
|
<Box flexDirection="column" width="100%">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ComponentSearchResult, ComponentDetails, InstallResult, InstalledComponent, LibraryStatus, EasyEDACommunityComponent } from '@jlcpcb/core';
|
|
1
|
+
import type { ComponentSearchResult, ComponentDetails, InstallOptions, InstallResult, InstalledComponent, LibraryStatus, EasyEDACommunityComponent, SearchOptions } from '@jlcpcb/core';
|
|
2
2
|
|
|
3
3
|
export type ScreenName = 'search' | 'info' | 'install' | 'library' | 'library-setup' | 'installed' | 'easyeda-info';
|
|
4
4
|
|
|
@@ -8,16 +8,21 @@ export type ComponentInfo = ComponentSearchResult | ComponentDetails;
|
|
|
8
8
|
export interface SearchParams {
|
|
9
9
|
query: string;
|
|
10
10
|
results: ComponentSearchResult[];
|
|
11
|
+
searchOptions?: SearchOptions;
|
|
12
|
+
selectedIndex?: number;
|
|
13
|
+
installOptions?: InstallOptions;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
export interface InfoParams {
|
|
14
17
|
componentId: string;
|
|
15
18
|
component?: ComponentInfo;
|
|
19
|
+
installOptions?: InstallOptions;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
export interface InstallParams {
|
|
19
23
|
componentId: string;
|
|
20
24
|
component: ComponentInfo;
|
|
25
|
+
installOptions?: InstallOptions;
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
export interface LibraryParams {
|
|
@@ -28,6 +33,7 @@ export interface LibraryParams {
|
|
|
28
33
|
export interface LibrarySetupParams {
|
|
29
34
|
componentId: string;
|
|
30
35
|
component: ComponentInfo;
|
|
36
|
+
installOptions?: InstallOptions;
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
export interface InstalledParams {
|
|
@@ -40,6 +46,7 @@ export interface InstalledParams {
|
|
|
40
46
|
export interface EasyEDAInfoParams {
|
|
41
47
|
uuid: string;
|
|
42
48
|
component?: EasyEDACommunityComponent;
|
|
49
|
+
installOptions?: InstallOptions;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
export interface ScreenParams {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
describe('interactive navigation', () => {
|
|
6
|
+
for (const [scenario, behavior] of [
|
|
7
|
+
['filter-back', 'preserves search constraints and filtered results across detail/back navigation'],
|
|
8
|
+
['setup-escape', 'pops setup once on Escape'],
|
|
9
|
+
['setup-complete-escape', 'returns to details on Escape after successful setup and install'],
|
|
10
|
+
['setup-complete-key', 'returns to details on an ordinary key after successful setup and install'],
|
|
11
|
+
['setup-pending-back', 'does not navigate after abandoned setup resolves'],
|
|
12
|
+
['installed-escape', 'pops completion once on Escape'],
|
|
13
|
+
['installed-key', 'retains ordinary-key completion navigation'],
|
|
14
|
+
]) {
|
|
15
|
+
it(behavior, () => {
|
|
16
|
+
// Each real Ink render gets isolated services, module state and terminal streams.
|
|
17
|
+
const result = spawnSync(process.execPath, [join(import.meta.dir, '__fixtures__/navigation-scenarios.tsx'), scenario], {
|
|
18
|
+
encoding: 'utf8',
|
|
19
|
+
timeout: 15_000,
|
|
20
|
+
});
|
|
21
|
+
expect({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: '' });
|
|
22
|
+
}, 20_000);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useEffect, useState, useRef } from 'react';
|
|
2
2
|
import { Box, Text, useInput } from 'ink';
|
|
3
3
|
import { createComponentService, createLibraryService, type EasyEDACommunityComponent } from '@jlcpcb/core';
|
|
4
|
-
import {
|
|
4
|
+
import { useCurrentScreen } from '../navigation/NavigationContext.js';
|
|
5
5
|
import type { EasyEDAInfoParams } from '../navigation/types.js';
|
|
6
6
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
|
7
7
|
import { EasyEDADetailView } from '../components/EasyEDADetailView.js';
|
|
@@ -10,7 +10,6 @@ const componentService = createComponentService();
|
|
|
10
10
|
const libraryService = createLibraryService();
|
|
11
11
|
|
|
12
12
|
export function EasyEDAInfoScreen() {
|
|
13
|
-
const { replace } = useNavigation();
|
|
14
13
|
const { params } = useCurrentScreen() as { screen: 'easyeda-info'; params: EasyEDAInfoParams };
|
|
15
14
|
const { columns: terminalWidth } = useTerminalSize();
|
|
16
15
|
|
|
@@ -32,15 +31,14 @@ export function EasyEDAInfoScreen() {
|
|
|
32
31
|
}
|
|
33
32
|
|
|
34
33
|
try {
|
|
35
|
-
|
|
36
|
-
await libraryService.ensureGlobalTables();
|
|
34
|
+
if (!params.installOptions?.projectPath) await libraryService.ensureGlobalTables();
|
|
37
35
|
|
|
38
36
|
// Fetch component
|
|
39
37
|
const fetched = await componentService.fetchCommunity(params.uuid);
|
|
40
38
|
if (fetched) {
|
|
41
39
|
setComponent(fetched);
|
|
42
40
|
// Check if already installed
|
|
43
|
-
const installed = await libraryService.isEasyEDAInstalled(fetched.title);
|
|
41
|
+
const installed = await libraryService.isEasyEDAInstalled(fetched.title, params.installOptions?.projectPath);
|
|
44
42
|
setIsInstalled(installed);
|
|
45
43
|
} else {
|
|
46
44
|
setError('Component not found');
|
|
@@ -53,7 +51,7 @@ export function EasyEDAInfoScreen() {
|
|
|
53
51
|
};
|
|
54
52
|
|
|
55
53
|
init();
|
|
56
|
-
}, [params.uuid]);
|
|
54
|
+
}, [params.uuid, params.installOptions?.projectPath]);
|
|
57
55
|
|
|
58
56
|
useInput((input, key) => {
|
|
59
57
|
if (isLoading || !component || isInstalling) return;
|
|
@@ -67,7 +65,7 @@ export function EasyEDAInfoScreen() {
|
|
|
67
65
|
setIsInstalling(true);
|
|
68
66
|
setStatusMessage('Regenerating symbol and footprint...');
|
|
69
67
|
|
|
70
|
-
libraryService.install(params.uuid, { force: true })
|
|
68
|
+
libraryService.install(params.uuid, { ...params.installOptions, force: true })
|
|
71
69
|
.then((result) => {
|
|
72
70
|
setStatusMessage(`✓ Reinstalled: ${result.symbolRef}`);
|
|
73
71
|
setIsInstalled(true);
|
|
@@ -92,8 +90,8 @@ export function EasyEDAInfoScreen() {
|
|
|
92
90
|
setStatusMessage('Installing component...');
|
|
93
91
|
|
|
94
92
|
// Ensure libraries are set up
|
|
95
|
-
libraryService.ensureGlobalTables()
|
|
96
|
-
.then(() => libraryService.install(params.uuid,
|
|
93
|
+
(params.installOptions?.projectPath ? Promise.resolve() : libraryService.ensureGlobalTables())
|
|
94
|
+
.then(() => libraryService.install(params.uuid, params.installOptions))
|
|
97
95
|
.then((result) => {
|
|
98
96
|
if (result.symbolAction === 'exists') {
|
|
99
97
|
setStatusMessage(`⚡ Already installed (use R to reinstall)`);
|
|
@@ -5,7 +5,7 @@ import open from 'open';
|
|
|
5
5
|
import { useNavigation, useCurrentScreen } from '../navigation/NavigationContext.js';
|
|
6
6
|
import type { InfoParams, ComponentInfo } from '../navigation/types.js';
|
|
7
7
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
|
8
|
-
import { DetailView
|
|
8
|
+
import { DetailView } from '../components/DetailView.js';
|
|
9
9
|
|
|
10
10
|
const componentService = createComponentService();
|
|
11
11
|
const libraryService = createLibraryService();
|
|
@@ -33,16 +33,15 @@ export function InfoScreen() {
|
|
|
33
33
|
const fetchData = async () => {
|
|
34
34
|
setIsLoading(true);
|
|
35
35
|
try {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
if (!params.installOptions?.projectPath) {
|
|
37
|
+
const status = await libraryService.getStatus();
|
|
38
|
+
setLibraryStatus(status);
|
|
39
|
+
}
|
|
39
40
|
|
|
40
41
|
// Check if already installed
|
|
41
|
-
const installed = await libraryService.listInstalled({});
|
|
42
|
+
const installed = await libraryService.listInstalled({ projectPath: params.installOptions?.projectPath });
|
|
42
43
|
const found = installed.find(c => c.lcscId === componentId);
|
|
43
|
-
|
|
44
|
-
setInstalledInfo(found);
|
|
45
|
-
}
|
|
44
|
+
setInstalledInfo(found ?? null);
|
|
46
45
|
|
|
47
46
|
// Fetch full details from API (always, to get price/stock/attributes)
|
|
48
47
|
const searchResults = await componentService.search(componentId, { limit: 1 });
|
|
@@ -67,10 +66,11 @@ export function InfoScreen() {
|
|
|
67
66
|
};
|
|
68
67
|
|
|
69
68
|
fetchData();
|
|
70
|
-
}, [params.componentId, params.component]);
|
|
69
|
+
}, [params.componentId, params.component, params.installOptions?.projectPath]);
|
|
71
70
|
|
|
72
71
|
// Get datasheet URL (different field names in different types)
|
|
73
72
|
const datasheetUrl = component && ('datasheetPdf' in component ? component.datasheetPdf : 'datasheet' in component ? component.datasheet : undefined);
|
|
73
|
+
const componentLcscId = component?.lcscId ?? params.componentId;
|
|
74
74
|
|
|
75
75
|
const [isRegenerating, setIsRegenerating] = useState(false);
|
|
76
76
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
@@ -82,11 +82,11 @@ export function InfoScreen() {
|
|
|
82
82
|
const lowerInput = input.toLowerCase();
|
|
83
83
|
|
|
84
84
|
// R - Regenerate symbol and footprint
|
|
85
|
-
if (lowerInput === 'r' && installedInfo) {
|
|
85
|
+
if (lowerInput === 'r' && installedInfo && componentLcscId) {
|
|
86
86
|
setIsRegenerating(true);
|
|
87
87
|
setRegenerateMessage('Regenerating symbol and footprint...');
|
|
88
88
|
|
|
89
|
-
libraryService.install(
|
|
89
|
+
libraryService.install(componentLcscId, { ...params.installOptions, force: true })
|
|
90
90
|
.then((result) => {
|
|
91
91
|
setRegenerateMessage(`✓ Regenerated: ${result.symbolAction}`);
|
|
92
92
|
// Clear message after 2 seconds
|
|
@@ -105,7 +105,7 @@ export function InfoScreen() {
|
|
|
105
105
|
setIsDeleting(true);
|
|
106
106
|
setRegenerateMessage('Deleting component...');
|
|
107
107
|
|
|
108
|
-
libraryService.remove(installedInfo.lcscId)
|
|
108
|
+
libraryService.remove(installedInfo.lcscId, { projectPath: params.installOptions?.projectPath })
|
|
109
109
|
.then(() => {
|
|
110
110
|
setRegenerateMessage('✓ Component deleted');
|
|
111
111
|
setInstalledInfo(null);
|
|
@@ -125,25 +125,33 @@ export function InfoScreen() {
|
|
|
125
125
|
return;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
//
|
|
129
|
-
if (key.return && !installedInfo) {
|
|
128
|
+
// Explicit install commands may update an already-installed component.
|
|
129
|
+
if (key.return && (!installedInfo || params.installOptions)) {
|
|
130
|
+
if (!componentLcscId) {
|
|
131
|
+
setRegenerateMessage('✗ Missing LCSC part number');
|
|
132
|
+
setTimeout(() => setRegenerateMessage(null), 3000);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
130
136
|
if (checkingRef.current) return;
|
|
131
137
|
checkingRef.current = true;
|
|
132
138
|
setIsCheckingLibrary(true);
|
|
133
139
|
|
|
134
|
-
if (libraryStatus && (!libraryStatus.installed || !libraryStatus.linked)) {
|
|
140
|
+
if (!params.installOptions?.projectPath && libraryStatus && (!libraryStatus.installed || !libraryStatus.linked)) {
|
|
135
141
|
// Libraries not set up - show setup screen
|
|
136
142
|
push('library-setup', {
|
|
137
|
-
componentId:
|
|
143
|
+
componentId: componentLcscId,
|
|
138
144
|
component,
|
|
145
|
+
installOptions: params.installOptions,
|
|
139
146
|
});
|
|
140
147
|
setIsCheckingLibrary(false);
|
|
141
148
|
checkingRef.current = false;
|
|
142
149
|
} else {
|
|
143
150
|
// Libraries ready - proceed to install
|
|
144
151
|
push('install', {
|
|
145
|
-
componentId:
|
|
152
|
+
componentId: componentLcscId,
|
|
146
153
|
component,
|
|
154
|
+
installOptions: params.installOptions,
|
|
147
155
|
});
|
|
148
156
|
setIsCheckingLibrary(false);
|
|
149
157
|
checkingRef.current = false;
|
|
@@ -176,20 +184,30 @@ export function InfoScreen() {
|
|
|
176
184
|
);
|
|
177
185
|
}
|
|
178
186
|
|
|
187
|
+
if (!componentLcscId) {
|
|
188
|
+
return (
|
|
189
|
+
<Box flexDirection="column">
|
|
190
|
+
<Text color="red">✗ Missing LCSC part number</Text>
|
|
191
|
+
<Text dimColor>Press Esc to go back</Text>
|
|
192
|
+
</Box>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
179
196
|
return (
|
|
180
197
|
<Box flexDirection="column">
|
|
181
198
|
<Box marginBottom={1}>
|
|
182
199
|
<Text bold>
|
|
183
|
-
Component: <Text color="cyan">{
|
|
200
|
+
Component: <Text color="cyan">{componentLcscId}</Text>
|
|
184
201
|
{' '}
|
|
185
202
|
<Text dimColor>({component.name})</Text>
|
|
186
203
|
{installedInfo && <Text color="green"> ✓ Installed</Text>}
|
|
187
204
|
</Text>
|
|
188
205
|
</Box>
|
|
189
206
|
<DetailView
|
|
190
|
-
component={component}
|
|
207
|
+
component={{ ...component, lcscId: componentLcscId }}
|
|
191
208
|
terminalWidth={terminalWidth}
|
|
192
209
|
isInstalled={!!installedInfo}
|
|
210
|
+
canInstall={!installedInfo || !!params.installOptions}
|
|
193
211
|
installedInfo={installedInfo}
|
|
194
212
|
statusMessage={regenerateMessage}
|
|
195
213
|
/>
|