@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
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
function run(scenario: string) {
|
|
6
|
+
// Isolate module mocks and process.exit from other CLI/core tests.
|
|
7
|
+
return spawnSync(process.execPath, [join(import.meta.dir, '__fixtures__/failure-scenarios.ts'), scenario], {
|
|
8
|
+
encoding: 'utf8',
|
|
9
|
+
timeout: 15_000,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('command failure exit status', () => {
|
|
14
|
+
it('reports failed regenerations and exits unsuccessfully', () => {
|
|
15
|
+
const result = run('regenerate');
|
|
16
|
+
expect(result.status).toBe(1);
|
|
17
|
+
expect(result.stdout).toContain('C123');
|
|
18
|
+
expect(result.stdout).toContain('conversion failed');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('does not pass absent requested reference SVGs in JSON', () => {
|
|
22
|
+
const result = run('missing');
|
|
23
|
+
expect(result.status).toBe(1);
|
|
24
|
+
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
25
|
+
success: false,
|
|
26
|
+
result: { passed: false, error: expect.stringContaining('footprint, symbol') },
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('reports absent requested references in text', () => {
|
|
31
|
+
const result = run('text-missing');
|
|
32
|
+
expect(result.status).toBe(1);
|
|
33
|
+
expect(result.stdout).toContain('Missing reference SVG: footprint, symbol');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('counts missing references as batch errors instead of passes', () => {
|
|
37
|
+
const result = run('batch-missing');
|
|
38
|
+
expect(result.status).toBe(1);
|
|
39
|
+
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
40
|
+
success: false,
|
|
41
|
+
summary: { total: 1, passed: 0, failed: 0, errors: 1 },
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('allows an absent footprint reference when its check was disabled', () => {
|
|
46
|
+
const result = run('disabled-footprint');
|
|
47
|
+
expect(result.status).toBe(0);
|
|
48
|
+
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
49
|
+
success: true,
|
|
50
|
+
result: { passed: true, footprint: null, symbol: { passed: true } },
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('rejects symbol pin displacement through the real comparator', () => {
|
|
55
|
+
const result = run('symbol-moved');
|
|
56
|
+
expect(result.status).toBe(1);
|
|
57
|
+
const output = JSON.parse(result.stdout);
|
|
58
|
+
expect(output.success).toBe(false);
|
|
59
|
+
expect(output.result.symbol.errors.some((e: { field: string }) => e.field === 'position')).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('does not pass symbol pins with unknown reference direction', () => {
|
|
63
|
+
const result = run('symbol-unverified');
|
|
64
|
+
expect(result.status).toBe(1);
|
|
65
|
+
const output = JSON.parse(result.stdout);
|
|
66
|
+
expect(output.success).toBe(false);
|
|
67
|
+
expect(output.result.symbol.errors.some((e: { field: string }) => e.field === 'coverage')).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('rejects a manufacturing position mismatch through the real comparator', () => {
|
|
71
|
+
const result = run('footprint-moved');
|
|
72
|
+
expect(result.status).toBe(1);
|
|
73
|
+
const output = JSON.parse(result.stdout);
|
|
74
|
+
expect(output.success).toBe(false);
|
|
75
|
+
expect(output.result.footprint.errors.some((e: { field: string }) => e.field === 'position')).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('does not claim success for an unverified reference copper layer', () => {
|
|
79
|
+
const result = run('footprint-unverified');
|
|
80
|
+
expect(result.status).toBe(1);
|
|
81
|
+
const output = JSON.parse(result.stdout);
|
|
82
|
+
expect(output.success).toBe(false);
|
|
83
|
+
expect(output.result.footprint.errors.some((e: { field: string }) => e.field === 'coverage')).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('validates matching, fully described manufacturing geometry', () => {
|
|
87
|
+
const result = run('footprint-matching');
|
|
88
|
+
expect(result.status).toBe(0);
|
|
89
|
+
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
90
|
+
success: true,
|
|
91
|
+
result: { passed: true, footprint: { passed: true, errors: [] } },
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it.each(['mpn', 'package'])('rejects a changed fixture %s during batch validation', (field) => {
|
|
96
|
+
const result = run(`footprint-identity-${field}`);
|
|
97
|
+
expect(result.status).toBe(1);
|
|
98
|
+
const output = JSON.parse(result.stdout);
|
|
99
|
+
expect(output).toMatchObject({
|
|
100
|
+
success: false,
|
|
101
|
+
summary: { total: 1, passed: 0, failed: 0, errors: 1 },
|
|
102
|
+
});
|
|
103
|
+
expect(output.results[0].error).toMatch(field === 'mpn' ? /Fixture MPN differs/ : /Fixture package differs/);
|
|
104
|
+
});
|
|
105
|
+
});
|
package/src/commands/info.ts
CHANGED
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
* Display component details
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import * as p from '@clack/prompts';
|
|
7
|
-
import chalk from 'chalk';
|
|
8
6
|
import { createComponentService } from '@jlcpcb/core';
|
|
9
7
|
import { renderApp } from '../app/App.js';
|
|
8
|
+
import { printJson, printJsonError, getErrorMessage } from '../utils/agent-output.js';
|
|
10
9
|
|
|
11
10
|
const componentService = createComponentService();
|
|
12
11
|
|
|
@@ -17,16 +16,14 @@ interface InfoOptions {
|
|
|
17
16
|
export async function infoCommand(id: string, options: InfoOptions): Promise<void> {
|
|
18
17
|
// JSON mode - non-interactive output for scripting
|
|
19
18
|
if (options.json) {
|
|
20
|
-
const spinner = p.spinner();
|
|
21
|
-
spinner.start(`Fetching component ${id}...`);
|
|
22
|
-
|
|
23
19
|
try {
|
|
24
20
|
const details = await componentService.getDetails(id);
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
printJson({
|
|
22
|
+
success: true,
|
|
23
|
+
component: details,
|
|
24
|
+
});
|
|
27
25
|
} catch (error) {
|
|
28
|
-
|
|
29
|
-
p.log.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
26
|
+
printJsonError('component_fetch_failed', getErrorMessage(error), { retryable: true });
|
|
30
27
|
process.exit(1);
|
|
31
28
|
}
|
|
32
29
|
return;
|
package/src/commands/install.ts
CHANGED
|
@@ -7,6 +7,7 @@ import * as p from '@clack/prompts';
|
|
|
7
7
|
import chalk from 'chalk';
|
|
8
8
|
import { createComponentService, createLibraryService, type SearchOptions } from '@jlcpcb/core';
|
|
9
9
|
import { renderApp } from '../app/App.js';
|
|
10
|
+
import { printJson, printJsonError, getErrorMessage } from '../utils/agent-output.js';
|
|
10
11
|
|
|
11
12
|
const componentService = createComponentService();
|
|
12
13
|
const libraryService = createLibraryService();
|
|
@@ -19,34 +20,60 @@ function isLcscId(id: string): boolean {
|
|
|
19
20
|
interface InstallOptions {
|
|
20
21
|
projectPath?: string;
|
|
21
22
|
include3d?: boolean;
|
|
23
|
+
yes?: boolean;
|
|
24
|
+
json?: boolean;
|
|
22
25
|
force?: boolean;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
export async function installCommand(id: string | undefined, options: InstallOptions): Promise<void> {
|
|
29
|
+
if (!id && (options.yes || options.json)) {
|
|
30
|
+
if (options.json) {
|
|
31
|
+
printJsonError('missing_id', 'Direct install requires an LCSC part number');
|
|
32
|
+
} else {
|
|
33
|
+
p.log.error('Direct install requires an LCSC part number.');
|
|
34
|
+
}
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
26
38
|
// Check if ID looks like an EasyEDA UUID (not an LCSC ID)
|
|
27
39
|
if (id && !isLcscId(id)) {
|
|
40
|
+
if (options.json) {
|
|
41
|
+
printJsonError('invalid_lcsc_id', `"${id}" is not an LCSC part number`, {
|
|
42
|
+
details: { hint: `Use jlc easyeda install ${id}` },
|
|
43
|
+
});
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
28
47
|
p.log.error(`"${id}" is not an LCSC part number (e.g., C2040).`);
|
|
29
48
|
p.log.info(`For EasyEDA community components, use: ${chalk.cyan(`jlc easyeda install ${id}`)}`);
|
|
30
49
|
process.exit(1);
|
|
31
50
|
}
|
|
32
51
|
|
|
33
|
-
// If ID provided with --
|
|
34
|
-
if (id && options.
|
|
35
|
-
const spinner = p.spinner();
|
|
36
|
-
spinner
|
|
52
|
+
// If ID provided with --yes or --json, do direct install (non-interactive).
|
|
53
|
+
if (id && (options.yes || options.json)) {
|
|
54
|
+
const spinner = options.json ? null : p.spinner();
|
|
55
|
+
spinner?.start(`Installing component ${id}...`);
|
|
37
56
|
|
|
38
57
|
try {
|
|
39
58
|
// Ensure libraries are set up
|
|
40
|
-
await libraryService.ensureGlobalTables();
|
|
59
|
+
if (!options.projectPath) await libraryService.ensureGlobalTables();
|
|
41
60
|
|
|
42
61
|
// Install the component
|
|
43
62
|
const result = await libraryService.install(id, {
|
|
44
63
|
projectPath: options.projectPath,
|
|
45
64
|
include3d: options.include3d,
|
|
46
|
-
force:
|
|
65
|
+
force: options.force,
|
|
47
66
|
});
|
|
48
67
|
|
|
49
|
-
spinner
|
|
68
|
+
spinner?.stop(chalk.green('✓ Component installed'));
|
|
69
|
+
|
|
70
|
+
if (options.json) {
|
|
71
|
+
printJson({
|
|
72
|
+
success: true,
|
|
73
|
+
result,
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
50
77
|
|
|
51
78
|
// Display result
|
|
52
79
|
console.log();
|
|
@@ -59,14 +86,18 @@ export async function installCommand(id: string | undefined, options: InstallOpt
|
|
|
59
86
|
console.log();
|
|
60
87
|
console.log(chalk.dim(`Library: ${result.files.symbolLibrary}`));
|
|
61
88
|
} catch (error) {
|
|
62
|
-
spinner
|
|
63
|
-
|
|
89
|
+
spinner?.stop(chalk.red('✗ Installation failed'));
|
|
90
|
+
if (options.json) {
|
|
91
|
+
printJsonError('install_failed', getErrorMessage(error), { retryable: true });
|
|
92
|
+
} else {
|
|
93
|
+
p.log.error(`Error: ${getErrorMessage(error)}`);
|
|
94
|
+
}
|
|
64
95
|
process.exit(1);
|
|
65
96
|
}
|
|
66
97
|
return;
|
|
67
98
|
}
|
|
68
99
|
|
|
69
|
-
// If ID provided
|
|
100
|
+
// If ID provided, fetch component and launch TUI for install.
|
|
70
101
|
if (id) {
|
|
71
102
|
console.log(`Fetching component ${id}...`);
|
|
72
103
|
|
|
@@ -74,7 +105,7 @@ export async function installCommand(id: string | undefined, options: InstallOpt
|
|
|
74
105
|
const details = await componentService.getDetails(id);
|
|
75
106
|
// Clear the "Fetching..." line and launch interactive UI
|
|
76
107
|
process.stdout.write('\x1b[1A\x1b[2K');
|
|
77
|
-
await renderApp('info', { componentId: id, component: details
|
|
108
|
+
await renderApp('info', { componentId: id, component: details, installOptions: options });
|
|
78
109
|
} catch (error) {
|
|
79
110
|
console.error(`Failed to fetch component: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
80
111
|
process.exit(1);
|
|
@@ -116,5 +147,5 @@ export async function installCommand(id: string | undefined, options: InstallOpt
|
|
|
116
147
|
|
|
117
148
|
// Clear the "Searching..." line and launch interactive UI
|
|
118
149
|
process.stdout.write('\x1b[1A\x1b[2K');
|
|
119
|
-
await renderApp('search', { query: query as string, results });
|
|
150
|
+
await renderApp('search', { query: query as string, results, searchOptions, installOptions: options });
|
|
120
151
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
describe('interactive install artifacts', () => {
|
|
8
|
+
for (const [scenario, behavior] of [
|
|
9
|
+
['lcsc', 'reinstalls the selected LCSC project artifact with downloaded 3D bytes through no-ID search'],
|
|
10
|
+
['community', 'ignores global community installation status and installs/reinstalls locally without 3D through no-ID search'],
|
|
11
|
+
]) {
|
|
12
|
+
it(behavior, () => {
|
|
13
|
+
// Core services and Ink are real; only upstream clients and terminal/prompt IO are substituted.
|
|
14
|
+
const root = mkdtempSync(join(tmpdir(), 'jlc-interactive-install-'));
|
|
15
|
+
try {
|
|
16
|
+
const result = spawnSync(process.execPath, [
|
|
17
|
+
join(import.meta.dir, '../app/__fixtures__/interactive-install-scenarios.tsx'), scenario, root,
|
|
18
|
+
], {
|
|
19
|
+
encoding: 'utf8',
|
|
20
|
+
timeout: 15_000,
|
|
21
|
+
env: { ...process.env, HOME: join(root, 'home'), USERPROFILE: join(root, 'home'), XDG_CONFIG_HOME: join(root, 'config') },
|
|
22
|
+
});
|
|
23
|
+
expect({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: '' });
|
|
24
|
+
} finally {
|
|
25
|
+
rmSync(root, { recursive: true, force: true });
|
|
26
|
+
}
|
|
27
|
+
}, 20_000);
|
|
28
|
+
}
|
|
29
|
+
});
|
package/src/commands/library.ts
CHANGED
|
@@ -7,6 +7,7 @@ import * as p from '@clack/prompts';
|
|
|
7
7
|
import chalk from 'chalk';
|
|
8
8
|
import { createLibraryService, type InstalledComponent } from '@jlcpcb/core';
|
|
9
9
|
import { renderApp } from '../app/App.js';
|
|
10
|
+
import { printJson, printJsonError, getErrorMessage } from '../utils/agent-output.js';
|
|
10
11
|
|
|
11
12
|
const libraryService = createLibraryService();
|
|
12
13
|
|
|
@@ -17,36 +18,25 @@ interface LibraryOptions {
|
|
|
17
18
|
export async function libraryCommand(options: LibraryOptions): Promise<void> {
|
|
18
19
|
// JSON mode - non-interactive output for scripting
|
|
19
20
|
if (options.json) {
|
|
20
|
-
const spinner = p.spinner();
|
|
21
|
-
spinner.start('Loading library status...');
|
|
22
|
-
|
|
23
21
|
try {
|
|
24
22
|
const [status, components] = await Promise.all([
|
|
25
23
|
libraryService.getStatus(),
|
|
26
24
|
libraryService.listInstalled({}),
|
|
27
25
|
]);
|
|
28
26
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
},
|
|
41
|
-
components,
|
|
42
|
-
},
|
|
43
|
-
null,
|
|
44
|
-
2
|
|
45
|
-
)
|
|
46
|
-
);
|
|
27
|
+
printJson({
|
|
28
|
+
success: true,
|
|
29
|
+
status: {
|
|
30
|
+
installed: status.installed,
|
|
31
|
+
linked: status.linked,
|
|
32
|
+
version: status.version,
|
|
33
|
+
componentCount: status.componentCount,
|
|
34
|
+
paths: status.paths,
|
|
35
|
+
},
|
|
36
|
+
components,
|
|
37
|
+
});
|
|
47
38
|
} catch (error) {
|
|
48
|
-
|
|
49
|
-
p.log.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
39
|
+
printJsonError('library_status_failed', getErrorMessage(error), { retryable: false });
|
|
50
40
|
process.exit(1);
|
|
51
41
|
}
|
|
52
42
|
return;
|
|
@@ -101,5 +91,6 @@ export async function regenerateCommand(options: RegenerateOptions): Promise<voi
|
|
|
101
91
|
for (const comp of result.components.filter(c => c.status === 'failed')) {
|
|
102
92
|
console.log(chalk.red(` • ${comp.name} (${comp.id}): ${comp.error}`));
|
|
103
93
|
}
|
|
94
|
+
process.exitCode = 1;
|
|
104
95
|
}
|
|
105
96
|
}
|
package/src/commands/search.ts
CHANGED
|
@@ -5,11 +5,19 @@
|
|
|
5
5
|
|
|
6
6
|
import { createComponentService, type SearchOptions } from '@jlcpcb/core';
|
|
7
7
|
import { renderApp } from '../app/App.js';
|
|
8
|
+
import { printJson, printJsonError, getErrorMessage } from '../utils/agent-output.js';
|
|
9
|
+
import { formatSearchResultForJson } from '../utils/search-result-output.js';
|
|
8
10
|
|
|
9
11
|
const componentService = createComponentService();
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
interface SearchCommandOptions extends SearchOptions {
|
|
14
|
+
json?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function searchCommand(query: string, options: SearchCommandOptions): Promise<void> {
|
|
18
|
+
if (!options.json) {
|
|
19
|
+
console.log(`Searching for "${query}"...`);
|
|
20
|
+
}
|
|
13
21
|
|
|
14
22
|
try {
|
|
15
23
|
let results = await componentService.search(query, options);
|
|
@@ -24,15 +32,39 @@ export async function searchCommand(query: string, options: SearchOptions): Prom
|
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
if (results.length === 0) {
|
|
35
|
+
if (options.json) {
|
|
36
|
+
printJson({
|
|
37
|
+
success: true,
|
|
38
|
+
query,
|
|
39
|
+
count: 0,
|
|
40
|
+
results: [],
|
|
41
|
+
});
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
27
45
|
console.log('No components found. Try a different search term.');
|
|
28
46
|
return;
|
|
29
47
|
}
|
|
30
48
|
|
|
49
|
+
if (options.json) {
|
|
50
|
+
printJson({
|
|
51
|
+
success: true,
|
|
52
|
+
query,
|
|
53
|
+
count: results.length,
|
|
54
|
+
results: results.map(formatSearchResultForJson),
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
31
59
|
// Clear the "Searching..." line and launch interactive UI
|
|
32
60
|
process.stdout.write('\x1b[1A\x1b[2K');
|
|
33
|
-
await renderApp('search', { query, results });
|
|
61
|
+
await renderApp('search', { query, results, searchOptions: options });
|
|
34
62
|
} catch (error) {
|
|
35
|
-
|
|
63
|
+
if (options.json) {
|
|
64
|
+
printJsonError('search_failed', getErrorMessage(error), { retryable: true });
|
|
65
|
+
} else {
|
|
66
|
+
console.error(`Search failed: ${getErrorMessage(error)}`);
|
|
67
|
+
}
|
|
36
68
|
process.exit(1);
|
|
37
69
|
}
|
|
38
70
|
}
|