@l4yercak3/cli 1.0.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.
Files changed (61) hide show
  1. package/.claude/settings.local.json +18 -0
  2. package/.cursor/rules.md +203 -0
  3. package/.eslintrc.js +31 -0
  4. package/README.md +227 -0
  5. package/bin/cli.js +61 -0
  6. package/docs/ADDING_NEW_PROJECT_TYPE.md +156 -0
  7. package/docs/ARCHITECTURE_RELATIONSHIPS.md +411 -0
  8. package/docs/CLI_AUTHENTICATION.md +214 -0
  9. package/docs/DETECTOR_ARCHITECTURE.md +326 -0
  10. package/docs/DEVELOPMENT.md +194 -0
  11. package/docs/IMPLEMENTATION_PHASES.md +468 -0
  12. package/docs/OAUTH_CLARIFICATION.md +258 -0
  13. package/docs/OAUTH_SETUP_GUIDE_TEMPLATE.md +211 -0
  14. package/docs/PHASE_0_PROGRESS.md +120 -0
  15. package/docs/PHASE_1_COMPLETE.md +366 -0
  16. package/docs/PHASE_SUMMARY.md +149 -0
  17. package/docs/PLAN.md +511 -0
  18. package/docs/README.md +56 -0
  19. package/docs/STRIPE_INTEGRATION.md +447 -0
  20. package/docs/SUMMARY.md +230 -0
  21. package/docs/UPDATED_PLAN.md +447 -0
  22. package/package.json +53 -0
  23. package/src/api/backend-client.js +148 -0
  24. package/src/commands/login.js +146 -0
  25. package/src/commands/logout.js +24 -0
  26. package/src/commands/spread.js +364 -0
  27. package/src/commands/status.js +62 -0
  28. package/src/config/config-manager.js +205 -0
  29. package/src/detectors/api-client-detector.js +85 -0
  30. package/src/detectors/base-detector.js +77 -0
  31. package/src/detectors/github-detector.js +74 -0
  32. package/src/detectors/index.js +80 -0
  33. package/src/detectors/nextjs-detector.js +139 -0
  34. package/src/detectors/oauth-detector.js +122 -0
  35. package/src/detectors/registry.js +97 -0
  36. package/src/generators/api-client-generator.js +197 -0
  37. package/src/generators/env-generator.js +162 -0
  38. package/src/generators/gitignore-generator.js +92 -0
  39. package/src/generators/index.js +50 -0
  40. package/src/generators/nextauth-generator.js +242 -0
  41. package/src/generators/oauth-guide-generator.js +277 -0
  42. package/src/logo.js +116 -0
  43. package/tests/api-client-detector.test.js +214 -0
  44. package/tests/api-client-generator.test.js +169 -0
  45. package/tests/backend-client.test.js +361 -0
  46. package/tests/base-detector.test.js +101 -0
  47. package/tests/commands/login.test.js +98 -0
  48. package/tests/commands/logout.test.js +70 -0
  49. package/tests/commands/status.test.js +167 -0
  50. package/tests/config-manager.test.js +313 -0
  51. package/tests/detector-index.test.js +209 -0
  52. package/tests/detector-registry.test.js +93 -0
  53. package/tests/env-generator.test.js +278 -0
  54. package/tests/generators-index.test.js +215 -0
  55. package/tests/github-detector.test.js +145 -0
  56. package/tests/gitignore-generator.test.js +109 -0
  57. package/tests/logo.test.js +96 -0
  58. package/tests/nextauth-generator.test.js +231 -0
  59. package/tests/nextjs-detector.test.js +235 -0
  60. package/tests/oauth-detector.test.js +264 -0
  61. package/tests/oauth-guide-generator.test.js +273 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Tests for Status Command
