@nexical/cli-core 0.1.0

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.
@@ -0,0 +1,200 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { CLI } from '../../../src/CLI.js';
3
+ import { CommandLoader } from '../../../src/CommandLoader.js';
4
+ import { BaseCommand } from '../../../src/BaseCommand.js';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { logger } from '../../../src/utils/logger.js';
8
+
9
+ vi.mock('node:fs');
10
+ vi.mock('../../../src/utils/logger.js', () => ({
11
+ logger: {
12
+ debug: vi.fn(),
13
+ error: vi.fn(),
14
+ info: vi.fn(),
15
+ warn: vi.fn(),
16
+ success: vi.fn()
17
+ }
18
+ }));
19
+
20
+ class MockCommand extends BaseCommand {
21
+ async run() { }
22
+ }
23
+
24
+ describe('CommandLoader', () => {
25
+ let loader: CommandLoader;
26
+ let cli: any;
27
+ let mockImporter: any;
28
+
29
+ beforeEach(() => {
30
+ vi.clearAllMocks();
31
+ cli = new CLI({ commandName: 'app' });
32
+ mockImporter = vi.fn();
33
+ loader = new CommandLoader(cli, mockImporter);
34
+ });
35
+
36
+ it('should ignore index.ts if it does not map to a command', async () => {
37
+ const rootDir = '/commands';
38
+ (fs.existsSync as any).mockReturnValue(true);
39
+ (fs.readdirSync as any).mockReturnValue(['index.ts']);
40
+ (fs.statSync as any).mockReturnValue({ isDirectory: () => false });
41
+
42
+ const commands = await loader.load(rootDir);
43
+ expect(commands).toHaveLength(0);
44
+ });
45
+
46
+ it('should skip directory if does not exist', async () => {
47
+ (fs.existsSync as any).mockReturnValue(false);
48
+ const commands = await loader.load('/non-existent');
49
+ expect(commands).toHaveLength(0);
50
+ });
51
+
52
+ it('should handle class loading error gracefully', async () => {
53
+ const rootDir = '/commands';
54
+ (fs.existsSync as any).mockReturnValue(true);
55
+ (fs.readdirSync as any).mockReturnValue(['error.ts']);
56
+ (fs.statSync as any).mockReturnValue({ isDirectory: () => false });
57
+
58
+ mockImporter.mockRejectedValue(new Error('Load failed'));
59
+
60
+ const commands = await loader.load(rootDir);
61
+
62
+ expect(commands).toHaveLength(0);
63
+ expect(logger.error).toHaveBeenCalled();
64
+ });
65
+
66
+ it('should skip files that do not default export a class', async () => {
67
+ const rootDir = '/commands';
68
+ (fs.existsSync as any).mockReturnValue(true);
69
+ (fs.readdirSync as any).mockReturnValue(['no_class.ts']);
70
+ (fs.statSync as any).mockReturnValue({ isDirectory: () => false });
71
+ // - module/ (dir)
72
+ // - add.ts
73
+
74
+ (fs.readdirSync as any).mockImplementation((p: string) => {
75
+ if (p === rootDir) return ['create.ts', 'module'];
76
+ if (p === path.join(rootDir, 'module')) return ['add.ts'];
77
+ return [];
78
+ });
79
+
80
+ (fs.statSync as any).mockImplementation((p: string) => ({
81
+ isDirectory: () => {
82
+ if (p === path.join(rootDir, 'module')) return true;
83
+ return false;
84
+ }
85
+ }));
86
+
87
+ // Mock valid command class
88
+ class MockRecursiveCommand extends BaseCommand {
89
+ async run() { }
90
+ }
91
+ mockImporter.mockResolvedValue({ default: MockRecursiveCommand });
92
+
93
+ const commands = await loader.load(rootDir);
94
+
95
+ expect(commands).toHaveLength(2);
96
+
97
+ const moduleAdd = commands.find(c => c.command === 'module add');
98
+ expect(moduleAdd).toBeDefined();
99
+ expect(moduleAdd?.path).toBe(path.join(rootDir, 'module', 'add.ts'));
100
+ });
101
+ it('should use default importer if none provided', () => {
102
+ const cli = new CLI({ commandName: 'app' });
103
+ const defaultLoader = new CommandLoader(cli);
104
+ expect(defaultLoader).toBeDefined();
105
+ });
106
+ it('should attempt default import execution', async () => {
107
+ // This test ensures the default parameter (p) => import(p) is executed.
108
+ // It will fail to import 'fake.ts' but that's caught.
109
+
110
+ const cli = new CLI({ commandName: 'app' });
111
+ const loader = new CommandLoader(cli); // Default importer
112
+ const rootDir = '/commands';
113
+ (fs.existsSync as any).mockReturnValue(true);
114
+ (fs.readdirSync as any).mockReturnValue(['fake.ts']);
115
+ (fs.statSync as any).mockReturnValue({ isDirectory: () => false });
116
+
117
+ // Suppress error log
118
+ // const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
119
+
120
+ await loader.load(rootDir);
121
+
122
+ // If we reach here, we survived the import error, meaning the catch block was hit
123
+ // and impliedly the try block (and importer) was executed.
124
+ expect(logger.error).toHaveBeenCalled();
125
+ });
126
+
127
+ it('should load .js files', async () => {
128
+ const rootDir = '/commands';
129
+ (fs.existsSync as any).mockReturnValue(true);
130
+ (fs.readdirSync as any).mockReturnValue(['script.js']);
131
+ (fs.statSync as any).mockReturnValue({ isDirectory: () => false });
132
+
133
+ class JsCommand extends BaseCommand { async run() { } }
134
+ mockImporter.mockResolvedValue({ default: JsCommand });
135
+
136
+ const commands = await loader.load(rootDir);
137
+ expect(commands).toHaveLength(1);
138
+ expect(commands[0].command).toBe('script');
139
+ });
140
+ it('should support nested index.ts as parent command', async () => {
141
+ // Structure: /commands/module/index.ts -> "module"
142
+ const rootDir = '/commands';
143
+ (fs.existsSync as any).mockReturnValue(true);
144
+
145
+ (fs.readdirSync as any).mockImplementation((p: string) => {
146
+ if (p === rootDir) return ['module'];
147
+ if (p === path.join(rootDir, 'module')) return ['index.ts'];
148
+ return [];
149
+ });
150
+
151
+ (fs.statSync as any).mockImplementation((p: string) => ({
152
+ isDirectory: () => {
153
+ if (p === path.join(rootDir, 'module')) return true;
154
+ return false;
155
+ }
156
+ }));
157
+
158
+ class ModuleIndexCommand extends BaseCommand { async run() { } }
159
+ mockImporter.mockResolvedValue({ default: ModuleIndexCommand });
160
+
161
+ const commands = await loader.load(rootDir);
162
+ expect(commands).toHaveLength(1);
163
+ expect(commands[0].command).toBe('module');
164
+ expect(commands[0].path).toBe(path.join(rootDir, 'module', 'index.ts'));
165
+ });
166
+
167
+ it('should ignore files with non-executable extensions', async () => {
168
+ const rootDir = '/commands';
169
+ (fs.existsSync as any).mockReturnValue(true);
170
+ (fs.readdirSync as any).mockReturnValue(['readme.md', 'styles.css']);
171
+ (fs.statSync as any).mockReturnValue({ isDirectory: () => false });
172
+
173
+ const commands = await loader.load(rootDir);
174
+ expect(commands).toHaveLength(0);
175
+ });
176
+ it('should skip files that export null', async () => {
177
+ const rootDir = '/commands';
178
+ (fs.existsSync as any).mockReturnValue(true);
179
+ (fs.readdirSync as any).mockReturnValue(['null_export.ts']);
180
+ (fs.statSync as any).mockReturnValue({ isDirectory: () => false });
181
+
182
+ mockImporter.mockResolvedValue({ default: null });
183
+
184
+ const commands = await loader.load(rootDir);
185
+ expect(commands).toHaveLength(0);
186
+ });
187
+
188
+ it('should skip index.ts at root level', async () => {
189
+ const rootDir = '/commands';
190
+ (fs.existsSync as any).mockReturnValue(true);
191
+ (fs.readdirSync as any).mockReturnValue(['index.ts']);
192
+ (fs.statSync as any).mockReturnValue({ isDirectory: () => false });
193
+
194
+ class RootIndexCommand extends BaseCommand { async run() { } }
195
+ mockImporter.mockResolvedValue({ default: RootIndexCommand });
196
+
197
+ const commands = await loader.load(rootDir);
198
+ expect(commands).toHaveLength(0);
199
+ });
200
+ });
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { findProjectRoot, loadConfig } from '../../../src/utils/config.js';
3
+ import { lilconfig } from 'lilconfig';
4
+ import path from 'node:path';
5
+
6
+ // Mock lilconfig
7
+ vi.mock('lilconfig');
8
+
9
+ describe('Config Utilities', () => {
10
+ const mockSearch = vi.fn();
11
+
12
+ beforeEach(() => {
13
+ vi.clearAllMocks();
14
+ (lilconfig as any).mockReturnValue({
15
+ search: mockSearch,
16
+ });
17
+ });
18
+
19
+ describe('findProjectRoot', () => {
20
+ it('should return null if no config found', async () => {
21
+ mockSearch.mockResolvedValue(null);
22
+ const root = await findProjectRoot('app', '/some/path');
23
+ expect(root).toBeNull();
24
+ });
25
+
26
+ it('should return directory path if config found', async () => {
27
+ mockSearch.mockResolvedValue({
28
+ filepath: '/abs/path/to/app.yml',
29
+ config: {},
30
+ });
31
+ const root = await findProjectRoot('app', '/some/path');
32
+ expect(root).toBe('/abs/path/to');
33
+ });
34
+ });
35
+
36
+ describe('loadConfig', () => {
37
+ it('should return empty object if no config found', async () => {
38
+ mockSearch.mockResolvedValue(null);
39
+ const config = await loadConfig('app', '/some/path');
40
+ expect(config).toEqual({});
41
+ });
42
+
43
+ it('should return config object if found', async () => {
44
+ const mockConfig = { project: 'test' };
45
+ mockSearch.mockResolvedValue({
46
+ filepath: '/abs/path/to/app.yml',
47
+ config: mockConfig,
48
+ });
49
+ const config = await loadConfig('app', '/some/path');
50
+ expect(config).toEqual(mockConfig);
51
+ });
52
+ });
53
+ describe('loadYaml', () => {
54
+ it('should parse yaml content', async () => {
55
+ // Dynamic import to bypass potential mocking issues if we used static import earlier
56
+ // or just verify if exported
57
+ const { loadYaml } = await import('../../../src/utils/config.js');
58
+ const content = 'foo: bar';
59
+ const result = loadYaml('file.yml', content);
60
+ expect(result).toEqual({ foo: 'bar' });
61
+ });
62
+ });
63
+ });
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { logger, setDebugMode } from '../../../src/utils/logger.js';
3
+ import { LogLevels } from 'consola';
4
+
5
+ describe('Logger', () => {
6
+ it('should be defined', () => {
7
+ expect(logger).toBeDefined();
8
+ });
9
+
10
+ it('should have standard methods', () => {
11
+ expect(typeof logger.info).toBe('function');
12
+ expect(typeof logger.success).toBe('function');
13
+ expect(typeof logger.warn).toBe('function');
14
+ expect(typeof logger.error).toBe('function');
15
+ });
16
+
17
+ it('should allows toggling debug mode', () => {
18
+ // Default is info (3)
19
+ expect(logger.level).toBe(LogLevels.info);
20
+
21
+ setDebugMode(true);
22
+ expect(logger.level).toBe(LogLevels.debug);
23
+
24
+ setDebugMode(false);
25
+ expect(logger.level).toBe(LogLevels.info);
26
+ });
27
+ });
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { runCommand } from '../../../src/utils/shell.js';
3
+ import { logger } from '../../../src/utils/logger.js';
4
+ import * as cp from 'node:child_process';
5
+
6
+ vi.mock('../../../src/utils/logger.js');
7
+ vi.mock('node:child_process', async () => {
8
+ return {
9
+ exec: vi.fn(),
10
+ };
11
+ });
12
+
13
+ describe('shell utils', () => {
14
+ beforeEach(() => {
15
+ vi.clearAllMocks();
16
+ });
17
+
18
+ afterEach(() => {
19
+ vi.resetAllMocks();
20
+ });
21
+
22
+ it('should execute command successfully', async () => {
23
+ const mockExec = vi.mocked(cp.exec);
24
+ mockExec.mockImplementation(((cmd: string, options: any, cb: any) => {
25
+ const callback = cb || options;
26
+ callback(null, { stdout: 'stdout output', stderr: '' }); // exec result is an object with stdout/stderr
27
+ return {} as any;
28
+ }) as any);
29
+
30
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
31
+
32
+ await runCommand('ls -la');
33
+
34
+ expect(mockExec).toHaveBeenCalledWith('ls -la', expect.anything(), expect.anything());
35
+ expect(consoleSpy).toHaveBeenCalledWith('stdout output');
36
+ });
37
+
38
+ it('should pass cwd to exec', async () => {
39
+ const mockExec = vi.mocked(cp.exec);
40
+ mockExec.mockImplementation(((cmd: string, options: any, cb: any) => {
41
+ const callback = cb || options;
42
+ callback(null, 'stdout', '');
43
+ return {} as any;
44
+ }) as any);
45
+
46
+ await runCommand('ls', '/tmp');
47
+
48
+ expect(mockExec).toHaveBeenCalledWith('ls', expect.objectContaining({ cwd: '/tmp' }), expect.anything());
49
+ });
50
+
51
+ it('should handle execution errors', async () => {
52
+ const error: any = new Error('Shell Error');
53
+ error.code = 1;
54
+
55
+ const mockExec = vi.mocked(cp.exec);
56
+ mockExec.mockImplementation(((cmd: string, options: any, cb: any) => {
57
+ const callback = cb || options;
58
+ callback(error, '', 'stderr output');
59
+ return {} as any;
60
+ }) as any);
61
+
62
+ await expect(runCommand('fail')).rejects.toThrow('Command failed: fail');
63
+
64
+ expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Command failed'));
65
+ });
66
+
67
+ it('should log stderr if available on error', async () => {
68
+ const error: any = new Error('Shell Error');
69
+ error.stderr = 'Some serious shell error';
70
+
71
+ const mockExec = vi.mocked(cp.exec);
72
+ mockExec.mockImplementation(((cmd: string, options: any, cb: any) => {
73
+ const callback = cb || options;
74
+ callback(error, '', 'stderr output');
75
+ return {} as any;
76
+ }) as any);
77
+
78
+ await expect(runCommand('fail')).rejects.toThrow();
79
+ expect(logger.error).toHaveBeenCalledWith('Some serious shell error');
80
+ });
81
+ });
@@ -0,0 +1,22 @@
1
+ import path from 'node:path';
2
+ import { execa } from 'execa';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ // Constants
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ export const CLI_BIN = path.resolve(__dirname, '../../dist/index.js');
8
+
9
+ /**
10
+ * Runs the CLI command against the compiled binary (E2E style)
11
+ */
12
+ export async function runCLI(args: string[], cwd: string, options: any = {}) {
13
+ return execa('node', [CLI_BIN, ...args], {
14
+ cwd,
15
+ ...options,
16
+ env: {
17
+ ...process.env,
18
+ ...options.env
19
+ },
20
+ reject: false // Allow checking exit code in tests
21
+ });
22
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": [
7
+ "ESNext"
8
+ ],
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "resolveJsonModule": true,
14
+ "outDir": "./dist"
15
+ },
16
+ "include": [
17
+ "src/**/*",
18
+ "test/**/*",
19
+ "../utils/environment.ts"
20
+ ],
21
+ "exclude": [
22
+ "node_modules",
23
+ "dist"
24
+ ]
25
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { Options } from 'tsup';
2
+
3
+ export default <Options>{
4
+ entry: ['index.ts', 'src/**/*.ts'],
5
+ format: ['esm'],
6
+ target: 'node18',
7
+ clean: true,
8
+ bundle: true,
9
+ sourcemap: true,
10
+ dts: true,
11
+ minify: false,
12
+ splitting: true,
13
+ outDir: 'dist',
14
+ shims: true, // Enable shims (including __require shim for legacy deps)
15
+ banner: {
16
+ js: 'import { createRequire } from "module"; const require = createRequire(import.meta.url);'
17
+ },
18
+ };
@@ -0,0 +1,15 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ include: ['test/unit/**/*.test.ts'],
8
+ coverage: {
9
+ provider: 'v8',
10
+ reporter: ['text', 'json', 'html'],
11
+ include: ['src/**/*.ts'],
12
+ exclude: ['index.ts', 'src/CommandInterface.ts', '**/*.d.ts'], // Exclude entry points that are hard to test in unit tests
13
+ },
14
+ },
15
+ });
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ include: ['test/e2e/**/*.test.ts'],
8
+ testTimeout: 60000,
9
+ },
10
+ });
@@ -0,0 +1,17 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ include: ['test/integration/**/*.test.ts'],
8
+ // Increase timeout for integration tests as they do real IO
9
+ testTimeout: 60000,
10
+ fileParallelism: false,
11
+ coverage: {
12
+ provider: 'v8',
13
+ reporter: ['text', 'json', 'html'],
14
+ include: ['src/**/*.ts'],
15
+ },
16
+ },
17
+ });