@nexical/cli 0.11.8 → 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 (42) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/index.js.map +1 -1
  3. package/dist/src/commands/init.js +3 -3
  4. package/dist/src/commands/module/add.js +53 -22
  5. package/dist/src/commands/module/add.js.map +1 -1
  6. package/dist/src/commands/module/list.d.ts +1 -0
  7. package/dist/src/commands/module/list.js +54 -45
  8. package/dist/src/commands/module/list.js.map +1 -1
  9. package/dist/src/commands/module/remove.js +37 -12
  10. package/dist/src/commands/module/remove.js.map +1 -1
  11. package/dist/src/commands/module/update.js +15 -3
  12. package/dist/src/commands/module/update.js.map +1 -1
  13. package/dist/src/commands/run.js +18 -1
  14. package/dist/src/commands/run.js.map +1 -1
  15. package/package.json +1 -1
  16. package/src/commands/module/add.ts +74 -31
  17. package/src/commands/module/list.ts +80 -57
  18. package/src/commands/module/remove.ts +50 -14
  19. package/src/commands/module/update.ts +19 -5
  20. package/src/commands/run.ts +21 -1
  21. package/test/e2e/lifecycle.e2e.test.ts +3 -2
  22. package/test/integration/commands/deploy.integration.test.ts +102 -0
  23. package/test/integration/commands/init.integration.test.ts +16 -1
  24. package/test/integration/commands/module.integration.test.ts +81 -55
  25. package/test/integration/commands/run.integration.test.ts +69 -74
  26. package/test/integration/commands/setup.integration.test.ts +53 -0
  27. package/test/unit/commands/deploy.test.ts +285 -0
  28. package/test/unit/commands/init.test.ts +15 -0
  29. package/test/unit/commands/module/add.test.ts +363 -254
  30. package/test/unit/commands/module/list.test.ts +100 -99
  31. package/test/unit/commands/module/remove.test.ts +143 -58
  32. package/test/unit/commands/module/update.test.ts +45 -62
  33. package/test/unit/commands/run.test.ts +16 -1
  34. package/test/unit/commands/setup.test.ts +25 -66
  35. package/test/unit/deploy/config-manager.test.ts +65 -0
  36. package/test/unit/deploy/providers/cloudflare.test.ts +210 -0
  37. package/test/unit/deploy/providers/github.test.ts +139 -0
  38. package/test/unit/deploy/providers/railway.test.ts +328 -0
  39. package/test/unit/deploy/registry.test.ts +227 -0
  40. package/test/unit/deploy/utils.test.ts +30 -0
  41. package/test/unit/utils/command-discovery.test.ts +145 -142
  42. package/test/unit/utils/git_utils.test.ts +49 -0
@@ -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
  }
@@ -1,97 +1,92 @@
1
- import { CLI } from '@nexical/cli-core';
2
- import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from 'vitest';
1
+ import { describe, it, beforeEach, afterAll } from 'vitest';
3
2
  import RunCommand from '../../../src/commands/run.js';
4
- import { createTempDir } from '../../utils/integration-helpers.js';
3
+ import { createTempDir, createMockRepo, cleanupTestRoot } from '../../utils/integration-helpers.js';
5
4
  import path from 'node:path';
6
5
  import fs from 'fs-extra';
7
- import { spawn } from 'child_process';
8
- import EventEmitter from 'events';
9
-
10
- vi.mock('child_process', () => ({
11
- spawn: vi.fn(),
12
- exec: vi.fn(),
13
- }));
6
+ import { CLI } from '@nexical/cli-core';
14
7
 