3
+ */
4
+
5
+ jest.mock('../../src/config/config-manager');
6
+ jest.mock('../../src/api/backend-client');
7
+ jest.mock('chalk', () => ({
8
+ bold: (str) => str,
9
+ red: (str) => str,
10
+ green: (str) => str,
11
+ yellow: (str) => str,
12
+ gray: (str) => str,
13
+ }));
14
+
15
+ const configManager = require('../../src/config/config-manager');
16
+ const backendClient = require('../../src/api/backend-client');
17
+ const statusCommand = require('../../src/commands/status');
18
+
19
+ describe('Status Command', () => {
20
+ let consoleOutput = [];
21
+ const originalConsoleLog = console.log;
22
+
23
+ beforeEach(() => {
24
+ jest.clearAllMocks();
25
+ consoleOutput = [];
26
+ console.log = jest.fn((...args) => {
27
+ consoleOutput.push(args.join(' '));
28
+ });
29
+ configManager.getBackendUrl.mockReturnValue('https://backend.test.com');
30
+ });
31
+
32
+ afterEach(() => {
33
+ console.log = originalConsoleLog;
34
+ });
35
+
36
+ describe('module exports', () => {
37
+ it('exports command name', () => {
38
+ expect(statusCommand.command).toBe('status');
39
+ });
40
+
41
+ it('exports description', () => {
42
+ expect(statusCommand.description).toBe('Show authentication status');
43
+ });
44
+
45
+ it('exports handler function', () => {
46
+ expect(typeof statusCommand.handler).toBe('function');
47
+ });
48
+ });
49
+
50
+ describe('handler - not logged in', () => {
51
+ beforeEach(() => {
52
+ configManager.isLoggedIn.mockReturnValue(false);
53
+ configManager.getSession.mockReturnValue(null);
54
+ });
55
+
56
+ it('shows not logged in message', async () => {
57
+ await statusCommand.handler();
58
+
59
+ expect(consoleOutput.some((line) => line.includes('Not logged in'))).toBe(true);
60
+ });
61
+
62
+ it('shows login hint', async () => {
63
+ await statusCommand.handler();
64
+
65
+ expect(consoleOutput.some((line) => line.includes('l4yercak3 login'))).toBe(true);
66
+ });
67
+
68
+ it('does not validate session with backend', async () => {
69
+ await statusCommand.handler();
70
+
71
+ expect(backendClient.validateSession).not.toHaveBeenCalled();
72
+ });
73
+ });
74
+
75
+ describe('handler - logged in', () => {
76
+ beforeEach(() => {
77
+ configManager.isLoggedIn.mockReturnValue(true);
78
+ });
79
+
80
+ it('shows logged in status', async () => {
81
+ configManager.getSession.mockReturnValue({ token: 'test-token' });
82
+ backendClient.validateSession.mockResolvedValue(null);
83
+
84
+ await statusCommand.handler();
85
+
86
+ expect(consoleOutput.some((line) => line.includes('Logged in'))).toBe(true);
87
+ });
88
+
89
+ it('displays email when available', async () => {
90
+ configManager.getSession.mockReturnValue({
91
+ token: 'test-token',
92
+ email: 'user@example.com',
93
+ });
94
+ backendClient.validateSession.mockResolvedValue(null);
95
+
96
+ await statusCommand.handler();
97
+
98
+ expect(consoleOutput.some((line) => line.includes('user@example.com'))).toBe(true);
99
+ });
100
+
101
+ it('displays expiration with days remaining', async () => {
102
+ const futureDate = Date.now() + 5 * 24 * 60 * 60 * 1000; // 5 days
103
+ configManager.getSession.mockReturnValue({
104
+ token: 'test-token',
105
+ expiresAt: futureDate,
106
+ });
107
+ backendClient.validateSession.mockResolvedValue(null);
108
+
109
+ await statusCommand.handler();
110
+
111
+ // Check that session expiration info is shown (days may vary based on timing)
112
+ expect(consoleOutput.some((line) => line.includes('Session expires'))).toBe(true);
113
+ });
114
+
115
+ it('shows expired warning when session expired', async () => {
116
+ const pastDate = Date.now() - 24 * 60 * 60 * 1000; // Yesterday
117
+ configManager.getSession.mockReturnValue({
118
+ token: 'test-token',
119
+ expiresAt: pastDate,
120
+ });
121
+ backendClient.validateSession.mockResolvedValue(null);
122
+
123
+ await statusCommand.handler();
124
+
125
+ expect(consoleOutput.some((line) => line.includes('expired'))).toBe(true);
126
+ });
127
+
128
+ it('validates session with backend', async () => {
129
+ configManager.getSession.mockReturnValue({ token: 'test-token' });
130
+ backendClient.validateSession.mockResolvedValue({ userId: '123' });
131
+
132
+ await statusCommand.handler();
133
+
134
+ expect(backendClient.validateSession).toHaveBeenCalled();
135
+ });
136
+
137
+ it('displays backend URL on successful validation', async () => {
138
+ configManager.getSession.mockReturnValue({ token: 'test-token' });
139
+ backendClient.validateSession.mockResolvedValue({ userId: '123' });
140
+
141
+ await statusCommand.handler();
142
+
143
+ expect(consoleOutput.some((line) => line.includes('Backend URL'))).toBe(true);
144
+ });
145
+
146
+ it('displays organization count when available', async () => {
147
+ configManager.getSession.mockReturnValue({ token: 'test-token' });
148
+ backendClient.validateSession.mockResolvedValue({
149
+ userId: '123',
150
+ organizations: [{ id: '1' }, { id: '2' }],
151
+ });
152
+
153
+ await statusCommand.handler();
154
+
155
+ expect(consoleOutput.some((line) => line.includes('Organizations: 2'))).toBe(true);
156
+ });
157
+
158
+ it('handles backend validation error gracefully', async () => {
159
+ configManager.getSession.mockReturnValue({ token: 'test-token' });
160
+ backendClient.validateSession.mockRejectedValue(new Error('Network error'));
161
+
162
+ await statusCommand.handler();
163
+
164
+ expect(consoleOutput.some((line) => line.includes('Could not validate session'))).toBe(true);
165
+ });
166
+ });
167
+ });
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Tests for ConfigManager
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+
9
+ // Mock fs module
10
+ jest.mock('fs');
11
+
12
+ // We need to require after mocking
13
+ const ConfigManager = require('../src/config/config-manager');
14
+
15
+ describe('ConfigManager', () => {
16
+ const mockConfigDir = path.join(os.homedir(), '.l4yercak3');
17
+ const mockConfigFile = path.join(mockConfigDir, 'config.json');
18
+
19
+ beforeEach(() => {
20
+ jest.clearAllMocks();
21
+ // Default: config directory exists
22
+ fs.existsSync.mockImplementation((p) => {
23
+ if (p === mockConfigDir) return true;
24
+ if (p === mockConfigFile) return false;
25
+ return false;
26
+ });
27
+ });
28
+
29
+ describe('getConfig', () => {
30
+ it('returns default config when no config file exists', () => {
31
+ fs.existsSync.mockReturnValue(false);
32
+
33
+ const config = ConfigManager.getConfig();
34
+
35
+ expect(config).toEqual({
36
+ session: null,
37
+ organizations: [],
38
+ settings: {
39
+ backendUrl: 'https://backend.l4yercak3.com',
40
+ },
41
+ });
42
+ });
43
+
44
+ it('reads and parses existing config file', () => {
45
+ const mockConfig = {
46
+ session: { token: 'test-token' },
47
+ organizations: [{ id: '1', name: 'Test Org' }],
48
+ settings: { backendUrl: 'https://custom.url' },
49
+ };
50
+
51
+ fs.existsSync.mockReturnValue(true);
52
+ fs.readFileSync.mockReturnValue(JSON.stringify(mockConfig));
53
+
54
+ const config = ConfigManager.getConfig();
55
+
56
+ expect(config).toEqual(mockConfig);
57
+ expect(fs.readFileSync).toHaveBeenCalledWith(mockConfigFile, 'utf8');
58
+ });
59
+
60
+ it('returns default config on parse error', () => {
61
+ fs.existsSync.mockReturnValue(true);
62
+ fs.readFileSync.mockReturnValue('invalid json');
63
+
64
+ const config = ConfigManager.getConfig();
65
+
66
+ expect(config).toEqual({
67
+ session: null,
68
+ organizations: [],
69
+ settings: {},
70
+ });
71
+ });
72
+ });
73
+
74
+ describe('saveConfig', () => {
75
+ it('creates config directory if it does not exist', () => {
76
+ fs.existsSync.mockReturnValue(false);
77
+ fs.mkdirSync.mockReturnValue(undefined);
78
+ fs.writeFileSync.mockReturnValue(undefined);
79
+
80
+ ConfigManager.saveConfig({ test: true });
81
+
82
+ expect(fs.mkdirSync).toHaveBeenCalledWith(mockConfigDir, {
83
+ recursive: true,
84
+ mode: 0o700,
85
+ });
86
+ });
87
+
88
+ it('writes config with secure permissions', () => {
89
+ fs.existsSync.mockReturnValue(true);
90
+ fs.writeFileSync.mockReturnValue(undefined);
91
+
92
+ const config = { session: { token: 'test' } };
93
+ const result = ConfigManager.saveConfig(config);
94
+
95
+ expect(result).toBe(true);
96
+ expect(fs.writeFileSync).toHaveBeenCalledWith(
97
+ mockConfigFile,
98
+ JSON.stringify(config, null, 2),
99
+ { mode: 0o600 }
100
+ );
101
+ });
102
+
103
+ it('returns false on write error', () => {
104
+ fs.existsSync.mockReturnValue(true);
105
+ fs.writeFileSync.mockImplementation(() => {
106
+ throw new Error('Write failed');
107
+ });
108
+
109
+ const result = ConfigManager.saveConfig({ test: true });
110
+
111
+ expect(result).toBe(false);
112
+ });
113
+ });
114
+
115
+ describe('session management', () => {
116
+ it('getSession returns null when no session exists', () => {
117
+ fs.existsSync.mockImplementation((p) => p === mockConfigDir);
118
+
119
+ const session = ConfigManager.getSession();
120
+
121
+ expect(session).toBeNull();
122
+ });
123
+
124
+ it('saveSession updates session in config', () => {
125
+ fs.existsSync.mockReturnValue(true);
126
+ fs.readFileSync.mockReturnValue(JSON.stringify({
127
+ session: null,
128
+ organizations: [],
129
+ settings: {},
130
+ }));
131
+ fs.writeFileSync.mockReturnValue(undefined);
132
+
133
+ const session = { token: 'new-token', expiresAt: Date.now() + 3600000 };
134
+ ConfigManager.saveSession(session);
135
+
136
+ expect(fs.writeFileSync).toHaveBeenCalled();
137
+ const savedConfig = JSON.parse(fs.writeFileSync.mock.calls[0][1]);
138
+ expect(savedConfig.session).toEqual(session);
139
+ });
140
+
141
+ it('clearSession removes session from config', () => {
142
+ fs.existsSync.mockReturnValue(true);
143
+ fs.readFileSync.mockReturnValue(JSON.stringify({
144
+ session: { token: 'existing' },
145
+ organizations: [],
146
+ settings: {},
147
+ }));
148
+ fs.writeFileSync.mockReturnValue(undefined);
149
+
150
+ ConfigManager.clearSession();
151
+
152
+ const savedConfig = JSON.parse(fs.writeFileSync.mock.calls[0][1]);
153
+ expect(savedConfig.session).toBeNull();
154
+ });
155
+ });
156
+
157
+ describe('isLoggedIn', () => {
158
+ it('returns false when no session', () => {
159
+ fs.existsSync.mockImplementation((p) => p === mockConfigDir);
160
+
161
+ expect(ConfigManager.isLoggedIn()).toBe(false);
162
+ });
163
+
164
+ it('returns false when session expired', () => {
165
+ fs.existsSync.mockReturnValue(true);
166
+ fs.readFileSync.mockReturnValue(JSON.stringify({
167
+ session: {
168
+ token: 'test-token',
169
+ expiresAt: Date.now() - 1000, // Expired
170
+ },
171
+ organizations: [],
172
+ settings: {},
173
+ }));
174
+
175
+ expect(ConfigManager.isLoggedIn()).toBe(false);
176
+ });
177
+
178
+ it('returns true when session valid', () => {
179
+ fs.existsSync.mockReturnValue(true);
180
+ fs.readFileSync.mockReturnValue(JSON.stringify({
181
+ session: {
182
+ token: 'test-token',
183
+ expiresAt: Date.now() + 3600000, // Valid for 1 hour
184
+ },
185
+ organizations: [],
186
+ settings: {},
187
+ }));
188
+
189
+ expect(ConfigManager.isLoggedIn()).toBe(true);
190
+ });
191
+ });
192
+
193
+ describe('getBackendUrl', () => {
194
+ it('returns default URL when not configured', () => {
195
+ fs.existsSync.mockImplementation((p) => p === mockConfigDir);
196
+
197
+ const url = ConfigManager.getBackendUrl();
198
+
199
+ expect(url).toBe('https://backend.l4yercak3.com');
200
+ });
201
+
202
+ it('returns configured URL from settings', () => {
203
+ fs.existsSync.mockReturnValue(true);
204
+ fs.readFileSync.mockReturnValue(JSON.stringify({
205
+ session: null,
206
+ organizations: [],
207
+ settings: { backendUrl: 'https://custom.backend.com' },
208
+ }));
209
+
210
+ const url = ConfigManager.getBackendUrl();
211
+
212
+ expect(url).toBe('https://custom.backend.com');
213
+ });
214
+ });
215
+
216
+ describe('organization management', () => {
217
+ it('addOrganization adds new organization', () => {
218
+ fs.existsSync.mockReturnValue(true);
219
+ fs.readFileSync.mockReturnValue(JSON.stringify({
220
+ session: null,
221
+ organizations: [],
222
+ settings: {},
223
+ }));
224
+ fs.writeFileSync.mockReturnValue(undefined);
225
+
226
+ const org = { id: '123', name: 'New Org' };
227
+ ConfigManager.addOrganization(org);
228
+
229
+ const savedConfig = JSON.parse(fs.writeFileSync.mock.calls[0][1]);
230
+ expect(savedConfig.organizations).toContainEqual(org);
231
+ });
232
+
233
+ it('addOrganization replaces existing organization with same id', () => {
234
+ fs.existsSync.mockReturnValue(true);
235
+ fs.readFileSync.mockReturnValue(JSON.stringify({
236
+ session: null,
237
+ organizations: [{ id: '123', name: 'Old Name' }],
238
+ settings: {},
239
+ }));
240
+ fs.writeFileSync.mockReturnValue(undefined);
241
+
242
+ const org = { id: '123', name: 'New Name' };
243
+ ConfigManager.addOrganization(org);
244
+
245
+ const savedConfig = JSON.parse(fs.writeFileSync.mock.calls[0][1]);
246
+ expect(savedConfig.organizations).toHaveLength(1);
247
+ expect(savedConfig.organizations[0].name).toBe('New Name');
248
+ });
249
+
250
+ it('getOrganizations returns empty array when none exist', () => {
251
+ fs.existsSync.mockImplementation((p) => p === mockConfigDir);
252
+
253
+ const orgs = ConfigManager.getOrganizations();
254
+
255
+ expect(orgs).toEqual([]);
256
+ });
257
+ });
258
+
259
+ describe('project configuration', () => {
260
+ it('saveProjectConfig stores project config by path', () => {
261
+ fs.existsSync.mockReturnValue(true);
262
+ fs.readFileSync.mockReturnValue(JSON.stringify({
263
+ session: null,
264
+ organizations: [],
265
+ settings: {},
266
+ }));
267
+ fs.writeFileSync.mockReturnValue(undefined);
268
+
269
+ const projectPath = '/path/to/project';
270
+ const projectConfig = { apiKey: 'key123', features: ['crm'] };
271
+ ConfigManager.saveProjectConfig(projectPath, projectConfig);
272
+
273
+ const savedConfig = JSON.parse(fs.writeFileSync.mock.calls[0][1]);
274
+ const normalizedPath = path.resolve(projectPath);
275
+ expect(savedConfig.projects[normalizedPath]).toMatchObject(projectConfig);
276
+ expect(savedConfig.projects[normalizedPath].updatedAt).toBeDefined();
277
+ });
278
+
279
+ it('getProjectConfig returns null when project not found', () => {
280
+ fs.existsSync.mockReturnValue(true);
281
+ fs.readFileSync.mockReturnValue(JSON.stringify({
282
+ session: null,
283
+ organizations: [],
284
+ settings: {},
285
+ projects: {},
286
+ }));
287
+
288
+ const config = ConfigManager.getProjectConfig('/nonexistent/path');
289
+
290
+ expect(config).toBeNull();
291
+ });
292
+
293
+ it('getProjectConfig returns config for existing project', () => {
294
+ const projectPath = '/path/to/project';
295
+ const normalizedPath = path.resolve(projectPath);
296
+ const projectConfig = { apiKey: 'key123' };
297
+
298
+ fs.existsSync.mockReturnValue(true);
299
+ fs.readFileSync.mockReturnValue(JSON.stringify({
300
+ session: null,
301
+ organizations: [],
302
+ settings: {},
303
+ projects: {
304
+ [normalizedPath]: projectConfig,
305
+ },
306
+ }));
307
+
308
+ const config = ConfigManager.getProjectConfig(projectPath);
309
+
310
+ expect(config).toEqual(projectConfig);
311
+ });
312
+ });
313
+ });
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Tests for Detector Index (ProjectDetector)
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const { execSync } = require('child_process');
7
+ const path = require('path');
8
+
9
+ jest.mock('fs');
10
+ jest.mock('child_process');
11
+
12
+ const ProjectDetector = require('../src/detectors/index');
13
+
14
+ describe('ProjectDetector', () => {
15
+ const mockProjectPath = '/test/project';
16
+
17
+ beforeEach(() => {
18
+ jest.clearAllMocks();
19
+ fs.existsSync.mockReturnValue(false);
20
+ execSync.mockReturnValue('');
21
+ });
22
+
23
+ describe('detect', () => {
24
+ it('returns combined detection results', () => {
25
+ const result = ProjectDetector.detect(mockProjectPath);
26
+
27
+ expect(result).toHaveProperty('framework');
28
+ expect(result).toHaveProperty('github');
29
+ expect(result).toHaveProperty('apiClient');
30
+ expect(result).toHaveProperty('oauth');
31
+ expect(result).toHaveProperty('projectPath');
32
+ expect(result).toHaveProperty('_raw');
33
+ });
34
+
35
+ it('includes projectPath in results', () => {
36
+ const result = ProjectDetector.detect(mockProjectPath);
37
+
38
+ expect(result.projectPath).toBe(mockProjectPath);
39
+ });
40
+
41
+ describe('framework detection', () => {
42
+ it('returns null type when no framework detected', () => {
43
+ const result = ProjectDetector.detect(mockProjectPath);
44
+
45
+ expect(result.framework.type).toBeNull();
46
+ expect(result.framework.confidence).toBe(0);
47
+ });
48
+
49
+ it('detects Next.js framework', () => {
50
+ fs.existsSync.mockImplementation((p) => {
51
+ if (p === path.join(mockProjectPath, 'package.json')) return true;
52
+ return false;
53
+ });
54
+ fs.readFileSync.mockReturnValue(JSON.stringify({
55
+ dependencies: { next: '^14.0.0' },
56
+ }));
57
+
58
+ const result = ProjectDetector.detect(mockProjectPath);
59
+
60
+ expect(result.framework.type).toBe('nextjs');
61
+ expect(result.framework.confidence).toBeGreaterThan(0.8);
62
+ });
63
+
64
+ it('includes supported features for detected framework', () => {
65
+ fs.existsSync.mockImplementation((p) => {
66
+ if (p === path.join(mockProjectPath, 'package.json')) return true;
67
+ return false;
68
+ });
69
+ fs.readFileSync.mockReturnValue(JSON.stringify({
70
+ dependencies: { next: '^14.0.0' },
71
+ }));
72
+
73
+ const result = ProjectDetector.detect(mockProjectPath);
74
+
75
+ expect(result.framework.supportedFeatures).toHaveProperty('oauth');
76
+ expect(result.framework.supportedFeatures).toHaveProperty('stripe');
77
+ });
78
+
79
+ it('includes available generators for detected framework', () => {
80
+ fs.existsSync.mockImplementation((p) => {
81
+ if (p === path.join(mockProjectPath, 'package.json')) return true;
82
+ return false;
83
+ });
84
+ fs.readFileSync.mockReturnValue(JSON.stringify({
85
+ dependencies: { next: '^14.0.0' },
86
+ }));
87
+
88
+ const result = ProjectDetector.detect(mockProjectPath);
89
+
90
+ expect(Array.isArray(result.framework.availableGenerators)).toBe(true);
91
+ expect(result.framework.availableGenerators).toContain('api-client');
92
+ });
93
+
94
+ it('returns empty features/generators when no framework detected', () => {
95
+ const result = ProjectDetector.detect(mockProjectPath);
96
+
97
+ expect(result.framework.supportedFeatures).toEqual({});
98
+ expect(result.framework.availableGenerators).toEqual([]);
99
+ });
100
+ });
101
+
102
+ describe('github detection', () => {
103
+ it('detects GitHub repository info', () => {
104
+ fs.existsSync.mockImplementation((p) =>
105
+ p === path.join(mockProjectPath, '.git')
106
+ );
107
+ execSync
108
+ .mockReturnValueOnce('https://github.com/owner/repo.git\n')
109
+ .mockReturnValueOnce('main\n');
110
+
111
+ const result = ProjectDetector.detect(mockProjectPath);
112
+
113
+ expect(result.github.isGitHub).toBe(true);
114
+ expect(result.github.owner).toBe('owner');
115
+ expect(result.github.repo).toBe('repo');
116
+ });
117
+
118
+ it('returns github info even without GitHub remote', () => {
119
+ const result = ProjectDetector.detect(mockProjectPath);
120
+
121
+ expect(result.github).toHaveProperty('hasGit');
122
+ expect(result.github).toHaveProperty('isGitHub');
123
+ });
124
+ });
125
+
126
+ describe('apiClient detection', () => {
127
+ it('detects existing API client', () => {
128
+ fs.existsSync.mockImplementation((p) =>
129
+ p === path.join(mockProjectPath, 'lib/api-client.ts')
130
+ );
131
+ fs.readFileSync.mockReturnValue('import axios');
132
+
133
+ const result = ProjectDetector.detect(mockProjectPath);
134
+
135
+ expect(result.apiClient.hasApiClient).toBe(true);
136
+ expect(result.apiClient.clientType).toBe('axios');
137
+ });
138
+
139
+ it('returns apiClient info even without existing client', () => {
140
+ const result = ProjectDetector.detect(mockProjectPath);
141
+
142
+ expect(result.apiClient).toHaveProperty('hasApiClient');
143
+ expect(result.apiClient).toHaveProperty('clientPath');
144
+ });
145
+ });
146
+
147
+ describe('oauth detection', () => {
148
+ it('detects OAuth setup', () => {
149
+ fs.existsSync.mockImplementation((p) =>
150
+ p === path.join(mockProjectPath, 'app/api/auth/[...nextauth]/route.ts')
151
+ );
152
+ fs.readFileSync.mockReturnValue('GoogleProvider');
153
+
154
+ const result = ProjectDetector.detect(mockProjectPath);
155
+
156
+ expect(result.oauth.hasOAuth).toBe(true);
157
+ expect(result.oauth.providers).toContain('google');
158
+ });
159
+
160
+ it('returns oauth info even without existing setup', () => {
161
+ const result = ProjectDetector.detect(mockProjectPath);
162
+
163
+ expect(result.oauth).toHaveProperty('hasOAuth');
164
+ expect(result.oauth).toHaveProperty('providers');
165
+ });
166
+ });
167
+
168
+ describe('raw results', () => {
169
+ it('includes raw framework detection results', () => {
170
+ const result = ProjectDetector.detect(mockProjectPath);
171
+
172
+ expect(result._raw).toHaveProperty('frameworkResults');
173
+ expect(Array.isArray(result._raw.frameworkResults)).toBe(true);
174
+ });
175
+ });
176
+ });
177
+
178
+ describe('getDetector', () => {
179
+ it('returns detector by type name', () => {
180
+ const detector = ProjectDetector.getDetector('nextjs');
181
+
182
+ expect(detector).not.toBeNull();
183
+ expect(detector.name).toBe('nextjs');
184
+ });
185
+
186
+ it('returns null for unknown type', () => {
187
+ const detector = ProjectDetector.getDetector('unknown');
188
+
189
+ expect(detector).toBeNull();
190
+ });
191
+ });
192
+
193
+ describe('getAvailableTypes', () => {
194
+ it('returns array of detector names', () => {
195
+ const types = ProjectDetector.getAvailableTypes();
196
+
197
+ expect(Array.isArray(types)).toBe(true);
198
+ expect(types).toContain('nextjs');
199
+ });
200
+
201
+ it('returns strings only', () => {
202
+ const types = ProjectDetector.getAvailableTypes();
203
+
204
+ types.forEach((type) => {
205
+ expect(typeof type).toBe('string');
206
+ });
207
+ });
208
+ });
209
+ });