@girardmedia/bootspring 2.1.2 → 2.2.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.
package/cli/project.js DELETED
@@ -1,825 +0,0 @@
1
- /**
2
- * Bootspring Project Command
3
- *
4
- * Manage projects from the CLI.
5
- *
6
- * @package bootspring
7
- * @module cli/project
8
- */
9
-
10
- const https = require('https');
11
- const http = require('http');
12
- const auth = require('../core/auth');
13
- const session = require('../core/session');
14
- const projectActivity = require('../core/project-activity');
15
- const readline = require('readline');
16
-
17
- const API_BASE = process.env.BOOTSPRING_API_URL || 'https://www.bootspring.com';
18
-
19
- /**
20
- * Make direct API request (without v1 prefix)
21
- */
22
- async function apiRequest(method, path, data = null) {
23
- const token = auth.getToken();
24
- const apiKey = auth.getApiKey();
25
- const url = new URL(`/api${path}`, API_BASE);
26
- const isHttps = url.protocol === 'https:';
27
- const httpModule = isHttps ? https : http;
28
-
29
- const headers = {
30
- 'Content-Type': 'application/json',
31
- 'User-Agent': `bootspring-cli/${require('../package.json').version}`,
32
- };
33
-
34
- // Use API key if available, otherwise use JWT token
35
- if (apiKey) {
36
- headers['X-API-Key'] = apiKey;
37
- } else if (token) {
38
- headers['Authorization'] = `Bearer ${token}`;
39
- }
40
-
41
- return new Promise((resolve, reject) => {
42
- const req = httpModule.request(url, {
43
- method,
44
- headers,
45
- timeout: 30000
46
- }, (res) => {
47
- let body = '';
48
- res.on('data', chunk => body += chunk);
49
- res.on('end', () => {
50
- try {
51
- const json = JSON.parse(body);
52
- if (res.statusCode >= 400) {
53
- const error = new Error(json.message || json.error || 'API Error');
54
- error.status = res.statusCode;
55
- error.code = json.error || json.code;
56
- reject(error);
57
- } else {
58
- resolve(json);
59
- }
60
- } catch {
61
- if (res.statusCode >= 400) {
62
- const error = new Error(body || 'API Error');
63
- error.status = res.statusCode;
64
- reject(error);
65
- } else {
66
- resolve(body);
67
- }
68
- }
69
- });
70
- });
71
-
72
- req.on('error', reject);
73
- req.on('timeout', () => {
74
- req.destroy();
75
- reject(new Error('Request timeout'));
76
- });
77
-
78
- if (data) {
79
- req.write(JSON.stringify(data));
80
- }
81
- req.end();
82
- });
83
- }
84
-
85
- const BRAND = {
86
- name: 'Bootspring',
87
- color: '\x1b[36m',
88
- reset: '\x1b[0m',
89
- bold: '\x1b[1m',
90
- dim: '\x1b[2m',
91
- green: '\x1b[32m',
92
- yellow: '\x1b[33m',
93
- red: '\x1b[31m'
94
- };
95
-
96
- /**
97
- * Prompt for input
98
- */
99
- function prompt(question) {
100
- const rl = readline.createInterface({
101
- input: process.stdin,
102
- output: process.stdout
103
- });
104
-
105
- return new Promise((resolve) => {
106
- rl.question(question, (answer) => {
107
- rl.close();
108
- resolve(answer.trim());
109
- });
110
- });
111
- }
112
-
113
- /**
114
- * Print a single project
115
- */
116
- function printProject(project, currentProject, showOwner = false) {
117
- const isCurrent = currentProject && currentProject.id === project.id;
118
- const marker = isCurrent ? `${BRAND.green}→${BRAND.reset} ` : ' ';
119
- const activeTag = project.isActive ? '' : ` ${BRAND.dim}(inactive)${BRAND.reset}`;
120
- const roleTag = project.role && project.role !== 'owner' ? ` ${BRAND.dim}(${project.role})${BRAND.reset}` : '';
121
-
122
- console.log(`${marker}${BRAND.color}${project.name}${BRAND.reset}${roleTag}${activeTag}`);
123
- console.log(` ${BRAND.dim}ID: ${project.id}${BRAND.reset}`);
124
- console.log(` ${BRAND.dim}Slug: ${project.slug}${BRAND.reset}`);
125
- if (showOwner && project.owner) {
126
- console.log(` ${BRAND.dim}Owner: ${project.owner.email}${BRAND.reset}`);
127
- }
128
- if (project.description) {
129
- console.log(` ${BRAND.dim}${project.description}${BRAND.reset}`);
130
- }
131
- console.log(` ${BRAND.dim}API Keys: ${project.apiKeyCount}${BRAND.reset}`);
132
- if (project.memberCount > 1) {
133
- console.log(` ${BRAND.dim}Members: ${project.memberCount}${BRAND.reset}`);
134
- }
135
- console.log();
136
- }
137
-
138
- /**
139
- * List all projects (owned and shared)
140
- */
141
- async function listProjects() {
142
- if (!auth.isAuthenticated()) {
143
- console.log(`${BRAND.red}Not logged in.${BRAND.reset} Run: bootspring auth login`);
144
- process.exit(1);
145
- }
146
-
147
- try {
148
- const response = await apiRequest('GET', '/projects?grouped=true');
149
- const owned = response.owned || [];
150
- const shared = response.shared || [];
151
-
152
- if (owned.length === 0 && shared.length === 0) {
153
- console.log(`\n${BRAND.dim}No projects found.${BRAND.reset}`);
154
- console.log(`Create one with: ${BRAND.color}bootspring project create${BRAND.reset}\n`);
155
- return;
156
- }
157
-
158
- const currentProject = session.getEffectiveProject();
159
-
160
- // Show owned projects
161
- if (owned.length > 0) {
162
- console.log(`\n${BRAND.bold}Your Projects${BRAND.reset}\n`);
163
- for (const project of owned) {
164
- printProject(project, currentProject, false);
165
- }
166
- }
167
-
168
- // Show shared projects
169
- if (shared.length > 0) {
170
- console.log(`${BRAND.bold}Shared With You${BRAND.reset}\n`);
171
- for (const project of shared) {
172
- printProject(project, currentProject, true);
173
- }
174
- }
175
-
176
- console.log(`${BRAND.dim}Limits: ${response.limits.current}/${response.limits.limit} projects${BRAND.reset}\n`);
177
- } catch (error) {
178
- console.log(`${BRAND.red}Error: ${error.message}${BRAND.reset}`);
179
- process.exit(1);
180
- }
181
- }
182
-
183
- /**
184
- * Parse flags from args
185
- */
186
- function parseFlags(args) {
187
- const flags = {};
188
- const positional = [];
189
-
190
- for (let i = 0; i < args.length; i++) {
191
- const arg = args[i];
192
- if (arg.startsWith('--')) {
193
- const [key, ...valueParts] = arg.slice(2).split('=');
194
- flags[key] = valueParts.length > 0 ? valueParts.join('=') : (args[i + 1] && !args[i + 1].startsWith('-') ? args[++i] : true);
195
- } else if (arg.startsWith('-')) {
196
- flags[arg.slice(1)] = args[i + 1] && !args[i + 1].startsWith('-') ? args[++i] : true;
197
- } else {
198
- positional.push(arg);
199
- }
200
- }
201
-
202
- return { flags, positional };
203
- }
204
-
205
- function ensureAuthenticated() {
206
- if (!auth.isAuthenticated()) {
207
- console.log(`${BRAND.red}Not logged in.${BRAND.reset} Run: bootspring auth login`);
208
- process.exit(1);
209
- }
210
- }
211
-
212
- function resolveProjectId(flags, positionalProjectId) {
213
- const fromFlags = typeof flags.project === 'string' ? flags.project : (typeof flags.p === 'string' ? flags.p : undefined);
214
- const resolved = positionalProjectId || fromFlags || session.getEffectiveProject()?.id;
215
- if (!resolved) {
216
- console.log(`\n${BRAND.yellow}No project specified.${BRAND.reset}`);
217
- console.log(`${BRAND.dim}Use --project <id> or run bootspring switch.${BRAND.reset}\n`);
218
- process.exit(1);
219
- }
220
- return resolved;
221
- }
222
-
223
- function trackCollaborationEvent(event, payload = {}) {
224
- projectActivity.trackProjectActivity(event, payload);
225
-
226
- const messageMap = {
227
- project_invitation_sent: `Invitation sent to ${String(payload.email || payload.target || 'member')}`,
228
- project_invitation_accepted: `${String(payload.email || payload.target || 'Invitation')} accepted`,
229
- project_invitation_declined: `${String(payload.email || payload.target || 'Invitation')} declined`,
230
- project_member_added: `Member added: ${String(payload.email || payload.target || 'member')}`,
231
- project_member_removed: `Member removed: ${String(payload.targetUserId || payload.target || 'member')}`,
232
- project_member_role_updated: `Role updated for ${String(payload.targetUserId || payload.target || 'member')}`,
233
- project_owner_transferred: `Ownership transferred to ${String(payload.newOwnerId || payload.target || 'new owner')}`
234
- };
235
-
236
- projectActivity.trackMembershipNotification({
237
- event,
238
- projectId: payload.projectId,
239
- actor: payload.actor || 'cli',
240
- target: payload.target || payload.email || payload.targetUserId || payload.newOwnerId,
241
- message: messageMap[event] || String(payload.message || 'Project membership updated'),
242
- metadata: payload
243
- });
244
- }
245
-
246
- function formatTimestamp(value) {
247
- const ts = new Date(String(value || ''));
248
- if (Number.isNaN(ts.getTime())) return 'unknown time';
249
- return ts.toLocaleString();
250
- }
251
-
252
- /**
253
- * Check for similar projects (duplicate detection)
254
- */
255
- async function checkSimilarProjects(name, repoUrl) {
256
- try {
257
- const params = new URLSearchParams();
258
- if (name) params.set('name', name);
259
- if (repoUrl) params.set('repo', repoUrl);
260
-
261
- const response = await apiRequest('GET', `/projects/similar?${params.toString()}`);
262
- return response.matches || [];
263
- } catch (error) {
264
- // Don't block creation if similar check fails
265
- console.log(`${BRAND.dim}(Could not check for similar projects)${BRAND.reset}`);
266
- return [];
267
- }
268
- }
269
-
270
- /**
271
- * Join an existing project as a member
272
- */
273
- async function joinProject(projectId) {
274
- try {
275
- // Get current user's email to add themselves
276
- // For now, we'll inform the user to request access
277
- console.log(`\n${BRAND.yellow}To join this project, ask the owner to add you as a member.${BRAND.reset}`);
278
- console.log(`${BRAND.dim}They can do this from the project settings in the dashboard.${BRAND.reset}\n`);
279
- return false;
280
- } catch (error) {
281
- console.log(`${BRAND.red}Error joining project: ${error.message}${BRAND.reset}`);
282
- return false;
283
- }
284
- }
285
-
286
- async function listMembers(args) {
287
- ensureAuthenticated();
288
- const { flags, positional } = parseFlags(args);
289
- const projectId = resolveProjectId(flags, positional[0]);
290
-
291
- try {
292
- const response = await apiRequest('GET', `/projects/${encodeURIComponent(projectId)}/members`);
293
- const members = Array.isArray(response) ? response : (response.members || []);
294
-
295
- console.log(`\n${BRAND.bold}Project Members${BRAND.reset} ${BRAND.dim}(${projectId})${BRAND.reset}\n`);
296
- if (members.length === 0) {
297
- console.log(`${BRAND.dim}No members found.${BRAND.reset}\n`);
298
- return;
299
- }
300
-
301
- for (const member of members) {
302
- const email = member.email || member.userId;
303
- const invited = member.invitedAt ? ` ${BRAND.dim}(invited ${formatTimestamp(member.invitedAt)})${BRAND.reset}` : '';
304
- console.log(` ${BRAND.color}${email}${BRAND.reset} ${BRAND.dim}[${member.role}]${BRAND.reset}${invited}`);
305
- }
306
- console.log();
307
- } catch (error) {
308
- console.log(`\n${BRAND.red}Error listing members: ${error.message}${BRAND.reset}\n`);
309
- process.exit(1);
310
- }
311
- }
312
-
313
- async function inviteMember(args) {
314
- ensureAuthenticated();
315
- const { flags, positional } = parseFlags(args);
316
- const email = positional[0];
317
- if (!email) {
318
- console.log(`${BRAND.red}Email is required.${BRAND.reset}`);
319
- console.log(`${BRAND.dim}Usage: bootspring project invite <email> [--role member] [--project <id>]${BRAND.reset}\n`);
320
- process.exit(1);
321
- }
322
-
323
- const projectId = resolveProjectId(flags, typeof flags.project === 'string' ? flags.project : undefined);
324
- const role = typeof flags.role === 'string' ? flags.role : 'member';
325
-
326
- try {
327
- const invitation = await apiRequest('POST', `/projects/${encodeURIComponent(projectId)}/invitations`, {
328
- email,
329
- role
330
- });
331
-
332
- trackCollaborationEvent('project_invitation_sent', {
333
- projectId,
334
- invitationId: invitation.id,
335
- email,
336
- role,
337
- actor: 'cli'
338
- });
339
-
340
- console.log(`\n${BRAND.green}✓${BRAND.reset} Invitation sent to ${BRAND.color}${email}${BRAND.reset} (${role}).`);
341
- if (invitation.id) {
342
- console.log(`${BRAND.dim}Invitation ID: ${invitation.id}${BRAND.reset}`);
343
- }
344
- console.log();
345
- } catch (error) {
346
- if (error.status === 404 || error.status === 405) {
347
- const member = await apiRequest('POST', `/projects/${encodeURIComponent(projectId)}/members`, {
348
- email,
349
- role
350
- });
351
- trackCollaborationEvent('project_member_added', {
352
- projectId,
353
- email: member.email || email,
354
- role: member.role || role,
355
- actor: 'cli',
356
- fallback: 'direct_member_add'
357
- });
358
- console.log(`\n${BRAND.green}✓${BRAND.reset} Added ${BRAND.color}${email}${BRAND.reset} directly as ${role}.`);
359
- console.log(`${BRAND.dim}(Invitation API unavailable; used direct add fallback.)${BRAND.reset}\n`);
360
- return;
361
- }
362
- console.log(`\n${BRAND.red}Error inviting member: ${error.message}${BRAND.reset}\n`);
363
- process.exit(1);
364
- }
365
- }
366
-
367
- async function listInvitations(args) {
368
- ensureAuthenticated();
369
- const { flags, positional } = parseFlags(args);
370
- const projectId = resolveProjectId(flags, positional[0]);
371
-
372
- try {
373
- const response = await apiRequest('GET', `/projects/${encodeURIComponent(projectId)}/invitations`);
374
- const invitations = Array.isArray(response) ? response : (response.invitations || []);
375
-
376
- console.log(`\n${BRAND.bold}Pending Invitations${BRAND.reset} ${BRAND.dim}(${projectId})${BRAND.reset}\n`);
377
- if (invitations.length === 0) {
378
- console.log(`${BRAND.dim}No pending invitations.${BRAND.reset}\n`);
379
- return;
380
- }
381
-
382
- for (const invitation of invitations) {
383
- const status = invitation.status || 'pending';
384
- const role = invitation.role || 'member';
385
- const expires = invitation.expiresAt ? ` expires ${formatTimestamp(invitation.expiresAt)}` : '';
386
- console.log(` ${BRAND.color}${invitation.email}${BRAND.reset} ${BRAND.dim}[${role}] [${status}]${BRAND.reset}`);
387
- console.log(` ${BRAND.dim}id=${invitation.id}${expires}${BRAND.reset}`);
388
- }
389
- console.log();
390
- } catch (error) {
391
- if (error.status === 404 || error.status === 405) {
392
- console.log(`\n${BRAND.yellow}Invitation listing is not supported by this API version.${BRAND.reset}\n`);
393
- return;
394
- }
395
- console.log(`\n${BRAND.red}Error loading invitations: ${error.message}${BRAND.reset}\n`);
396
- process.exit(1);
397
- }
398
- }
399
-
400
- async function respondToInvitation(args, decision) {
401
- ensureAuthenticated();
402
- const { flags, positional } = parseFlags(args);
403
- const invitationId = positional[0];
404
- if (!invitationId) {
405
- console.log(`${BRAND.red}Invitation ID is required.${BRAND.reset}`);
406
- console.log(`${BRAND.dim}Usage: bootspring project ${decision} <invitation-id>${BRAND.reset}\n`);
407
- process.exit(1);
408
- }
409
-
410
- try {
411
- const response = await apiRequest('POST', `/projects/invitations/${encodeURIComponent(invitationId)}/${decision}`);
412
- const project = response.project || {};
413
- const projectId = String(response.projectId || project.id || '');
414
- const email = String(response.email || response.targetEmail || '');
415
- const event = decision === 'accept' ? 'project_invitation_accepted' : 'project_invitation_declined';
416
- trackCollaborationEvent(event, {
417
- invitationId,
418
- projectId,
419
- email,
420
- actor: 'cli'
421
- });
422
-
423
- console.log(`\n${BRAND.green}✓${BRAND.reset} Invitation ${decision}ed.`);
424
- if (decision === 'accept' && project.id && project.name) {
425
- const shouldSwitch = flags.switch !== false && flags.switch !== 'false';
426
- if (shouldSwitch) {
427
- session.setCurrentProject({
428
- id: project.id,
429
- name: project.name,
430
- slug: project.slug || project.id
431
- });
432
- console.log(`${BRAND.dim}Switched to project ${project.name}.${BRAND.reset}`);
433
- }
434
- }
435
- console.log();
436
- } catch (error) {
437
- console.log(`\n${BRAND.red}Error updating invitation: ${error.message}${BRAND.reset}\n`);
438
- process.exit(1);
439
- }
440
- }
441
-
442
- async function updateMemberRole(args) {
443
- ensureAuthenticated();
444
- const { flags, positional } = parseFlags(args);
445
- const userId = positional[0];
446
- const role = typeof flags.role === 'string' ? flags.role : '';
447
- if (!userId || !role) {
448
- console.log(`${BRAND.red}userId and --role are required.${BRAND.reset}`);
449
- console.log(`${BRAND.dim}Usage: bootspring project update-member <user-id> --role <owner|admin|member|viewer>${BRAND.reset}\n`);
450
- process.exit(1);
451
- }
452
-
453
- const projectId = resolveProjectId(flags, typeof flags.project === 'string' ? flags.project : undefined);
454
- try {
455
- await apiRequest('PATCH', `/projects/${encodeURIComponent(projectId)}/members/${encodeURIComponent(userId)}`, { role });
456
- trackCollaborationEvent('project_member_role_updated', {
457
- projectId,
458
- targetUserId: userId,
459
- role,
460
- actor: 'cli'
461
- });
462
- console.log(`\n${BRAND.green}✓${BRAND.reset} Updated role for ${userId} to ${role}.\n`);
463
- } catch (error) {
464
- console.log(`\n${BRAND.red}Error updating member role: ${error.message}${BRAND.reset}\n`);
465
- process.exit(1);
466
- }
467
- }
468
-
469
- async function removeMember(args) {
470
- ensureAuthenticated();
471
- const { flags, positional } = parseFlags(args);
472
- const userId = positional[0];
473
- if (!userId) {
474
- console.log(`${BRAND.red}userId is required.${BRAND.reset}`);
475
- console.log(`${BRAND.dim}Usage: bootspring project remove-member <user-id> [--project <id>]${BRAND.reset}\n`);
476
- process.exit(1);
477
- }
478
-
479
- const projectId = resolveProjectId(flags, typeof flags.project === 'string' ? flags.project : undefined);
480
- try {
481
- await apiRequest('DELETE', `/projects/${encodeURIComponent(projectId)}/members/${encodeURIComponent(userId)}`);
482
- trackCollaborationEvent('project_member_removed', {
483
- projectId,
484
- targetUserId: userId,
485
- actor: 'cli'
486
- });
487
- console.log(`\n${BRAND.green}✓${BRAND.reset} Removed member ${userId}.\n`);
488
- } catch (error) {
489
- console.log(`\n${BRAND.red}Error removing member: ${error.message}${BRAND.reset}\n`);
490
- process.exit(1);
491
- }
492
- }
493
-
494
- async function transferOwnership(args) {
495
- ensureAuthenticated();
496
- const { flags, positional } = parseFlags(args);
497
- const newOwnerId = positional[0];
498
- if (!newOwnerId) {
499
- console.log(`${BRAND.red}newOwnerId is required.${BRAND.reset}`);
500
- console.log(`${BRAND.dim}Usage: bootspring project transfer <new-owner-user-id> [--project <id>]${BRAND.reset}\n`);
501
- process.exit(1);
502
- }
503
-
504
- const projectId = resolveProjectId(flags, typeof flags.project === 'string' ? flags.project : undefined);
505
- try {
506
- await apiRequest('POST', `/projects/${encodeURIComponent(projectId)}/transfer`, { newOwnerId });
507
- trackCollaborationEvent('project_owner_transferred', {
508
- projectId,
509
- newOwnerId,
510
- actor: 'cli'
511
- });
512
- console.log(`\n${BRAND.green}✓${BRAND.reset} Transferred ownership to ${newOwnerId}.\n`);
513
- } catch (error) {
514
- console.log(`\n${BRAND.red}Error transferring ownership: ${error.message}${BRAND.reset}\n`);
515
- process.exit(1);
516
- }
517
- }
518
-
519
- async function showActivity(args) {
520
- ensureAuthenticated();
521
- const { flags, positional } = parseFlags(args);
522
- const projectId = resolveProjectId(flags, positional[0]);
523
- const limit = Number(flags.limit);
524
- const effectiveLimit = Number.isFinite(limit) && limit > 0 ? Math.min(limit, 100) : 20;
525
-
526
- let entries = [];
527
- try {
528
- const response = await apiRequest('GET', `/projects/${encodeURIComponent(projectId)}/activity?limit=${effectiveLimit}`);
529
- entries = Array.isArray(response) ? response : (response.events || response.activities || []);
530
- } catch {
531
- entries = projectActivity.getProjectActivityFeed({ projectId, limit: effectiveLimit });
532
- }
533
-
534
- console.log(`\n${BRAND.bold}Project Activity${BRAND.reset} ${BRAND.dim}(${projectId})${BRAND.reset}\n`);
535
- if (entries.length === 0) {
536
- console.log(`${BRAND.dim}No activity found.${BRAND.reset}\n`);
537
- return;
538
- }
539
-
540
- for (const entry of entries) {
541
- const actor = entry.actor ? `${entry.actor}: ` : '';
542
- console.log(` ${BRAND.dim}${formatTimestamp(entry.timestamp)}${BRAND.reset} ${actor}${entry.message}`);
543
- }
544
- console.log();
545
- }
546
-
547
- async function showNotifications(args) {
548
- ensureAuthenticated();
549
- const { flags, positional } = parseFlags(args);
550
- const projectId = resolveProjectId(flags, positional[0]);
551
- const limit = Number(flags.limit);
552
- const notifications = projectActivity.getMembershipNotifications({
553
- projectId,
554
- limit: Number.isFinite(limit) && limit > 0 ? Math.min(limit, 100) : 20
555
- });
556
-
557
- console.log(`\n${BRAND.bold}Membership Notifications${BRAND.reset} ${BRAND.dim}(${projectId})${BRAND.reset}\n`);
558
- if (notifications.length === 0) {
559
- console.log(`${BRAND.dim}No membership notifications yet.${BRAND.reset}\n`);
560
- return;
561
- }
562
-
563
- for (const notification of notifications) {
564
- console.log(` ${BRAND.dim}${formatTimestamp(notification.timestamp)}${BRAND.reset} ${notification.message}`);
565
- }
566
- console.log();
567
- }
568
-
569
- /**
570
- * Create a new project
571
- */
572
- async function createProject(args) {
573
- if (!auth.isAuthenticated()) {
574
- console.log(`${BRAND.red}Not logged in.${BRAND.reset} Run: bootspring auth login`);
575
- process.exit(1);
576
- }
577
-
578
- const { flags, positional } = parseFlags(args);
579
- const isNonInteractive = flags.y || flags.yes || !process.stdin.isTTY;
580
- const skipDuplicateCheck = flags['skip-duplicate-check'] || flags.force;
581
-
582
- console.log(`\n${BRAND.bold}Create New Project${BRAND.reset}\n`);
583
-
584
- // Get project name from args or prompt
585
- let name = positional[0] || flags.name;
586
- if (!name && !isNonInteractive) {
587
- name = await prompt(`${BRAND.color}Project name:${BRAND.reset} `);
588
- }
589
-
590
- if (!name || name.trim().length === 0) {
591
- console.log(`${BRAND.red}Project name is required.${BRAND.reset}`);
592
- console.log(`\n${BRAND.dim}Usage: bootspring project create <name> [--description "..."] [--framework nodejs] [-y]${BRAND.reset}\n`);
593
- process.exit(1);
594
- }
595
-
596
- // Get repository URL from flag
597
- const repoUrl = flags.repo || flags.repository;
598
-
599
- // Check for similar projects (duplicate detection)
600
- if (!skipDuplicateCheck && !isNonInteractive) {
601
- console.log(`${BRAND.dim}Checking for similar projects...${BRAND.reset}`);
602
- const similar = await checkSimilarProjects(name.trim(), repoUrl);
603
-
604
- if (similar.length > 0) {
605
- console.log(`\n${BRAND.yellow}Similar project(s) found:${BRAND.reset}\n`);
606
-
607
- for (const match of similar) {
608
- const reasonMap = {
609
- github_repo: 'GitHub repo match',
610
- slug_match: 'Same slug',
611
- name_similar: 'Similar name'
612
- };
613
- const reason = reasonMap[match.matchReason] || match.matchReason;
614
- const ownerInfo = match.owner ? ` (owned by ${match.owner.email})` : '';
615
- console.log(` ${BRAND.color}${match.name}${BRAND.reset}${ownerInfo}`);
616
- console.log(` ${BRAND.dim}[${reason}]${BRAND.reset}\n`);
617
- }
618
-
619
- const proceed = await prompt(`${BRAND.color}Create a new project anyway? (y/N):${BRAND.reset} `);
620
- if (proceed.toLowerCase() !== 'y') {
621
- console.log(`\n${BRAND.dim}To join an existing project, ask the owner to add you.${BRAND.reset}\n`);
622
- return;
623
- }
624
- console.log();
625
- }
626
- }
627
-
628
- // Get description from flag or prompt
629
- let description = flags.description || flags.d;
630
- if (!description && !isNonInteractive) {
631
- description = await prompt(`${BRAND.dim}Description (optional):${BRAND.reset} `);
632
- }
633
-
634
- // Get framework from flag or prompt
635
- let framework = flags.framework || flags.f;
636
- if (!framework && !isNonInteractive) {
637
- framework = await prompt(`${BRAND.dim}Framework (e.g., nextjs, react, nodejs):${BRAND.reset} `);
638
- }
639
-
640
- console.log(`\n${BRAND.dim}Creating project...${BRAND.reset}`);
641
-
642
- try {
643
- const project = await apiRequest('POST', '/projects', {
644
- name: name.trim(),
645
- description: description || undefined,
646
- framework: framework || undefined,
647
- repositoryUrl: repoUrl || undefined
648
- });
649
-
650
- console.log(`\n${BRAND.green}✓${BRAND.reset} Project created successfully!\n`);
651
- console.log(` ${BRAND.bold}Name:${BRAND.reset} ${project.name}`);
652
- console.log(` ${BRAND.bold}ID:${BRAND.reset} ${project.id}`);
653
- console.log(` ${BRAND.bold}Slug:${BRAND.reset} ${project.slug}`);
654
-
655
- // Ask if user wants to switch to this project (auto-yes in non-interactive mode)
656
- let switchTo = 'y';
657
- if (!isNonInteractive) {
658
- switchTo = await prompt(`\n${BRAND.color}Switch to this project now? (Y/n):${BRAND.reset} `);
659
- }
660
-
661
- if (switchTo.toLowerCase() !== 'n') {
662
- session.setCurrentProject({
663
- id: project.id,
664
- name: project.name,
665
- slug: project.slug
666
- });
667
- console.log(`\n${BRAND.green}✓${BRAND.reset} Switched to ${BRAND.color}${project.name}${BRAND.reset}\n`);
668
- } else {
669
- console.log(`\n${BRAND.dim}Run 'bootspring switch ${project.slug}' to switch to this project.${BRAND.reset}\n`);
670
- }
671
- } catch (error) {
672
- if (error.status === 403 && error.message.includes('limit')) {
673
- console.log(`\n${BRAND.yellow}Project limit reached.${BRAND.reset}`);
674
- console.log(`${BRAND.dim}Upgrade your plan at: https://bootspring.com/dashboard/billing${BRAND.reset}\n`);
675
- } else {
676
- console.log(`\n${BRAND.red}Error: ${error.message}${BRAND.reset}`);
677
- }
678
- process.exit(1);
679
- }
680
- }
681
-
682
- /**
683
- * Show current project info
684
- */
685
- async function showProjectInfo() {
686
- const project = session.getEffectiveProject();
687
-
688
- if (!project) {
689
- console.log(`\n${BRAND.yellow}No project context set.${BRAND.reset}`);
690
- console.log(`${BRAND.dim}Run 'bootspring switch' to select a project.${BRAND.reset}\n`);
691
- return;
692
- }
693
-
694
- console.log(`\n${BRAND.bold}Current Project${BRAND.reset}\n`);
695
- console.log(` ${BRAND.bold}Name:${BRAND.reset} ${BRAND.color}${project.name}${BRAND.reset}`);
696
- console.log(` ${BRAND.bold}ID:${BRAND.reset} ${project.id}`);
697
- if (project.slug) {
698
- console.log(` ${BRAND.bold}Slug:${BRAND.reset} ${project.slug}`);
699
- }
700
- console.log(` ${BRAND.bold}Source:${BRAND.reset} ${project.source || 'session'}`);
701
- console.log();
702
- }
703
-
704
- /**
705
- * Show usage for this command
706
- */
707
- function showUsage() {
708
- console.log(`
709
- ${BRAND.bold}Usage:${BRAND.reset} bootspring project <command> [args]
710
-
711
- ${BRAND.bold}Commands:${BRAND.reset}
712
- list List all projects (owned and shared)
713
- create [name] Create a new project
714
- info Show current project info
715
- members [projectId] List project members
716
- invite <email> [--role role] Invite a member (accept/decline via invitation id)
717
- invitations [projectId] List pending invitations
718
- accept <invitationId> Accept an invitation
719
- decline <invitationId> Decline an invitation
720
- update-member <userId> --role <r> Update member role
721
- remove-member <userId> Remove a project member
722
- transfer <newOwnerUserId> Transfer project ownership
723
- activity [projectId] Show auditable collaboration feed
724
- notifications [projectId] Show membership change notifications
725
-
726
- ${BRAND.bold}Common options:${BRAND.reset}
727
- --project, -p Project id/slug override
728
- --limit <n> Limit list size for activity/notifications
729
-
730
- ${BRAND.bold}Options for create:${BRAND.reset}
731
- --description, -d Project description
732
- --framework, -f Project framework (e.g., nextjs, nodejs)
733
- --repo Repository URL
734
- --force Skip duplicate project check
735
- -y, --yes Non-interactive mode
736
-
737
- ${BRAND.bold}Examples:${BRAND.reset}
738
- bootspring project list
739
- bootspring project create "My App"
740
- bootspring project create "My App" --framework nextjs --repo https://github.com/user/repo
741
- bootspring project invite dev@example.com --role admin
742
- bootspring project accept inv_123
743
- bootspring project activity --limit 15
744
- bootspring project info
745
- `);
746
- }
747
-
748
- /**
749
- * Main entry point
750
- */
751
- async function run(args) {
752
- const subcommand = args[0];
753
-
754
- switch (subcommand) {
755
- case 'list':
756
- case 'ls':
757
- await listProjects();
758
- break;
759
-
760
- case 'create':
761
- case 'new':
762
- await createProject(args.slice(1));
763
- break;
764
-
765
- case 'info':
766
- case 'current':
767
- await showProjectInfo();
768
- break;
769
-
770
- case 'members':
771
- await listMembers(args.slice(1));
772
- break;
773
-
774
- case 'invite':
775
- await inviteMember(args.slice(1));
776
- break;
777
-
778
- case 'invitations':
779
- case 'invites':
780
- await listInvitations(args.slice(1));
781
- break;
782
-
783
- case 'accept':
784
- await respondToInvitation(args.slice(1), 'accept');
785
- break;
786
-
787
- case 'decline':
788
- await respondToInvitation(args.slice(1), 'decline');
789
- break;
790
-
791
- case 'update-member':
792
- await updateMemberRole(args.slice(1));
793
- break;
794
-
795
- case 'remove-member':
796
- await removeMember(args.slice(1));
797
- break;
798
-
799
- case 'transfer':
800
- await transferOwnership(args.slice(1));
801
- break;
802
-
803
- case 'activity':
804
- await showActivity(args.slice(1));
805
- break;
806
-
807
- case 'notifications':
808
- await showNotifications(args.slice(1));
809
- break;
810
-
811
- case undefined:
812
- case 'help':
813
- case '--help':
814
- case '-h':
815
- showUsage();
816
- break;
817
-
818
- default:
819
- console.log(`${BRAND.red}Unknown subcommand: ${subcommand}${BRAND.reset}`);
820
- showUsage();
821
- process.exit(1);
822
- }
823
- }
824
-
825
- module.exports = { run };