@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jlcpcb/cli",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "CLI for JLC/EasyEDA component sourcing and KiCad library management",
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, history } = useNavigation();
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
- // Footer text based on installation status
144
- const footerText = isInstalled
145
- ? 'R Regenerate D Delete O Datasheet • Esc Back'
146
- : 'Enter Install • O Datasheet • Esc Back';
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%">
@@ -33,7 +33,7 @@ export function ListView({
33
33
  const row = formatListRow(r, widths);
34
34
 
35
35
  return (
36
- <Box key={r.lcscId}>
36
+ <Box key={r.id ?? r.lcscId}>
37
37
  <Text color="cyan">{isSelected ? '▶' : ' '}</Text>
38
38
  {isSelected ? (
39
39
  <Text inverse>
@@ -13,6 +13,8 @@ function createResult(
13
13
  overrides: Partial<ComponentSearchResult>
14
14
  ): ComponentSearchResult {
15
15
  return {
16
+ id: 'C123',
17
+ idType: 'lcsc',
16
18
  lcscId: 'C123',
17
19
  name: 'LM2940CT-5.0/NOPB',
18
20
  description:
@@ -37,7 +37,7 @@ export function NavigationProvider<T extends ScreenName>({
37
37
 
38
38
  const pop = useCallback(() => {
39
39
  if (currentIndex > 0) {
40
- setCurrentIndex((prev) => prev - 1);
40
+ setCurrentIndex((prev) => Math.max(0, prev - 1));
41
41
  return true;
42
42
  }
43
43
  return false;
@@ -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 { useNavigation, useCurrentScreen } from '../navigation/NavigationContext.js';
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
- // Ensure global tables are set up
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, type DetailViewComponent } from '../components/DetailView.js';
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
- // Fetch library status (for paths)
37
- const status = await libraryService.getStatus();
38
- setLibraryStatus(status);
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
- if (found) {
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(component.lcscId, { force: true })
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
- // Enter - Install (only when not installed)
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: component.lcscId,
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: component.lcscId,
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">{component.lcscId}</Text>
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
  />