@nexical/cli 0.11.0 → 0.11.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 (75) hide show
  1. package/.github/workflows/deploy.yml +1 -1
  2. package/.husky/pre-commit +1 -0
  3. package/.prettierignore +8 -0
  4. package/.prettierrc +7 -0
  5. package/GEMINI.md +36 -30
  6. package/README.md +85 -56
  7. package/dist/chunk-AC4B3HPJ.js +93 -0
  8. package/dist/chunk-AC4B3HPJ.js.map +1 -0
  9. package/dist/{chunk-JYASTIIW.js → chunk-PJIOCW2A.js} +1 -1
  10. package/dist/chunk-PJIOCW2A.js.map +1 -0
  11. package/dist/{chunk-WKERTCM6.js → chunk-Q7YLW5HJ.js} +5 -2
  12. package/dist/chunk-Q7YLW5HJ.js.map +1 -0
  13. package/dist/index.js +41 -12
  14. package/dist/index.js.map +1 -1
  15. package/dist/src/commands/init.d.ts +4 -1
  16. package/dist/src/commands/init.js +8 -4
  17. package/dist/src/commands/init.js.map +1 -1
  18. package/dist/src/commands/module/add.d.ts +3 -1
  19. package/dist/src/commands/module/add.js +24 -13
  20. package/dist/src/commands/module/add.js.map +1 -1
  21. package/dist/src/commands/module/list.js +9 -5
  22. package/dist/src/commands/module/list.js.map +1 -1
  23. package/dist/src/commands/module/remove.d.ts +3 -1
  24. package/dist/src/commands/module/remove.js +13 -7
  25. package/dist/src/commands/module/remove.js.map +1 -1
  26. package/dist/src/commands/module/update.d.ts +3 -1
  27. package/dist/src/commands/module/update.js +7 -5
  28. package/dist/src/commands/module/update.js.map +1 -1
  29. package/dist/src/commands/run.d.ts +4 -1
  30. package/dist/src/commands/run.js +10 -2
  31. package/dist/src/commands/run.js.map +1 -1
  32. package/dist/src/commands/setup.js +17 -4
  33. package/dist/src/commands/setup.js.map +1 -1
  34. package/dist/src/utils/discovery.js +1 -1
  35. package/dist/src/utils/git.js +1 -1
  36. package/dist/src/utils/url-resolver.js +1 -1
  37. package/eslint.config.mjs +67 -0
  38. package/index.ts +34 -20
  39. package/package.json +56 -32
  40. package/src/commands/init.ts +79 -76
  41. package/src/commands/module/add.ts +158 -148
  42. package/src/commands/module/list.ts +61 -50
  43. package/src/commands/module/remove.ts +59 -54
  44. package/src/commands/module/update.ts +44 -42
  45. package/src/commands/run.ts +89 -81
  46. package/src/commands/setup.ts +78 -60
  47. package/src/utils/discovery.ts +98 -113
  48. package/src/utils/git.ts +35 -28
  49. package/src/utils/url-resolver.ts +50 -45
  50. package/test/e2e/lifecycle.e2e.test.ts +139 -131
  51. package/test/integration/commands/init.integration.test.ts +64 -64
  52. package/test/integration/commands/module.integration.test.ts +122 -122
  53. package/test/integration/commands/run.integration.test.ts +70 -63
  54. package/test/integration/utils/command-loading.integration.test.ts +40 -53
  55. package/test/unit/commands/init.test.ts +163 -128
  56. package/test/unit/commands/module/add.test.ts +312 -245
  57. package/test/unit/commands/module/list.test.ts +108 -91
  58. package/test/unit/commands/module/remove.test.ts +74 -67
  59. package/test/unit/commands/module/update.test.ts +74 -70
  60. package/test/unit/commands/run.test.ts +253 -201
  61. package/test/unit/commands/setup.test.ts +146 -128
  62. package/test/unit/utils/command-discovery.test.ts +138 -125
  63. package/test/unit/utils/git.test.ts +135 -117
  64. package/test/unit/utils/integration-helpers.test.ts +59 -49
  65. package/test/unit/utils/url-resolver.test.ts +46 -34
  66. package/test/utils/integration-helpers.ts +36 -29
  67. package/tsconfig.json +15 -25
  68. package/tsup.config.ts +14 -14
  69. package/vitest.config.ts +10 -10
  70. package/vitest.e2e.config.ts +6 -6
  71. package/vitest.integration.config.ts +17 -17
  72. package/dist/chunk-JYASTIIW.js.map +0 -1
  73. package/dist/chunk-OKXOCNXP.js +0 -105
  74. package/dist/chunk-OKXOCNXP.js.map +0 -1
  75. package/dist/chunk-WKERTCM6.js.map +0 -1
