@nexical/cli 0.11.7 → 0.11.9

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 (46) hide show
  1. package/dist/{chunk-LZ3YQWAR.js → chunk-OUGA4CB4.js} +15 -11
  2. package/dist/chunk-OUGA4CB4.js.map +1 -0
  3. package/dist/index.js +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/src/commands/init.js +1 -1
  6. package/dist/src/commands/module/add.js +51 -20
  7. package/dist/src/commands/module/add.js.map +1 -1
  8. package/dist/src/commands/module/list.d.ts +1 -0
  9. package/dist/src/commands/module/list.js +55 -46
  10. package/dist/src/commands/module/list.js.map +1 -1
  11. package/dist/src/commands/module/remove.js +38 -13
  12. package/dist/src/commands/module/remove.js.map +1 -1
  13. package/dist/src/commands/module/update.js +16 -4
  14. package/dist/src/commands/module/update.js.map +1 -1
  15. package/dist/src/commands/run.js +19 -2
  16. package/dist/src/commands/run.js.map +1 -1
  17. package/dist/src/commands/setup.js +1 -1
  18. package/package.json +1 -1
  19. package/src/commands/module/add.ts +74 -31
  20. package/src/commands/module/list.ts +80 -57
  21. package/src/commands/module/remove.ts +50 -14
  22. package/src/commands/module/update.ts +19 -5
  23. package/src/commands/run.ts +21 -1
  24. package/test/e2e/lifecycle.e2e.test.ts +3 -2
  25. package/test/integration/commands/deploy.integration.test.ts +102 -0
  26. package/test/integration/commands/init.integration.test.ts +16 -1
  27. package/test/integration/commands/module.integration.test.ts +81 -55
  28. package/test/integration/commands/run.integration.test.ts +69 -74
  29. package/test/integration/commands/setup.integration.test.ts +53 -0
  30. package/test/unit/commands/deploy.test.ts +285 -0
  31. package/test/unit/commands/init.test.ts +15 -0
  32. package/test/unit/commands/module/add.test.ts +363 -254
  33. package/test/unit/commands/module/list.test.ts +100 -99
  34. package/test/unit/commands/module/remove.test.ts +143 -58
  35. package/test/unit/commands/module/update.test.ts +45 -62
  36. package/test/unit/commands/run.test.ts +16 -1
  37. package/test/unit/commands/setup.test.ts +25 -66
  38. package/test/unit/deploy/config-manager.test.ts +65 -0
  39. package/test/unit/deploy/providers/cloudflare.test.ts +210 -0
  40. package/test/unit/deploy/providers/github.test.ts +139 -0
  41. package/test/unit/deploy/providers/railway.test.ts +328 -0
  42. package/test/unit/deploy/registry.test.ts +227 -0
  43. package/test/unit/deploy/utils.test.ts +30 -0
  44. package/test/unit/utils/command-discovery.test.ts +145 -142
  45. package/test/unit/utils/git_utils.test.ts +49 -0
  46. package/dist/chunk-LZ3YQWAR.js.map +0 -1
@@ -1,8 +1,15 @@
1
- import { BaseCommand, logger } from '@nexical/cli-core';
1
+ import { BaseCommand } from '@nexical/cli-core';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
4
  import YAML from 'yaml';
5
5
 
