@startanaicompany/cli 1.4.15 → 1.4.17

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/CLAUDE.md CHANGED
@@ -275,6 +275,71 @@ saac create web -s web -r git@git... -t abc123 \
275
275
  - Saves project config to `.saac/config.json` after successful creation
276
276
  - Displays next steps and useful commands
277
277
 
278
+ **Deployment Behavior (NEW):**
279
+
280
+ The `create` command now **waits for the initial deployment to complete** (up to 5 minutes) before returning.
281
+
282
+ **Response Time:**
283
+ - Typical: 30-120 seconds
284
+ - Maximum: 5 minutes (timeout)
285
+
286
+ **Success Response:**
287
+ ```json
288
+ {
289
+ "success": true,
290
+ "coolify_app_uuid": "...",
291
+ "app_name": "my-app",
292
+ "domain": "https://myapp.startanaicompany.com",
293
+ "deployment_status": "finished",
294
+ "deployment_uuid": "...",
295
+ "git_branch": "master"
296
+ }
297
+ ```
298
+
299
+ **Failure Response (HTTP 200 with success: false):**
300
+ ```json
301
+ {
302
+ "success": false,
303
+ "coolify_app_uuid": "...",
304
+ "app_name": "my-app",
305
+ "deployment_status": "failed",
306
+ "message": "Port 8080 is already in use. Remove host port bindings...",
307
+ "errors": [
308
+ {
309
+ "type": "PORT_CONFLICT",
310
+ "message": "Port 8080 is already in use...",
311
+ "detail": "Bind for 0.0.0.0:8080 failed..."
312
+ }
313
+ ],
314
+ "relevant_logs": [...],
315
+ "last_logs": [...]
316
+ }
317
+ ```
318
+
319
+ **Error Types:**
320
+ - `PORT_CONFLICT` - Host port binding conflict in docker-compose.yml
321
+ - `BUILD_FAILED` - Build process returned non-zero exit code
322
+ - `TIMEOUT` - Deployment didn't complete in 5 minutes
323
+ - `UNKNOWN` - Generic deployment failure
324
+
325
+ **Important Notes:**
326
+ 1. **Application is created even if deployment fails** - the UUID is saved to `.saac/config.json`
327
+ 2. Failed deployments return HTTP 200 (not 4xx) with `success: false`
328
+ 3. CLI must check the `success` field, not just HTTP status code
329
+ 4. Detailed error information is displayed with actionable advice
330
+ 5. User can fix the issue and run `saac deploy` to retry
331
+
332
+ **Error Display:**
333
+ The CLI displays comprehensive error information:
334
+ - Error summary message
335
+ - Structured error details with types
336
+ - Relevant error logs (filtered)
337
+ - Last log lines for context
338
+ - Actionable advice based on error type:
339
+ - `PORT_CONFLICT`: Remove host port bindings from docker-compose.yml
340
+ - `BUILD_FAILED`: Check Dockerfile, run `docker build .` locally
341
+ - `TIMEOUT`: Check `saac status` and `saac logs`, may still be running
342
+
278
343
  ### Update Command Implementation
279
344
 
280
345
  The `update` command allows modifying application configuration after deployment using `PATCH /api/v1/applications/:uuid`.
@@ -373,7 +438,7 @@ saac git disconnect git.startanaicompany.com
373
438
 
374
439
  ### Deploy Command Implementation
375
440
 
376
- The `deploy` command triggers deployment for the current application.
441
+ The `deploy` command triggers deployment and **waits for completion** (up to 5 minutes).
377
442
 
378
443
  **Usage:**
