@webmate-studio/cli 0.1.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.
@@ -0,0 +1,579 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { input, select, confirm } from '@inquirer/prompts';
4
+ import pc from 'picocolors';
5
+ import { loadConfig } from '../utils/config.js';
6
+
7
+ /**
8
+ * Generate a new component or island
9
+ */
10
+ export async function generate(type, name, options = {}) {
11
+ // Handle --list-templates flag
12
+ if (options.listTemplates) {
13
+ listAvailableTemplates();
14
+ return;
15
+ }
16
+
17
+ console.log(pc.cyan('\nšŸŽØ Webmate Component Generator\n'));
18
+
19
+ // Resolve type aliases
20
+ const typeAliases = {
21
+ c: 'component',
22
+ comp: 'component'
23
+ };
24
+
25
+ if (type && typeAliases[type]) {
26
+ type = typeAliases[type];
27
+ }
28
+
29
+ // Only component type supported (islands are part of components now)
30
+ if (!type) {
31
+ type = 'component';
32
+ } else if (type !== 'component') {
33
+ console.log(pc.red(`\nāŒ Unknown type: ${type}\n`));
34
+ console.log(pc.dim('Valid type: component (c, comp)\n'));
35
+ return;
36
+ }
37
+
38
+ // Create component
39
+ await createComponent(name, options);
40
+ }
41
+
42
+ /**
43
+ * Create a new HTML component
44
+ */
45
+ async function createComponent(name, options) {
46
+ const config = await loadConfig();
47
+ const componentsDir = config.components.path;
48
+
49
+ // Ask for component name if not provided
50
+ if (!name) {
51
+ name = await input({
52
+ message: 'Component name (PascalCase):',
53
+ validate: (value) => {
54
+ if (!value) return 'Component name is required';
55
+ if (!/^[A-Z][a-zA-Z0-9]*$/.test(value)) {
56
+ return 'Component name must be in PascalCase (e.g., MyComponent)';
57
+ }
58
+ return true;
59
+ }
60
+ });
61
+ }
62
+
63
+ // Validate component name
64
+ if (!/^[A-Z][a-zA-Z0-9]*$/.test(name)) {
65
+ console.log(
66
+ pc.red(
67
+ `\nāŒ Invalid component name: ${name}\nComponent names must be in PascalCase (e.g., MyComponent)\n`
68
+ )
69
+ );
70
+ return;
71
+ }
72
+
73
+ // ALWAYS create directory-based components (consistent structure)
74
+ const componentPath = path.join(componentsDir, name);
75
+
76
+ // Check if component already exists
77
+ try {
78
+ await fs.access(componentPath);
79
+ console.log(pc.red(`\nāŒ Component already exists: ${componentPath}\n`));
80
+ return;
81
+ } catch {
82
+ // Component doesn't exist, continue
83
+ }
84
+
85
+ // Skip wizard if requested
86
+ if (options.skipWizard) {
87
+ // Use flags if provided, otherwise defaults
88
+ const islandFramework = options.template || (options.islands ? 'vanilla' : null);
89
+ await generateDirectoryComponent(componentPath, name, options, {
90
+ description: `${name} component`,
91
+ category: 'General',
92
+ icon: 'mdi:puzzle',
93
+ props: {},
94
+ islandFramework
95
+ });
96
+ console.log(pc.green(`\nāœ“ Component created: ${componentPath}\n`));
97
+ return;
98
+ }
99
+
100
+ // Interactive wizard
101
+ console.log(pc.dim('\nLet\'s configure your component:\n'));
102
+
103
+ const description = await input({
104
+ message: 'Component description:',
105
+ default: `${name} component`
106
+ });
107
+
108
+ const category = await select({
109
+ message: 'Component category:',
110
+ choices: [
111
+ { name: 'Layout', value: 'Layout' },
112
+ { name: 'Navigation', value: 'Navigation' },
113
+ { name: 'Forms', value: 'Forms' },
114
+ { name: 'Content', value: 'Content' },
115
+ { name: 'Media', value: 'Media' },
116
+ { name: 'General', value: 'General' }
117
+ ],
118
+ default: 'General'
119
+ });
120
+
121
+ const icon = await input({
122
+ message: 'Iconify icon (e.g., mdi:puzzle):',
123
+ default: 'mdi:puzzle'
124
+ });
125
+
126
+ // Ask if component should have an interactive island
127
+ const addIsland = await confirm({
128
+ message: 'Add interactive island?',
129
+ default: false
130
+ });
131
+
132
+ let islandFramework = null;
133
+ if (addIsland) {
134
+ islandFramework = await select({
135
+ message: 'Island framework:',
136
+ choices: [
137
+ { name: 'Vanilla JS (0kb runtime)', value: 'vanilla' },
138
+ { name: 'Svelte (Compiled, ~2kb)', value: 'svelte' },
139
+ { name: 'Preact (React-like, ~4kb)', value: 'preact' },
140
+ { name: 'Lit (Web Components, ~8kb)', value: 'lit' },
141
+ { name: 'Alpine.js (HTML-first, ~15kb)', value: 'alpine' },
142
+ { name: 'Vue (~35kb)', value: 'vue' },
143
+ { name: 'React (~45kb)', value: 'react' }
144
+ ],
145
+ default: 'vanilla'
146
+ });
147
+ }
148
+
149
+ // Ask for props
150
+ const props = {};
151
+ let addMoreProps = true;
152
+
153
+ console.log(pc.dim('\nAdd component properties (Enter = skip, adds default "title" prop):\n'));
154
+
155
+ while (addMoreProps) {
156
+ const addProp = await confirm({
157
+ message: 'Add a property?',
158
+ default: false
159
+ });
160
+
161
+ if (!addProp) {
162
+ addMoreProps = false;
163
+ // Add default title prop if no props were added
164
+ if (Object.keys(props).length === 0) {
165
+ props.title = {
166
+ type: 'string',
167
+ label: 'Title',
168
+ default: 'Beispiel Titel',
169
+ description: 'Titel der Komponente'
170
+ };
171
+ console.log(pc.dim(' ℹ Default "title" prop hinzugefügt\n'));
172
+ }
173
+ break;
174
+ }
175
+
176
+ const propName = await input({
177
+ message: 'Property name (camelCase):',
178
+ validate: (value) => {
179
+ if (!value) return 'Property name is required';
180
+ if (!/^[a-z][a-zA-Z0-9]*$/.test(value)) {
181
+ return 'Property name must be in camelCase (e.g., myProperty)';
182
+ }
183
+ if (props[value]) return 'Property already exists';
184
+ return true;
185
+ }
186
+ });
187
+
188
+ const propType = await select({
189
+ message: 'Property type:',
190
+ choices: [
191
+ { name: 'String (text input)', value: 'string' },
192
+ { name: 'Boolean (checkbox)', value: 'boolean' },
193
+ { name: 'Number (number input)', value: 'number' },
194
+ { name: 'Select (dropdown)', value: 'select' },
195
+ { name: 'Color (color picker)', value: 'color' },
196
+ { name: 'Image (image upload)', value: 'image' },
197
+ { name: 'Rich Text (WYSIWYG editor)', value: 'richtext' }
198
+ ]
199
+ });
200
+
201
+ const propLabel = await input({
202
+ message: 'Property label (display name):',
203
+ default: propName.charAt(0).toUpperCase() + propName.slice(1).replace(/([A-Z])/g, ' $1')
204
+ });
205
+
206
+ const propDescription = await input({
207
+ message: 'Property description (optional):',
208
+ default: ''
209
+ });
210
+
211
+ let propDefault = '';
212
+ let propOptions = null;
213
+ let propMin = null;
214
+ let propMax = null;
215
+
216
+ // Type-specific configuration
217
+ if (propType === 'string') {
218
+ propDefault = await input({
219
+ message: 'Default value:',
220
+ default: ''
221
+ });
222
+ } else if (propType === 'boolean') {
223
+ const defaultBool = await confirm({
224
+ message: 'Default value:',
225
+ default: false
226
+ });
227
+ propDefault = defaultBool;
228
+ } else if (propType === 'number') {
229
+ propDefault = await input({
230
+ message: 'Default value:',
231
+ default: '0',
232
+ validate: (v) => (!isNaN(Number(v)) ? true : 'Must be a number')
233
+ });
234
+ propDefault = Number(propDefault);
235
+
236
+ const hasMin = await confirm({
237
+ message: 'Set minimum value?',
238
+ default: false
239
+ });
240
+
241
+ if (hasMin) {
242
+ propMin = await input({
243
+ message: 'Minimum value:',
244
+ validate: (v) => (!isNaN(Number(v)) ? true : 'Must be a number')
245
+ });
246
+ propMin = Number(propMin);
247
+ }
248
+
249
+ const hasMax = await confirm({
250
+ message: 'Set maximum value?',
251
+ default: false
252
+ });
253
+
254
+ if (hasMax) {
255
+ propMax = await input({
256
+ message: 'Maximum value:',
257
+ validate: (v) => (!isNaN(Number(v)) ? true : 'Must be a number')
258
+ });
259
+ propMax = Number(propMax);
260
+ }
261
+ } else if (propType === 'select') {
262
+ const optionsInput = await input({
263
+ message: 'Options (comma-separated):',
264
+ validate: (v) => (v ? true : 'At least one option required')
265
+ });
266
+ propOptions = optionsInput.split(',').map((o) => o.trim());
267
+ propDefault = propOptions[0];
268
+ } else if (propType === 'color') {
269
+ propDefault = await input({
270
+ message: 'Default color (hex):',
271
+ default: '#000000',
272
+ validate: (v) => (/^#[0-9A-Fa-f]{6}$/.test(v) ? true : 'Must be hex color (e.g., #ff0000)')
273
+ });
274
+ } else if (propType === 'richtext') {
275
+ propDefault = '<p></p>';
276
+ }
277
+
278
+ // Build prop definition
279
+ props[propName] = {
280
+ type: propType,
281
+ label: propLabel,
282
+ default: propDefault
283
+ };
284
+
285
+ if (propDescription) props[propName].description = propDescription;
286
+ if (propOptions) props[propName].options = propOptions;
287
+ if (propMin !== null) props[propName].min = propMin;
288
+ if (propMax !== null) props[propName].max = propMax;
289
+ }
290
+
291
+ // Generate component directory
292
+ await generateDirectoryComponent(componentPath, name, options, {
293
+ description,
294
+ category,
295
+ icon,
296
+ props,
297
+ islandFramework
298
+ });
299
+
300
+ console.log(pc.green(`\nāœ“ Component created: ${componentPath}`));
301
+ console.log(pc.dim(`\nNext steps:`));
302
+ console.log(pc.dim(` 1. Edit component.html in ${name}/ directory`));
303
+ if (islandFramework) {
304
+ console.log(pc.dim(` 2. Add island logic in islands/ directory`));
305
+ console.log(pc.dim(` 3. Test with: wm dev`));
306
+ } else {
307
+ console.log(pc.dim(` 2. Test with: wm dev`));
308
+ }
309
+ console.log('');
310
+ }
311
+
312
+ /**
313
+ * Generate directory-based component with component.json
314
+ */
315
+ async function generateDirectoryComponent(componentDir, name, options, config = {}) {
316
+ const {
317
+ description = `${name} component`,
318
+ category = 'General',
319
+ props = {},
320
+ islandFramework = null
321
+ } = config;
322
+
323
+ // Create component directory
324
+ await fs.mkdir(componentDir, { recursive: true });
325
+
326
+ // Create component.json (metadata + props + islands config)
327
+ const componentJson = {
328
+ name,
329
+ version: '1.0.0',
330
+ description,
331
+ category
332
+ };
333
+
334
+ // Add props if any
335
+ if (Object.keys(props).length > 0) {
336
+ componentJson.props = props;
337
+ }
338
+
339
+ // Add islands config if needed
340
+ if (islandFramework) {
341
+ // Use .jsx extension for React/Preact, .js for others
342
+ const fileExtension = (islandFramework === 'react' || islandFramework === 'preact') ? '.jsx' : '.js';
343
+
344
+ componentJson.islands = [
345
+ {
346
+ name: toKebabCase(name),
347
+ file: `islands/${toKebabCase(name)}${fileExtension}`,
348
+ framework: islandFramework,
349
+ props: {}
350
+ }
351
+ ];
352
+ }
353
+
354
+ await fs.writeFile(
355
+ path.join(componentDir, 'component.json'),
356
+ JSON.stringify(componentJson, null, 2),
357
+ 'utf-8'
358
+ );
359
+
360
+ // Generate component.html (just HTML, no script blocks)
361
+ let exampleHtml = '';
362
+
363
+ if (islandFramework) {
364
+ // Island component: demonstriere title prop
365
+ const hasTitle = props.title;
366
+ const islandPropsJson = hasTitle ? `{"title": "{{title}}"}` : '{}';
367
+
368
+ exampleHtml = `\t<div
369
+ data-island="${toKebabCase(name)}"
370
+ data-island-props='${islandPropsJson}'
371
+ class="p-6 bg-white rounded-lg border border-gray-200"
372
+ >
373
+ <!-- Island wird hier initialisiert -->
374
+ </div>`;
375
+ } else {
376
+ // Normale component: zeige title an
377
+ const hasTitle = props.title;
378
+ if (hasTitle) {
379
+ exampleHtml = `\t<div class="p-6 bg-white rounded-lg border border-gray-200">
380
+ <h2 class="text-2xl font-bold text-gray-900 mb-4">{{title}}</h2>
381
+ <p class="text-gray-600">Dein Component-Inhalt hier</p>
382
+ </div>`;
383
+ } else {
384
+ exampleHtml = `\t<div class="p-6 bg-white rounded-lg border border-gray-200">
385
+ <p class="text-gray-600">Dein Component-Inhalt hier</p>
386
+ </div>`;
387
+ }
388
+ }
389
+
390
+ const componentHtml = `<!-- ${name} Component -->
391
+ <div class="${toKebabCase(name)}">
392
+ ${exampleHtml}
393
+ </div>
394
+
395
+ <style>
396
+ /* Custom CSS falls benƶtigt */
397
+ .${toKebabCase(name)} {
398
+ /* Beispiel für Custom Styles */
399
+ }
400
+ </style>
401
+ `;
402
+
403
+ await fs.writeFile(path.join(componentDir, 'component.html'), componentHtml, 'utf-8');
404
+
405
+ // Create islands directory if needed
406
+ if (islandFramework) {
407
+ const islandsDir = path.join(componentDir, 'islands');
408
+ await fs.mkdir(islandsDir, { recursive: true });
409
+
410
+ // Determine file extension based on framework
411
+ const fileExtension = (islandFramework === 'react' || islandFramework === 'preact') ? '.jsx' : '.js';
412
+ const templateExtension = (islandFramework === 'react' || islandFramework === 'preact') ? '.jsx' : '.js';
413
+
414
+ // Copy template file
415
+ const templateFile =
416
+ islandFramework === 'svelte'
417
+ ? path.join(
418
+ import.meta.dirname,
419
+ '../templates/islands/svelte.js'
420
+ )
421
+ : path.join(
422
+ import.meta.dirname,
423
+ `../templates/islands/${islandFramework}${templateExtension}`
424
+ );
425
+
426
+ let templateContent = await fs.readFile(templateFile, 'utf-8');
427
+
428
+ // Replace placeholders
429
+ templateContent = templateContent
430
+ .replace(/\{\{NAME\}\}/g, name)
431
+ .replace(/\{\{KEBAB_NAME\}\}/g, toKebabCase(name));
432
+
433
+ await fs.writeFile(
434
+ path.join(islandsDir, `${toKebabCase(name)}${fileExtension}`),
435
+ templateContent,
436
+ 'utf-8'
437
+ );
438
+
439
+ // If Svelte, also copy the .svelte component
440
+ if (islandFramework === 'svelte') {
441
+ const svelteComponentTemplate = path.join(
442
+ import.meta.dirname,
443
+ '../templates/islands/svelte-component.svelte'
444
+ );
445
+
446
+ let svelteContent = await fs.readFile(svelteComponentTemplate, 'utf-8');
447
+ svelteContent = svelteContent
448
+ .replace(/\{\{NAME\}\}/g, name)
449
+ .replace(/\{\{KEBAB_NAME\}\}/g, toKebabCase(name));
450
+
451
+ await fs.writeFile(path.join(islandsDir, `${name}.svelte`), svelteContent, 'utf-8');
452
+ }
453
+ }
454
+
455
+ // Create assets directory if needed
456
+ if (options.assets) {
457
+ await fs.mkdir(path.join(componentDir, 'assets'), { recursive: true });
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Convert PascalCase to kebab-case
463
+ */
464
+ function toKebabCase(str) {
465
+ return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
466
+ }
467
+
468
+ /**
469
+ * List available island templates
470
+ */
471
+ function listAvailableTemplates() {
472
+ console.log(pc.cyan('\nšŸ“‹ Available Island Templates\n'));
473
+
474
+ const templates = [
475
+ {
476
+ name: 'vanilla',
477
+ runtime: '0kb',
478
+ compiled: 'āœ…',
479
+ description: 'Pure JavaScript - No dependencies'
480
+ },
481
+ {
482
+ name: 'lit',
483
+ runtime: '~8kb',
484
+ compiled: 'āŒ',
485
+ description: 'Web Components - Standard-based'
486
+ },
487
+ {
488
+ name: 'preact',
489
+ runtime: '~4kb',
490
+ compiled: 'āŒ',
491
+ description: 'React-like - Smallest alternative'
492
+ },
493
+ {
494
+ name: 'alpine',
495
+ runtime: '~15kb',
496
+ compiled: 'āŒ',
497
+ description: 'HTML-first - Like Vue directives'
498
+ },
499
+ {
500
+ name: 'react',
501
+ runtime: '~45kb',
502
+ compiled: 'āŒ',
503
+ description: 'React - Largest ecosystem'
504
+ },
505
+ {
506
+ name: 'svelte',
507
+ runtime: '~2kb*',
508
+ compiled: 'āœ…',
509
+ description: 'Svelte - Compiles to Vanilla JS'
510
+ }
511
+ ];
512
+
513
+ console.log(
514
+ pc.dim(
515
+ 'ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”'
516
+ )
517
+ );
518
+ console.log(
519
+ pc.dim('│') +
520
+ pc.bold(' Template ') +
521
+ pc.dim('│') +
522
+ pc.bold(' Runtime Size ') +
523
+ pc.dim('│') +
524
+ pc.bold(' Compiled? ') +
525
+ pc.dim('│') +
526
+ pc.bold(' Description ') +
527
+ pc.dim('│')
528
+ );
529
+ console.log(
530
+ pc.dim(
531
+ 'ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤'
532
+ )
533
+ );
534
+
535
+ templates.forEach((template) => {
536
+ const nameCol = template.name.padEnd(11);
537
+ const runtimeCol = template.runtime.padEnd(12);
538
+ const compiledCol = template.compiled.padEnd(9);
539
+ const descCol = template.description.padEnd(35);
540
+
541
+ console.log(
542
+ pc.dim('│ ') +
543
+ pc.cyan(nameCol) +
544
+ pc.dim(' │ ') +
545
+ pc.green(runtimeCol) +
546
+ pc.dim(' │ ') +
547
+ compiledCol +
548
+ pc.dim(' │ ') +
549
+ descCol +
550
+ pc.dim(' │')
551
+ );
552
+ });
553
+
554
+ console.log(
555
+ pc.dim(
556
+ 'ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜'
557
+ )
558
+ );
559
+
560
+ console.log(pc.dim('\n*Svelte runtime is minimal because most logic compiles at build-time\n'));
561
+
562
+ console.log(pc.bold('Usage Examples:\n'));
563
+ console.log(pc.dim(' # Generate component with vanilla JavaScript island'));
564
+ console.log(pc.cyan(' wm generate component Counter --islands\n'));
565
+
566
+ console.log(pc.dim(' # Generate component with React island'));
567
+ console.log(pc.cyan(' wm generate component ProductList --islands --template=react\n'));
568
+
569
+ console.log(pc.dim(' # Generate component with Svelte island and assets'));
570
+ console.log(pc.cyan(' wm generate component ImageGallery --islands --template=svelte --assets\n'));
571
+
572
+ console.log(pc.bold('Next Steps:\n'));
573
+ console.log(pc.dim(' 1. Choose a template based on your needs'));
574
+ console.log(pc.dim(' 2. Generate component: ') + pc.cyan('wm g component Name --islands --template=<name>'));
575
+ console.log(pc.dim(' 3. Install dependencies: ') + pc.cyan('cd Name && npm install'));
576
+ console.log(pc.dim(' 4. Edit island code in islands/ directory'));
577
+ console.log(pc.dim(' 5. Build and test: ') + pc.cyan('wm build && wm push'));
578
+ console.log('');
579
+ }
@@ -0,0 +1,49 @@
1
+ import { loadAuth } from '../utils/auth.js';
2
+ import { logger } from '../../../core/src/index.js';
3
+ import pc from 'picocolors';
4
+
5
+ /**
6
+ * Info command - Show current project information
7
+ */
8
+ export async function info(options = {}) {
9
+ try {
10
+ const auth = loadAuth();
11
+
12
+ if (!auth) {
13
+ logger.error('Not logged in. Run ' + pc.cyan('wm login') + ' first');
14
+ process.exit(1);
15
+ }
16
+
17
+ showProjectInfo(auth);
18
+ } catch (error) {
19
+ logger.error(`Info command failed: ${error.message}`);
20
+ process.exit(1);
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Show current project information
26
+ */
27
+ function showProjectInfo(auth) {
28
+ console.log();
29
+ logger.info(pc.bold('Current Project'));
30
+ console.log();
31
+
32
+ // Build CMS URL from base URL and tenant subdomain
33
+ let cmsUrl = 'Not set';
34
+ if (auth.baseUrl && auth.tenant?.subdomain) {
35
+ try {
36
+ const baseUrl = new URL(auth.baseUrl);
37
+ const subdomain = auth.tenant.subdomain;
38
+ cmsUrl = `${baseUrl.protocol}//${subdomain}.cms.${baseUrl.host}`;
39
+ } catch (error) {
40
+ cmsUrl = auth.baseUrl;
41
+ }
42
+ }
43
+
44
+ console.log(pc.gray(' CMS URL: ') + pc.cyan(cmsUrl));
45
+ console.log(pc.gray(' User: ') + pc.cyan(auth.user?.email || 'Not set'));
46
+ console.log();
47
+ console.log(pc.gray(' To switch project, run: ') + pc.cyan('wm switch'));
48
+ console.log();
49
+ }