@@ -4,131 +4,116 @@ import fs from 'node:fs';
4
4
 
5
5
  /**
6
6
  * Discovers command directories to load into the CLI.
7
- *
7
+ *
8
8
  * Scans for:
9
9
  * 1. Core commands (projectRoot/src/commands)
10
10
  * 2. Module commands (projectRoot/src/modules/ * /src/commands)
11
- *
11
+ *
12
12
  * @param projectRoot - The root directory of the project
13
13
  * @returns Array of absolute paths to command directories
14
14
  */
15
15
  export function discoverCommandDirectories(projectRoot: string): string[] {
16
- const directories: string[] = [];
17
- const visited = new Set<string>();
18
-
19
- const addDir = (dir: string) => {
20
- const resolved = path.resolve(dir);
21
- if (visited.has(resolved)) return;
22
-
23
- if (fs.existsSync(resolved)) {
24
- // Check if we already have a similar path (e.g. dist/src/commands vs src/commands)
25
- // If we are adding src/commands and dist/src/commands already exists in visited, skip it
26
- // and vice versa.
27
- const isSrc = resolved.endsWith(path.join('src', 'commands'));
28
- const isDist = resolved.includes(path.join('dist', 'src', 'commands')) ||
29
- resolved.endsWith(path.join('dist', 'commands'));
30
-
31
- if (isSrc) {
32
- const distEquivalent1 = resolved.replace(path.sep + 'src' + path.sep, path.sep + 'dist' + path.sep + 'src' + path.sep);
33
- const distEquivalent2 = resolved.replace(path.sep + 'src' + path.sep, path.sep + 'dist' + path.sep);
34
- if (visited.has(distEquivalent1) || visited.has(distEquivalent2)) {
35
- logger.debug(`Skipping ${resolved} because a dist version is already registered`);
36
- return;
37
- }
38
- }
16
+ const directories: string[] = [];
17
+ const visited = new Set<string>();
18
+
19
+ const isTsEnvironment =
20
+ process.argv[1]?.endsWith('.ts') ||
21
+ process.argv[1]?.endsWith('.mts') ||
22
+ process.execArgv.some(
23
+ (arg) => arg.includes('tsx') || arg.includes('ts-node') || arg.includes('vitest'),
24
+ ) ||
25
+ process.env.VITEST === 'true' ||
26
+ process.env.NODE_ENV === 'test';
27
+
28
+ const addDir = (dir: string) => {
29
+ const resolved = path.resolve(dir);
30
+ if (!fs.existsSync(resolved)) {
31
+ logger.debug(`Command directory not found (skipping): ${resolved}`);
32
+ return;
33
+ }
39
34
 
40
- if (isDist) {
41
- const srcEquivalent1 = resolved.replace(path.sep + 'dist' + path.sep, path.sep);
42
- const srcEquivalent2 = resolved.replace(path.sep + 'dist' + path.sep + 'src' + path.sep, path.sep + 'src' + path.sep);
43
- if (visited.has(srcEquivalent1) || visited.has(srcEquivalent2)) {
44
- // If we just added src, and now we find dist, we should actually REPLACE src with dist
45
- // but for now, the loop order prefers dist, so this case shouldn't happen much.
46
- // However, let's keep it simple.
47
- logger.debug(`Skipping ${resolved} because a src version is already registered`);
48
- return;
49
- }
50
- }
35
+ if (visited.has(resolved)) return;
36
+
37
+ // Detect if this is a source command directory
38
+ const srcPattern = path.join(path.sep, 'src', 'commands');
39
+ const distPattern = path.join(path.sep, 'dist');
40
+ const isSrcDir = resolved.endsWith(srcPattern) && !resolved.includes(distPattern);
41
+
42
+ // Strict check: if we are adding a 'src' directory...
43
+ if (isSrcDir) {
44
+ // 1. Check if an equivalent 'dist' exists in the same package
45
+ const distPath1 = resolved.replace(
46
+ srcPattern,
47
+ path.join(path.sep, 'dist', 'src', 'commands'),
48
+ );
49
+ const distPath2 = resolved.replace(srcPattern, path.join(path.sep, 'dist', 'commands'));
50
+
51
+ if (fs.existsSync(distPath1) || fs.existsSync(distPath2)) {
52
+ logger.debug(`Skipping src commands at ${resolved} because dist exists`);
53
+ return;
54
+ }
55
+
56
+ // 2. If no TS loader, skip src/commands entirely IF it's likely to contain .ts
57
+ if (!isTsEnvironment) {
58
+ logger.debug(`Skipping src commands at ${resolved}: no TS loader detected`);
59
+ return;
60
+ }
61
+ }
51
62
 
52
- logger.debug(`Found command directory: ${resolved}`);
53
- directories.push(resolved);
54
- visited.add(resolved);
55
- } else {
56
- logger.debug(`Command directory not found (skipping): ${resolved}`);
57
- }
58
- };
59
-
60
- // 1. Core commands
61
- // Search in projectRoot
62
- const possibleCorePaths = [
63
- path.join(projectRoot, 'src/commands'),
64
- ];
65
-
66
- possibleCorePaths.forEach(addDir);
67
-
68
- // 2. Module commands
69
- const possibleModuleDirs = [
70
- path.join(projectRoot, 'modules'),
71
- path.join(projectRoot, 'src', 'modules') // Support both flat and src-nested
72
- ];
73
-
74
- possibleModuleDirs.forEach(modulesDir => {
75
- if (fs.existsSync(modulesDir)) {
76
- try {
77
- const modules = fs.readdirSync(modulesDir);
78
- for (const mod of modules) {
79
- // exclude system files/dirs like .keep
80
- if (mod.startsWith('.')) continue;
81
-
82
- const modPath = path.join(modulesDir, mod);
83
- if (!fs.statSync(modPath).isDirectory()) continue;
84
-
85
- // Check for commands inside the module/package
86
- // Order matters: prefer dist if it exists
87
- const possibleCmdPaths = [
88
- path.join(modPath, 'dist/src/commands'),
89
- path.join(modPath, 'dist/commands'),
90
- path.join(modPath, 'src/commands')
91
- ];
92
-
93
- for (const cmdPath of possibleCmdPaths) {
94
- if (fs.existsSync(cmdPath) && fs.statSync(cmdPath).isDirectory()) {
95
- addDir(cmdPath);
96
- }
97
- }
98
- }
99
- } catch (e: any) {
100
- logger.debug(`Error scanning modules directory ${modulesDir}: ${e.message}`);
63
+ logger.debug(`Found command directory: ${resolved}`);
64
+ directories.push(resolved);
65
+ visited.add(resolved);
66
+ };
67
+
68
+ // 1. Core commands
69
+ const possibleCorePaths = [path.join(projectRoot, 'src/commands')];
70
+ possibleCorePaths.forEach(addDir);
71
+
72
+ // 2. Module & Package commands
73
+ const searchRoots = [
74
+ path.join(projectRoot, 'modules'),
75
+ path.join(projectRoot, 'src', 'modules'),
76
+ path.join(projectRoot, 'packages'),
77
+ ];
78
+
79
+ searchRoots.forEach((root) => {
80
+ if (!fs.existsSync(root)) return;
81
+ try {
82
+ const entries = fs.readdirSync(root);
83
+ for (const entry of entries) {
84
+ if (entry.startsWith('.')) continue;
85
+ const entryPath = path.join(root, entry);
86
+ if (!fs.statSync(entryPath).isDirectory()) continue;
87
+
88
+ // Preference: dist/src/commands > dist/commands > src/commands
89
+ const possiblePaths = [
90
+ path.join(entryPath, 'dist/src/commands'),
91
+ path.join(entryPath, 'dist/commands'),
92
+ path.join(entryPath, 'src/commands'),
93
+ ];
94
+
95
+ let foundDist = false;
96
+ for (const p of possiblePaths) {
97
+ if (fs.existsSync(p) && fs.statSync(p).isDirectory()) {
98
+ if (p.includes(path.sep + 'dist' + path.sep)) {
99
+ addDir(p);
100
+ foundDist = true;
101
+ break; // Found a dist version, skip others for this entry
101
102
  }
103
+ }
102
104
  }
103
- });
104
-
105
- // 3. Package commands (e.g. packages/*)
106
- const packagesDir = path.join(projectRoot, 'packages');
107
- if (fs.existsSync(packagesDir)) {
108
- try {
109
- const packages = fs.readdirSync(packagesDir);
110
- for (const pkg of packages) {
111
- if (pkg.startsWith('.')) continue;
112
-
113
- const pkgPath = path.join(packagesDir, pkg);
114
- if (!fs.statSync(pkgPath).isDirectory()) continue;
115
-
116
- const possibleCmdPaths = [
117
- path.join(pkgPath, 'dist/src/commands'),
118
- path.join(pkgPath, 'dist/commands'),
119
- path.join(pkgPath, 'src/commands')
120
- ];
121
-
122
- for (const cmdPath of possibleCmdPaths) {
123
- if (fs.existsSync(cmdPath) && fs.statSync(cmdPath).isDirectory()) {
124
- addDir(cmdPath);
125
- }
126
- }
127
- }
128
- } catch (e: any) {
129
- logger.debug(`Error scanning packages directory: ${e.message}`);
105
+
106
+ if (!foundDist) {
107
+ const srcPath = path.join(entryPath, 'src/commands');
108
+ if (fs.existsSync(srcPath) && fs.statSync(srcPath).isDirectory()) {
109
+ addDir(srcPath);
110
+ }
130
111
  }
112
+ }
113
+ } catch (e: unknown) {
114
+ logger.debug(`Error scanning root ${root}: ${e instanceof Error ? e.message : String(e)}`);
131
115
  }
116
+ });
132
117
 
133
- return directories;
118
+ return directories;
134
119
  }
package/src/utils/git.ts CHANGED
@@ -4,62 +4,69 @@ import { promisify } from 'node:util';
4
4
 
5
5
  const execAsync = promisify(exec);
6
6
 
7
- export async function clone(url: string, destination: string, options: { recursive?: boolean; depth?: number } = {}): Promise<void> {
8
- const { recursive = false, depth } = options;
9
- const cmd = `git clone ${recursive ? '--recursive ' : ''}${depth ? `--depth ${depth} ` : ''}${url} .`;
10
- logger.debug(`Git clone: ${url} to ${destination}`);
11
- await runCommand(cmd, destination);
7
+ export async function clone(
8
+ url: string,
9
+ destination: string,
10
+ options: { recursive?: boolean; depth?: number } = {},
11
+ ): Promise<void> {
12
+ const { recursive = false, depth } = options;
13
+ const cmd = `git clone ${recursive ? '--recursive ' : ''}${depth ? `--depth ${depth} ` : ''}${url} .`;
14
+ logger.debug(`Git clone: ${url} to ${destination}`);
15
+ await runCommand(cmd, destination);
12
16
  }
13
17
 
14
18
  export async function getRemoteUrl(cwd: string, remote = 'origin'): Promise<string> {
15
- try {
16
- const { stdout } = await execAsync(`git remote get-url ${remote}`, { cwd });
17
- return stdout.trim();
18
- } catch (e) {
19
- console.error('getRemoteUrl failed:', e);
20
- return '';
21
- }
19
+ try {
20
+ const { stdout } = await execAsync(`git remote get-url ${remote}`, { cwd });
21
+ return stdout.trim();
22
+ } catch (e) {
23
+ console.error('getRemoteUrl failed:', e);
24
+ return '';
25
+ }
22
26
  }
23
27
 
24
28
  export async function updateSubmodules(cwd: string): Promise<void> {
25
- logger.debug(`Updating submodules in ${cwd}`);
26
- await runCommand('git submodule foreach --recursive "git checkout main && git pull origin main"', cwd);
29
+ logger.debug(`Updating submodules in ${cwd}`);
30
+ await runCommand(
31
+ 'git submodule foreach --recursive "git checkout main && git pull origin main"',
32
+ cwd,
33
+ );
27
34
  }
28
35
 
29
36
  export async function checkoutOrphan(branch: string, cwd: string): Promise<void> {
30
- await runCommand(`git checkout --orphan ${branch}`, cwd);
37
+ await runCommand(`git checkout --orphan ${branch}`, cwd);
31
38
  }
32
39
 
33
40
  export async function addAll(cwd: string): Promise<void> {
34
- await runCommand('git add -A', cwd);
41
+ await runCommand('git add -A', cwd);
35
42
  }
36
43
 
37
44
  export async function commit(message: string, cwd: string): Promise<void> {
38
- // Escape quotes in message if needed, for now assuming simple messages
39
- await runCommand(`git commit -m "${message}"`, cwd);
45
+ // Escape quotes in message if needed, for now assuming simple messages
46
+ await runCommand(`git commit -m "${message}"`, cwd);
40
47
  }
41
48
 
42
49
  export async function deleteBranch(branch: string, cwd: string): Promise<void> {
43
- await runCommand(`git branch -D ${branch}`, cwd);
50
+ await runCommand(`git branch -D ${branch}`, cwd);
44
51
  }
45
52
 
46
53
  export async function renameBranch(branch: string, cwd: string): Promise<void> {
47
- await runCommand(`git branch -m ${branch}`, cwd);
54
+ await runCommand(`git branch -m ${branch}`, cwd);
48
55
  }
49
56
 
50
57
  export async function removeRemote(remote: string, cwd: string): Promise<void> {
51
- await runCommand(`git remote remove ${remote}`, cwd);
58
+ await runCommand(`git remote remove ${remote}`, cwd);
52
59
  }
53
60
 
54
61
  export async function renameRemote(oldName: string, newName: string, cwd: string): Promise<void> {
55
- await runCommand(`git remote rename ${oldName} ${newName}`, cwd);
62
+ await runCommand(`git remote rename ${oldName} ${newName}`, cwd);
56
63
  }
57
64
 
58
65
  export async function branchExists(branch: string, cwd: string): Promise<boolean> {
59
- try {
60
- await execAsync(`git show-ref --verify --quiet refs/heads/${branch}`, { cwd });
61
- return true;
62
- } catch {
63
- return false;
64
- }
66
+ try {
67
+ await execAsync(`git show-ref --verify --quiet refs/heads/${branch}`, { cwd });
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
65
72
  }
@@ -1,57 +1,62 @@
1
1
  /**
2
2
  * Resolves a git URL from various shorthand formats.
3
- *
3
+ *
4
4
  * Supported formats:
5
5
  * - gh@org/repo -> https://github.com/org/repo.git
6
6
  * - gh@org/repo//path -> https://github.com/org/repo.git//path
7
7
  * - https://github.com/org/repo -> https://github.com/org/repo.git
8
8
  * - https://github.com/org/repo.git -> https://github.com/org/repo.git
9
- *
9
+ *
10
10
  * @param url The URL string to resolve
11
11
  * @returns The fully qualified git URL with .git extension
12
12
  */
13
13
  export function resolveGitUrl(url: string): string {
14
- if (!url) {
15
- throw new Error('URL cannot be empty');
16
- }
17
-
18
- let resolved = url;
19
-
20
- // Handle gh@ syntax
21
- if (resolved.startsWith('gh@')) {
22
- resolved = resolved.replace(/^gh@/, 'https://github.com/');
23
- }
24
-
25
- // Handle subpaths (split by //)
26
- // We must be careful not to split the protocol (e.g. https://)
27
- const protocolMatch = resolved.match(/^[a-z0-9]+:\/\//i);
28
- let splitIndex = -1;
29
-
30
- if (protocolMatch) {
31
- splitIndex = resolved.indexOf('//', protocolMatch[0].length);
32
- } else {
33
- splitIndex = resolved.indexOf('//');
34
- }
35
-
36
- let repoUrl = resolved;
37
- let subPath = '';
38
-
39
- if (splitIndex !== -1) {
40
- repoUrl = resolved.substring(0, splitIndex);
41
- subPath = resolved.substring(splitIndex + 2);
42
- }
43
-
44
- // Ensure .git extension, but ONLY for remote URLs (not local paths)
45
- const isLocal = repoUrl.startsWith('/') || repoUrl.startsWith('./') || repoUrl.startsWith('../') || repoUrl.startsWith('file:') || repoUrl.startsWith('~');
46
-
47
- if (!isLocal && !repoUrl.endsWith('.git')) {
48
- repoUrl += '.git';
49
- }
50
-
51
- // Reconstruction
52
- if (subPath) {
53
- return `${repoUrl}//${subPath}`;
54
- }
55
-
56
- return repoUrl;
14
+ if (!url) {
15
+ throw new Error('URL cannot be empty');
16
+ }
17
+
18
+ let resolved = url;
19
+
20
+ // Handle gh@ syntax
21
+ if (resolved.startsWith('gh@')) {
22
+ resolved = resolved.replace(/^gh@/, 'https://github.com/');
23
+ }
24
+
25
+ // Handle subpaths (split by //)
26
+ // We must be careful not to split the protocol (e.g. https://)
27
+ const protocolMatch = resolved.match(/^[a-z0-9]+:\/\//i);
28
+ let splitIndex = -1;
29
+
30
+ if (protocolMatch) {
31
+ splitIndex = resolved.indexOf('//', protocolMatch[0].length);
32
+ } else {
33
+ splitIndex = resolved.indexOf('//');
34
+ }
35
+
36
+ let repoUrl = resolved;
37
+ let subPath = '';
38
+
39
+ if (splitIndex !== -1) {
40
+ repoUrl = resolved.substring(0, splitIndex);
41
+ subPath = resolved.substring(splitIndex + 2);
42
+ }
43
+
44
+ // Ensure .git extension, but ONLY for remote URLs (not local paths)
45
+ const isLocal =
46
+ repoUrl.startsWith('/') ||
47
+ repoUrl.startsWith('./') ||
48
+ repoUrl.startsWith('../') ||
49
+ repoUrl.startsWith('file:') ||
50
+ repoUrl.startsWith('~');
51
+
52
+ if (!isLocal && !repoUrl.endsWith('.git')) {
53
+ repoUrl += '.git';
54
+ }
55
+
56
+ // Reconstruction
57
+ if (subPath) {
58
+ return `${repoUrl}//${subPath}`;
59
+ }
60
+
61
+ return repoUrl;
57
62
  }