@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.
- package/.github/workflows/deploy.yml +1 -1
- package/.husky/pre-commit +1 -0
- package/.prettierignore +8 -0
- package/.prettierrc +7 -0
- package/GEMINI.md +36 -30
- package/README.md +85 -56
- package/dist/chunk-AC4B3HPJ.js +93 -0
- package/dist/chunk-AC4B3HPJ.js.map +1 -0
- package/dist/{chunk-JYASTIIW.js → chunk-PJIOCW2A.js} +1 -1
- package/dist/chunk-PJIOCW2A.js.map +1 -0
- package/dist/{chunk-WKERTCM6.js → chunk-Q7YLW5HJ.js} +5 -2
- package/dist/chunk-Q7YLW5HJ.js.map +1 -0
- package/dist/index.js +41 -12
- package/dist/index.js.map +1 -1
- package/dist/src/commands/init.d.ts +4 -1
- package/dist/src/commands/init.js +8 -4
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/module/add.d.ts +3 -1
- package/dist/src/commands/module/add.js +24 -13
- package/dist/src/commands/module/add.js.map +1 -1
- package/dist/src/commands/module/list.js +9 -5
- package/dist/src/commands/module/list.js.map +1 -1
- package/dist/src/commands/module/remove.d.ts +3 -1
- package/dist/src/commands/module/remove.js +13 -7
- package/dist/src/commands/module/remove.js.map +1 -1
- package/dist/src/commands/module/update.d.ts +3 -1
- package/dist/src/commands/module/update.js +7 -5
- package/dist/src/commands/module/update.js.map +1 -1
- package/dist/src/commands/run.d.ts +4 -1
- package/dist/src/commands/run.js +10 -2
- package/dist/src/commands/run.js.map +1 -1
- package/dist/src/commands/setup.js +17 -4
- package/dist/src/commands/setup.js.map +1 -1
- package/dist/src/utils/discovery.js +1 -1
- package/dist/src/utils/git.js +1 -1
- package/dist/src/utils/url-resolver.js +1 -1
- package/eslint.config.mjs +67 -0
- package/index.ts +34 -20
- package/package.json +56 -32
- package/src/commands/init.ts +79 -76
- package/src/commands/module/add.ts +158 -148
- package/src/commands/module/list.ts +61 -50
- package/src/commands/module/remove.ts +59 -54
- package/src/commands/module/update.ts +44 -42
- package/src/commands/run.ts +89 -81
- package/src/commands/setup.ts +78 -60
- package/src/utils/discovery.ts +98 -113
- package/src/utils/git.ts +35 -28
- package/src/utils/url-resolver.ts +50 -45
- package/test/e2e/lifecycle.e2e.test.ts +139 -131
- package/test/integration/commands/init.integration.test.ts +64 -64
- package/test/integration/commands/module.integration.test.ts +122 -122
- package/test/integration/commands/run.integration.test.ts +70 -63
- package/test/integration/utils/command-loading.integration.test.ts +40 -53
- package/test/unit/commands/init.test.ts +163 -128
- package/test/unit/commands/module/add.test.ts +312 -245
- package/test/unit/commands/module/list.test.ts +108 -91
- package/test/unit/commands/module/remove.test.ts +74 -67
- package/test/unit/commands/module/update.test.ts +74 -70
- package/test/unit/commands/run.test.ts +253 -201
- package/test/unit/commands/setup.test.ts +146 -128
- package/test/unit/utils/command-discovery.test.ts +138 -125
- package/test/unit/utils/git.test.ts +135 -117
- package/test/unit/utils/integration-helpers.test.ts +59 -49
- package/test/unit/utils/url-resolver.test.ts +46 -34
- package/test/utils/integration-helpers.ts +36 -29
- package/tsconfig.json +15 -25
- package/tsup.config.ts +14 -14
- package/vitest.config.ts +10 -10
- package/vitest.e2e.config.ts +6 -6
- package/vitest.integration.config.ts +17 -17
- package/dist/chunk-JYASTIIW.js.map +0 -1
- package/dist/chunk-OKXOCNXP.js +0 -105
- package/dist/chunk-OKXOCNXP.js.map +0 -1
- package/dist/chunk-WKERTCM6.js.map +0 -1
package/src/utils/discovery.ts
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (fs.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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(
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
37
|
+
await runCommand(`git checkout --orphan ${branch}`, cwd);
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
export async function addAll(cwd: string): Promise<void> {
|
|
34
|
-
|
|
41
|
+
await runCommand('git add -A', cwd);
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
export async function commit(message: string, cwd: string): Promise<void> {
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
}
|