6
+ interface ModuleInfo {
7
+ name: string;
8
+ version: string;
9
+ description: string;
10
+ type: 'backend' | 'frontend' | 'legacy';
11
+ }
12
+
6
13
  export default class ModuleListCommand extends BaseCommand {
7
14
  static usage = 'module list';
8
15
  static description = 'List installed modules.';
@@ -10,71 +17,87 @@ export default class ModuleListCommand extends BaseCommand {
10
17
 
11
18
  async run() {
12
19
  const projectRoot = this.projectRoot as string;
13
- const modulesDir = path.resolve(projectRoot, 'modules');
14
- logger.debug(`Scanning for modules in: ${modulesDir}`);
15
20
 
16
- if (!(await fs.pathExists(modulesDir))) {
17
- this.info('No modules installed (modules directory missing).');
18
- return;
19
- }
21
+ // Define locations to scan
22
+ const builtInLocations = [
23
+ { type: 'backend', path: path.join(projectRoot, 'apps/backend/modules') },
24
+ { type: 'frontend', path: path.join(projectRoot, 'apps/frontend/modules') },
25
+ // Check legacy `modules` folder just in case?
26
+ { type: 'legacy', path: path.join(projectRoot, 'modules') },
27
+ ];
20
28
 
21
- try {
22
- const modules = await fs.readdir(modulesDir);
23
- const validModules: { name: string; version: string; description: string }[] = [];
24
-
25
- for (const moduleName of modules) {
26
- const modulePath = path.join(modulesDir, moduleName);
27
- if ((await fs.stat(modulePath)).isDirectory()) {
28
- let version = 'unknown';
29
- let description = '';
30
-
31
- const pkgJsonPath = path.join(modulePath, 'package.json');
32
- const moduleYamlPath = path.join(modulePath, 'module.yaml');
33
- const moduleYmlPath = path.join(modulePath, 'module.yml');
34
-
35
- let pkg: Record<string, unknown> = {};
36
- let modConfig: Record<string, unknown> = {};
37
-
38
- if (await fs.pathExists(pkgJsonPath)) {
39
- try {
40
- pkg = await fs.readJson(pkgJsonPath);
41
- } catch {
42
- /* ignore */
43
- }
44
- }
29
+ const allModules: ModuleInfo[] = [];
45
30
 
46
- if ((await fs.pathExists(moduleYamlPath)) || (await fs.pathExists(moduleYmlPath))) {
47
- try {
48
- const configPath = (await fs.pathExists(moduleYamlPath))
49
- ? moduleYamlPath
50
- : moduleYmlPath;
51
- const content = await fs.readFile(configPath, 'utf8');
52
- modConfig = YAML.parse(content) || {};
53
- } catch {
54
- /* ignore */
55
- }
56
- }
31
+ for (const loc of builtInLocations) {
32
+ if (await fs.pathExists(loc.path)) {
33
+ const modules = await fs.readdir(loc.path);
57
34
 
58
- version = (pkg.version as string) || (modConfig.version as string) || 'unknown';
59
- description = (pkg.description as string) || (modConfig.description as string) || '';
60
- // Optionally use display name from module.yaml if present, but strictly list is usually dir name.
61
- // Let's stick to dir name for "name" column, but description from module.yaml is good.
62
- validModules.push({ name: moduleName, version, description });
35
+ for (const moduleName of modules) {
36
+ const modulePath = path.join(loc.path, moduleName);
37
+ if ((await fs.stat(modulePath)).isDirectory()) {
38
+ const info = await this.getModuleInfo(
39
+ modulePath,
40
+ moduleName,
41
+ loc.type as 'backend' | 'frontend' | 'legacy',
42
+ );
43
+ allModules.push(info);
44
+ }
63
45
  }
64
46
  }
47
+ }
48
+
49
+ if (allModules.length === 0) {
50
+ this.info('No modules installed.');
51
+ } else {
52
+ // Sort by type then name
53
+ allModules.sort((a, b) => {
54
+ if (a.type !== b.type) return a.type.localeCompare(b.type);
55
+ return a.name.localeCompare(b.name);
56
+ });
57
+ // eslint-disable-next-line no-console
58
+ console.table(allModules);
59
+ }
60
+ }
61
+
62
+ private async getModuleInfo(
63
+ modulePath: string,
64
+ dirName: string,
65
+ type: 'backend' | 'frontend' | 'legacy',
66
+ ): Promise<ModuleInfo> {
67
+ let version = 'unknown';
68
+ let description = '';
69
+
70
+ const pkgJsonPath = path.join(modulePath, 'package.json');
71
+ const moduleYamlPath = path.join(modulePath, 'module.yaml');
72
+ const moduleYmlPath = path.join(modulePath, 'module.yml');
65
73
 
66
- if (validModules.length === 0) {
67
- this.info('No modules installed.');
68
- } else {
69
- // eslint-disable-next-line no-console
70
- console.table(validModules);
74
+ let pkg: Record<string, unknown> = {};
75
+ let modConfig: Record<string, unknown> = {};
76
+
77
+ if (await fs.pathExists(pkgJsonPath)) {
78
+ try {
79
+ pkg = (await fs.readJson(pkgJsonPath)) || {};
80
+ } catch {
81
+ /* ignore */
71
82
  }
72
- } catch (error: unknown) {
73
- if (error instanceof Error) {
74
- this.error(`Failed to list modules: ${error.message}`);
75
- } else {
76
- this.error(`Failed to list modules: ${String(error)}`);
83
+ }
84
+
85
+ if ((await fs.pathExists(moduleYamlPath)) || (await fs.pathExists(moduleYmlPath))) {
86
+ try {
87
+ const configPath = (await fs.pathExists(moduleYamlPath)) ? moduleYamlPath : moduleYmlPath;
88
+ const content = await fs.readFile(configPath, 'utf8');
89
+ modConfig = YAML.parse(content) || {};
90
+ } catch {
91
+ /* ignore */
77
92
  }
78
93
  }
94
+
95
+ version = (pkg.version as string) || (modConfig.version as string) || 'unknown';
96
+ description = (pkg.description as string) || (modConfig.description as string) || '';
97
+
98
+ // Use config name if available, else dirName
99
+ const name = (modConfig.name as string) || dirName;
100
+
101
+ return { name, version, description, type };
79
102
  }
80
103
  }
@@ -16,27 +16,44 @@ export default class ModuleRemoveCommand extends BaseCommand {
16
16
  const projectRoot = this.projectRoot as string;
17
17
  const { name } = options;
18
18
 
19
- const relativePath = `modules/${name}`;
20
- const fullPath = path.resolve(projectRoot, relativePath);
21
-
22
- logger.debug('Removing module at:', fullPath);
19
+ // Check locations
20
+ const locations = [
21
+ { type: 'backend', path: `apps/backend/modules/${name}` },
22
+ { type: 'frontend', path: `apps/frontend/modules/${name}` },
23
+ { type: 'legacy', path: `modules/${name}` },
24
+ ];
25
+
26
+ let targetLoc: { type: string; path: string } | null = null;
27
+ let fullPath = '';
28
+
29
+ for (const loc of locations) {
30
+ const absPath = path.resolve(projectRoot, loc.path);
31
+ if (await fs.pathExists(absPath)) {
32
+ targetLoc = loc;
33
+ fullPath = absPath;
34
+ break;
35
+ }
36
+ }
23
37
 
24
- if (!(await fs.pathExists(fullPath))) {
25
- this.error(`Module ${name} not found at ${relativePath}.`);
38
+ if (!targetLoc) {
39
+ this.error(`Module ${name} not found in any standard location.`);
26
40
  return;
27
41
  }
28
42
 
29
- this.info(`Removing module ${name}...`);
43
+ const relativePath = targetLoc.path;
44
+
45
+ logger.debug('Removing module at:', fullPath);
46
+ this.info(`Removing module ${name} (${targetLoc.type})...`);
30
47
 
31
48
  try {
32
49
  await runCommand(`git submodule deinit -f ${relativePath}`, projectRoot);
33
50
  await runCommand(`git rm -f ${relativePath}`, projectRoot);
34
51
 
35
- // Clean up .git/modules
36
- const gitModulesDir = path.resolve(projectRoot, '.git', 'modules', 'modules', name);
37
- if (await fs.pathExists(gitModulesDir)) {
38
- await fs.remove(gitModulesDir);
39
- }
52
+ // Clean up .git/modules if needed (git rm often handles this but sometimes leaves stale dirs in .git/modules)
53
+ // The path in .git/modules depends on how it was added.
54
+ // Usually .git/modules/apps/backend/modules/name
55
+ // We'll leave strict git cleanup to git, manually removing can be risky if path structure varies.
56
+ // But we can check for the directory itself just in case.
40
57
 
41
58
  this.info('Syncing workspace dependencies...');
42
59
  await runCommand('npm install', projectRoot);
@@ -63,8 +80,27 @@ export default class ModuleRemoveCommand extends BaseCommand {
63
80
  const content = await fs.readFile(configPath, 'utf8');
64
81
  const config = YAML.parse(content) || {};
65
82
 
66
- if (config.modules && config.modules.includes(moduleName)) {
67
- config.modules = config.modules.filter((m: string) => m !== moduleName);
83
+ let changed = false;
84
+
85
+ if (config.modules) {
86
+ // Check if object
87
+ if (!Array.isArray(config.modules)) {
88
+ for (const key of Object.keys(config.modules)) {
89
+ if (Array.isArray(config.modules[key]) && config.modules[key].includes(moduleName)) {
90
+ config.modules[key] = config.modules[key].filter((m: string) => m !== moduleName);
91
+ changed = true;
92
+ }
93
+ }
94
+ } else {
95
+ // Legacy array
96
+ if (config.modules.includes(moduleName)) {
97
+ config.modules = config.modules.filter((m: string) => m !== moduleName);
98
+ changed = true;
99
+ }
100
+ }
101
+ }
102
+
103
+ if (changed) {
68
104
  await fs.writeFile(configPath, YAML.stringify(config));
69
105
  logger.debug(`Removed ${moduleName} from nexical.yaml modules list.`);
70
106
  }
@@ -20,17 +20,31 @@ export default class ModuleUpdateCommand extends BaseCommand {
20
20
 
21
21
  try {
22
22
  if (name) {
23
- const relativePath = `modules/${name}`;
24
- const fullPath = path.resolve(projectRoot, relativePath);
23
+ // Check locations
24
+ const locations = [
25
+ { type: 'backend', path: `apps/backend/modules/${name}` },
26
+ { type: 'frontend', path: `apps/frontend/modules/${name}` },
27
+ { type: 'legacy', path: `modules/${name}` },
28
+ ];
25
29
 
26
- if (!(await fs.pathExists(fullPath))) {
30
+ let targetLoc: { type: string; path: string } | null = null;
31
+
32
+ for (const loc of locations) {
33
+ const absPath = path.resolve(projectRoot, loc.path);
34
+ if (await fs.pathExists(absPath)) {
35
+ targetLoc = loc;
36
+ break;
37
+ }
38
+ }
39
+
40
+ if (!targetLoc) {
27
41
  this.error(`Module ${name} not found.`);
28
42
  return;
29
43
  }
30
44
 
45
+ const relativePath = targetLoc.path;
46
+
31
47
  // Update specific module
32
- // We enter the directory and pull? Or generic submodule update?
33
- // Generic submodule update --remote src/modules/name
34
48
  await runCommand(`git submodule update --remote --merge ${relativePath}`, projectRoot);
35
49
  } else {
36
50
  // Update all
@@ -38,9 +38,29 @@ export default class RunCommand extends BaseCommand {
38
38
  // Handle module:script syntax
39
39
  if (script.includes(':')) {
40
40
  const [moduleName, name] = script.split(':');
41
- execPath = path.resolve(projectRoot, 'modules', moduleName);
42
41
  scriptName = name;
43
42
 
43
+ const locations = [
44
+ { type: 'backend', path: `apps/backend/modules/${moduleName}` },
45
+ { type: 'frontend', path: `apps/frontend/modules/${moduleName}` },
46
+ { type: 'legacy', path: `modules/${moduleName}` },
47
+ ];
48
+
49
+ let found = false;
50
+ for (const loc of locations) {
51
+ const absPath = path.resolve(projectRoot, loc.path);
52
+ if (await fs.pathExists(absPath)) {
53
+ execPath = absPath;
54
+ found = true;
55
+ break;
56
+ }
57
+ }
58
+
59
+ if (!found) {
60
+ this.error(`Module ${moduleName} not found.`);
61
+ return;
62
+ }
63
+
44
64
  logger.debug(`Resolving module script: ${moduleName}:${scriptName} at ${execPath}`);
45
65
  } else {
46
66
  logger.debug(`Resolving core script: ${scriptName} at ${execPath}`);
@@ -122,7 +122,7 @@ if (args[0] === 'build') {
122
122
  'module',
123
123
  'add',
124
124
  moduleDir,
125
- 'my-test-module', // Explicit name
125
+ // Name is inferred from module.yaml
126
126
  ],
127
127
  projectDir,
128
128
  { env },
@@ -132,7 +132,8 @@ if (args[0] === 'build') {
132
132
  console.error('Module Add Failed:', modResult.stderr || modResult.stdout);
133
133
  }
134
134
  expect(modResult.exitCode).toBe(0);
135
- expect(fs.existsSync(path.join(projectDir, 'modules/my-test-module'))).toBe(true);
135
+ // Defaults to backend module
136
+ expect(fs.existsSync(path.join(projectDir, 'apps/backend/modules/my-test-module'))).toBe(true);
136
137
 
137
138
  // --- STEP 3: BUILD ---
138
139
  // Run: nexical run build
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
2
+ import DeployCommand from '../../../src/commands/deploy.js';
3
+ import { createTempDir, createMockRepo, cleanupTestRoot } from '../../utils/integration-helpers.js';
4
+ import path from 'node:path';
5
+ import fs from 'fs-extra';
6
+ import { CLI } from '@nexical/cli-core';
7
+
8
+ // Mock ConfigManager and Registry to control provider behavior without relying on real files or dynamic imports
9
+ vi.mock('../../../src/deploy/config-manager.js', () => {
10
+ return {
11
+ ConfigManager: vi.fn().mockImplementation(function () {
12
+ return {
13
+ load: vi.fn().mockResolvedValue({
14
+ deploy: {
15
+ backend: { provider: 'railway' },
16
+ frontend: { provider: 'cloudflare' },
17
+ repository: { provider: 'github' },
18
+ },
19
+ }),
20
+ };
21
+ }),
22
+ };
23
+ });
24
+
25
+ vi.mock('../../../src/deploy/registry.js', () => {
26
+ return {
27
+ ProviderRegistry: vi.fn().mockImplementation(function () {
28
+ return {
29
+ loadCoreProviders: vi.fn(),
30
+ loadLocalProviders: vi.fn(),
31
+ getDeploymentProvider: vi.fn().mockImplementation((name) => {
32
+ if (name === 'railway') {
33
+ return {
34
+ name: 'railway',
35
+ provision: vi.fn().mockResolvedValue(undefined),
36
+ getSecrets: vi.fn().mockResolvedValue({ R_SEC: 'val' }),
37
+ getVariables: vi.fn().mockResolvedValue({ R_VAR: 'val' }),
38
+ };
39
+ }
40
+ if (name === 'cloudflare') {
41
+ return {
42
+ name: 'cloudflare',
43
+ provision: vi.fn().mockResolvedValue(undefined),
44
+ getSecrets: vi.fn().mockResolvedValue({ C_SEC: 'val' }),
45
+ getVariables: vi.fn().mockResolvedValue({ C_VAR: 'val' }),
46
+ };
47
+ }
48
+ return undefined;
49
+ }),
50
+ getRepositoryProvider: vi.fn().mockReturnValue({
51
+ name: 'github',
52
+ configureSecrets: vi.fn().mockResolvedValue(undefined),
53
+ configureVariables: vi.fn().mockResolvedValue(undefined),
54
+ generateWorkflow: vi.fn().mockImplementation(async (ctx, vars) => {
55
+ // Simulate writing a workflow file to verify context
56
+ const targetDir = path.join(ctx.cwd, '.github/workflows');
57
+ const targetFile = path.join(targetDir, 'deploy.yml');
58
+ await fs.ensureDir(targetDir);
59
+ await fs.writeFile(targetFile, 'yaml content');
60
+ }),
61
+ }),
62
+ };
63
+ }),
64
+ };
65
+ });
66
+
67
+ describe('Deploy Command Integration', () => {
68
+ let projectDir: string;
69
+
70
+ beforeEach(async () => {
71
+ const temp = await createTempDir('deploy-project-');
72
+ projectDir = await createMockRepo(temp, {
73
+ 'package.json': '{"name": "deploy-project", "version": "1.0.0"}',
74
+ 'nexical.yaml': 'site: deploy-test\nmodules: []',
75
+ '.env': 'TEST_ENV=true',
76
+ });
77
+ });
78
+
79
+ afterAll(async () => {
80
+ await cleanupTestRoot();
81
+ });
82
+
83
+ it('should execute full deployment flow', async () => {
84
+ const originalCwd = process.cwd();
85
+ try {
86
+ // Create a CLI instance
87
+ const cli = new CLI({ commandName: 'nexical' });
88
+ process.chdir(projectDir);
89
+
90
+ const deployCmd = new DeployCommand(cli);
91
+
92
+ // Execute run
93
+ await deployCmd.run({ env: 'production' });
94
+
95
+ // Verify file creation from our mocked provider
96
+ const workflowPath = path.join(projectDir, '.github/workflows/deploy.yml');
97
+ expect(await fs.pathExists(workflowPath)).toBe(true);
98
+ } finally {
99
+ process.chdir(originalCwd);
100
+ }
101
+ });
102
+ });
@@ -81,5 +81,20 @@ describe('InitCommand Integration', () => {
81
81
  // However, `InitCommand` runs `npm install`. If that fails, the command throws/exits.
82
82
  // We provided a minimal package.json so it should succeed.
83
83
  expect(fs.existsSync(path.join(targetPath, 'node_modules'))).toBe(true);
84
- }, 60000); // Increase timeout for real git/npm ops
84
+ }, 60000);
85
+
86
+ it('should initialize with a custom repo', async () => {
87
+ const targetProjectName = 'custom-repo-project';
88
+ const targetPath = path.join(tempDir, targetProjectName);
89
+ const cli = new CLI({ commandName: 'nexical' });
90
+ const command = new InitCommand(cli);
91
+
92
+ await command.run({
93
+ directory: targetPath,
94
+ repo: starterRepoDir, // Reusing starter repo as "custom"
95
+ });
96
+
97
+ expect(fs.existsSync(targetPath)).toBe(true);
98
+ expect(fs.existsSync(path.join(targetPath, 'package.json'))).toBe(true);
99
+ }, 60000);
85
100
  });
@@ -1,5 +1,5 @@
1
1
  import { CLI } from '@nexical/cli-core';
2
- import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from 'vitest';
2
+ import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';
3
3
  import ModuleAddCommand from '../../../src/commands/module/add.js';
4
4
  import ModuleRemoveCommand from '../../../src/commands/module/remove.js';
5
5
  import ModuleListCommand from '../../../src/commands/module/list.js';
@@ -8,6 +8,7 @@ import ModuleUpdateCommand from '../../../src/commands/module/update.js';
8
8
  import { createTempDir, createMockRepo, cleanupTestRoot } from '../../utils/integration-helpers.js';
9
9
  import path from 'node:path';
10
10
  import fs from 'fs-extra';
11
+ import { execa } from 'execa';
11
12
 
12
13
  // Mock picocolors to return strings as-is for easy matching
13
14
  vi.mock('picocolors', () => ({
@@ -25,7 +26,6 @@ vi.mock('picocolors', () => ({
25
26
 
26
27
  describe('Module Commands Integration', () => {
27
28
  let projectDir: string;
28
- let moduleRepo: string;
29
29
  let consoleTableSpy: unknown;
30
30
 
31
31
  beforeEach(async () => {
@@ -42,7 +42,7 @@ describe('Module Commands Integration', () => {
42
42
 
43
43
  // 2. Create a "Module" that is a SEPARATE git repo
44
44
  const modTemp = await createTempDir('module-source-');
45
- moduleRepo = await createMockRepo(modTemp, {
45
+ await createMockRepo(modTemp, {
46
46
  'package.json': '{"name": "my-module", "version": "1.0.0", "description": "Awesome module"}',
47
47
  'module.yaml': 'name: my-module\nversion: 1.0.0',
48
48
  'index.ts': 'export const hello = "world";',
@@ -65,78 +65,104 @@ describe('Module Commands Integration', () => {
65
65
  await cleanupTestRoot();
66
66
  });
67
67
 
68
- it('should add, list, update and remove a module', async () => {
68
+ it('should add, list, update and remove backend and frontend modules', async () => {
69
69
  const originalCwd = process.cwd();
70
- const cli = new CLI({ commandName: 'nexical' });
70
+ // Re-initialize CLI for this test to ensure clean state if needed, though previously it was new per test
71
+ // We can reuse the CLI instance from beforeEach if we moved it there, but here it is fine.
72
+
73
+ // 1. Setup Backend Module Repo
74
+ const backendTemp = await createTempDir('backend-mod-');
75
+ const backendRepo = await createMockRepo(backendTemp, {
76
+ 'package.json': '{"name": "backend-api", "version": "1.0.0"}',
77
+ 'module.yaml': 'name: backend-api\nversion: 1.0.0',
78
+ 'models.yaml': '- name: User\n fields: {}', // Indicator
79
+ });
80
+
81
+ // 2. Setup Frontend Module Repo
82
+ const frontendTemp = await createTempDir('frontend-mod-');
83
+ const frontendRepo = await createMockRepo(frontendTemp, {
84
+ 'package.json': '{"name": "frontend-ui", "version": "1.0.0"}',
85
+ 'module.yaml': 'name: frontend-ui\nversion: 1.0.0',
86
+ 'ui.yaml': 'theme: dark', // Indicator
87
+ });
88
+
71
89
  try {
72
90
  process.chdir(projectDir);
73
91
 
74
- // 1. ADD MODULE
75
- const addCmd = new ModuleAddCommand(cli);
92
+ // --- ADD BACKEND ---
93
+ // --- ADD BACKEND ---
94
+ // Actually `run` uses `this.projectRoot` which is set by `BaseCommand.init()`.
95
+
96
+ // Let's rely on the pattern from the existing file:
97
+ // imports: import { CLI } from '@nexical/cli-core';
98
+ // const cli = new CLI({ commandName: 'nexical' });
99
+ // const addCmd = new ModuleAddCommand(cli);
100
+
101
+ // I need to instantiate CLI first.
102
+ const cli = new CLI({ commandName: 'nexical' });
103
+
104
+ const addBackend = new ModuleAddCommand(cli);
105
+ (addBackend as unknown as { projectRoot: string }).projectRoot = projectDir;
106
+ // or we can rely on init() finding it if CWD is correct.
107
+ // Let's try to set it explicitly to be safe.
108
+
109
+ await addBackend.run({ url: backendRepo });
76
110
 
77
- await addCmd.init();
78
- await addCmd.run({ url: moduleRepo });
111
+ const backendPath = path.join(projectDir, 'apps/backend/modules/backend-api');
112
+ expect(fs.existsSync(backendPath)).toBe(true);
113
+ expect(fs.existsSync(path.join(backendPath, 'models.yaml'))).toBe(true);
79
114
 
80
- const modulePath = path.join(projectDir, 'modules/my-module');
81
- expect(fs.existsSync(modulePath)).toBe(true);
82
- expect(fs.existsSync(path.join(modulePath, 'package.json'))).toBe(true);
115
+ // --- ADD FRONTEND ---
116
+ const addFrontend = new ModuleAddCommand(cli);
117
+ (addFrontend as unknown as { projectRoot: string }).projectRoot = projectDir;
118
+ await addFrontend.run({ url: frontendRepo });
83
119
 
84
- // Verify nexical.yaml updated
120
+ const frontendPath = path.join(projectDir, 'apps/frontend/modules/frontend-ui');
121
+ expect(fs.existsSync(frontendPath)).toBe(true);
122
+ expect(fs.existsSync(path.join(frontendPath, 'ui.yaml'))).toBe(true);
123
+
124
+ // --- VERIFY CONFIG ---
85
125
  const config = await fs.readFile(path.join(projectDir, 'nexical.yaml'), 'utf8');
86
126
  expect(config).toContain('modules:');
87
- expect(config).toContain('- my-module');
88
-
89
- // Check it is a submodule
90
- // .git file in module dir pointing to gitdir
91
- expect(fs.existsSync(path.join(modulePath, '.git'))).toBe(true);
92
- const gitModules = await fs.readFile(path.join(projectDir, '.gitmodules'), 'utf-8');
93
- expect(gitModules).toContain('path = modules/my-module');
127
+ expect(config).toContain('backend:');
128
+ expect(config).toContain(' - backend-api'); // Indentation check might be flaky with yaml stringify, just check existence
129
+ expect(config).toContain('frontend:');
130
+ expect(config).toContain(' - frontend-ui');
94
131
 
95
- // 2. LIST MODULES with valid module
132
+ // --- LIST ---
96
133
  const listCmd = new ModuleListCommand(cli);
97
- await listCmd.init();
134
+ (listCmd as unknown as { projectRoot: string }).projectRoot = projectDir;
98
135
  await listCmd.run();
99
136
 
100
- // Check console.table called with module info
101
137
  expect(consoleTableSpy).toHaveBeenCalledWith(
102
138
  expect.arrayContaining([
103
- expect.objectContaining({
104
- name: 'my-module',
105
- version: '1.0.0',
106
- description: 'Awesome module',
107
- }),
139
+ expect.objectContaining({ name: 'backend-api', type: 'backend' }),
140
+ expect.objectContaining({ name: 'frontend-ui', type: 'frontend' }),
108
141
  ]),
109
142
  );
110
143
 
111
- // 3. UPDATE MODULE
144
+ // --- REMOVE BACKEND ---
145
+ const removeCmd = new ModuleRemoveCommand(cli);
146
+ (removeCmd as unknown as { projectRoot: string }).projectRoot = projectDir;
147
+ await removeCmd.run({ name: 'backend-api' });
148
+
149
+ expect(fs.existsSync(backendPath)).toBe(false);
150
+
151
+ const configAfterRemove = await fs.readFile(path.join(projectDir, 'nexical.yaml'), 'utf8');
152
+ expect(configAfterRemove).not.toContain('backend-api');
153
+ expect(configAfterRemove).toContain('frontend-ui');
154
+
155
+ // --- UPDATE FRONTEND ---
156
+ // Commit a change to frontend repo
157
+ await execa('git', ['commit', '--allow-empty', '-m', 'New version'], { cwd: frontendRepo });
158
+
112
159
  const updateCmd = new ModuleUpdateCommand(cli);
113
- await updateCmd.init();
114
- await updateCmd.run({ name: 'my-module' });
115
- // Hard to check "update" without changing the remote first.
116
- // But we verify it ran without throwing.
160
+ (updateCmd as unknown as { projectRoot: string }).projectRoot = projectDir;
161
+ await updateCmd.run({});
117
162
 
118
- // 4. REMOVE MODULE
119
- const removeCmd = new ModuleRemoveCommand(cli);
120
- await removeCmd.init();
121
- await removeCmd.run({ name: 'my-module' });
122
-
123
- expect(fs.existsSync(modulePath)).toBe(false);
124
-
125
- // Verify git cleanup
126
- // .git/modules/modules/my-module should be gone
127
- const gitInternalModuleDir = path.join(projectDir, '.git/modules/modules/my-module');
128
- expect(fs.existsSync(gitInternalModuleDir)).toBe(false);
129
-
130
- // .gitmodules entry gone? `git rm` usually handles this.
131
- // Check if .gitmodules file exists (if empty it might remain or be deleted depending on git version, usually implicitly updated)
132
- if (fs.existsSync(path.join(projectDir, '.gitmodules'))) {
133
- const updatedGitModules = await fs.readFile(path.join(projectDir, '.gitmodules'), 'utf-8');
134
- expect(updatedGitModules).not.toContain('modules/my-module');
135
- }
136
-
137
- // Verify nexical.yaml updated
138
- const configRemoved = await fs.readFile(path.join(projectDir, 'nexical.yaml'), 'utf8');
139
- expect(configRemoved).not.toContain('- my-module');
163
+ // Verify submodule update?
164
+ // Diff hard to check without actually checking git status inside.
165
+ // But command should succeed.
140
166
  } finally {
141
167
  process.chdir(originalCwd);
142
168
  }