@startanaicompany/cli 1.3.1 → 1.4.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.
- package/CLAUDE.md +52 -0
- package/README.md +90 -1
- package/bin/saac.js +23 -1
- package/git_auth.md +961 -0
- package/package.json +4 -3
- package/src/commands/create.js +53 -6
- package/src/commands/git.js +254 -0
- package/src/commands/list.js +109 -4
- package/src/commands/register.js +4 -4
- package/src/lib/api.js +3 -3
- package/src/lib/oauth.js +205 -0
package/git_auth.md
ADDED
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
# Git Authentication Implementation Guide for saac-cli
|
|
2
|
+
|
|
3
|
+
**Date**: 2026-01-26
|
|
4
|
+
**Status**: Wrapper OAuth implementation COMPLETE, CLI integration PENDING
|
|
5
|
+
**Related Project**: `~/projects/coolifywrapper` (OAuth backend)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Executive Summary
|
|
10
|
+
|
|
11
|
+
The Coolify Wrapper API now has **full OAuth support** for Git authentication, matching industry standards (Railway, Vercel, Netlify). Users can connect their Git accounts once and deploy unlimited applications without providing tokens repeatedly.
|
|
12
|
+
|
|
13
|
+
**What's Changed:**
|
|
14
|
+
- ✅ Wrapper API supports OAuth for Gitea, GitHub, and GitLab
|
|
15
|
+
- ✅ OAuth tokens stored encrypted (AES-256-GCM) with auto-refresh
|
|
16
|
+
- ✅ Fallback to manual `--git-token` if OAuth not connected
|
|
17
|
+
- ⏳ **CLI needs updates to support OAuth flow**
|
|
18
|
+
|
|
19
|
+
**User Experience Goal:**
|
|
20
|
+
```bash
|
|
21
|
+
# First time - user connects Git account
|
|
22
|
+
saac create my-app --git git@git.startanaicompany.com:user/repo.git
|
|
23
|
+
❌ Git account not connected for git.startanaicompany.com
|
|
24
|
+
Would you like to connect now? (Y/n): y
|
|
25
|
+
Opening browser for authentication...
|
|
26
|
+
✅ Connected to git.startanaicompany.com as mikael.westoo
|
|
27
|
+
✅ Application created: my-app
|
|
28
|
+
|
|
29
|
+
# Future deployments - no token needed!
|
|
30
|
+
saac create another-app --git git@git.startanaicompany.com:user/repo2.git
|
|
31
|
+
✅ Using connected account: mikael.westoo@git.startanaicompany.com
|
|
32
|
+
✅ Application created: another-app
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## What the Wrapper API Provides
|
|
38
|
+
|
|
39
|
+
### OAuth Endpoints (Already Implemented)
|
|
40
|
+
|
|
41
|
+
**1. Initiate OAuth Flow**
|
|
42
|
+
```
|
|
43
|
+
GET /oauth/authorize?git_host=<host>&session_id=<id>
|
|
44
|
+
Headers: X-API-Key: cw_xxx
|
|
45
|
+
|
|
46
|
+
Response: HTTP 302 Redirect to Git provider OAuth page
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**2. Callback Handler** (Automatic)
|
|
50
|
+
```
|
|
51
|
+
GET /oauth/callback?code=<code>&state=<state>
|
|
52
|
+
|
|
53
|
+
Response: HTML success page (user closes browser)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**3. Poll for Completion** (CLI uses this)
|
|
57
|
+
```
|
|
58
|
+
GET /oauth/poll/:session_id
|
|
59
|
+
Headers: X-API-Key: cw_xxx
|
|
60
|
+
|
|
61
|
+
Response:
|
|
62
|
+
{
|
|
63
|
+
"sessionId": "abc123",
|
|
64
|
+
"gitHost": "git.startanaicompany.com",
|
|
65
|
+
"status": "pending" | "completed" | "failed",
|
|
66
|
+
"gitUsername": "mikael.westoo",
|
|
67
|
+
"completedAt": "2026-01-26T12:00:00Z"
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**4. List Connections**
|
|
72
|
+
```
|
|
73
|
+
GET /api/v1/users/me/oauth
|
|
74
|
+
Headers: X-API-Key: cw_xxx
|
|
75
|
+
|
|
76
|
+
Response:
|
|
77
|
+
{
|
|
78
|
+
"connections": [
|
|
79
|
+
{
|
|
80
|
+
"gitHost": "git.startanaicompany.com",
|
|
81
|
+
"gitUsername": "mikael.westoo",
|
|
82
|
+
"providerType": "gitea",
|
|
83
|
+
"scopes": ["read:repository", "write:repository"],
|
|
84
|
+
"expiresAt": "2026-01-26T13:00:00Z",
|
|
85
|
+
"createdAt": "2026-01-26T12:00:00Z",
|
|
86
|
+
"lastUsedAt": "2026-01-26T12:30:00Z"
|
|
87
|
+
}
|
|
88
|
+
],
|
|
89
|
+
"count": 1
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**5. Revoke Connection**
|
|
94
|
+
```
|
|
95
|
+
DELETE /api/v1/users/me/oauth/:git_host
|
|
96
|
+
Headers: X-API-Key: cw_xxx
|
|
97
|
+
|
|
98
|
+
Response: { "success": true }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Application Creation (Auto-Uses OAuth)
|
|
102
|
+
|
|
103
|
+
**Wrapper's `createApplication()` logic:**
|
|
104
|
+
1. Extract `git_host` from `git_repository` URL
|
|
105
|
+
2. Check if user has OAuth connection for that `git_host`
|
|
106
|
+
3. If YES → Use OAuth token (automatic)
|
|
107
|
+
4. If NO → Fall back to manual `git_api_token` (if provided)
|
|
108
|
+
5. If NEITHER → Return error with OAuth connection URL
|
|
109
|
+
|
|
110
|
+
**Error Response Format:**
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"error": "Git account not connected for git.startanaicompany.com. Please connect your Git account at https://apps.startanaicompany.com/oauth/authorize?git_host=git.startanaicompany.com or provide --git-token when creating the application."
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## CLI Implementation Tasks
|
|
120
|
+
|
|
121
|
+
### Task 1: Add OAuth Helper Module
|
|
122
|
+
|
|
123
|
+
**File**: `lib/oauth.js`
|
|
124
|
+
|
|
125
|
+
```javascript
|
|
126
|
+
// lib/oauth.js
|
|
127
|
+
const axios = require('axios');
|
|
128
|
+
const open = require('open');
|
|
129
|
+
const crypto = require('crypto');
|
|
130
|
+
const chalk = require('chalk');
|
|
131
|
+
|
|
132
|
+
const WRAPPER_API_URL = process.env.WRAPPER_API_URL || 'https://apps.startanaicompany.com';
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Extract git_host from repository URL
|
|
136
|
+
* @param {string} gitUrl - Git repository URL (SSH or HTTPS)
|
|
137
|
+
* @returns {string} - Git host domain
|
|
138
|
+
*/
|
|
139
|
+
function extractGitHost(gitUrl) {
|
|
140
|
+
// SSH format: git@git.startanaicompany.com:user/repo.git
|
|
141
|
+
const sshMatch = gitUrl.match(/git@([^:]+):/);
|
|
142
|
+
if (sshMatch) {
|
|
143
|
+
return sshMatch[1];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// HTTPS format: https://git.startanaicompany.com/user/repo.git
|
|
147
|
+
const httpsMatch = gitUrl.match(/https?:\/\/([^/]+)/);
|
|
148
|
+
if (httpsMatch) {
|
|
149
|
+
return httpsMatch[1];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
throw new Error('Invalid Git repository URL format');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Initiate OAuth flow for a git_host
|
|
157
|
+
* @param {string} gitHost - Git host domain
|
|
158
|
+
* @param {string} apiKey - User's API key
|
|
159
|
+
* @returns {Promise<object>} - { gitUsername, gitHost }
|
|
160
|
+
*/
|
|
161
|
+
async function connectGitAccount(gitHost, apiKey) {
|
|
162
|
+
const sessionId = crypto.randomBytes(16).toString('hex');
|
|
163
|
+
|
|
164
|
+
console.log(chalk.blue(`\n🔐 Connecting to ${gitHost}...`));
|
|
165
|
+
console.log(chalk.gray(`Session ID: ${sessionId}\n`));
|
|
166
|
+
|
|
167
|
+
// Build authorization URL
|
|
168
|
+
const authUrl = `${WRAPPER_API_URL}/oauth/authorize?git_host=${encodeURIComponent(gitHost)}&session_id=${sessionId}`;
|
|
169
|
+
|
|
170
|
+
console.log(chalk.yellow('Opening browser for authentication...'));
|
|
171
|
+
console.log(chalk.gray(`If browser doesn't open, visit: ${authUrl}\n`));
|
|
172
|
+
|
|
173
|
+
// Open browser
|
|
174
|
+
await open(authUrl);
|
|
175
|
+
|
|
176
|
+
console.log(chalk.blue('Waiting for authorization...'));
|
|
177
|
+
|
|
178
|
+
// Poll for completion
|
|
179
|
+
return await pollForCompletion(sessionId, apiKey);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Poll OAuth session until completed
|
|
184
|
+
* @param {string} sessionId - Session ID
|
|
185
|
+
* @param {string} apiKey - User's API key
|
|
186
|
+
* @returns {Promise<object>} - { gitUsername, gitHost }
|
|
187
|
+
*/
|
|
188
|
+
async function pollForCompletion(sessionId, apiKey) {
|
|
189
|
+
const pollInterval = 2000; // 2 seconds
|
|
190
|
+
const maxAttempts = 150; // 5 minutes total (150 * 2s)
|
|
191
|
+
|
|
192
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
193
|
+
await sleep(pollInterval);
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const response = await axios.get(
|
|
197
|
+
`${WRAPPER_API_URL}/oauth/poll/${sessionId}`,
|
|
198
|
+
{
|
|
199
|
+
headers: {
|
|
200
|
+
'X-API-Key': apiKey,
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const { status, gitUsername, gitHost } = response.data;
|
|
206
|
+
|
|
207
|
+
if (status === 'completed') {
|
|
208
|
+
console.log(chalk.green(`\n✅ Connected to ${gitHost} as ${gitUsername}\n`));
|
|
209
|
+
return { gitUsername, gitHost };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (status === 'failed') {
|
|
213
|
+
throw new Error('OAuth authorization failed');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Still pending, continue polling
|
|
217
|
+
process.stdout.write(chalk.gray('.'));
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (error.response?.status === 404) {
|
|
220
|
+
throw new Error('OAuth session not found or expired');
|
|
221
|
+
}
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
throw new Error('OAuth authorization timed out (5 minutes)');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Check if user has OAuth connection for git_host
|
|
231
|
+
* @param {string} gitHost - Git host domain
|
|
232
|
+
* @param {string} apiKey - User's API key
|
|
233
|
+
* @returns {Promise<object|null>} - Connection object or null
|
|
234
|
+
*/
|
|
235
|
+
async function getConnection(gitHost, apiKey) {
|
|
236
|
+
try {
|
|
237
|
+
const response = await axios.get(
|
|
238
|
+
`${WRAPPER_API_URL}/api/v1/users/me/oauth`,
|
|
239
|
+
{
|
|
240
|
+
headers: {
|
|
241
|
+
'X-API-Key': apiKey,
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const connection = response.data.connections.find(
|
|
247
|
+
(conn) => conn.gitHost === gitHost
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
return connection || null;
|
|
251
|
+
} catch (error) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* List all OAuth connections
|
|
258
|
+
* @param {string} apiKey - User's API key
|
|
259
|
+
* @returns {Promise<array>} - Array of connection objects
|
|
260
|
+
*/
|
|
261
|
+
async function listConnections(apiKey) {
|
|
262
|
+
const response = await axios.get(
|
|
263
|
+
`${WRAPPER_API_URL}/api/v1/users/me/oauth`,
|
|
264
|
+
{
|
|
265
|
+
headers: {
|
|
266
|
+
'X-API-Key': apiKey,
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
return response.data.connections;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Revoke OAuth connection for git_host
|
|
276
|
+
* @param {string} gitHost - Git host domain
|
|
277
|
+
* @param {string} apiKey - User's API key
|
|
278
|
+
*/
|
|
279
|
+
async function revokeConnection(gitHost, apiKey) {
|
|
280
|
+
await axios.delete(
|
|
281
|
+
`${WRAPPER_API_URL}/api/v1/users/me/oauth/${encodeURIComponent(gitHost)}`,
|
|
282
|
+
{
|
|
283
|
+
headers: {
|
|
284
|
+
'X-API-Key': apiKey,
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function sleep(ms) {
|
|
291
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
module.exports = {
|
|
295
|
+
extractGitHost,
|
|
296
|
+
connectGitAccount,
|
|
297
|
+
getConnection,
|
|
298
|
+
listConnections,
|
|
299
|
+
revokeConnection,
|
|
300
|
+
};
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
**Dependencies to Add:**
|
|
304
|
+
```bash
|
|
305
|
+
npm install open axios crypto
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
### Task 2: Update `saac create` Command
|
|
311
|
+
|
|
312
|
+
**File**: `bin/saac-create.js`
|
|
313
|
+
|
|
314
|
+
**Changes Needed:**
|
|
315
|
+
|
|
316
|
+
```javascript
|
|
317
|
+
// bin/saac-create.js
|
|
318
|
+
const { extractGitHost, getConnection, connectGitAccount } = require('../lib/oauth');
|
|
319
|
+
|
|
320
|
+
// ... existing imports and code ...
|
|
321
|
+
|
|
322
|
+
program
|
|
323
|
+
.name('create')
|
|
324
|
+
.description('Create a new application')
|
|
325
|
+
.requiredOption('--name <name>', 'Application name')
|
|
326
|
+
.requiredOption('--git <repository>', 'Git repository URL (SSH or HTTPS)')
|
|
327
|
+
.option('--git-branch <branch>', 'Git branch', 'master')
|
|
328
|
+
.option('--git-token <token>', 'Git API token (optional if OAuth connected)')
|
|
329
|
+
.option('--subdomain <subdomain>', 'Subdomain for the application')
|
|
330
|
+
// ... other options ...
|
|
331
|
+
.action(async (options) => {
|
|
332
|
+
try {
|
|
333
|
+
const apiKey = process.env.WRAPPER_API_KEY;
|
|
334
|
+
if (!apiKey) {
|
|
335
|
+
console.error(chalk.red('❌ WRAPPER_API_KEY not set'));
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Extract git_host from repository URL
|
|
340
|
+
const gitHost = extractGitHost(options.git);
|
|
341
|
+
console.log(chalk.gray(`Git host: ${gitHost}`));
|
|
342
|
+
|
|
343
|
+
// Check if OAuth connected for this git_host
|
|
344
|
+
const connection = await getConnection(gitHost, apiKey);
|
|
345
|
+
|
|
346
|
+
if (connection) {
|
|
347
|
+
console.log(
|
|
348
|
+
chalk.green(
|
|
349
|
+
`✅ Using connected account: ${connection.gitUsername}@${connection.gitHost}`
|
|
350
|
+
)
|
|
351
|
+
);
|
|
352
|
+
// No need for --git-token, OAuth will be used automatically
|
|
353
|
+
} else if (!options.gitToken) {
|
|
354
|
+
// No OAuth connection AND no manual token provided
|
|
355
|
+
console.log(
|
|
356
|
+
chalk.yellow(
|
|
357
|
+
`\n⚠️ Git account not connected for ${gitHost}`
|
|
358
|
+
)
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
const readline = require('readline');
|
|
362
|
+
const rl = readline.createInterface({
|
|
363
|
+
input: process.stdin,
|
|
364
|
+
output: process.stdout,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
const answer = await new Promise((resolve) => {
|
|
368
|
+
rl.question(
|
|
369
|
+
chalk.blue('Would you like to connect now? (Y/n): '),
|
|
370
|
+
resolve
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
rl.close();
|
|
374
|
+
|
|
375
|
+
if (answer.toLowerCase() === 'n') {
|
|
376
|
+
console.log(
|
|
377
|
+
chalk.red(
|
|
378
|
+
`\n❌ Cannot create application without Git authentication.\n`
|
|
379
|
+
)
|
|
380
|
+
);
|
|
381
|
+
console.log(chalk.gray('Options:'));
|
|
382
|
+
console.log(chalk.gray(' 1. Connect Git account: saac git connect'));
|
|
383
|
+
console.log(
|
|
384
|
+
chalk.gray(' 2. Provide token: saac create ... --git-token <token>\n')
|
|
385
|
+
);
|
|
386
|
+
process.exit(1);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Initiate OAuth flow
|
|
390
|
+
await connectGitAccount(gitHost, apiKey);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Proceed with application creation
|
|
394
|
+
console.log(chalk.blue('\n📦 Creating application...\n'));
|
|
395
|
+
|
|
396
|
+
const createData = {
|
|
397
|
+
name: options.name,
|
|
398
|
+
subdomain: options.subdomain || options.name,
|
|
399
|
+
git_repository: options.git,
|
|
400
|
+
git_branch: options.gitBranch,
|
|
401
|
+
git_api_token: options.gitToken, // Optional - wrapper will use OAuth if not provided
|
|
402
|
+
template_type: options.template || 'custom',
|
|
403
|
+
environment_variables: options.env ? parseEnv(options.env) : {},
|
|
404
|
+
// ... other fields ...
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const response = await axios.post(
|
|
408
|
+
`${WRAPPER_API_URL}/api/v1/applications`,
|
|
409
|
+
createData,
|
|
410
|
+
{
|
|
411
|
+
headers: {
|
|
412
|
+
'X-API-Key': apiKey,
|
|
413
|
+
'Content-Type': 'application/json',
|
|
414
|
+
},
|
|
415
|
+
}
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
console.log(chalk.green('✅ Application created successfully!\n'));
|
|
419
|
+
console.log(chalk.bold('Application Details:'));
|
|
420
|
+
console.log(chalk.gray(` Name: ${response.data.name}`));
|
|
421
|
+
console.log(chalk.gray(` UUID: ${response.data.uuid}`));
|
|
422
|
+
console.log(chalk.gray(` Domain: ${response.data.domain}`));
|
|
423
|
+
console.log(chalk.gray(` Status: ${response.data.status}\n`));
|
|
424
|
+
|
|
425
|
+
} catch (error) {
|
|
426
|
+
if (error.response?.data?.error) {
|
|
427
|
+
console.error(chalk.red(`\n❌ ${error.response.data.error}\n`));
|
|
428
|
+
|
|
429
|
+
// Check if error is about missing OAuth connection
|
|
430
|
+
if (error.response.data.error.includes('Git account not connected')) {
|
|
431
|
+
console.log(chalk.yellow('💡 Tip: Connect your Git account to skip providing tokens:'));
|
|
432
|
+
console.log(chalk.gray(` saac git connect ${gitHost}\n`));
|
|
433
|
+
}
|
|
434
|
+
} else {
|
|
435
|
+
console.error(chalk.red(`\n❌ Error: ${error.message}\n`));
|
|
436
|
+
}
|
|
437
|
+
process.exit(1);
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
program.parse();
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
---
|
|
445
|
+
|
|
446
|
+
### Task 3: Add `saac git` Commands
|
|
447
|
+
|
|
448
|
+
**File**: `bin/saac-git.js` (NEW FILE)
|
|
449
|
+
|
|
450
|
+
```javascript
|
|
451
|
+
#!/usr/bin/env node
|
|
452
|
+
// bin/saac-git.js
|
|
453
|
+
const { Command } = require('commander');
|
|
454
|
+
const chalk = require('chalk');
|
|
455
|
+
const {
|
|
456
|
+
connectGitAccount,
|
|
457
|
+
listConnections,
|
|
458
|
+
revokeConnection,
|
|
459
|
+
extractGitHost,
|
|
460
|
+
} = require('../lib/oauth');
|
|
461
|
+
|
|
462
|
+
const program = new Command();
|
|
463
|
+
|
|
464
|
+
program
|
|
465
|
+
.name('git')
|
|
466
|
+
.description('Manage Git account connections (OAuth)');
|
|
467
|
+
|
|
468
|
+
// saac git connect <repository-or-host>
|
|
469
|
+
program
|
|
470
|
+
.command('connect')
|
|
471
|
+
.description('Connect a Git account via OAuth')
|
|
472
|
+
.argument('[host]', 'Git host domain or repository URL')
|
|
473
|
+
.action(async (hostOrUrl) => {
|
|
474
|
+
try {
|
|
475
|
+
const apiKey = process.env.WRAPPER_API_KEY;
|
|
476
|
+
if (!apiKey) {
|
|
477
|
+
console.error(chalk.red('❌ WRAPPER_API_KEY not set'));
|
|
478
|
+
process.exit(1);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
let gitHost;
|
|
482
|
+
|
|
483
|
+
if (!hostOrUrl) {
|
|
484
|
+
// No argument - ask user which provider
|
|
485
|
+
const readline = require('readline');
|
|
486
|
+
const rl = readline.createInterface({
|
|
487
|
+
input: process.stdin,
|
|
488
|
+
output: process.stdout,
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
console.log(chalk.blue('Select Git provider:'));
|
|
492
|
+
console.log(' 1. git.startanaicompany.com (Gitea)');
|
|
493
|
+
console.log(' 2. github.com');
|
|
494
|
+
console.log(' 3. gitlab.com');
|
|
495
|
+
console.log(' 4. Custom host\n');
|
|
496
|
+
|
|
497
|
+
const choice = await new Promise((resolve) => {
|
|
498
|
+
rl.question(chalk.blue('Enter choice (1-4): '), resolve);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
switch (choice) {
|
|
502
|
+
case '1':
|
|
503
|
+
gitHost = 'git.startanaicompany.com';
|
|
504
|
+
break;
|
|
505
|
+
case '2':
|
|
506
|
+
gitHost = 'github.com';
|
|
507
|
+
break;
|
|
508
|
+
case '3':
|
|
509
|
+
gitHost = 'gitlab.com';
|
|
510
|
+
break;
|
|
511
|
+
case '4':
|
|
512
|
+
const custom = await new Promise((resolve) => {
|
|
513
|
+
rl.question(chalk.blue('Enter Git host domain: '), resolve);
|
|
514
|
+
});
|
|
515
|
+
gitHost = custom;
|
|
516
|
+
break;
|
|
517
|
+
default:
|
|
518
|
+
console.error(chalk.red('Invalid choice'));
|
|
519
|
+
process.exit(1);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
rl.close();
|
|
523
|
+
} else if (hostOrUrl.includes('git@') || hostOrUrl.includes('http')) {
|
|
524
|
+
// Repository URL provided
|
|
525
|
+
gitHost = extractGitHost(hostOrUrl);
|
|
526
|
+
} else {
|
|
527
|
+
// Host domain provided
|
|
528
|
+
gitHost = hostOrUrl;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
await connectGitAccount(gitHost, apiKey);
|
|
532
|
+
} catch (error) {
|
|
533
|
+
console.error(chalk.red(`\n❌ Error: ${error.message}\n`));
|
|
534
|
+
process.exit(1);
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// saac git list
|
|
539
|
+
program
|
|
540
|
+
.command('list')
|
|
541
|
+
.description('List connected Git accounts')
|
|
542
|
+
.action(async () => {
|
|
543
|
+
try {
|
|
544
|
+
const apiKey = process.env.WRAPPER_API_KEY;
|
|
545
|
+
if (!apiKey) {
|
|
546
|
+
console.error(chalk.red('❌ WRAPPER_API_KEY not set'));
|
|
547
|
+
process.exit(1);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const connections = await listConnections(apiKey);
|
|
551
|
+
|
|
552
|
+
if (connections.length === 0) {
|
|
553
|
+
console.log(chalk.yellow('\n⚠️ No Git accounts connected\n'));
|
|
554
|
+
console.log(chalk.gray('Connect an account: saac git connect\n'));
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
console.log(chalk.bold('\n🔐 Connected Git Accounts:\n'));
|
|
559
|
+
|
|
560
|
+
connections.forEach((conn, index) => {
|
|
561
|
+
console.log(chalk.blue(`${index + 1}. ${conn.gitHost}`));
|
|
562
|
+
console.log(chalk.gray(` Username: ${conn.gitUsername}`));
|
|
563
|
+
console.log(chalk.gray(` Provider: ${conn.providerType}`));
|
|
564
|
+
console.log(
|
|
565
|
+
chalk.gray(
|
|
566
|
+
` Expires: ${new Date(conn.expiresAt).toLocaleString()}`
|
|
567
|
+
)
|
|
568
|
+
);
|
|
569
|
+
console.log(
|
|
570
|
+
chalk.gray(
|
|
571
|
+
` Last used: ${new Date(conn.lastUsedAt).toLocaleString()}`
|
|
572
|
+
)
|
|
573
|
+
);
|
|
574
|
+
console.log('');
|
|
575
|
+
});
|
|
576
|
+
} catch (error) {
|
|
577
|
+
console.error(chalk.red(`\n❌ Error: ${error.message}\n`));
|
|
578
|
+
process.exit(1);
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// saac git disconnect <host>
|
|
583
|
+
program
|
|
584
|
+
.command('disconnect')
|
|
585
|
+
.description('Disconnect a Git account')
|
|
586
|
+
.argument('<host>', 'Git host domain to disconnect')
|
|
587
|
+
.action(async (host) => {
|
|
588
|
+
try {
|
|
589
|
+
const apiKey = process.env.WRAPPER_API_KEY;
|
|
590
|
+
if (!apiKey) {
|
|
591
|
+
console.error(chalk.red('❌ WRAPPER_API_KEY not set'));
|
|
592
|
+
process.exit(1);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
console.log(chalk.yellow(`\n⚠️ Disconnecting from ${host}...\n`));
|
|
596
|
+
|
|
597
|
+
await revokeConnection(host, apiKey);
|
|
598
|
+
|
|
599
|
+
console.log(chalk.green(`✅ Disconnected from ${host}\n`));
|
|
600
|
+
} catch (error) {
|
|
601
|
+
if (error.response?.status === 404) {
|
|
602
|
+
console.error(
|
|
603
|
+
chalk.red(`\n❌ No connection found for ${host}\n`)
|
|
604
|
+
);
|
|
605
|
+
} else {
|
|
606
|
+
console.error(chalk.red(`\n❌ Error: ${error.message}\n`));
|
|
607
|
+
}
|
|
608
|
+
process.exit(1);
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
program.parse();
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
**Register in `bin/saac.js`:**
|
|
616
|
+
|
|
617
|
+
```javascript
|
|
618
|
+
// bin/saac.js
|
|
619
|
+
program
|
|
620
|
+
.command('git', 'Manage Git account connections')
|
|
621
|
+
.description('Connect, list, or disconnect Git accounts (OAuth)');
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
---
|
|
625
|
+
|
|
626
|
+
### Task 4: Update `saac status` Command
|
|
627
|
+
|
|
628
|
+
**File**: `bin/saac-status.js`
|
|
629
|
+
|
|
630
|
+
**Add OAuth connection info to status output:**
|
|
631
|
+
|
|
632
|
+
```javascript
|
|
633
|
+
// bin/saac-status.js
|
|
634
|
+
const { listConnections } = require('../lib/oauth');
|
|
635
|
+
|
|
636
|
+
// ... existing status code ...
|
|
637
|
+
|
|
638
|
+
// Add OAuth connections section
|
|
639
|
+
try {
|
|
640
|
+
const connections = await listConnections(apiKey);
|
|
641
|
+
|
|
642
|
+
if (connections.length > 0) {
|
|
643
|
+
console.log(chalk.bold('\n🔐 Connected Git Accounts:'));
|
|
644
|
+
connections.forEach((conn) => {
|
|
645
|
+
console.log(chalk.green(` ✅ ${conn.gitHost} (${conn.gitUsername})`));
|
|
646
|
+
});
|
|
647
|
+
} else {
|
|
648
|
+
console.log(chalk.gray('\n🔐 No Git accounts connected'));
|
|
649
|
+
console.log(chalk.gray(' Connect: saac git connect'));
|
|
650
|
+
}
|
|
651
|
+
} catch (error) {
|
|
652
|
+
// Non-fatal, just skip OAuth section
|
|
653
|
+
console.log(chalk.gray('\n🔐 OAuth status unavailable'));
|
|
654
|
+
}
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
---
|
|
658
|
+
|
|
659
|
+
### Task 5: Update Documentation
|
|
660
|
+
|
|
661
|
+
**File**: `README.md`
|
|
662
|
+
|
|
663
|
+
Add OAuth section:
|
|
664
|
+
|
|
665
|
+
```markdown
|
|
666
|
+
## Git Authentication
|
|
667
|
+
|
|
668
|
+
saac-cli supports two methods for Git authentication:
|
|
669
|
+
|
|
670
|
+
### Method 1: OAuth (Recommended)
|
|
671
|
+
|
|
672
|
+
Connect your Git account once, deploy unlimited applications:
|
|
673
|
+
|
|
674
|
+
```bash
|
|
675
|
+
# Connect Git account
|
|
676
|
+
saac git connect git.startanaicompany.com
|
|
677
|
+
# Opens browser for authentication
|
|
678
|
+
|
|
679
|
+
# List connected accounts
|
|
680
|
+
saac git list
|
|
681
|
+
|
|
682
|
+
# Disconnect account
|
|
683
|
+
saac git disconnect git.startanaicompany.com
|
|
684
|
+
|
|
685
|
+
# Create app - no token needed!
|
|
686
|
+
saac create my-app --git git@git.startanaicompany.com:user/repo.git
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
### Method 2: Manual Token
|
|
690
|
+
|
|
691
|
+
Provide Git API token for each application:
|
|
692
|
+
|
|
693
|
+
```bash
|
|
694
|
+
saac create my-app \
|
|
695
|
+
--git git@git.startanaicompany.com:user/repo.git \
|
|
696
|
+
--git-token your_gitea_token_here
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
**OAuth automatically tries first, falls back to manual token if not connected.**
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
---
|
|
703
|
+
|
|
704
|
+
## Implementation Checklist
|
|
705
|
+
|
|
706
|
+
### Phase 1: Core OAuth Support (Week 1)
|
|
707
|
+
- [ ] Create `lib/oauth.js` with OAuth helper functions
|
|
708
|
+
- [ ] Install dependencies: `npm install open axios crypto`
|
|
709
|
+
- [ ] Test OAuth flow manually with wrapper API
|
|
710
|
+
- [ ] Verify polling works correctly
|
|
711
|
+
|
|
712
|
+
### Phase 2: Update Commands (Week 2)
|
|
713
|
+
- [ ] Create `bin/saac-git.js` with `connect`, `list`, `disconnect` subcommands
|
|
714
|
+
- [ ] Update `bin/saac-create.js` to auto-prompt for OAuth
|
|
715
|
+
- [ ] Update `bin/saac-status.js` to show OAuth connections
|
|
716
|
+
- [ ] Register `git` command in main `bin/saac.js`
|
|
717
|
+
|
|
718
|
+
### Phase 3: Testing (Week 3)
|
|
719
|
+
- [ ] Test OAuth flow with Gitea (git.startanaicompany.com)
|
|
720
|
+
- [ ] Test OAuth flow with GitHub (github.com)
|
|
721
|
+
- [ ] Test application creation with OAuth token
|
|
722
|
+
- [ ] Test fallback to manual `--git-token`
|
|
723
|
+
- [ ] Test error handling (expired sessions, timeouts)
|
|
724
|
+
- [ ] Test `saac git list` and `saac git disconnect`
|
|
725
|
+
|
|
726
|
+
### Phase 4: Documentation & Release (Week 4)
|
|
727
|
+
- [ ] Update README.md with OAuth documentation
|
|
728
|
+
- [ ] Create migration guide for existing users
|
|
729
|
+
- [ ] Update examples in all command files
|
|
730
|
+
- [ ] Create demo video/GIF showing OAuth flow
|
|
731
|
+
- [ ] Announce OAuth support to users
|
|
732
|
+
|
|
733
|
+
---
|
|
734
|
+
|
|
735
|
+
## Testing Guide
|
|
736
|
+
|
|
737
|
+
### Test 1: OAuth Connection Flow
|
|
738
|
+
|
|
739
|
+
```bash
|
|
740
|
+
# Set API key
|
|
741
|
+
export WRAPPER_API_KEY="cw_your_key_here"
|
|
742
|
+
|
|
743
|
+
# Test connect command
|
|
744
|
+
saac git connect git.startanaicompany.com
|
|
745
|
+
|
|
746
|
+
# Expected:
|
|
747
|
+
# - Browser opens to OAuth page
|
|
748
|
+
# - User authorizes
|
|
749
|
+
# - CLI shows "✅ Connected to git.startanaicompany.com as username"
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
### Test 2: List Connections
|
|
753
|
+
|
|
754
|
+
```bash
|
|
755
|
+
saac git list
|
|
756
|
+
|
|
757
|
+
# Expected output:
|
|
758
|
+
# 🔐 Connected Git Accounts:
|
|
759
|
+
#
|
|
760
|
+
# 1. git.startanaicompany.com
|
|
761
|
+
# Username: mikael.westoo
|
|
762
|
+
# Provider: gitea
|
|
763
|
+
# Expires: 2026-01-26 13:00:00
|
|
764
|
+
# Last used: 2026-01-26 12:30:00
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
### Test 3: Create App with OAuth
|
|
768
|
+
|
|
769
|
+
```bash
|
|
770
|
+
saac create test-oauth-app \
|
|
771
|
+
--git git@git.startanaicompany.com:user/repo.git \
|
|
772
|
+
--subdomain testoauth
|
|
773
|
+
|
|
774
|
+
# Expected:
|
|
775
|
+
# ✅ Using connected account: mikael.westoo@git.startanaicompany.com
|
|
776
|
+
# 📦 Creating application...
|
|
777
|
+
# ✅ Application created successfully!
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
### Test 4: Create App Without OAuth (Should Prompt)
|
|
781
|
+
|
|
782
|
+
```bash
|
|
783
|
+
# Disconnect first
|
|
784
|
+
saac git disconnect git.startanaicompany.com
|
|
785
|
+
|
|
786
|
+
# Try to create without --git-token
|
|
787
|
+
saac create test-app --git git@git.startanaicompany.com:user/repo.git
|
|
788
|
+
|
|
789
|
+
# Expected:
|
|
790
|
+
# ⚠️ Git account not connected for git.startanaicompany.com
|
|
791
|
+
# Would you like to connect now? (Y/n):
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
### Test 5: Fallback to Manual Token
|
|
795
|
+
|
|
796
|
+
```bash
|
|
797
|
+
saac create test-manual \
|
|
798
|
+
--git git@git.startanaicompany.com:user/repo.git \
|
|
799
|
+
--git-token gto_your_token_here
|
|
800
|
+
|
|
801
|
+
# Expected:
|
|
802
|
+
# ✅ Application created successfully!
|
|
803
|
+
# (Using manual token, not OAuth)
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
---
|
|
807
|
+
|
|
808
|
+
## Error Handling
|
|
809
|
+
|
|
810
|
+
### Common Errors & Solutions
|
|
811
|
+
|
|
812
|
+
**1. "OAuth session not found or expired"**
|
|
813
|
+
- User took too long to authorize (>10 minutes)
|
|
814
|
+
- Solution: Try `saac git connect` again
|
|
815
|
+
|
|
816
|
+
**2. "OAuth authorization timed out (5 minutes)"**
|
|
817
|
+
- Polling timed out waiting for user
|
|
818
|
+
- Solution: Increase `maxAttempts` in `pollForCompletion()`
|
|
819
|
+
|
|
820
|
+
**3. "Git account not connected"**
|
|
821
|
+
- User hasn't connected Git account yet
|
|
822
|
+
- Solution: Prompt to run `saac git connect <host>`
|
|
823
|
+
|
|
824
|
+
**4. Browser doesn't open automatically**
|
|
825
|
+
- `open` package failed on some systems
|
|
826
|
+
- Solution: Display URL and ask user to open manually
|
|
827
|
+
|
|
828
|
+
**5. "Invalid Git repository URL format"**
|
|
829
|
+
- Repository URL not in SSH or HTTPS format
|
|
830
|
+
- Solution: Show expected formats in error message
|
|
831
|
+
|
|
832
|
+
---
|
|
833
|
+
|
|
834
|
+
## Architecture Notes
|
|
835
|
+
|
|
836
|
+
### Why git_host Instead of Provider Name?
|
|
837
|
+
|
|
838
|
+
**User-Friendly:**
|
|
839
|
+
- Users know "git.startanaicompany.com" (from their repository URL)
|
|
840
|
+
- Users DON'T know "gitea" vs "github" vs "gitlab"
|
|
841
|
+
|
|
842
|
+
**Example:**
|
|
843
|
+
```bash
|
|
844
|
+
# ❌ Confusing (users don't know provider names)
|
|
845
|
+
saac git connect gitea
|
|
846
|
+
|
|
847
|
+
# ✅ Clear (users recognize the domain)
|
|
848
|
+
saac git connect git.startanaicompany.com
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
### Auto-Detection Flow
|
|
852
|
+
|
|
853
|
+
```
|
|
854
|
+
Repository URL: git@git.startanaicompany.com:user/repo.git
|
|
855
|
+
↓
|
|
856
|
+
Extract git_host: git.startanaicompany.com
|
|
857
|
+
↓
|
|
858
|
+
Check OAuth connection for git.startanaicompany.com
|
|
859
|
+
↓
|
|
860
|
+
Connected?
|
|
861
|
+
├─ YES → Use OAuth token (automatic)
|
|
862
|
+
└─ NO → Fall back to --git-token (if provided)
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
### Security Considerations
|
|
866
|
+
|
|
867
|
+
1. **API Key Storage**: Never commit API keys, always use environment variables
|
|
868
|
+
2. **OAuth State**: Wrapper handles CSRF protection with state parameter
|
|
869
|
+
3. **Token Storage**: Wrapper encrypts tokens (AES-256-GCM), CLI never stores tokens
|
|
870
|
+
4. **Session IDs**: Use crypto.randomBytes() for unpredictable session IDs
|
|
871
|
+
5. **Polling Timeout**: Limit polling to prevent infinite loops (5 minutes max)
|
|
872
|
+
|
|
873
|
+
---
|
|
874
|
+
|
|
875
|
+
## Dependencies
|
|
876
|
+
|
|
877
|
+
### Required npm Packages
|
|
878
|
+
|
|
879
|
+
```json
|
|
880
|
+
{
|
|
881
|
+
"dependencies": {
|
|
882
|
+
"axios": "^1.6.0",
|
|
883
|
+
"chalk": "^4.1.2",
|
|
884
|
+
"commander": "^11.0.0",
|
|
885
|
+
"open": "^10.0.0"
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
**Install:**
|
|
891
|
+
```bash
|
|
892
|
+
cd ~/projects/saac-cli
|
|
893
|
+
npm install open axios
|
|
894
|
+
```
|
|
895
|
+
|
|
896
|
+
---
|
|
897
|
+
|
|
898
|
+
## Wrapper API Contract
|
|
899
|
+
|
|
900
|
+
### What Wrapper Guarantees
|
|
901
|
+
|
|
902
|
+
1. **OAuth Flow**: Redirects to correct provider based on git_host
|
|
903
|
+
2. **Token Management**: Auto-refreshes expired tokens (1-hour expiration)
|
|
904
|
+
3. **Fallback**: Always supports manual `git_api_token` as fallback
|
|
905
|
+
4. **Error Messages**: Provides actionable error messages with OAuth URLs
|
|
906
|
+
5. **Multi-Provider**: Supports Gitea, GitHub, GitLab without CLI changes
|
|
907
|
+
|
|
908
|
+
### What CLI Must Do
|
|
909
|
+
|
|
910
|
+
1. **Extract git_host**: Parse repository URL to get git_host domain
|
|
911
|
+
2. **Prompt User**: Ask user to connect if OAuth not available and no token provided
|
|
912
|
+
3. **Poll Session**: Poll `/oauth/poll/:session_id` every 2 seconds until completed
|
|
913
|
+
4. **Handle Timeouts**: Abort after 5 minutes with clear error message
|
|
914
|
+
5. **Show Feedback**: Display connection status and connected username
|
|
915
|
+
|
|
916
|
+
---
|
|
917
|
+
|
|
918
|
+
## Timeline & Milestones
|
|
919
|
+
|
|
920
|
+
### Week 1: Foundation
|
|
921
|
+
- Implement `lib/oauth.js`
|
|
922
|
+
- Test OAuth flow manually with curl
|
|
923
|
+
- Verify polling mechanism works
|
|
924
|
+
|
|
925
|
+
### Week 2: Integration
|
|
926
|
+
- Update `saac create` command
|
|
927
|
+
- Implement `saac git` commands
|
|
928
|
+
- Update `saac status` command
|
|
929
|
+
|
|
930
|
+
### Week 3: Testing
|
|
931
|
+
- End-to-end testing with Gitea
|
|
932
|
+
- End-to-end testing with GitHub
|
|
933
|
+
- Error scenario testing
|
|
934
|
+
- Performance testing (polling overhead)
|
|
935
|
+
|
|
936
|
+
### Week 4: Release
|
|
937
|
+
- Documentation updates
|
|
938
|
+
- Migration guide for existing users
|
|
939
|
+
- Release notes
|
|
940
|
+
- User announcement
|
|
941
|
+
|
|
942
|
+
---
|
|
943
|
+
|
|
944
|
+
## Support & Questions
|
|
945
|
+
|
|
946
|
+
**Wrapper API Status**: ✅ PRODUCTION READY (commit a0d171a)
|
|
947
|
+
**Documentation**: All docs in `~/projects/coolifywrapper/*.md`
|
|
948
|
+
**OAuth Endpoints**: https://apps.startanaicompany.com/oauth/*
|
|
949
|
+
|
|
950
|
+
**Key Reference Files:**
|
|
951
|
+
- `~/projects/coolifywrapper/OAUTH_USER_FLOW_CORRECTED.md` - Complete user flow
|
|
952
|
+
- `~/projects/coolifywrapper/OAUTH_IMPLEMENTATION_ARCHITECTURE.md` - Technical details
|
|
953
|
+
- `~/projects/coolifywrapper/src/routes/oauth.js` - Endpoint implementation
|
|
954
|
+
|
|
955
|
+
**Contact**: Check wrapper deployment status at https://apps.startanaicompany.com/api/v1/health
|
|
956
|
+
|
|
957
|
+
---
|
|
958
|
+
|
|
959
|
+
**READY TO IMPLEMENT** 🚀
|
|
960
|
+
|
|
961
|
+
All backend infrastructure is complete. CLI team can start implementation immediately.
|