@l4yercak3/cli 1.2.21 → 1.3.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.
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Init Helpers
3
+ * Shared helper functions for project initialization, connection, and sync commands
4
+ * Extracted from spread.js for reuse across connect.js, scaffold.js, sync.js
5
+ */
6
+
7
+ const { execSync } = require('child_process');
8
+ const inquirer = require('inquirer');
9
+ const chalk = require('chalk');
10
+ const backendClient = require('../api/backend-client');
11
+
12
+ /**
13
+ * Create an organization on the platform
14
+ * @param {string} orgName - Organization name
15
+ * @returns {Promise<{organizationId: string, organizationName: string}>}
16
+ */
17
+ async function createOrganization(orgName) {
18
+ console.log(chalk.gray(` Creating organization "${orgName}"...`));
19
+ const newOrg = await backendClient.createOrganization(orgName);
20
+ const organizationId = newOrg.organizationId || newOrg.id || newOrg.data?.organizationId || newOrg.data?.id;
21
+ const organizationName = newOrg.name || orgName;
22
+
23
+ if (!organizationId) {
24
+ throw new Error('Organization ID not found in response. Please check backend API endpoint.');
25
+ }
26
+
27
+ console.log(chalk.green(` ✅ Organization created: ${organizationName}\n`));
28
+ return { organizationId, organizationName };
29
+ }
30
+
31
+ /**
32
+ * Generate a new API key for an organization
33
+ * @param {string} organizationId
34
+ * @returns {Promise<string>} The API key
35
+ */
36
+ async function generateNewApiKey(organizationId) {
37
+ console.log(chalk.gray(' Generating API key...'));
38
+ const apiKeyResponse = await backendClient.generateApiKey(
39
+ organizationId,
40
+ 'CLI Generated Key',
41
+ ['*']
42
+ );
43
+ const apiKey = apiKeyResponse.key || apiKeyResponse.apiKey || apiKeyResponse.data?.key || apiKeyResponse.data?.apiKey;
44
+
45
+ if (!apiKey) {
46
+ throw new Error('API key not found in response. Please check backend API endpoint.');
47
+ }
48
+
49
+ console.log(chalk.green(` ✅ API key generated\n`));
50
+ return apiKey;
51
+ }
52
+
53
+ /**
54
+ * Check if the project is a git repository
55
+ * @param {string} projectPath
56
+ * @returns {boolean}
57
+ */
58
+ function isGitRepo(projectPath) {
59
+ try {
60
+ execSync('git rev-parse --is-inside-work-tree', {
61
+ cwd: projectPath,
62
+ stdio: 'pipe',
63
+ });
64
+ return true;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Get git status (uncommitted changes)
72
+ * @param {string} projectPath
73
+ * @returns {string}
74
+ */
75
+ function getGitStatus(projectPath) {
76
+ try {
77
+ const status = execSync('git status --porcelain', {
78
+ cwd: projectPath,
79
+ encoding: 'utf8',
80
+ });
81
+ return status.trim();
82
+ } catch {
83
+ return '';
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Check for uncommitted changes and prompt user to commit first
89
+ * @param {string} projectPath
90
+ * @returns {Promise<boolean>} true if should proceed, false to abort
91
+ */
92
+ async function checkGitStatusBeforeGeneration(projectPath) {
93
+ const debug = process.env.L4YERCAK3_DEBUG;
94
+
95
+ if (debug) {
96
+ console.log('\n[DEBUG] Git status check:');
97
+ console.log(` projectPath: "${projectPath}"`);
98
+ }
99
+
100
+ if (!isGitRepo(projectPath)) {
101
+ if (debug) console.log(' → Not a git repo, skipping check');
102
+ return true;
103
+ }
104
+
105
+ const status = getGitStatus(projectPath);
106
+
107
+ if (debug) {
108
+ console.log(` → Git status: "${status.substring(0, 100)}${status.length > 100 ? '...' : ''}"`);
109
+ }
110
+
111
+ if (!status) {
112
+ if (debug) console.log(' → No uncommitted changes, proceeding');
113
+ return true;
114
+ }
115
+
116
+ const changes = status.split('\n').filter(line => line.trim());
117
+ const modifiedCount = changes.filter(line => line.startsWith(' M') || line.startsWith('M ')).length;
118
+ const untrackedCount = changes.filter(line => line.startsWith('??')).length;
119
+ const stagedCount = changes.filter(line => /^[MADRC]/.test(line)).length;
120
+
121
+ console.log(chalk.yellow(' ⚠️ Uncommitted changes detected\n'));
122
+
123
+ if (modifiedCount > 0) console.log(chalk.gray(` ${modifiedCount} modified file(s)`));
124
+ if (untrackedCount > 0) console.log(chalk.gray(` ${untrackedCount} untracked file(s)`));
125
+ if (stagedCount > 0) console.log(chalk.gray(` ${stagedCount} staged file(s)`));
126
+
127
+ console.log('');
128
+ console.log(chalk.gray(' We recommend committing your changes before generating'));
129
+ console.log(chalk.gray(' new files, so you can easily revert if needed.\n'));
130
+
131
+ const { action } = await inquirer.prompt([
132
+ {
133
+ type: 'list',
134
+ name: 'action',
135
+ message: 'How would you like to proceed?',
136
+ choices: [
137
+ { name: 'Continue anyway - I\'ll handle it later', value: 'continue' },
138
+ { name: 'Commit changes now - Create a checkpoint commit', value: 'commit' },
139
+ { name: 'Abort - I\'ll commit manually first', value: 'abort' },
140
+ ],
141
+ },
142
+ ]);
143
+
144
+ if (action === 'abort') {
145
+ console.log(chalk.gray('\n No worries! Run the command again after committing.\n'));
146
+ return false;
147
+ }
148
+
149
+ if (action === 'commit') {
150
+ try {
151
+ execSync('git add -A', { cwd: projectPath, stdio: 'pipe' });
152
+ const commitMessage = 'chore: checkpoint before L4YERCAK3 integration';
153
+ execSync(`git commit -m "${commitMessage}"`, { cwd: projectPath, stdio: 'pipe' });
154
+
155
+ console.log(chalk.green('\n ✅ Changes committed successfully'));
156
+ console.log(chalk.gray(` Message: "${commitMessage}"`));
157
+ console.log(chalk.gray(' You can revert with: git reset --soft HEAD~1\n'));
158
+ } catch (error) {
159
+ console.log(chalk.yellow('\n ⚠️ Could not create commit automatically'));
160
+ console.log(chalk.gray(` ${error.message}`));
161
+ console.log(chalk.gray(' Proceeding with file generation anyway...\n'));
162
+ }
163
+ }
164
+
165
+ return true;
166
+ }
167
+
168
+ /**
169
+ * Select or create an organization
170
+ * @returns {Promise<{organizationId: string, organizationName: string}>}
171
+ */
172
+ async function selectOrganization(defaultName) {
173
+ const orgsResponse = await backendClient.getOrganizations();
174
+ const organizations = Array.isArray(orgsResponse)
175
+ ? orgsResponse
176
+ : orgsResponse.organizations || orgsResponse.data || [];
177
+
178
+ if (organizations.length === 0) {
179
+ const { orgName } = await inquirer.prompt([
180
+ {
181
+ type: 'input',
182
+ name: 'orgName',
183
+ message: 'Organization name:',
184
+ default: defaultName || 'My Organization',
185
+ validate: (input) => input.trim().length > 0 || 'Organization name is required',
186
+ },
187
+ ]);
188
+ return createOrganization(orgName);
189
+ }
190
+
191
+ const { orgChoice } = await inquirer.prompt([
192
+ {
193
+ type: 'list',
194
+ name: 'orgChoice',
195
+ message: 'Select organization:',
196
+ choices: [
197
+ ...organizations.map(org => ({
198
+ name: `${org.name} (${org.id})`,
199
+ value: org.id,
200
+ })),
201
+ { name: '➕ Create new organization', value: '__create__' },
202
+ ],
203
+ },
204
+ ]);
205
+
206
+ if (orgChoice === '__create__') {
207
+ const { orgName } = await inquirer.prompt([
208
+ {
209
+ type: 'input',
210
+ name: 'orgName',
211
+ message: 'Organization name:',
212
+ default: defaultName || 'My Organization',
213
+ validate: (input) => input.trim().length > 0 || 'Organization name is required',
214
+ },
215
+ ]);
216
+ return createOrganization(orgName);
217
+ }
218
+
219
+ const selectedOrg = organizations.find(org => org.id === orgChoice);
220
+ console.log(chalk.green(` ✅ Selected organization: ${selectedOrg.name}\n`));
221
+ return { organizationId: orgChoice, organizationName: selectedOrg.name };
222
+ }
223
+
224
+ /**
225
+ * Require the user to be logged in, exit if not
226
+ */
227
+ function requireAuth(configManager) {
228
+ if (!configManager.isLoggedIn()) {
229
+ console.log(chalk.yellow(' ⚠️ You must be logged in first'));
230
+ console.log(chalk.gray('\n Run "l4yercak3 login" to authenticate\n'));
231
+ process.exit(1);
232
+ }
233
+ }
234
+
235
+ module.exports = {
236
+ createOrganization,
237
+ generateNewApiKey,
238
+ isGitRepo,
239
+ getGitStatus,
240
+ checkGitStatusBeforeGeneration,
241
+ selectOrganization,
242
+ requireAuth,
243
+ };
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Page Detector Tests
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ // Mock fs module
9
+ jest.mock('fs');
10
+
11
+ const pageDetector = require('../src/detectors/page-detector');
12
+
13
+ describe('PageDetector', () => {
14
+ beforeEach(() => {
15
+ jest.clearAllMocks();
16
+ });
17
+
18
+ describe('Next.js App Router', () => {
19
+ it('detects pages in app directory', () => {
20
+ const projectPath = '/test/project';
21
+
22
+ // Mock directory structure
23
+ fs.existsSync.mockImplementation((p) => {
24
+ const validPaths = [
25
+ path.join(projectPath, 'app'),
26
+ path.join(projectPath, 'app', 'page.tsx'),
27
+ path.join(projectPath, 'app', 'about'),
28
+ path.join(projectPath, 'app', 'about', 'page.tsx'),
29
+ path.join(projectPath, 'app', 'dashboard'),
30
+ path.join(projectPath, 'app', 'dashboard', 'page.tsx'),
31
+ path.join(projectPath, 'app', 'dashboard', '[id]'),
32
+ path.join(projectPath, 'app', 'dashboard', '[id]', 'page.tsx'),
33
+ ];
34
+ return validPaths.includes(p);
35
+ });
36
+
37
+ fs.readdirSync.mockImplementation((dir) => {
38
+ if (dir === path.join(projectPath, 'app')) {
39
+ return [
40
+ { name: 'page.tsx', isDirectory: () => false, isFile: () => true },
41
+ { name: 'about', isDirectory: () => true, isFile: () => false },
42
+ { name: 'dashboard', isDirectory: () => true, isFile: () => false },
43
+ ];
44
+ }
45
+ if (dir === path.join(projectPath, 'app', 'about')) {
46
+ return [
47
+ { name: 'page.tsx', isDirectory: () => false, isFile: () => true },
48
+ ];
49
+ }
50
+ if (dir === path.join(projectPath, 'app', 'dashboard')) {
51
+ return [
52
+ { name: 'page.tsx', isDirectory: () => false, isFile: () => true },
53
+ { name: '[id]', isDirectory: () => true, isFile: () => false },
54
+ ];
55
+ }
56
+ if (dir === path.join(projectPath, 'app', 'dashboard', '[id]')) {
57
+ return [
58
+ { name: 'page.tsx', isDirectory: () => false, isFile: () => true },
59
+ ];
60
+ }
61
+ return [];
62
+ });
63
+
64
+ const pages = pageDetector.detect(projectPath, 'nextjs', { routerType: 'app' });
65
+
66
+ expect(pages).toHaveLength(4);
67
+ expect(pages).toContainEqual(expect.objectContaining({
68
+ path: '/',
69
+ name: 'Home',
70
+ pageType: 'static',
71
+ }));
72
+ expect(pages).toContainEqual(expect.objectContaining({
73
+ path: '/about',
74
+ name: 'About',
75
+ pageType: 'static',
76
+ }));
77
+ expect(pages).toContainEqual(expect.objectContaining({
78
+ path: '/dashboard',
79
+ name: 'Dashboard',
80
+ pageType: 'static',
81
+ }));
82
+ expect(pages).toContainEqual(expect.objectContaining({
83
+ path: '/dashboard/[id]',
84
+ name: 'Dashboard Detail',
85
+ pageType: 'dynamic',
86
+ }));
87
+ });
88
+
89
+ it('detects API routes', () => {
90
+ const projectPath = '/test/project';
91
+
92
+ fs.existsSync.mockImplementation((p) => {
93
+ const validPaths = [
94
+ path.join(projectPath, 'app'),
95
+ path.join(projectPath, 'app', 'api'),
96
+ path.join(projectPath, 'app', 'api', 'users'),
97
+ path.join(projectPath, 'app', 'api', 'users', 'route.ts'),
98
+ ];
99
+ return validPaths.includes(p);
100
+ });
101
+
102
+ fs.readdirSync.mockImplementation((dir) => {
103
+ if (dir === path.join(projectPath, 'app')) {
104
+ return [
105
+ { name: 'api', isDirectory: () => true, isFile: () => false },
106
+ ];
107
+ }
108
+ if (dir === path.join(projectPath, 'app', 'api')) {
109
+ return [
110
+ { name: 'users', isDirectory: () => true, isFile: () => false },
111
+ ];
112
+ }
113
+ if (dir === path.join(projectPath, 'app', 'api', 'users')) {
114
+ return [
115
+ { name: 'route.ts', isDirectory: () => false, isFile: () => true },
116
+ ];
117
+ }
118
+ return [];
119
+ });
120
+
121
+ const pages = pageDetector.detect(projectPath, 'nextjs', { routerType: 'app' });
122
+
123
+ expect(pages).toHaveLength(1);
124
+ expect(pages[0]).toMatchObject({
125
+ path: '/api/users',
126
+ name: 'API: Users',
127
+ pageType: 'api_route',
128
+ });
129
+ });
130
+
131
+ it('skips route groups but preserves children', () => {
132
+ const projectPath = '/test/project';
133
+
134
+ fs.existsSync.mockImplementation((p) => {
135
+ const validPaths = [
136
+ path.join(projectPath, 'app'),
137
+ path.join(projectPath, 'app', '(auth)'),
138
+ path.join(projectPath, 'app', '(auth)', 'login'),
139
+ path.join(projectPath, 'app', '(auth)', 'login', 'page.tsx'),
140
+ ];
141
+ return validPaths.includes(p);
142
+ });
143
+
144
+ fs.readdirSync.mockImplementation((dir) => {
145
+ if (dir === path.join(projectPath, 'app')) {
146
+ return [
147
+ { name: '(auth)', isDirectory: () => true, isFile: () => false },
148
+ ];
149
+ }
150
+ if (dir === path.join(projectPath, 'app', '(auth)')) {
151
+ return [
152
+ { name: 'login', isDirectory: () => true, isFile: () => false },
153
+ ];
154
+ }
155
+ if (dir === path.join(projectPath, 'app', '(auth)', 'login')) {
156
+ return [
157
+ { name: 'page.tsx', isDirectory: () => false, isFile: () => true },
158
+ ];
159
+ }
160
+ return [];
161
+ });
162
+
163
+ const pages = pageDetector.detect(projectPath, 'nextjs', { routerType: 'app' });
164
+
165
+ expect(pages).toHaveLength(1);
166
+ // Route group (auth) is stripped from path
167
+ expect(pages[0].path).toBe('/login');
168
+ });
169
+ });
170
+
171
+ describe('Next.js Pages Router', () => {
172
+ it('detects pages in pages directory', () => {
173
+ const projectPath = '/test/project';
174
+
175
+ fs.existsSync.mockImplementation((p) => {
176
+ const validPaths = [
177
+ path.join(projectPath, 'pages'),
178
+ ];
179
+ return validPaths.includes(p);
180
+ });
181
+
182
+ fs.readdirSync.mockImplementation((dir) => {
183
+ if (dir === path.join(projectPath, 'pages')) {
184
+ return [
185
+ { name: 'index.tsx', isDirectory: () => false, isFile: () => true },
186
+ { name: 'about.tsx', isDirectory: () => false, isFile: () => true },
187
+ { name: '[slug].tsx', isDirectory: () => false, isFile: () => true },
188
+ { name: '_app.tsx', isDirectory: () => false, isFile: () => true },
189
+ ];
190
+ }
191
+ return [];
192
+ });
193
+
194
+ const pages = pageDetector.detect(projectPath, 'nextjs', { routerType: 'pages' });
195
+
196
+ expect(pages).toHaveLength(3); // Excludes _app.tsx
197
+ expect(pages).toContainEqual(expect.objectContaining({
198
+ path: '/',
199
+ name: 'Home',
200
+ pageType: 'static',
201
+ }));
202
+ expect(pages).toContainEqual(expect.objectContaining({
203
+ path: '/about',
204
+ name: 'About',
205
+ pageType: 'static',
206
+ }));
207
+ expect(pages).toContainEqual(expect.objectContaining({
208
+ path: '/[slug]',
209
+ name: 'Detail',
210
+ pageType: 'dynamic',
211
+ }));
212
+ });
213
+
214
+ it('detects API routes in pages/api', () => {
215
+ const projectPath = '/test/project';
216
+
217
+ fs.existsSync.mockImplementation((p) => {
218
+ return p === path.join(projectPath, 'pages');
219
+ });
220
+
221
+ fs.readdirSync.mockImplementation((dir) => {
222
+ if (dir === path.join(projectPath, 'pages')) {
223
+ return [
224
+ { name: 'api', isDirectory: () => true, isFile: () => false },
225
+ ];
226
+ }
227
+ if (dir === path.join(projectPath, 'pages', 'api')) {
228
+ return [
229
+ { name: 'hello.ts', isDirectory: () => false, isFile: () => true },
230
+ ];
231
+ }
232
+ return [];
233
+ });
234
+
235
+ const pages = pageDetector.detect(projectPath, 'nextjs', { routerType: 'pages' });
236
+
237
+ expect(pages).toHaveLength(1);
238
+ expect(pages[0]).toMatchObject({
239
+ path: '/api/hello',
240
+ name: 'API: Hello',
241
+ pageType: 'api_route',
242
+ });
243
+ });
244
+ });
245
+
246
+ describe('Expo Router', () => {
247
+ it('detects screens in expo-router app directory', () => {
248
+ const projectPath = '/test/project';
249
+
250
+ fs.existsSync.mockImplementation((p) => {
251
+ const validPaths = [
252
+ path.join(projectPath, 'app'),
253
+ ];
254
+ return validPaths.includes(p);
255
+ });
256
+
257
+ fs.readdirSync.mockImplementation((dir) => {
258
+ if (dir === path.join(projectPath, 'app')) {
259
+ return [
260
+ { name: 'index.tsx', isDirectory: () => false, isFile: () => true },
261
+ { name: 'profile.tsx', isDirectory: () => false, isFile: () => true },
262
+ { name: '[id].tsx', isDirectory: () => false, isFile: () => true },
263
+ { name: '_layout.tsx', isDirectory: () => false, isFile: () => true },
264
+ { name: '+not-found.tsx', isDirectory: () => false, isFile: () => true },
265
+ ];
266
+ }
267
+ return [];
268
+ });
269
+
270
+ const screens = pageDetector.detect(projectPath, 'expo', { routerType: 'expo-router' });
271
+
272
+ expect(screens).toHaveLength(4); // Excludes _layout.tsx
273
+ expect(screens).toContainEqual(expect.objectContaining({
274
+ path: '/',
275
+ name: 'Home',
276
+ pageType: 'static',
277
+ }));
278
+ expect(screens).toContainEqual(expect.objectContaining({
279
+ path: '/profile',
280
+ name: 'Profile',
281
+ pageType: 'static',
282
+ }));
283
+ expect(screens).toContainEqual(expect.objectContaining({
284
+ path: '/[id]',
285
+ name: 'Detail',
286
+ pageType: 'dynamic',
287
+ }));
288
+ expect(screens).toContainEqual(expect.objectContaining({
289
+ path: '/*',
290
+ name: 'Not Found',
291
+ pageType: 'static',
292
+ }));
293
+ });
294
+ });
295
+
296
+ describe('React Navigation', () => {
297
+ it('detects screens in screens directory', () => {
298
+ const projectPath = '/test/project';
299
+
300
+ fs.existsSync.mockImplementation((p) => {
301
+ const validPaths = [
302
+ path.join(projectPath, 'src', 'screens'),
303
+ ];
304
+ return validPaths.includes(p);
305
+ });
306
+
307
+ fs.readdirSync.mockImplementation((dir) => {
308
+ if (dir === path.join(projectPath, 'src', 'screens')) {
309
+ return [
310
+ { name: 'HomeScreen.tsx', isDirectory: () => false, isFile: () => true },
311
+ { name: 'ProfileScreen.tsx', isDirectory: () => false, isFile: () => true },
312
+ { name: 'SettingsScreen.tsx', isDirectory: () => false, isFile: () => true },
313
+ { name: 'index.ts', isDirectory: () => false, isFile: () => true },
314
+ ];
315
+ }
316
+ return [];
317
+ });
318
+
319
+ const screens = pageDetector.detect(projectPath, 'expo', { routerType: 'react-navigation' });
320
+
321
+ expect(screens).toHaveLength(3); // Excludes index.ts
322
+ expect(screens).toContainEqual(expect.objectContaining({
323
+ path: '/',
324
+ name: 'Home',
325
+ pageType: 'static',
326
+ }));
327
+ expect(screens).toContainEqual(expect.objectContaining({
328
+ path: '/profile',
329
+ name: 'Profile',
330
+ pageType: 'static',
331
+ }));
332
+ expect(screens).toContainEqual(expect.objectContaining({
333
+ path: '/settings',
334
+ name: 'Settings',
335
+ pageType: 'static',
336
+ }));
337
+ });
338
+ });
339
+
340
+ describe('Page name generation', () => {
341
+ it('generates correct names for various paths', () => {
342
+ expect(pageDetector.generatePageName('/')).toBe('Home');
343
+ expect(pageDetector.generatePageName('/about')).toBe('About');
344
+ expect(pageDetector.generatePageName('/user-profile')).toBe('User Profile');
345
+ expect(pageDetector.generatePageName('/dashboard/[id]')).toBe('Dashboard Detail');
346
+ expect(pageDetector.generatePageName('/api/users', true)).toBe('API: Users');
347
+ expect(pageDetector.generatePageName('/api', true)).toBe('API: Root');
348
+ });
349
+ });
350
+
351
+ describe('Page type detection', () => {
352
+ it('detects dynamic pages', () => {
353
+ expect(pageDetector.detectPageType('/dashboard/[id]')).toBe('dynamic');
354
+ expect(pageDetector.detectPageType('/blog/[...slug]')).toBe('dynamic');
355
+ expect(pageDetector.detectPageType('/users/[userId]/posts/[postId]')).toBe('dynamic');
356
+ });
357
+
358
+ it('detects static pages', () => {
359
+ expect(pageDetector.detectPageType('/')).toBe('static');
360
+ expect(pageDetector.detectPageType('/about')).toBe('static');
361
+ expect(pageDetector.detectPageType('/dashboard')).toBe('static');
362
+ });
363
+ });
364
+
365
+ describe('Unknown framework', () => {
366
+ it('returns empty array for unknown framework', () => {
367
+ const pages = pageDetector.detect('/test/project', 'unknown', {});
368
+ expect(pages).toEqual([]);
369
+ });
370
+ });
371
+ });