@jlcpcb/cli 0.4.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jlcpcb/cli",
3
- "version": "0.4.0",
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%">
@@ -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,7 +66,7 @@ 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);
@@ -87,7 +86,7 @@ export function InfoScreen() {
87
86
  setIsRegenerating(true);
88
87
  setRegenerateMessage('Regenerating symbol and footprint...');
89
88
 
90
- libraryService.install(componentLcscId, { force: true })
89
+ libraryService.install(componentLcscId, { ...params.installOptions, force: true })
91
90
  .then((result) => {
92
91
  setRegenerateMessage(`✓ Regenerated: ${result.symbolAction}`);
93
92
  // Clear message after 2 seconds
@@ -106,7 +105,7 @@ export function InfoScreen() {
106
105
  setIsDeleting(true);
107
106
  setRegenerateMessage('Deleting component...');
108
107
 
109
- libraryService.remove(installedInfo.lcscId)
108
+ libraryService.remove(installedInfo.lcscId, { projectPath: params.installOptions?.projectPath })
110
109
  .then(() => {
111
110
  setRegenerateMessage('✓ Component deleted');
112
111
  setInstalledInfo(null);
@@ -126,8 +125,8 @@ export function InfoScreen() {
126
125
  return;
127
126
  }
128
127
 
129
- // Enter - Install (only when not installed)
130
- if (key.return && !installedInfo) {
128
+ // Explicit install commands may update an already-installed component.
129
+ if (key.return && (!installedInfo || params.installOptions)) {
131
130
  if (!componentLcscId) {
132
131
  setRegenerateMessage('✗ Missing LCSC part number');
133
132
  setTimeout(() => setRegenerateMessage(null), 3000);
@@ -138,11 +137,12 @@ export function InfoScreen() {
138
137
  checkingRef.current = true;
139
138
  setIsCheckingLibrary(true);
140
139
 
141
- if (libraryStatus && (!libraryStatus.installed || !libraryStatus.linked)) {
140
+ if (!params.installOptions?.projectPath && libraryStatus && (!libraryStatus.installed || !libraryStatus.linked)) {
142
141
  // Libraries not set up - show setup screen
143
142
  push('library-setup', {
144
143
  componentId: componentLcscId,
145
144
  component,
145
+ installOptions: params.installOptions,
146
146
  });
147
147
  setIsCheckingLibrary(false);
148
148
  checkingRef.current = false;
@@ -151,6 +151,7 @@ export function InfoScreen() {
151
151
  push('install', {
152
152
  componentId: componentLcscId,
153
153
  component,
154
+ installOptions: params.installOptions,
154
155
  });
155
156
  setIsCheckingLibrary(false);
156
157
  checkingRef.current = false;
@@ -206,6 +207,7 @@ export function InfoScreen() {
206
207
  component={{ ...component, lcscId: componentLcscId }}
207
208
  terminalWidth={terminalWidth}
208
209
  isInstalled={!!installedInfo}
210
+ canInstall={!installedInfo || !!params.installOptions}
209
211
  installedInfo={installedInfo}
210
212
  statusMessage={regenerateMessage}
211
213
  />
@@ -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