@portl/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +348 -0
- package/bin/portl.js +1837 -0
- package/package.json +41 -0
- package/src/commands/init.js +663 -0
- package/src/utils/prompts.js +210 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@portl/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Portl CLI - BYOI orchestration for AI coding agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"portl": "./bin/portl.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"dev": "node ./bin/portl.js --help",
|
|
17
|
+
"pack:check": "npm pack --dry-run"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"portl",
|
|
27
|
+
"cli",
|
|
28
|
+
"mcp",
|
|
29
|
+
"byoi",
|
|
30
|
+
"ai-agents",
|
|
31
|
+
"backend-as-a-service"
|
|
32
|
+
],
|
|
33
|
+
"author": "Portl",
|
|
34
|
+
"license": "Apache-2.0",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/portl-dev/portl.git",
|
|
38
|
+
"directory": "portl-cli"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://portl.dev"
|
|
41
|
+
}
|
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simplified init command - conversational wizard for Portl setup
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { promises as fs } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import {
|
|
8
|
+
showBanner,
|
|
9
|
+
showSection,
|
|
10
|
+
showTip,
|
|
11
|
+
showSuccess,
|
|
12
|
+
showWarning,
|
|
13
|
+
showError,
|
|
14
|
+
showInfo,
|
|
15
|
+
showSummary,
|
|
16
|
+
showNextSteps,
|
|
17
|
+
askYesNo,
|
|
18
|
+
askNonEmpty,
|
|
19
|
+
selectOption,
|
|
20
|
+
Spinner,
|
|
21
|
+
c,
|
|
22
|
+
ANSI,
|
|
23
|
+
} from '../utils/prompts.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read existing portl.config.json if it exists
|
|
27
|
+
*/
|
|
28
|
+
async function readExistingConfig(cwd) {
|
|
29
|
+
try {
|
|
30
|
+
const configPath = path.join(cwd, 'portl.config.json');
|
|
31
|
+
const content = await fs.readFile(configPath, 'utf8');
|
|
32
|
+
return JSON.parse(content);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Detect API key from environment or .env file
|
|
40
|
+
*/
|
|
41
|
+
async function detectApiKey(cwd) {
|
|
42
|
+
// Check environment variable first
|
|
43
|
+
if (process.env.ACCESS_API_KEY) {
|
|
44
|
+
return process.env.ACCESS_API_KEY;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Try to read from .env file
|
|
48
|
+
try {
|
|
49
|
+
const envPath = path.join(cwd, '.env');
|
|
50
|
+
const content = await fs.readFile(envPath, 'utf8');
|
|
51
|
+
const match = content.match(/^ACCESS_API_KEY\s*=\s*(.+)$/m);
|
|
52
|
+
if (match && match[1]) {
|
|
53
|
+
return match[1].trim().replace(/^['"]|['"]$/g, '');
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// .env file doesn't exist or can't be read
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Collect database configuration
|
|
64
|
+
*/
|
|
65
|
+
async function collectDatabaseConfig(rl, apiKey, apiBaseUrl, projectId) {
|
|
66
|
+
showSection('DATABASE', '📦');
|
|
67
|
+
|
|
68
|
+
const needsDatabase = await askYesNo(rl, 'Do you need a database?', true);
|
|
69
|
+
showTip('Portl connects to your existing database (PostgreSQL, MySQL, MongoDB, Supabase, Neon)');
|
|
70
|
+
console.log('');
|
|
71
|
+
|
|
72
|
+
if (!needsDatabase) {
|
|
73
|
+
showInfo('Skipping database configuration');
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const provider = await selectOption(rl, 'Choose your database provider', [
|
|
78
|
+
{ label: 'Supabase', value: 'supabase', description: 'PostgreSQL + Auth + Storage' },
|
|
79
|
+
{ label: 'Neon', value: 'neon', description: 'Serverless PostgreSQL' },
|
|
80
|
+
{ label: 'PostgreSQL', value: 'postgres.generic', description: 'Self-hosted or managed' },
|
|
81
|
+
{ label: 'MySQL', value: 'mysql', description: 'Self-hosted or managed' },
|
|
82
|
+
{ label: 'MongoDB', value: 'mongodb', description: 'Document database' },
|
|
83
|
+
{ label: 'PlanetScale', value: 'planetscale', description: 'Serverless MySQL' },
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
console.log('');
|
|
87
|
+
|
|
88
|
+
if (provider.value === 'supabase') {
|
|
89
|
+
const useOAuth = await askYesNo(rl, '🔐 Connect via OAuth?', true);
|
|
90
|
+
console.log('');
|
|
91
|
+
|
|
92
|
+
if (useOAuth) {
|
|
93
|
+
if (apiKey) {
|
|
94
|
+
// Execute OAuth flow immediately
|
|
95
|
+
showInfo('Opening browser for Supabase authentication...');
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
// Import OAuth functions from main portl.js
|
|
99
|
+
const { startSupabaseOAuthRequest, waitForSupabaseOAuthConnection } = await import('../../bin/portl.js');
|
|
100
|
+
|
|
101
|
+
const resolvedApiBaseUrl = apiBaseUrl || 'http://localhost:7130/api';
|
|
102
|
+
const oauthStart = await startSupabaseOAuthRequest({
|
|
103
|
+
apiBaseUrl: resolvedApiBaseUrl,
|
|
104
|
+
apiKey,
|
|
105
|
+
projectId: projectId || 'local',
|
|
106
|
+
redirectUri: process.env.PORTL_OAUTH_CLI_REDIRECT_URI || `${resolvedApiBaseUrl.replace('/api', '')}/api/providers/oauth/cli/result`,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (oauthStart.authorizeUrl) {
|
|
110
|
+
console.log('');
|
|
111
|
+
console.log(c('🔗 OAuth URL:', ANSI.cyan));
|
|
112
|
+
console.log(c(oauthStart.authorizeUrl, ANSI.dim));
|
|
113
|
+
console.log('');
|
|
114
|
+
|
|
115
|
+
// Try to open browser
|
|
116
|
+
const { tryOpenBrowser } = await import('../../bin/portl.js');
|
|
117
|
+
const openResult = await tryOpenBrowser(oauthStart.authorizeUrl);
|
|
118
|
+
|
|
119
|
+
if (openResult.opened) {
|
|
120
|
+
showSuccess('Browser opened automatically');
|
|
121
|
+
} else {
|
|
122
|
+
showWarning('Could not open browser automatically');
|
|
123
|
+
showInfo('Please open the URL above in your browser');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Wait for OAuth callback
|
|
127
|
+
const spinner = new Spinner('Waiting for OAuth callback...');
|
|
128
|
+
spinner.start();
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const connection = await waitForSupabaseOAuthConnection({
|
|
132
|
+
apiBaseUrl: resolvedApiBaseUrl,
|
|
133
|
+
apiKey,
|
|
134
|
+
projectId: projectId || 'local',
|
|
135
|
+
startedAt: Date.now(),
|
|
136
|
+
timeoutMs: 120000, // 2 minutes
|
|
137
|
+
pollMs: 2000,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (connection?.externalProjectRef) {
|
|
141
|
+
spinner.success(`Connected to Supabase project: ${connection.externalProjectRef}`);
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
providerId: 'supabase',
|
|
145
|
+
category: 'database',
|
|
146
|
+
connectable: true,
|
|
147
|
+
config: {
|
|
148
|
+
setupMode: 'oauth',
|
|
149
|
+
projectRef: connection.externalProjectRef,
|
|
150
|
+
},
|
|
151
|
+
envTemplate: {
|
|
152
|
+
PORTL_DB_SUPABASE_SETUP_MODE: 'oauth',
|
|
153
|
+
PORTL_DB_SUPABASE_PROJECT_REF: connection.externalProjectRef,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
} else {
|
|
157
|
+
spinner.fail('OAuth completed but no project found');
|
|
158
|
+
showWarning('You may need to manually configure Supabase');
|
|
159
|
+
}
|
|
160
|
+
} catch (error) {
|
|
161
|
+
spinner.fail('OAuth timeout or failed');
|
|
162
|
+
showWarning('OAuth did not complete in time. Run "portl connect supabase" to retry');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
showError(`OAuth setup failed: ${error.message}`);
|
|
167
|
+
showInfo('Falling back to manual configuration');
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
// No API key - can't do OAuth now
|
|
171
|
+
showInfo('OAuth requires API key - will complete after init');
|
|
172
|
+
showTip('Run "portl connect supabase" after init to complete OAuth');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
providerId: 'supabase',
|
|
177
|
+
category: 'database',
|
|
178
|
+
connectable: true,
|
|
179
|
+
config: { setupMode: 'oauth' },
|
|
180
|
+
envTemplate: {
|
|
181
|
+
PORTL_DB_SUPABASE_SETUP_MODE: 'oauth',
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
} else {
|
|
185
|
+
// Manual setup
|
|
186
|
+
const url = await askNonEmpty(rl, 'Supabase URL', 'https://YOUR-PROJECT.supabase.co');
|
|
187
|
+
const serviceRoleKey = await askNonEmpty(rl, 'Service role key');
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
providerId: 'supabase',
|
|
191
|
+
category: 'database',
|
|
192
|
+
connectable: true,
|
|
193
|
+
config: { setupMode: 'manual', url, serviceRoleKey },
|
|
194
|
+
envTemplate: {
|
|
195
|
+
PORTL_DB_SUPABASE_SETUP_MODE: 'manual',
|
|
196
|
+
PORTL_DB_SUPABASE_URL: url,
|
|
197
|
+
PORTL_DB_SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
} else if (provider.value === 'neon') {
|
|
202
|
+
const connectionString = await askNonEmpty(rl, 'Neon connection string');
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
providerId: 'neon',
|
|
206
|
+
category: 'database',
|
|
207
|
+
connectable: true,
|
|
208
|
+
config: { connectionString },
|
|
209
|
+
envTemplate: {
|
|
210
|
+
PORTL_DB_NEON_CONNECTION_STRING: connectionString,
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
} else if (provider.value === 'mysql') {
|
|
214
|
+
const host = await askNonEmpty(rl, 'MySQL host', 'localhost');
|
|
215
|
+
const port = await askNonEmpty(rl, 'MySQL port', '3306');
|
|
216
|
+
const database = await askNonEmpty(rl, 'Database name');
|
|
217
|
+
const user = await askNonEmpty(rl, 'Database user');
|
|
218
|
+
const password = await askNonEmpty(rl, 'Database password');
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
providerId: 'mysql',
|
|
222
|
+
category: 'database',
|
|
223
|
+
connectable: false,
|
|
224
|
+
config: { host, port, database, user, password },
|
|
225
|
+
envTemplate: {
|
|
226
|
+
PORTL_DB_MYSQL_HOST: host,
|
|
227
|
+
PORTL_DB_MYSQL_PORT: port,
|
|
228
|
+
PORTL_DB_MYSQL_DATABASE: database,
|
|
229
|
+
PORTL_DB_MYSQL_USER: user,
|
|
230
|
+
PORTL_DB_MYSQL_PASSWORD: password,
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
} else if (provider.value === 'mongodb') {
|
|
234
|
+
const connectionString = await askNonEmpty(rl, 'MongoDB connection string', 'mongodb://localhost:27017/mydb');
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
providerId: 'mongodb',
|
|
238
|
+
category: 'database',
|
|
239
|
+
connectable: false,
|
|
240
|
+
config: { connectionString },
|
|
241
|
+
envTemplate: {
|
|
242
|
+
PORTL_DB_MONGODB_CONNECTION_STRING: connectionString,
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
} else if (provider.value === 'planetscale') {
|
|
246
|
+
const host = await askNonEmpty(rl, 'PlanetScale host', 'aws.connect.psdb.cloud');
|
|
247
|
+
const database = await askNonEmpty(rl, 'Database name');
|
|
248
|
+
const username = await askNonEmpty(rl, 'Username');
|
|
249
|
+
const password = await askNonEmpty(rl, 'Password');
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
providerId: 'planetscale',
|
|
253
|
+
category: 'database',
|
|
254
|
+
connectable: false,
|
|
255
|
+
config: { host, database, username, password },
|
|
256
|
+
envTemplate: {
|
|
257
|
+
PORTL_DB_PLANETSCALE_HOST: host,
|
|
258
|
+
PORTL_DB_PLANETSCALE_DATABASE: database,
|
|
259
|
+
PORTL_DB_PLANETSCALE_USERNAME: username,
|
|
260
|
+
PORTL_DB_PLANETSCALE_PASSWORD: password,
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
} else {
|
|
264
|
+
// Generic PostgreSQL
|
|
265
|
+
const host = await askNonEmpty(rl, 'PostgreSQL host', 'localhost');
|
|
266
|
+
const port = await askNonEmpty(rl, 'PostgreSQL port', '5432');
|
|
267
|
+
const database = await askNonEmpty(rl, 'Database name');
|
|
268
|
+
const user = await askNonEmpty(rl, 'Database user');
|
|
269
|
+
const password = await askNonEmpty(rl, 'Database password');
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
providerId: 'postgres.generic',
|
|
273
|
+
category: 'database',
|
|
274
|
+
connectable: true,
|
|
275
|
+
config: { host, port, database, user, password },
|
|
276
|
+
envTemplate: {
|
|
277
|
+
PORTL_DB_PG_HOST: host,
|
|
278
|
+
PORTL_DB_PG_PORT: port,
|
|
279
|
+
PORTL_DB_PG_DATABASE: database,
|
|
280
|
+
PORTL_DB_PG_USER: user,
|
|
281
|
+
PORTL_DB_PG_PASSWORD: password,
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Collect storage configuration
|
|
289
|
+
*/
|
|
290
|
+
async function collectStorageConfig(rl) {
|
|
291
|
+
showSection('STORAGE', '💾');
|
|
292
|
+
|
|
293
|
+
const needsStorage = await askYesNo(rl, 'Do you need file storage?', false);
|
|
294
|
+
showTip('Portl supports S3, Cloudflare R2, and compatible providers');
|
|
295
|
+
console.log('');
|
|
296
|
+
|
|
297
|
+
if (!needsStorage) {
|
|
298
|
+
showInfo('Skipping storage configuration');
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const provider = await selectOption(rl, 'Choose your storage provider', [
|
|
303
|
+
{ label: 'Cloudflare R2', value: 'r2', description: 'S3-compatible, zero egress fees' },
|
|
304
|
+
{ label: 'Generic S3', value: 's3.generic', description: 'AWS S3 or compatible' },
|
|
305
|
+
]);
|
|
306
|
+
|
|
307
|
+
console.log('');
|
|
308
|
+
|
|
309
|
+
if (provider.value === 'r2') {
|
|
310
|
+
const accountId = await askNonEmpty(rl, 'Cloudflare account ID');
|
|
311
|
+
const endpoint = `https://${accountId}.r2.cloudflarestorage.com`;
|
|
312
|
+
const accessKeyId = await askNonEmpty(rl, 'R2 access key ID');
|
|
313
|
+
const secretAccessKey = await askNonEmpty(rl, 'R2 secret access key');
|
|
314
|
+
const bucket = await askNonEmpty(rl, 'Bucket name');
|
|
315
|
+
|
|
316
|
+
showTip(`Auto-configured endpoint: ${endpoint}`);
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
providerId: 'r2',
|
|
320
|
+
category: 'storage',
|
|
321
|
+
connectable: true,
|
|
322
|
+
config: { endpoint, accessKeyId, secretAccessKey, bucket, accountId },
|
|
323
|
+
envTemplate: {
|
|
324
|
+
PORTL_STORAGE_R2_ACCOUNT_ID: accountId,
|
|
325
|
+
PORTL_STORAGE_R2_ENDPOINT: endpoint,
|
|
326
|
+
PORTL_STORAGE_R2_ACCESS_KEY_ID: accessKeyId,
|
|
327
|
+
PORTL_STORAGE_R2_SECRET_ACCESS_KEY: secretAccessKey,
|
|
328
|
+
PORTL_STORAGE_R2_BUCKET: bucket,
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
} else {
|
|
332
|
+
// Generic S3
|
|
333
|
+
const endpoint = await askNonEmpty(rl, 'S3 endpoint', 'https://s3.amazonaws.com');
|
|
334
|
+
const accessKeyId = await askNonEmpty(rl, 'Access key ID');
|
|
335
|
+
const secretAccessKey = await askNonEmpty(rl, 'Secret access key');
|
|
336
|
+
const bucket = await askNonEmpty(rl, 'Bucket name');
|
|
337
|
+
const region = await askNonEmpty(rl, 'Region', 'us-east-1');
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
providerId: 's3.generic',
|
|
341
|
+
category: 'storage',
|
|
342
|
+
connectable: true,
|
|
343
|
+
config: { endpoint, accessKeyId, secretAccessKey, bucket, region },
|
|
344
|
+
envTemplate: {
|
|
345
|
+
PORTL_STORAGE_S3_ENDPOINT: endpoint,
|
|
346
|
+
PORTL_STORAGE_S3_ACCESS_KEY_ID: accessKeyId,
|
|
347
|
+
PORTL_STORAGE_S3_SECRET_ACCESS_KEY: secretAccessKey,
|
|
348
|
+
PORTL_STORAGE_S3_BUCKET: bucket,
|
|
349
|
+
PORTL_STORAGE_S3_REGION: region,
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Collect payments configuration
|
|
357
|
+
*/
|
|
358
|
+
async function collectPaymentsConfig(rl) {
|
|
359
|
+
showSection('PAYMENTS', '💳');
|
|
360
|
+
|
|
361
|
+
const needsPayments = await askYesNo(rl, 'Do you need payments?', false);
|
|
362
|
+
showTip('Portl supports Stripe and Mercado Pago');
|
|
363
|
+
console.log('');
|
|
364
|
+
|
|
365
|
+
if (!needsPayments) {
|
|
366
|
+
showInfo('Skipping payments configuration');
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const provider = await selectOption(rl, 'Choose your payment provider', [
|
|
371
|
+
{ label: 'Stripe', value: 'stripe' },
|
|
372
|
+
{ label: 'Mercado Pago', value: 'mercadopago' },
|
|
373
|
+
]);
|
|
374
|
+
|
|
375
|
+
console.log('');
|
|
376
|
+
|
|
377
|
+
if (provider.value === 'stripe') {
|
|
378
|
+
const secretKey = await askNonEmpty(rl, 'Stripe secret key (sk_...)');
|
|
379
|
+
const publishableKey = await askNonEmpty(rl, 'Stripe publishable key (pk_...)');
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
providerId: 'stripe',
|
|
383
|
+
category: 'payments',
|
|
384
|
+
connectable: false,
|
|
385
|
+
config: { secretKey, publishableKey },
|
|
386
|
+
envTemplate: {
|
|
387
|
+
PORTL_PAYMENTS_STRIPE_SECRET_KEY: secretKey,
|
|
388
|
+
PORTL_PAYMENTS_STRIPE_PUBLISHABLE_KEY: publishableKey,
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
} else {
|
|
392
|
+
const accessToken = await askNonEmpty(rl, 'Mercado Pago access token');
|
|
393
|
+
const publicKey = await askNonEmpty(rl, 'Mercado Pago public key');
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
providerId: 'mercadopago',
|
|
397
|
+
category: 'payments',
|
|
398
|
+
connectable: false,
|
|
399
|
+
config: { accessToken, publicKey },
|
|
400
|
+
envTemplate: {
|
|
401
|
+
PORTL_PAYMENTS_MP_ACCESS_TOKEN: accessToken,
|
|
402
|
+
PORTL_PAYMENTS_MP_PUBLIC_KEY: publicKey,
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Collect CI/CD configuration
|
|
410
|
+
*/
|
|
411
|
+
async function collectCicdConfig(rl) {
|
|
412
|
+
showSection('CI/CD', '🔄');
|
|
413
|
+
|
|
414
|
+
const needsCicd = await askYesNo(rl, 'Do you need CI/CD integration?', false);
|
|
415
|
+
showTip('Portl supports GitHub Actions');
|
|
416
|
+
console.log('');
|
|
417
|
+
|
|
418
|
+
if (!needsCicd) {
|
|
419
|
+
showInfo('Skipping CI/CD configuration');
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const repository = await askNonEmpty(rl, 'GitHub repository (owner/repo)', 'yourusername/yourrepo');
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
providerId: 'github.actions',
|
|
427
|
+
category: 'cicd',
|
|
428
|
+
connectable: false,
|
|
429
|
+
config: { repository },
|
|
430
|
+
envTemplate: {
|
|
431
|
+
PORTL_CICD_GITHUB_REPOSITORY: repository,
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Write portl.config.json
|
|
438
|
+
*/
|
|
439
|
+
async function writeConfigFile(cwd, projectId, integrations) {
|
|
440
|
+
const config = {
|
|
441
|
+
projectId,
|
|
442
|
+
integrations: integrations.map(integration => ({
|
|
443
|
+
providerId: integration.providerId,
|
|
444
|
+
category: integration.category,
|
|
445
|
+
config: integration.config,
|
|
446
|
+
})),
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const configPath = path.join(cwd, 'portl.config.json');
|
|
450
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
451
|
+
|
|
452
|
+
return configPath;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Write .env.portl template
|
|
457
|
+
*/
|
|
458
|
+
async function writeEnvTemplate(cwd, integrations) {
|
|
459
|
+
const envLines = ['# Portl Environment Variables', '# Generated by: portl init', ''];
|
|
460
|
+
|
|
461
|
+
for (const integration of integrations) {
|
|
462
|
+
if (integration.envTemplate && Object.keys(integration.envTemplate).length > 0) {
|
|
463
|
+
envLines.push(`# ${integration.providerId} (${integration.category})`);
|
|
464
|
+
|
|
465
|
+
for (const [key, value] of Object.entries(integration.envTemplate)) {
|
|
466
|
+
envLines.push(`${key}=${value}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
envLines.push('');
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const envPath = path.join(cwd, '.env.portl');
|
|
474
|
+
await fs.writeFile(envPath, envLines.join('\n'), 'utf8');
|
|
475
|
+
|
|
476
|
+
return envPath;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Setup MCP configuration for AI agents
|
|
481
|
+
*/
|
|
482
|
+
async function setupMcpConfig(cwd, rl) {
|
|
483
|
+
showSection('MCP SETUP', '🤖');
|
|
484
|
+
|
|
485
|
+
const setupMcp = await askYesNo(rl, 'Configure MCP for AI agents?', true);
|
|
486
|
+
showTip('MCP lets Claude/Cursor/Copilot access your infrastructure directly');
|
|
487
|
+
console.log('');
|
|
488
|
+
|
|
489
|
+
if (!setupMcp) {
|
|
490
|
+
showInfo('Skipping MCP configuration');
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Read API key from .env.portl if exists
|
|
495
|
+
let apiKey = '';
|
|
496
|
+
let apiBaseUrl = 'http://localhost:7130';
|
|
497
|
+
try {
|
|
498
|
+
const envContent = await fs.readFile(path.join(cwd, '.env.portl'), 'utf8');
|
|
499
|
+
const keyMatch = envContent.match(/ACCESS_API_KEY=(.+)/);
|
|
500
|
+
if (keyMatch) apiKey = keyMatch[1].trim();
|
|
501
|
+
const urlMatch = envContent.match(/API_BASE_URL=(.+)/);
|
|
502
|
+
if (urlMatch) apiBaseUrl = urlMatch[1].trim();
|
|
503
|
+
} catch {
|
|
504
|
+
// No .env.portl yet
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// MCP server config
|
|
508
|
+
const mcpServerConfig = {
|
|
509
|
+
command: 'npx',
|
|
510
|
+
args: ['-y', '@portl/mcp@latest'],
|
|
511
|
+
env: {
|
|
512
|
+
API_BASE_URL: apiBaseUrl,
|
|
513
|
+
...(apiKey && { API_KEY: apiKey }),
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
// Detect Claude Desktop config path
|
|
518
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE;
|
|
519
|
+
const claudeConfigPaths = [
|
|
520
|
+
path.join(homeDir, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), // macOS
|
|
521
|
+
path.join(homeDir, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json'), // Windows
|
|
522
|
+
path.join(homeDir, '.config', 'claude', 'claude_desktop_config.json'), // Linux
|
|
523
|
+
];
|
|
524
|
+
|
|
525
|
+
let claudeConfigPath = null;
|
|
526
|
+
for (const p of claudeConfigPaths) {
|
|
527
|
+
try {
|
|
528
|
+
await fs.access(path.dirname(p));
|
|
529
|
+
claudeConfigPath = p;
|
|
530
|
+
break;
|
|
531
|
+
} catch {
|
|
532
|
+
// Path doesn't exist
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (claudeConfigPath) {
|
|
537
|
+
try {
|
|
538
|
+
// Read existing config or create new
|
|
539
|
+
let config = { mcpServers: {} };
|
|
540
|
+
try {
|
|
541
|
+
const existing = await fs.readFile(claudeConfigPath, 'utf8');
|
|
542
|
+
config = JSON.parse(existing);
|
|
543
|
+
if (!config.mcpServers) config.mcpServers = {};
|
|
544
|
+
} catch {
|
|
545
|
+
// File doesn't exist, use default
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Add Portl MCP server
|
|
549
|
+
config.mcpServers.portl = mcpServerConfig;
|
|
550
|
+
|
|
551
|
+
// Ensure directory exists
|
|
552
|
+
await fs.mkdir(path.dirname(claudeConfigPath), { recursive: true });
|
|
553
|
+
await fs.writeFile(claudeConfigPath, JSON.stringify(config, null, 2), 'utf8');
|
|
554
|
+
|
|
555
|
+
showSuccess('Claude Desktop MCP configured');
|
|
556
|
+
showTip('Restart Claude Desktop to activate');
|
|
557
|
+
} catch (error) {
|
|
558
|
+
showWarning(`Could not configure Claude Desktop: ${error.message}`);
|
|
559
|
+
}
|
|
560
|
+
} else {
|
|
561
|
+
showInfo('Claude Desktop not detected');
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Create local .mcp.json for other tools (Cursor, etc)
|
|
565
|
+
const localMcpConfig = {
|
|
566
|
+
mcpServers: {
|
|
567
|
+
portl: mcpServerConfig,
|
|
568
|
+
},
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
await fs.writeFile(
|
|
572
|
+
path.join(cwd, '.mcp.json'),
|
|
573
|
+
JSON.stringify(localMcpConfig, null, 2),
|
|
574
|
+
'utf8'
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
showSuccess('Local MCP config created (.mcp.json)');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Main init command
|
|
582
|
+
*/
|
|
583
|
+
export async function runInit(rl) {
|
|
584
|
+
const cwd = process.cwd();
|
|
585
|
+
|
|
586
|
+
showBanner();
|
|
587
|
+
|
|
588
|
+
// Get project name
|
|
589
|
+
const projectId = await askNonEmpty(rl, 'Project name', 'my-app');
|
|
590
|
+
console.log('');
|
|
591
|
+
|
|
592
|
+
// Detect API key automatically
|
|
593
|
+
const apiKey = await detectApiKey(cwd);
|
|
594
|
+
const apiBaseUrl = 'http://localhost:7130/api'; // Default for now
|
|
595
|
+
|
|
596
|
+
if (apiKey) {
|
|
597
|
+
showSuccess('API key detected from environment');
|
|
598
|
+
showTip('OAuth connections will be completed automatically');
|
|
599
|
+
} else {
|
|
600
|
+
showInfo('No API key found - generating config files only');
|
|
601
|
+
showTip('Add ACCESS_API_KEY to your .env to enable OAuth');
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Collect integrations
|
|
605
|
+
const integrations = [];
|
|
606
|
+
|
|
607
|
+
// Database
|
|
608
|
+
const dbConfig = await collectDatabaseConfig(rl, apiKey, apiBaseUrl, projectId);
|
|
609
|
+
if (dbConfig) integrations.push(dbConfig);
|
|
610
|
+
|
|
611
|
+
// Storage
|
|
612
|
+
const storageConfig = await collectStorageConfig(rl);
|
|
613
|
+
if (storageConfig) integrations.push(storageConfig);
|
|
614
|
+
|
|
615
|
+
// Payments
|
|
616
|
+
const paymentsConfig = await collectPaymentsConfig(rl);
|
|
617
|
+
if (paymentsConfig) integrations.push(paymentsConfig);
|
|
618
|
+
|
|
619
|
+
// CI/CD
|
|
620
|
+
const cicdConfig = await collectCicdConfig(rl);
|
|
621
|
+
if (cicdConfig) integrations.push(cicdConfig);
|
|
622
|
+
|
|
623
|
+
// Summary
|
|
624
|
+
const summaryItems = integrations.map(i => {
|
|
625
|
+
const status = i.connectable && apiKey ? c('(connected)', ANSI.green) : c('(configured)', ANSI.dim);
|
|
626
|
+
return `${i.category}: ${c(i.providerId, ANSI.bold)} ${status}`;
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
showSummary('SUMMARY', summaryItems);
|
|
630
|
+
|
|
631
|
+
// Write files
|
|
632
|
+
const spinner = new Spinner('Generating configuration files...');
|
|
633
|
+
spinner.start();
|
|
634
|
+
|
|
635
|
+
try {
|
|
636
|
+
await writeConfigFile(cwd, projectId, integrations);
|
|
637
|
+
await writeEnvTemplate(cwd, integrations);
|
|
638
|
+
spinner.success('Configuration files generated');
|
|
639
|
+
} catch (error) {
|
|
640
|
+
spinner.fail('Failed to write configuration files');
|
|
641
|
+
throw error;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Setup MCP
|
|
645
|
+
await setupMcpConfig(cwd, rl);
|
|
646
|
+
|
|
647
|
+
// Next steps
|
|
648
|
+
console.log('');
|
|
649
|
+
console.log(c('Files created:', ANSI.bold));
|
|
650
|
+
console.log(` ${c('📄', ANSI.green)} portl.config.json`);
|
|
651
|
+
console.log(` ${c('📄', ANSI.green)} .env.portl`);
|
|
652
|
+
console.log('');
|
|
653
|
+
|
|
654
|
+
const nextSteps = [
|
|
655
|
+
'Restart Claude Desktop / Cursor to load MCP',
|
|
656
|
+
'Start coding with AI - your infra is ready!',
|
|
657
|
+
];
|
|
658
|
+
|
|
659
|
+
showNextSteps(nextSteps);
|
|
660
|
+
|
|
661
|
+
console.log(c('Done! 🚀', ANSI.green));
|
|
662
|
+
console.log('');
|
|
663
|
+
}
|