379
444
  ```bash
@@ -385,16 +450,155 @@ saac deploy --force
385
450
  1. Validates authentication (session token not expired)
386
451
  2. Checks for project config (`.saac/config.json`)
387
452
  3. Makes POST request to `/api/v1/applications/:uuid/deploy`
388
- 4. Displays deployment status and deployment ID
389
- 5. Shows command to follow logs: `saac logs --follow`
453
+ 4. **Waits for deployment to complete** (up to 5 minutes)
454
+ 5. Displays deployment status with detailed error information on failure
390
455
 
391
- **Response Fields:**
392
- - `status` - Application status after deployment triggered
393
- - `domain` - Application domain (if available)
394
- - `deployment_id` - Unique ID for this deployment
456
+ **Response Time:**
457
+ - Typical: 30-120 seconds
458
+ - Maximum: 5 minutes (timeout)
459
+
460
+ **Success Response:**
461
+ ```json
462
+ {
463
+ "success": true,
464
+ "status": "finished",
465
+ "deployment_uuid": "...",
466
+ "git_branch": "master",
467
+ "domain": "https://myapp.startanaicompany.com",
468
+ "traefik_status": "queued"
469
+ }
470
+ ```
471
+
472
+ **Failure Response:**
473
+ ```json
474
+ {
475
+ "success": false,
476
+ "status": "failed",
477
+ "message": "Build failed with exit code 1",
478
+ "errors": [
479
+ {
480
+ "type": "BUILD_FAILED",
481
+ "message": "Build failed with exit code 1",
482
+ "detail": "npm ERR! code ELIFECYCLE..."
483
+ }
484
+ ],
485
+ "relevant_logs": [...],
486
+ "last_logs": [...]
487
+ }
488
+ ```
489
+
490
+ **Error Types:**
491
+ - `PORT_CONFLICT` - Host port binding conflict
492
+ - `BUILD_FAILED` - Build process failed
493
+ - `TIMEOUT` - Deployment didn't complete in 5 minutes
494
+ - `UNKNOWN` - Generic failure
495
+
496
+ **Error Display:**
497
+ The CLI displays:
498
+ 1. Error summary message
499
+ 2. Structured error details with types
500
+ 3. Relevant logs (filtered error logs)
501
+ 4. Last 5 log lines for context
502
+ 5. Actionable advice based on error type
503
+ 6. Suggestion to view full logs with `saac logs --follow`
395
504
 
396
505
  **Note:** The `--force` flag is defined in the CLI but not currently used by the API.
397
506
 
507
+ ### OTP-Based Login (NEW in 1.4.16)
508
+
509
+ The login command now supports two authentication methods:
510
+
511
+ **Method 1: Login with API Key (Fast Path)**
512
+ ```bash
513
+ saac login -e user@example.com -k cw_abc123
514
+ # → Immediate session token, no email verification needed
515
+ ```
516
+
517
+ **Method 2: Login with OTP (Recovery Path)**
518
+ ```bash
519
+ # Step 1: Request OTP
520
+ saac login -e user@example.com
521
+ # → Verification code sent to email (6 digits, 5-minute expiration)
522
+
523
+ # Step 2: Verify OTP
524
+ saac login -e user@example.com --otp 123456
525
+ # → Session token created, user is now logged in
526
+ ```
527
+
528
+ **Why This Feature?**
529
+
530
+ Solves the **API key lockout problem**:
531
+ - User loses API key
532
+ - All sessions expire
533
+ - Cannot login (no API key)
534
+ - Cannot regenerate key (requires login)
535
+ - **LOCKED OUT** ❌
536
+
537
+ With OTP login:
538
+ - User can always recover via email ✅
539
+ - No support tickets needed ✅
540
+ - Self-service account recovery ✅
541
+
542
+ **Implementation Details:**
543
+
544
+ The `login.js` command now has three modes:
545
+ 1. **API key login** - If `-k` flag provided (existing flow)
546
+ 2. **OTP request** - If no `-k` or `--otp` flag (new flow)
547
+ 3. **OTP verification** - If `--otp` flag provided (new flow)
548
+
549
+ **Backend Requirements:**
550
+ - `POST /api/v1/auth/login-otp` - Generate and send OTP
551
+ - `POST /api/v1/auth/verify-otp` - Verify OTP and create session
552
+
553
+ See `/data/sharedinfo/login-feature-update.md` for complete API specifications.
554
+
555
+ ### API Key Management (NEW in 1.4.16)
556
+
557
+ Users can now regenerate their API keys if lost or compromised.
558
+
559
+ **Commands:**
560
+
561
+ ```bash
562
+ # Regenerate API key (requires authentication)
563
+ saac keys regenerate
564
+ # → Shows new API key (only once!)
565
+
566
+ # Show API key info
567
+ saac keys show
568
+ # → Displays key prefix, created date, last used
569
+ ```
570
+
571
+ **Recovery Flow:**
572
+
573
+ ```bash
574
+ # 1. User loses API key but is not logged in
575
+ saac login -e user@example.com
576
+ # → OTP sent to email
577
+
578
+ # 2. Verify OTP
579
+ saac login -e user@example.com --otp 123456
580
+ # → Logged in with session token
581
+
582
+ # 3. Generate new API key
583
+ saac keys regenerate
584
+ # → New API key: cw_new_key_xyz...
585
+
586
+ # 4. On next machine, use new API key
587
+ saac login -e user@example.com -k cw_new_key_xyz...
588
+ ```
589
+
590
+ **Security Notes:**
591
+ - Regenerating API key invalidates the old key immediately
592
+ - Existing session tokens remain valid (no disruption)
593
+ - Email notification sent when key is regenerated
594
+ - Full API key shown only once (must be saved)
595
+
596
+ **Backend Requirements:**
597
+ - `POST /api/v1/users/regenerate-key` - Generate new API key
598
+ - `GET /api/v1/users/api-key` - Get API key info (optional)
599
+
600
+ See `/data/sharedinfo/login-feature-update.md` for complete specifications.
601
+
398
602
  ### Init Command Implementation
399
603
 
400
604
  The `init` command links an existing SAAC application to the current directory.
@@ -493,19 +697,26 @@ The wrapper API expects Git repositories to be hosted on the StartAnAiCompany Gi
493
697
  - During registration, Gitea username can be auto-detected or manually provided
494
698
  - Applications reference repositories in the format: `git@git.startanaicompany.com:user/repo.git`
495
699
 
496
- ## Current Status - Version 1.4.15
700
+ ## Current Status - Version 1.4.17
497
701
 
498
702
  ### Completed Features
499
703
 
500
704
  **Authentication & Sessions:**
501
705
  - ✅ `saac register` - Register with email only (git_username auto-detected from email)
502
- - ✅ `saac login` - Login with email + API key, receives 1-year session token
706
+ - ✅ `saac login` - Two methods: API key (fast) or OTP (recovery)
707
+ - `saac login -e email -k api-key` - Login with API key
708
+ - `saac login -e email` - Request OTP via email
709
+ - `saac login -e email --otp code` - Verify OTP and login
503
710
  - ✅ `saac verify` - Email verification, shows FULL API key for user to save
504
711
  - ✅ `saac logout` - Logout from current device
505
712
  - ✅ `saac logout-all` - Revoke all sessions
506
713
  - ✅ `saac sessions` - List all active sessions
507
714
  - ✅ `saac whoami` - Show current user info
508
715
 
716
+ **API Key Management (NEW in 1.4.16):**
717
+ - ✅ `saac keys regenerate` - Generate new API key (invalidates old one)
718
+ - ✅ `saac keys show` - Show API key information (prefix, created, last used)
719
+
509
720
  **Git OAuth (NEW in 1.4.0):**
510
721
  - ✅ `saac git connect [host]` - OAuth flow for Git authentication
511
722
  - ✅ `saac git list` - List connected Git accounts
@@ -706,4 +917,4 @@ Before publishing to npm:
706
917
  - `dotenv` - Environment variables
707
918
  - `open` - Open browser for OAuth (v8.4.2 for compatibility with chalk v4)
708
919
 
709
- **Version:** 1.4.15 (current)
920
+ **Version:** 1.4.17 (current)
package/bin/saac.js CHANGED
@@ -16,6 +16,7 @@ const verify = require('../src/commands/verify');
16
16
  const logout = require('../src/commands/logout');
17
17
  const logoutAll = require('../src/commands/logoutAll');
18
18
  const sessions = require('../src/commands/sessions');
19
+ const keys = require('../src/commands/keys');
19
20
  const git = require('../src/commands/git');
20
21
  const init = require('../src/commands/init');
21
22
  const create = require('../src/commands/create');
@@ -56,7 +57,8 @@ program
56
57
  .command('login')
57
58
  .description('Login with existing account')
58
59
  .option('-e, --email <email>', 'Email address')
59
- .option('-k, --api-key <key>', 'API key')
60
+ .option('-k, --api-key <key>', 'API key (for fast login)')
61
+ .option('--otp <code>', 'One-time password from email')
60
62
  .action(login);
61
63
 
62
64
  program
@@ -80,6 +82,22 @@ program
80
82
  .description('List all active sessions')
81
83
  .action(sessions);
82
84
 
85
+ // API Key management
86
+ const keysCommand = program
87
+ .command('keys')
88
+ .description('Manage API keys');
89
+
90
+ keysCommand
91
+ .command('regenerate')
92
+ .description('Generate a new API key (invalidates old one)')
93
+ .action(keys.regenerate);
94
+
95
+ keysCommand
96
+ .command('show')
97
+ .alias('info')
98
+ .description('Show API key information')
99
+ .action(keys.show);
100
+
83
101
  // Git OAuth commands
84
102
  const gitCommand = program
85
103
  .command('git')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startanaicompany/cli",
3
- "version": "1.4.15",
3
+ "version": "1.4.17",
4
4
  "description": "Official CLI for StartAnAiCompany.com - Deploy AI recruitment sites with ease",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -8,6 +8,7 @@ const logger = require('../lib/logger');
8
8
  const oauth = require('../lib/oauth');
9
9
  const inquirer = require('inquirer');
10
10
  const { execSync } = require('child_process');
11
+ const errorDisplay = require('../lib/errorDisplay');
11
12
 
12
13
  async function create(name, options) {
13
14
  try {
@@ -281,14 +282,12 @@ async function create(name, options) {
281
282
 
282
283
  logger.newline();
283
284
 
284
- const spin = logger.spinner('Creating application...').start();
285
+ const spin = logger.spinner('Creating application and deploying (this may take up to 5 minutes)...').start();
285
286
 
286
287
  try {
287
288
  const result = await api.createApplication(appData);
288
289
 
289
- spin.succeed('Application created successfully!');
290
-
291
- // Save project configuration
290
+ // Always save project configuration (even if deployment failed)
292
291
  saveProjectConfig({
293
292
  applicationUuid: result.coolify_app_uuid,
294
293
  applicationName: result.app_name,
@@ -297,13 +296,35 @@ async function create(name, options) {
297
296
  gitRepository: appData.git_repository,
298
297
  });
299
298
 
299
+ // Check if deployment failed
300
+ if (result.success === false) {
301
+ spin.fail('Deployment failed');
302
+
303
+ // Display detailed error information
304
+ errorDisplay.displayDeploymentError(result, logger);
305
+
306
+ // Show recovery instructions
307
+ errorDisplay.displayCreateRecoveryInstructions(result, logger);
308
+
309
+ process.exit(1);
310
+ }
311
+
312
+ // SUCCESS: Application created and deployed
313
+ spin.succeed('Application created and deployed successfully!');
314
+
300
315
  logger.newline();
301
- logger.success('Application created!');
316
+ logger.success('Your application is live!');
302
317
  logger.newline();
303
318
  logger.field('Name', result.app_name);
304
319
  logger.field('Domain', result.domain);
305
320
  logger.field('UUID', result.coolify_app_uuid);
306
- logger.field('Status', result.deployment_status);
321
+ logger.field('Status', result.deployment_status || 'finished');
322
+ if (result.git_branch) {
323
+ logger.field('Branch', result.git_branch);
324
+ }
325
+ if (result.deployment_uuid) {
326
+ logger.field('Deployment ID', result.deployment_uuid);
327
+ }
307
328
  logger.newline();
308
329
 
309
330
  // Show next steps
@@ -5,6 +5,7 @@
5
5
  const api = require('../lib/api');
6
6
  const { getProjectConfig, isAuthenticated } = require('../lib/config');
7
7
  const logger = require('../lib/logger');
8
+ const errorDisplay = require('../lib/errorDisplay');
8
9
 
9
10
  async function deploy(options) {
10
11
  try {
@@ -25,30 +26,62 @@ async function deploy(options) {
25
26
  const { applicationUuid, applicationName } = projectConfig;
26
27
 
27
28
  logger.section(`Deploying ${applicationName}`);
29
+ logger.newline();
28
30
 
29
- const spin = logger.spinner('Triggering deployment...').start();
31
+ const spin = logger.spinner('Deploying application (waiting for completion, up to 5 minutes)...').start();
30
32
 
31
33
  try {
32
34
  const result = await api.deployApplication(applicationUuid);
33
35
 
34
- spin.succeed('Deployment triggered!');
36
+ // Check if deployment failed
37
+ if (result.success === false) {
38
+ spin.fail('Deployment failed');
39
+
40
+ // Display detailed error information
41
+ errorDisplay.displayDeploymentError(result, logger);
42
+
43
+ // Handle timeout specifically
44
+ if (result.status === 'timeout') {
45
+ errorDisplay.displayTimeoutInstructions(logger);
46
+ }
47
+
48
+ process.exit(1);
49
+ }
50
+
51
+ // SUCCESS: Deployment completed
52
+ spin.succeed('Deployment completed successfully!');
35
53
 
36
54
  logger.newline();
37
- logger.success('Deployment started successfully');
55
+ logger.success('Your application has been deployed!');
38
56
  logger.newline();
39
57
  logger.field('Application', applicationName);
40
58
  logger.field('Status', result.status);
59
+ if (result.git_branch) {
60
+ logger.field('Branch', result.git_branch);
61
+ }
41
62
  if (result.domain) {
42
63
  logger.field('Domain', result.domain);
43
64
  }
44
- logger.field('Deployment ID', result.deployment_id);
65
+ if (result.deployment_uuid || result.deployment_id) {
66
+ logger.field('Deployment ID', result.deployment_uuid || result.deployment_id);
67
+ }
45
68
  logger.newline();
46
- logger.info(
47
- `View logs with: ${logger.chalk.yellow('saac logs --follow')}`
48
- );
69
+
70
+ // Show Traefik status if present
71
+ if (result.traefik_status === 'queued') {
72
+ logger.info('Routing configuration is being applied (may take a few seconds)');
73
+ logger.newline();
74
+ } else if (result.traefik_status === 'failed') {
75
+ logger.warn('Routing configuration failed - application may not be accessible');
76
+ logger.newline();
77
+ }
78
+
79
+ logger.info('Useful commands:');
80
+ logger.log(` saac logs --follow View live deployment logs`);
81
+ logger.log(` saac status Check application status`);
49
82
 
50
83
  } catch (error) {
51
- spin.fail('Deployment failed');
84
+ spin.fail('Deployment request failed');
52
85
  throw error;
53
86
  }
54
87
  } catch (error) {
@@ -0,0 +1,143 @@
1
+ /**
2
+ * API Key management commands
3
+ */
4
+
5
+ const api = require('../lib/api');
6
+ const { getUser, isAuthenticated } = require('../lib/config');
7
+ const logger = require('../lib/logger');
8
+ const inquirer = require('inquirer');
9
+
10
+ /**
11
+ * Regenerate API key
12
+ */
13
+ async function regenerate() {
14
+ try {
15
+ // Must be authenticated (via session token)
16
+ if (!isAuthenticated()) {
17
+ logger.error('Not logged in');
18
+ logger.newline();
19
+ logger.info('You must be logged in to regenerate your API key');
20
+ logger.newline();
21
+ logger.info('Login using email verification:');
22
+ logger.log(' saac login -e <email> # Request OTP');
23
+ logger.log(' saac login -e <email> --otp <code> # Verify OTP');
24
+ process.exit(1);
25
+ }
26
+
27
+ const user = getUser();
28
+
29
+ logger.section('Regenerate API Key');
30
+ logger.newline();
31
+
32
+ logger.warn('This will generate a new API key and invalidate your current one.');
33
+ logger.warn('Your active session tokens will continue to work.');
34
+ logger.newline();
35
+
36
+ // Confirmation prompt
37
+ const { confirm } = await inquirer.prompt([
38
+ {
39
+ type: 'confirm',
40
+ name: 'confirm',
41
+ message: 'Are you sure you want to regenerate your API key?',
42
+ default: false,
43
+ },
44
+ ]);
45
+
46
+ if (!confirm) {
47
+ logger.info('Cancelled');
48
+ return;
49
+ }
50
+
51
+ const spin = logger.spinner('Generating new API key...').start();
52
+
53
+ try {
54
+ const result = await api.regenerateApiKey();
55
+
56
+ spin.succeed('New API key generated!');
57
+
58
+ logger.newline();
59
+ logger.success('Your new API key:');
60
+ logger.newline();
61
+
62
+ // Show FULL API key (it's only shown once)
63
+ logger.field('API Key', result.api_key);
64
+
65
+ logger.newline();
66
+ logger.warn('Save this key securely. It will not be shown again.');
67
+ logger.newline();
68
+
69
+ logger.info('Your existing sessions remain active.');
70
+ logger.info('Use this new key for future logins or CI/CD.');
71
+ logger.newline();
72
+
73
+ logger.info('To login with the new key:');
74
+ logger.log(` saac login -e ${user.email} -k ${result.api_key}`);
75
+
76
+ } catch (error) {
77
+ spin.fail('Failed to regenerate API key');
78
+ throw error;
79
+ }
80
+ } catch (error) {
81
+ logger.error(error.response?.data?.message || error.message);
82
+ process.exit(1);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Show API key info (without revealing full key)
88
+ */
89
+ async function show() {
90
+ try {
91
+ if (!isAuthenticated()) {
92
+ logger.error('Not logged in');
93
+ logger.newline();
94
+ logger.info('Login first:');
95
+ logger.log(' saac login -e <email>');
96
+ process.exit(1);
97
+ }
98
+
99
+ logger.section('API Key Information');
100
+ logger.newline();
101
+
102
+ const spin = logger.spinner('Fetching API key info...').start();
103
+
104
+ try {
105
+ const result = await api.getApiKeyInfo();
106
+
107
+ spin.succeed('API key info retrieved');
108
+
109
+ logger.newline();
110
+ logger.field('Key Prefix', result.key_prefix); // e.g., "cw_RJ1gH8..."
111
+ logger.field('Created', new Date(result.created_at).toLocaleDateString());
112
+ logger.field('Last Used', result.last_used_at
113
+ ? new Date(result.last_used_at).toLocaleString()
114
+ : 'Never');
115
+
116
+ logger.newline();
117
+ logger.info('Commands:');
118
+ logger.log(' saac keys regenerate Generate new API key');
119
+ logger.log(' saac sessions View active sessions');
120
+
121
+ } catch (error) {
122
+ spin.fail('Failed to fetch API key info');
123
+
124
+ // If endpoint doesn't exist yet, show helpful message
125
+ if (error.response?.status === 404) {
126
+ logger.newline();
127
+ logger.warn('API key info endpoint not available yet');
128
+ logger.info('You can still regenerate your key with:');
129
+ logger.log(' saac keys regenerate');
130
+ } else {
131
+ throw error;
132
+ }
133
+ }
134
+ } catch (error) {
135
+ logger.error(error.response?.data?.message || error.message);
136
+ process.exit(1);
137
+ }
138
+ }
139
+
140
+ module.exports = {
141
+ regenerate,
142
+ show,
143
+ };
@@ -1,5 +1,7 @@
1
1
  /**
2
- * Login command
2
+ * Login command - Two methods:
3
+ * 1. With API key (fast): saac login -e email -k api_key
4
+ * 2. With OTP (recovery): saac login -e email, then saac login -e email --otp code
3
5
  */
4
6
 
5
7
  const inquirer = require('inquirer');
@@ -10,21 +12,23 @@ const logger = require('../lib/logger');
10
12
 
11
13
  async function login(options) {
12
14
  try {
13
- // Require both email and API key flags (no interactive prompts)
14
- if (!options.email || !options.apiKey) {
15
- logger.error('Email and API key are required');
15
+ // Require email flag
16
+ if (!options.email) {
17
+ logger.error('Email is required');
16
18
  logger.newline();
17
19
  logger.info('Usage:');
18
- logger.log(' saac login -e <email> -k <api-key>');
19
- logger.log(' saac login --email <email> --api-key <api-key>');
20
+ logger.log(' saac login -e <email> -k <api-key> # Login with API key');
21
+ logger.log(' saac login -e <email> # Request OTP via email');
22
+ logger.log(' saac login -e <email> --otp <code> # Verify OTP and login');
20
23
  logger.newline();
21
- logger.info('Example:');
22
- logger.log(' saac login -e user@example.com -k cw_your_api_key');
24
+ logger.info('Examples:');
25
+ logger.log(' saac login -e user@example.com -k cw_abc123 # Fast login');
26
+ logger.log(' saac login -e user@example.com # Send OTP to email');
27
+ logger.log(' saac login -e user@example.com --otp 123456 # Verify OTP');
23
28
  process.exit(1);
24
29
  }
25
30
 
26
31
  const email = options.email;
27
- const apiKey = options.apiKey;
28
32
 
29
33
  // Validate email format
30
34
  if (!validator.isEmail(email)) {
@@ -33,46 +37,143 @@ async function login(options) {
33
37
  }
34
38
 
35
39
  logger.section('Login to StartAnAiCompany');
40
+ logger.newline();
36
41
 
37
- // Login and get session token
38
- const spin = logger.spinner('Logging in...').start();
42
+ // CASE 1: Login with API key (fast path)
43
+ if (options.apiKey) {
44
+ return await loginWithApiKey(email, options.apiKey);
45
+ }
39
46
 
40
- try {
41
- // Call /auth/login endpoint to get session token
42
- const result = await api.login(email, apiKey);
47
+ // CASE 2: Verify OTP (second step)
48
+ if (options.otp) {
49
+ return await verifyOtpAndLogin(email, options.otp);
50
+ }
43
51
 
44
- spin.succeed('Login successful!');
52
+ // CASE 3: Request OTP (first step)
53
+ return await requestOtp(email);
45
54
 
46
- // Save session token and expiration
47
- saveUser({
48
- email: result.user.email || email,
49
- userId: result.user.id,
50
- sessionToken: result.session_token,
51
- expiresAt: result.expires_at,
52
- verified: result.user.verified,
53
- });
55
+ } catch (error) {
56
+ logger.error(error.response?.data?.message || error.message);
57
+ process.exit(1);
58
+ }
59
+ }
54
60
 
55
- logger.newline();
56
- logger.success('You are now logged in!');
57
- logger.newline();
58
- logger.field('Email', result.user.email || email);
59
- logger.field('Verified', result.user.verified ? 'Yes' : 'No');
60
-
61
- // Show expiration date
62
- if (result.expires_at) {
63
- const expirationDate = new Date(result.expires_at);
64
- logger.field('Session expires', expirationDate.toLocaleDateString());
65
- }
66
-
67
- } catch (error) {
68
- spin.fail('Login failed');
69
- throw error;
61
+ /**
62
+ * Login with API key (existing flow)
63
+ */
64
+ async function loginWithApiKey(email, apiKey) {
65
+ const spin = logger.spinner('Logging in with API key...').start();
66
+
67
+ try {
68
+ const result = await api.login(email, apiKey);
69
+
70
+ spin.succeed('Login successful!');
71
+
72
+ // Save session token
73
+ saveUser({
74
+ email: result.user.email || email,
75
+ userId: result.user.id,
76
+ sessionToken: result.session_token,
77
+ expiresAt: result.expires_at,
78
+ verified: result.user.verified,
79
+ });
80
+
81
+ logger.newline();
82
+ logger.success('You are now logged in!');
83
+ logger.newline();
84
+ logger.field('Email', result.user.email || email);
85
+ logger.field('Verified', result.user.verified ? 'Yes' : 'No');
86
+
87
+ if (result.expires_at) {
88
+ const expirationDate = new Date(result.expires_at);
89
+ logger.field('Session expires', expirationDate.toLocaleDateString());
70
90
  }
91
+
71
92
  } catch (error) {
72
- logger.error(
73
- error.response?.data?.message || 'Invalid credentials'
74
- );
75
- process.exit(1);
93
+ spin.fail('Login failed');
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Request OTP via email (new flow - step 1)
100
+ */
101
+ async function requestOtp(email) {
102
+ const spin = logger.spinner('Sending verification code to email...').start();
103
+
104
+ try {
105
+ const result = await api.requestLoginOtp(email);
106
+
107
+ spin.succeed('Verification code sent!');
108
+
109
+ logger.newline();
110
+ logger.success(`A verification code has been sent to ${logger.chalk.cyan(email)}`);
111
+ logger.newline();
112
+ logger.info('Check your email and run:');
113
+ logger.log(` ${logger.chalk.yellow(`saac login -e ${email} --otp <code>`)}`);
114
+ logger.newline();
115
+ logger.warn('Note: Check MailHog at https://mailhog.goryan.io for the verification code');
116
+ logger.newline();
117
+
118
+ if (result.otp_expires_at) {
119
+ const expiresAt = new Date(result.otp_expires_at);
120
+ const now = new Date();
121
+ const minutesLeft = Math.ceil((expiresAt - now) / 60000);
122
+ logger.info(`Code expires in ${minutesLeft} minutes`);
123
+ }
124
+
125
+ } catch (error) {
126
+ spin.fail('Failed to send verification code');
127
+ throw error;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Verify OTP and complete login (new flow - step 2)
133
+ */
134
+ async function verifyOtpAndLogin(email, otpCode) {
135
+ const spin = logger.spinner('Verifying code...').start();
136
+
137
+ try {
138
+ const result = await api.verifyLoginOtp(email, otpCode);
139
+
140
+ spin.succeed('Verification successful!');
141
+
142
+ // Save session token
143
+ saveUser({
144
+ email: result.user.email || email,
145
+ userId: result.user.id,
146
+ sessionToken: result.session_token,
147
+ expiresAt: result.expires_at,
148
+ verified: result.user.verified,
149
+ });
150
+
151
+ logger.newline();
152
+ logger.success('You are now logged in!');
153
+ logger.newline();
154
+ logger.field('Email', result.user.email || email);
155
+ logger.field('Verified', result.user.verified ? 'Yes' : 'No');
156
+
157
+ if (result.expires_at) {
158
+ const expirationDate = new Date(result.expires_at);
159
+ logger.field('Session expires', expirationDate.toLocaleDateString());
160
+ }
161
+
162
+ logger.newline();
163
+ logger.info('💡 Tip: Generate an API key for faster future logins:');
164
+ logger.log(' saac keys regenerate');
165
+
166
+ } catch (error) {
167
+ spin.fail('Verification failed');
168
+
169
+ if (error.response?.status === 401) {
170
+ logger.newline();
171
+ logger.error('Invalid or expired verification code');
172
+ logger.info('Request a new code:');
173
+ logger.log(` saac login -e ${email}`);
174
+ }
175
+
176
+ throw error;
76
177
  }
77
178
  }
78
179
 
package/src/lib/api.js CHANGED
@@ -9,8 +9,9 @@ const pkg = require('../../package.json');
9
9
 
10
10
  /**
11
11
  * Create axios instance with base configuration
12
+ * @param {number} timeout - Timeout in milliseconds (default: 30000)
12
13
  */
13
- function createClient() {
14
+ function createClient(timeout = 30000) {
14
15
  const user = getUser();
15
16
  const envApiKey = process.env.SAAC_API_KEY; // For CI/CD
16
17
 
@@ -33,7 +34,7 @@ function createClient() {
33
34
 
34
35
  return axios.create({
35
36
  baseURL: getApiUrl(),
36
- timeout: 30000,
37
+ timeout: timeout,
37
38
  headers,
38
39
  });
39
40
  }
@@ -88,9 +89,11 @@ async function getUserInfo() {
88
89
 
89
90
  /**
90
91
  * Create a new application
92
+ * Note: This waits for deployment to complete (up to 5 minutes)
91
93
  */
92
94
  async function createApplication(appData) {
93
- const client = createClient();
95
+ // Use 5-minute timeout for deployment waiting
96
+ const client = createClient(300000); // 5 minutes
94
97
  const response = await client.post('/applications', appData);
95
98
  return response.data;
96
99
  }
@@ -115,9 +118,11 @@ async function getApplication(uuid) {
115
118
 
116
119
  /**
117
120
  * Deploy application
121
+ * Note: This waits for deployment to complete (up to 5 minutes)
118
122
  */
119
123
  async function deployApplication(uuid) {
120
- const client = createClient();
124
+ // Use 5-minute timeout for deployment waiting
125
+ const client = createClient(300000); // 5 minutes
121
126
  const response = await client.post(`/applications/${uuid}/deploy`);
122
127
  return response.data;
123
128
  }
@@ -181,6 +186,61 @@ async function healthCheck() {
181
186
  return response.data;
182
187
  }
183
188
 
189
+ /**
190
+ * Request login OTP (no API key required)
191
+ */
192
+ async function requestLoginOtp(email) {
193
+ const client = axios.create({
194
+ baseURL: getApiUrl(),
195
+ timeout: 30000,
196
+ headers: {
197
+ 'Content-Type': 'application/json',
198
+ 'User-Agent': `saac-cli/${pkg.version} (${os.platform()}; ${os.arch()})`,
199
+ },
200
+ });
201
+
202
+ const response = await client.post('/auth/login-otp', { email });
203
+ return response.data;
204
+ }
205
+
206
+ /**
207
+ * Verify login OTP and get session token
208
+ */
209
+ async function verifyLoginOtp(email, otpCode) {
210
+ const client = axios.create({
211
+ baseURL: getApiUrl(),
212
+ timeout: 30000,
213
+ headers: {
214
+ 'Content-Type': 'application/json',
215
+ 'User-Agent': `saac-cli/${pkg.version} (${os.platform()}; ${os.arch()})`,
216
+ },
217
+ });
218
+
219
+ const response = await client.post('/auth/verify-otp', {
220
+ email,
221
+ otp_code: otpCode,
222
+ });
223
+ return response.data;
224
+ }
225
+
226
+ /**
227
+ * Regenerate API key (requires authentication)
228
+ */
229
+ async function regenerateApiKey() {
230
+ const client = createClient();
231
+ const response = await client.post('/users/regenerate-key');
232
+ return response.data;
233
+ }
234
+
235
+ /**
236
+ * Get API key info (prefix, created date, last used)
237
+ */
238
+ async function getApiKeyInfo() {
239
+ const client = createClient();
240
+ const response = await client.get('/users/api-key');
241
+ return response.data;
242
+ }
243
+
184
244
  module.exports = {
185
245
  createClient,
186
246
  login,
@@ -197,4 +257,8 @@ module.exports = {
197
257
  updateDomain,
198
258
  deleteApplication,
199
259
  healthCheck,
260
+ requestLoginOtp,
261
+ verifyLoginOtp,
262
+ regenerateApiKey,
263
+ getApiKeyInfo,
200
264
  };
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Error Display Utilities
3
+ * Formats and displays deployment errors with actionable advice
4
+ */
5
+
6
+ const chalk = require('chalk');
7
+
8
+ /**
9
+ * Get actionable advice for specific error types
10
+ */
11
+ function getErrorAdvice(errorType) {
12
+ const advice = {
13
+ PORT_CONFLICT: [
14
+ 'Fix: Remove host port bindings from your docker-compose.yml',
15
+ ' Change "8080:8080" to just "8080"',
16
+ ' Traefik will handle external routing automatically',
17
+ ],
18
+ BUILD_FAILED: [
19
+ 'Fix: Check your Dockerfile and build configuration',
20
+ ' Run "docker build ." locally to debug',
21
+ ' Verify all dependencies are properly specified',
22
+ ],
23
+ TIMEOUT: [
24
+ 'Note: Deployment may still be running in the background',
25
+ ' Check status with: saac status',
26
+ ' View logs with: saac logs --follow',
27
+ ],
28
+ UNKNOWN: [
29
+ 'Tip: Check the deployment logs for more details',
30
+ ' Run: saac logs --follow',
31
+ ],
32
+ PARSE_ERROR: [
33
+ 'Note: Could not parse deployment logs',
34
+ ' Contact support if this persists',
35
+ ],
36
+ };
37
+
38
+ return advice[errorType] || advice.UNKNOWN;
39
+ }
40
+
41
+ /**
42
+ * Format log lines for display
43
+ */
44
+ function formatLogs(logs, maxLines = 10) {
45
+ if (!logs || logs.length === 0) {
46
+ return [];
47
+ }
48
+
49
+ // If logs is an array of objects with 'output' field
50
+ if (logs[0] && typeof logs[0] === 'object' && logs[0].output) {
51
+ return logs.slice(-maxLines).map(log => log.output);
52
+ }
53
+
54
+ // If logs is an array of strings
55
+ return logs.slice(-maxLines);
56
+ }
57
+
58
+ /**
59
+ * Display detailed deployment error information
60
+ */
61
+ function displayDeploymentError(result, logger) {
62
+ logger.newline();
63
+ logger.error(`Deployment Error: ${result.message || 'Deployment failed'}`);
64
+ logger.newline();
65
+
66
+ // Display deployment info
67
+ if (result.deployment_status || result.status) {
68
+ logger.field('Status', result.deployment_status || result.status);
69
+ }
70
+ if (result.git_branch) {
71
+ logger.field('Branch', result.git_branch);
72
+ }
73
+ if (result.deployment_uuid) {
74
+ logger.field('Deployment ID', result.deployment_uuid);
75
+ }
76
+
77
+ logger.newline();
78
+
79
+ // Display structured errors
80
+ if (result.errors && result.errors.length > 0) {
81
+ logger.info('Error Details:');
82
+ result.errors.forEach(err => {
83
+ logger.log(` ${chalk.yellow(`[${err.type}]`)} ${err.message}`);
84
+ if (err.detail) {
85
+ const detailLines = err.detail.split('\n').slice(0, 3); // First 3 lines
86
+ detailLines.forEach(line => {
87
+ logger.log(` ${chalk.gray(line)}`);
88
+ });
89
+ }
90
+ });
91
+ logger.newline();
92
+
93
+ // Display actionable advice for the first error
94
+ const firstError = result.errors[0];
95
+ if (firstError && firstError.type) {
96
+ const advice = getErrorAdvice(firstError.type);
97
+ if (advice && advice.length > 0) {
98
+ logger.info('Suggested Fix:');
99
+ advice.forEach(line => {
100
+ logger.log(` ${chalk.cyan(line)}`);
101
+ });
102
+ logger.newline();
103
+ }
104
+ }
105
+ }
106
+
107
+ // Display relevant logs (filtered error logs)
108
+ if (result.relevant_logs && result.relevant_logs.length > 0) {
109
+ logger.info('Relevant Logs:');
110
+ const logLines = formatLogs(result.relevant_logs, 5);
111
+ logLines.forEach(line => {
112
+ logger.log(` ${chalk.gray(line)}`);
113
+ });
114
+ logger.newline();
115
+ }
116
+
117
+ // Display last logs (context)
118
+ if (result.last_logs && result.last_logs.length > 0) {
119
+ logger.info('Recent Log Output:');
120
+ const logLines = formatLogs(result.last_logs, 5);
121
+ logLines.forEach(line => {
122
+ logger.log(` ${chalk.gray(line)}`);
123
+ });
124
+ logger.newline();
125
+ }
126
+
127
+ // Suggest viewing full logs
128
+ if (result.coolify_app_uuid || result.deployment_uuid) {
129
+ const uuid = result.coolify_app_uuid || result.deployment_uuid;
130
+ logger.info('View full logs:');
131
+ logger.log(` ${chalk.yellow('saac logs --follow')}`);
132
+ logger.newline();
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Display recovery instructions after failed create
138
+ */
139
+ function displayCreateRecoveryInstructions(result, logger) {
140
+ if (result.coolify_app_uuid) {
141
+ logger.warn(`Application "${result.app_name}" was created but deployment failed.`);
142
+ logger.info('Fix the issue in your repository, then redeploy:');
143
+ logger.log(` ${chalk.yellow('saac deploy')}`);
144
+ logger.newline();
145
+ logger.info('Or delete and recreate:');
146
+ logger.log(` ${chalk.yellow(`saac delete ${result.coolify_app_uuid}`)}`);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Display timeout-specific instructions
152
+ */
153
+ function displayTimeoutInstructions(logger) {
154
+ logger.warn('Deployment timed out after 5 minutes');
155
+ logger.newline();
156
+ logger.info('The deployment may still be running in the background.');
157
+ logger.info('Check the status:');
158
+ logger.log(` ${chalk.yellow('saac status')}`);
159
+ logger.newline();
160
+ logger.info('Or view live logs:');
161
+ logger.log(` ${chalk.yellow('saac logs --follow')}`);
162
+ }
163
+
164
+ module.exports = {
165
+ getErrorAdvice,
166
+ formatLogs,
167
+ displayDeploymentError,
168
+ displayCreateRecoveryInstructions,
169
+ displayTimeoutInstructions,
170
+ };