15
- describe('RunCommand Integration', () => {
8
+ describe('Run Command Integration', () => {
16
9
  let projectDir: string;
17
- let spawnMock: ReturnType<typeof vi.mocked>;
18
10
 
19
11
  beforeEach(async () => {
20
- projectDir = await createTempDir('run-project-');
21
- vi.mocked(spawn).mockClear();
22
-
23
- // Setup minimal env (New Architecture: no site/ directory)
24
- await fs.ensureDir(projectDir);
25
- await fs.outputFile(
26
- path.join(projectDir, 'package.json'),
27
- JSON.stringify({
28
- name: 'nexical-core',
29
- scripts: {
30
- 'test-script': 'echo test',
31
- },
32
- }),
33
- );
34
-
35
- await fs.ensureDir(path.join(projectDir, 'modules', 'my-auth'));
36
- await fs.outputFile(
37
- path.join(projectDir, 'modules', 'my-auth', 'package.json'),
38
- JSON.stringify({
39
- scripts: {
40
- seed: 'node seed.js',
41
- },
42
- }),
43
- );
12
+ const temp = await createTempDir('run-project-');
13
+ projectDir = await createMockRepo(temp, {
14
+ 'package.json': '{"name": "run-project", "version": "1.0.0"}',
15
+ 'nexical.yaml': 'site: run-test\nmodules: []',
16
+ });
44
17
 
45
- spawnMock = vi.mocked(spawn).mockImplementation(() => {
46
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
47
- const child = new EventEmitter() as any;
48
- child.stdout = new EventEmitter();
49
- child.stderr = new EventEmitter();
50
- setTimeout(() => child.emit('close', 0), 10);
51
- child.kill = vi.fn();
52
- return child;
18
+ // Create a mock module with a script
19
+ const moduleDir = path.join(projectDir, 'apps/backend/modules/script-mod');
20
+ await fs.ensureDir(moduleDir);
21
+ await fs.writeJson(path.join(moduleDir, 'package.json'), {
22
+ name: 'script-mod',
23
+ version: '1.0.0',
24
+ scripts: {
25
+ 'test-script': 'echo "Hello from script-mod"',
26
+ },
53
27
  });
54
- });
55
28
 
56
- afterEach(() => {
57
- vi.clearAllMocks();
58
- vi.restoreAllMocks();
29
+ // Add module to nexical.yaml manually or via helper
30
+ // For RunCommand, it relies on discovery or explicit path?
31
+ // RunCommand iterates over ALL modules found in nexical.yaml or file system?
32
+ // RunCommand implementation:
33
+ // It runs a command in ALL modules or specific ones.
34
+
35
+ // We need to register it in nexical.yaml for it to be found commonly
36
+ const configPath = path.join(projectDir, 'nexical.yaml');
37
+ await fs.writeFile(configPath, 'site: run-test\nmodules:\n backend:\n - script-mod');
59
38
  });
60
39
 
61
40
  afterAll(async () => {
62
- if (projectDir) await fs.remove(projectDir);
41
+ await cleanupTestRoot();
63
42
  });
64
43
 
65
- it('should run standard npm scripts', async () => {
66
- const cli = new CLI({ commandName: 'nexical' });
67
- const command = new RunCommand(cli);
68
- Object.assign(command, { projectRoot: projectDir });
44
+ it('should run a script in a specific module', async () => {
45
+ const originalCwd = process.cwd();
46
+ try {
47
+ process.chdir(projectDir);
48
+ const cli = new CLI({ commandName: 'nexical' });
49
+ const runCmd = new RunCommand(cli);
50
+ (runCmd as unknown as { projectRoot: string }).projectRoot = projectDir;
51
+
52
+ // We need to capture stdout/stderr to verify execution
53
+ // But RunCommand uses `runCommand` from cli-core which uses execa.
54
+ // In integration test, execa is REAL.
55
+
56
+ // However, BaseCommand.run() might just spawn it.
57
+ // We can inspect the output if we could capture it.
58
+ // But `execa` streams to stdio usually.
59
+
60
+ // Let's rely on side effects or just that it doesn't throw.
61
+ // Or we can mock `runCommand` from `@nexical/cli-core` if we want to verify it called the script?
62
+ // But this is integration test, we want real execution.
63
+ // "Hello from script-mod" should be printed.
69
64
 
70
- await command.run({ script: 'test-script', args: ['--flag'] });
65
+ await runCmd.run({ script: 'script-mod:test-script' });
71
66
 
72
- expect(spawnMock).toHaveBeenCalledWith(
73
- 'npm',
74
- ['run', 'test-script', '--', '--flag'],
75
- expect.objectContaining({
76
- cwd: projectDir,
77
- }),
78
- );
67
+ // If passing, it means it found the module and ran the script (which echoed and exited 0).
68
+ } finally {
69
+ process.chdir(originalCwd);
70
+ }
79
71
  });
80
72
 
81
- it('should run module specific scripts', async () => {
82
- const cli = new CLI({ commandName: 'nexical' });
83
- const command = new RunCommand(cli);
84
- Object.assign(command, { projectRoot: projectDir });
73
+ it('should run a script in root', async () => {
74
+ const originalCwd = process.cwd();
75
+ try {
76
+ process.chdir(projectDir);
77
+ const cli = new CLI({ commandName: 'nexical' });
78
+ const runCmd = new RunCommand(cli);
79
+ (runCmd as unknown as { projectRoot: string }).projectRoot = projectDir;
85
80
 
86
- await command.run({ script: 'my-auth:seed', args: ['--force'] });
81
+ // Add a script to root package.json first
82
+ const pkgPath = path.join(projectDir, 'package.json');
83
+ const pkg = await fs.readJson(pkgPath);
84
+ pkg.scripts = { 'root-script': 'echo "Hello from root"' };
85
+ await fs.writeJson(pkgPath, pkg);
87
86
 
88
- // Module scripts run via npm run scriptName inside module dir
89
- expect(spawnMock).toHaveBeenCalledWith(
90
- 'npm',
91
- ['run', 'seed', '--', '--force'],
92
- expect.objectContaining({
93
- cwd: path.resolve(projectDir, 'modules', 'my-auth'),
94
- }),
95
- );
87
+ await runCmd.run({ script: 'root-script' });
88
+ } finally {
89
+ process.chdir(originalCwd);
90
+ }
96
91
  });
97
92
  });
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect, beforeEach, afterAll } from 'vitest';
2
+ import SetupCommand from '../../../src/commands/setup.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
+ describe('Setup Command Integration', () => {
9
+ let projectDir: string;
10
+
11
+ beforeEach(async () => {
12
+ const temp = await createTempDir('setup-project-');
13
+ projectDir = await createMockRepo(temp, {
14
+ 'package.json': '{"name": "setup-project", "version": "1.0.0"}',
15
+ 'nexical.yaml': 'site: setup-test\nmodules: []',
16
+ });
17
+
18
+ // Create core assets
19
+ await fs.ensureDir(path.join(projectDir, 'core/src'));
20
+ await fs.writeFile(path.join(projectDir, 'core/src/shared.ts'), 'export const shared = true;');
21
+
22
+ // Create app directories
23
+ await fs.ensureDir(path.join(projectDir, 'apps/backend'));
24
+ await fs.ensureDir(path.join(projectDir, 'apps/frontend'));
25
+ });
26
+
27
+ afterAll(async () => {
28
+ await cleanupTestRoot();
29
+ });
30
+
31
+ it('should symlink core assets to apps', async () => {
32
+ const originalCwd = process.cwd();
33
+ try {
34
+ process.chdir(projectDir); // Change CWD to simulate running from root
35
+ const cli = new CLI({ commandName: 'nexical' });
36
+ const setupCmd = new SetupCommand(cli);
37
+
38
+ // Execute setup command
39
+ await setupCmd.run();
40
+
41
+ // Verify symlinks
42
+ const backendSrc = path.join(projectDir, 'apps/backend/src');
43
+ expect(await fs.pathExists(backendSrc)).toBe(true);
44
+ const stat = await fs.lstat(backendSrc);
45
+ expect(stat.isSymbolicLink()).toBe(true);
46
+
47
+ const frontendSrc = path.join(projectDir, 'apps/frontend/src');
48
+ expect(await fs.pathExists(frontendSrc)).toBe(true);
49
+ } finally {
50
+ process.chdir(originalCwd);
51
+ }
52
+ });
53
+ });