@kirschbaum-development/sst-laravel 0.1.2 → 0.1.3

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/dist/bin/cli.js CHANGED
@@ -1,712 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
- import { ECSClient, ListTasksCommand, DescribeTasksCommand, ListClustersCommand, DescribeTaskDefinitionCommand } from '@aws-sdk/client-ecs';
4
- import { IAMClient, CreateOpenIDConnectProviderCommand, ListOpenIDConnectProvidersCommand, GetOpenIDConnectProviderCommand, AddClientIDToOpenIDConnectProviderCommand, CreateRoleCommand, AttachRolePolicyCommand, GetRoleCommand } from '@aws-sdk/client-iam';
5
- import { select } from '@inquirer/prompts';
6
- import { spawn, execSync } from 'child_process';
7
3
  import * as fs from 'fs';
8
4
  import * as path from 'path';
9
5
  import { fileURLToPath } from 'url';
6
+ import { initCommand } from './commands/init.js';
7
+ import { deployCommand } from './commands/deploy.js';
8
+ import { sshCommand } from './commands/ssh.js';
9
+ import { logsCommand } from './commands/logs.js';
10
+ import { githubIamCommand } from './commands/github-iam.js';
11
+ import { installCommand } from './commands/install.js';
10
12
  const __filename = fileURLToPath(import.meta.url);
11
13
  const __dirname = path.dirname(__filename);
12
- const packageJsonPath = path.join(__dirname, '..', '..', 'package.json');
14
+ const packageJsonPath = path.join(__dirname, '..', 'package.json');
13
15
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
14
16
  const version = packageJson.version;
15
- function findSstConfig() {
16
- const cwd = process.cwd();
17
- const possiblePaths = [
18
- path.join(cwd, 'sst.config.ts'),
19
- path.join(cwd, 'sst.config.js'),
20
- ];
21
- for (const configPath of possiblePaths) {
22
- if (fs.existsSync(configPath)) {
23
- return configPath;
24
- }
25
- }
26
- return null;
27
- }
28
- function extractSstProjectName(configPath) {
29
- const content = fs.readFileSync(configPath, 'utf-8');
30
- const match = content.match(/name\s*:\s*['"`]([^'"`]+)['"`]/);
31
- return match ? match[1] : null;
32
- }
33
- function extractLaravelComponents(configPath) {
34
- const content = fs.readFileSync(configPath, 'utf-8');
35
- const regex = /new\s+LaravelService\s*\(\s*['"`]([^'"`]+)['"`]/g;
36
- const components = [];
37
- let match;
38
- while ((match = regex.exec(content)) !== null) {
39
- components.push(match[1]);
40
- }
41
- return components;
42
- }
43
- function extractEnvironmentFile(configPath, stage) {
44
- const content = fs.readFileSync(configPath, 'utf-8');
45
- // Find the start of environment block
46
- const envMatch = content.match(/\benvironment\s*:\s*\{/);
47
- if (!envMatch || envMatch.index === undefined) {
48
- return null;
49
- }
50
- // Extract the environment block by counting braces
51
- const startIndex = envMatch.index + envMatch[0].length;
52
- let braceCount = 1;
53
- let endIndex = startIndex;
54
- for (let i = startIndex; i < content.length && braceCount > 0; i++) {
55
- if (content[i] === '{')
56
- braceCount++;
57
- if (content[i] === '}')
58
- braceCount--;
59
- endIndex = i;
60
- }
61
- const envBlock = content.substring(startIndex, endIndex);
62
- // Now find the file property within the environment block
63
- const fileMatch = envBlock.match(/\bfile\s*:\s*[`'"]([^`'"]+)[`'"]/);
64
- if (!fileMatch) {
65
- return null;
66
- }
67
- let envFile = fileMatch[1];
68
- // Replace ${$app.stage} with actual stage value
69
- envFile = envFile.replace(/\$\{?\$app\.stage\}?/g, stage);
70
- return envFile;
71
- }
72
- function detectGitHubRepo() {
73
- try {
74
- const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf-8' }).trim();
75
- const sshMatch = remoteUrl.match(/git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
76
- if (sshMatch) {
77
- return `${sshMatch[1]}/${sshMatch[2].replace(/\.git$/, '')}`;
78
- }
79
- const httpsMatch = remoteUrl.match(/https:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/);
80
- if (httpsMatch) {
81
- return `${httpsMatch[1]}/${httpsMatch[2].replace(/\.git$/, '')}`;
82
- }
83
- return null;
84
- }
85
- catch {
86
- return null;
87
- }
88
- }
89
- function validateDeployment(stage) {
90
- const configPath = findSstConfig();
91
- if (!configPath) {
92
- throw new Error('Could not find sst.config.ts or sst.config.js in current directory.');
93
- }
94
- const envFile = extractEnvironmentFile(configPath, stage);
95
- if (envFile) {
96
- const cwd = process.cwd();
97
- const envFilePath = path.join(cwd, envFile);
98
- if (!fs.existsSync(envFilePath)) {
99
- throw new Error(`Environment file "${envFile}" not found. Please create the file or update your sst.config.ts configuration.`);
100
- }
101
- }
102
- }
103
17
  const program = new Command();
104
18
  program
105
19
  .name('sst-laravel')
106
20
  .description('CLI tools for SST Laravel deployments')
107
21
  .version(version);
108
- program
109
- .command('init')
110
- .description('Initialize SST and SST Laravel, creating a new sst.config.ts file to deploy your Laravel application')
111
- .action(async () => {
112
- try {
113
- const cwd = process.cwd();
114
- const targetPath = path.join(cwd, 'sst.config.ts');
115
- if (fs.existsSync(targetPath)) {
116
- console.error('Warning: sst.config.ts already exists in the current directory.');
117
- console.error('Will not overwrite existing file.');
118
- process.exit(1);
119
- }
120
- const packageJsonPath = path.join(cwd, 'package.json');
121
- let packageJson = { dependencies: {}, devDependencies: {} };
122
- let hasPackageJson = false;
123
- if (fs.existsSync(packageJsonPath)) {
124
- hasPackageJson = true;
125
- packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
126
- }
127
- const hasSst = packageJson.dependencies?.sst || packageJson.devDependencies?.sst;
128
- if (!hasSst) {
129
- console.log('šŸ“¦ SST not found in project. Installing SST...');
130
- const installProcess = spawn('npm', ['install', '--save-dev', 'sst@latest'], {
131
- cwd,
132
- stdio: 'inherit',
133
- shell: true
134
- });
135
- await new Promise((resolve, reject) => {
136
- installProcess.on('exit', (code) => {
137
- if (code === 0) {
138
- console.log('āœ… SST installed successfully');
139
- resolve();
140
- }
141
- else {
142
- reject(new Error('Failed to install SST'));
143
- }
144
- });
145
- installProcess.on('error', reject);
146
- });
147
- }
148
- else {
149
- console.log('āœ… SST is already installed');
150
- }
151
- const initTemplatePath = path.join(__dirname, '..', '..', 'templates', 'sst.config.init.template');
152
- if (!fs.existsSync(initTemplatePath)) {
153
- console.error('Error: Init template file not found.');
154
- process.exit(1);
155
- }
156
- let initTemplateContent = fs.readFileSync(initTemplatePath, 'utf-8');
157
- const envPath = path.join(cwd, '.env');
158
- let appName = 'my-laravel-app';
159
- if (fs.existsSync(envPath)) {
160
- const envContent = fs.readFileSync(envPath, 'utf-8');
161
- const appNameMatch = envContent.match(/^APP_NAME=(.+)$/m);
162
- if (appNameMatch && appNameMatch[1]) {
163
- const rawAppName = appNameMatch[1].trim().replace(/^["']|["']$/g, '');
164
- appName = rawAppName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
165
- console.log(`Using APP_NAME from .env: ${rawAppName}`);
166
- }
167
- }
168
- initTemplateContent = initTemplateContent.replace('my-laravel-app', appName);
169
- fs.writeFileSync(targetPath, initTemplateContent, 'utf-8');
170
- console.log('āœ… Created initial sst.config.ts');
171
- console.log('šŸš€ Running sst install to set up providers...');
172
- const sstInstallProcess = spawn('npx', ['sst', 'install'], {
173
- cwd,
174
- stdio: 'inherit',
175
- shell: true
176
- });
177
- await new Promise((resolve, reject) => {
178
- sstInstallProcess.on('exit', (code) => {
179
- if (code === 0) {
180
- console.log('āœ… SST providers installed successfully');
181
- resolve();
182
- }
183
- else {
184
- reject(new Error('Failed to run sst install'));
185
- }
186
- });
187
- sstInstallProcess.on('error', reject);
188
- });
189
- const runTemplatePath = path.join(__dirname, '..', '..', 'templates', 'sst.config.run.template');
190
- if (!fs.existsSync(runTemplatePath)) {
191
- console.error('Error: Run template file not found.');
192
- process.exit(1);
193
- }
194
- const runTemplateContent = fs.readFileSync(runTemplatePath, 'utf-8');
195
- let finalConfig = fs.readFileSync(targetPath, 'utf-8');
196
- finalConfig = finalConfig.replace(' async run() {\n },', ` async run() {\n${runTemplateContent}\n },`);
197
- fs.writeFileSync(targetPath, finalConfig, 'utf-8');
198
- const deployTemplatePath = path.join(__dirname, '..', '..', 'templates', 'deploy.template');
199
- if (fs.existsSync(deployTemplatePath)) {
200
- const infraDir = path.join(cwd, 'infra');
201
- if (!fs.existsSync(infraDir)) {
202
- fs.mkdirSync(infraDir, { recursive: true });
203
- }
204
- const deployScriptPath = path.join(infraDir, 'deploy.sh');
205
- const deployTemplateContent = fs.readFileSync(deployTemplatePath, 'utf-8');
206
- fs.writeFileSync(deployScriptPath, deployTemplateContent, 'utf-8');
207
- fs.chmodSync(deployScriptPath, 0o755);
208
- console.log('āœ… Created infra/deploy.sh script');
209
- }
210
- console.log('\n');
211
- console.log('\n');
212
- console.log('āœ… Successfully configured sst.config.ts with Laravel boilerplate');
213
- console.log('šŸ’” You can now customize the configuration for your own Laravel application.');
214
- console.log('\n');
215
- console.log('šŸ”šŸ”šŸ” Your default configuration is set to look for a .env.{stage} file when deploying. You can customize this in the sst.config.ts file as needed.');
216
- console.log('\n');
217
- console.log('šŸ“šŸ“šŸ“ A deploy.sh script has been created with example deployment tasks (migrations, caching, etc.). Customize it as needed.');
218
- console.log('\n');
219
- console.log('šŸš€šŸš€šŸš€ Run `npx sst deploy --stage {stage}` to deploy your application.');
220
- }
221
- catch (error) {
222
- console.error('Error:', error.message);
223
- process.exit(1);
224
- }
225
- });
226
- program
227
- .command('deploy')
228
- .description('Deploy the application using SST')
229
- .requiredOption('-s, --stage <stage>', 'SST stage name')
230
- .action(async (options) => {
231
- try {
232
- validateDeployment(options.stage);
233
- const deployProcess = spawn('npx', ['sst', 'deploy', '--stage', options.stage], {
234
- cwd: process.cwd(),
235
- stdio: 'inherit',
236
- shell: true
237
- });
238
- await new Promise((resolve, reject) => {
239
- deployProcess.on('exit', (code) => {
240
- if (code === 0) {
241
- resolve();
242
- }
243
- else {
244
- reject(new Error(`Deploy failed with exit code ${code}`));
245
- }
246
- });
247
- deployProcess.on('error', reject);
248
- });
249
- }
250
- catch (error) {
251
- console.error('Error:', error.message);
252
- process.exit(1);
253
- }
254
- });
255
- program
256
- .command('ssh')
257
- .description('SSH into a running ECS task')
258
- .argument('[service]', 'Service to connect to (web, worker, or worker name) - optional')
259
- .option('-s, --stage <stage>', 'SST stage name (required)')
260
- .option('-c, --cluster <cluster>', 'ECS cluster name (optional, auto-detected from SST config)')
261
- .option('-r, --region <region>', 'AWS region', process.env.AWS_REGION || 'us-east-1')
262
- .action(async (service, options) => {
263
- try {
264
- const region = options.region;
265
- const stage = options.stage;
266
- if (!stage) {
267
- console.error('Error: Stage is required. Use --stage flag to specify the SST stage.');
268
- process.exit(1);
269
- }
270
- const ecsClient = new ECSClient({ region });
271
- let clusterArn = options.cluster;
272
- if (!clusterArn) {
273
- const configPath = findSstConfig();
274
- if (!configPath) {
275
- console.error('Error: Could not find sst.config.ts or sst.config.js in current directory.');
276
- console.error('Please use --cluster flag to specify cluster ARN manually.');
277
- process.exit(1);
278
- }
279
- const components = extractLaravelComponents(configPath);
280
- if (components.length === 0) {
281
- console.error('Error: No Laravel components found in SST config.');
282
- console.error('Please use --cluster flag to specify cluster ARN manually.');
283
- process.exit(1);
284
- }
285
- if (components.length > 1) {
286
- console.error('Error: Multiple Laravel components found in SST config.');
287
- console.error(`Found: ${components.join(', ')}`);
288
- console.error('Please use --cluster flag to specify which cluster to connect to.');
289
- process.exit(1);
290
- }
291
- const componentName = components[0].replace(/-/g, '');
292
- const clusterPattern = `${stage}-${componentName}Cluster`;
293
- console.log(`Looking for cluster matching pattern: *${clusterPattern}`);
294
- const listClustersCommand = new ListClustersCommand({});
295
- const listClustersResponse = await ecsClient.send(listClustersCommand);
296
- if (!listClustersResponse.clusterArns || listClustersResponse.clusterArns.length === 0) {
297
- console.error('Error: No ECS clusters found in this region.');
298
- process.exit(1);
299
- }
300
- const matchingCluster = listClustersResponse.clusterArns.find(arn => {
301
- const clusterName = arn.split('/').pop();
302
- return clusterName?.includes(stage) && clusterName?.includes(componentName);
303
- });
304
- if (!matchingCluster) {
305
- console.error(`Error: No cluster found matching stage "${stage}" and component "${components[0]}".`);
306
- console.error('Available clusters:');
307
- listClustersResponse.clusterArns.forEach(arn => {
308
- console.error(` - ${arn.split('/').pop()}`);
309
- });
310
- process.exit(1);
311
- }
312
- clusterArn = matchingCluster;
313
- console.log(`Auto-detected cluster: ${clusterArn.split('/').pop()}`);
314
- }
315
- console.log(`Cluster ARN: ${clusterArn}`);
316
- const listTasksCommand = new ListTasksCommand({
317
- cluster: clusterArn,
318
- desiredStatus: 'RUNNING'
319
- });
320
- const listTasksResponse = await ecsClient.send(listTasksCommand);
321
- if (!listTasksResponse.taskArns || listTasksResponse.taskArns.length === 0) {
322
- console.error('No running tasks found in cluster');
323
- process.exit(1);
324
- }
325
- const describeTasksCommand = new DescribeTasksCommand({
326
- cluster: clusterArn,
327
- tasks: listTasksResponse.taskArns
328
- });
329
- const describeTasksResponse = await ecsClient.send(describeTasksCommand);
330
- let matchingTask;
331
- if (service) {
332
- let servicePrefix;
333
- if (service === 'web') {
334
- servicePrefix = '-web';
335
- }
336
- else if (service === 'worker') {
337
- servicePrefix = '-worker';
338
- }
339
- else {
340
- servicePrefix = `-${service}`;
341
- }
342
- matchingTask = describeTasksResponse.tasks?.find(task => {
343
- const containerName = task.containers?.[0]?.name || '';
344
- return containerName.toLowerCase().includes(servicePrefix.toLowerCase());
345
- });
346
- }
347
- if (!matchingTask) {
348
- if (service) {
349
- console.log(`\nNo running task found matching service: ${service}`);
350
- }
351
- console.log('Available tasks in cluster:\n');
352
- const choices = describeTasksResponse.tasks?.map(task => {
353
- const taskId = task.taskArn?.split('/').pop() || '';
354
- const containerName = task.containers?.[0]?.name || 'unknown';
355
- const status = task.lastStatus || 'unknown';
356
- return {
357
- name: `${containerName} (${taskId.substring(0, 8)}...) - ${status}`,
358
- value: task,
359
- description: `Task: ${taskId}`
360
- };
361
- }) || [];
362
- if (choices.length === 0) {
363
- console.error('No tasks available to select from.');
364
- process.exit(1);
365
- }
366
- matchingTask = await select({
367
- message: 'Select a task to connect to:',
368
- choices
369
- });
370
- }
371
- const taskId = matchingTask.taskArn?.split('/').pop();
372
- console.log(`Connecting to task: ${taskId}`);
373
- const awsCommand = spawn('aws', [
374
- 'ecs',
375
- 'execute-command',
376
- '--cluster', clusterArn,
377
- '--task', taskId,
378
- '--container', matchingTask.containers?.[0]?.name || '',
379
- '--interactive',
380
- '--command', '/bin/bash'
381
- ], {
382
- stdio: 'inherit',
383
- env: { ...process.env, AWS_REGION: region }
384
- });
385
- awsCommand.on('exit', (code) => {
386
- process.exit(code || 0);
387
- });
388
- }
389
- catch (error) {
390
- console.error('Error:', error.message);
391
- process.exit(1);
392
- }
393
- });
394
- program
395
- .command('logs')
396
- .description('Stream CloudWatch logs from a running ECS task')
397
- .argument('[service]', 'Service to stream logs from (web, worker, or worker name) - optional')
398
- .option('-s, --stage <stage>', 'SST stage name (required)')
399
- .option('-c, --cluster <cluster>', 'ECS cluster name (optional, auto-detected from SST config)')
400
- .option('-r, --region <region>', 'AWS region', process.env.AWS_REGION || 'us-east-1')
401
- .option('-f, --follow', 'Follow log output (like tail -f)', true)
402
- .option('--since <time>', 'Start time for logs (e.g., 5m, 1h, 2d)', '10m')
403
- .action(async (service, options) => {
404
- try {
405
- const region = options.region;
406
- const stage = options.stage;
407
- if (!stage) {
408
- console.error('Error: Stage is required. Use --stage flag to specify the SST stage.');
409
- process.exit(1);
410
- }
411
- const ecsClient = new ECSClient({ region });
412
- let clusterArn = options.cluster;
413
- if (!clusterArn) {
414
- const configPath = findSstConfig();
415
- if (!configPath) {
416
- console.error('Error: Could not find sst.config.ts or sst.config.js in current directory.');
417
- console.error('Please use --cluster flag to specify cluster ARN manually.');
418
- process.exit(1);
419
- }
420
- const components = extractLaravelComponents(configPath);
421
- if (components.length === 0) {
422
- console.error('Error: No Laravel components found in SST config.');
423
- console.error('Please use --cluster flag to specify cluster ARN manually.');
424
- process.exit(1);
425
- }
426
- if (components.length > 1) {
427
- console.error('Error: Multiple Laravel components found in SST config.');
428
- console.error(`Found: ${components.join(', ')}`);
429
- console.error('Please use --cluster flag to specify which cluster to connect to.');
430
- process.exit(1);
431
- }
432
- const componentName = components[0].replace(/-/g, '');
433
- const clusterPattern = `${stage}-${componentName}Cluster`;
434
- console.log(`Looking for cluster matching pattern: *${clusterPattern}`);
435
- const listClustersCommand = new ListClustersCommand({});
436
- const listClustersResponse = await ecsClient.send(listClustersCommand);
437
- if (!listClustersResponse.clusterArns || listClustersResponse.clusterArns.length === 0) {
438
- console.error('Error: No ECS clusters found in this region.');
439
- process.exit(1);
440
- }
441
- const matchingCluster = listClustersResponse.clusterArns.find(arn => {
442
- const clusterName = arn.split('/').pop();
443
- return clusterName?.includes(stage) && clusterName?.includes(componentName);
444
- });
445
- if (!matchingCluster) {
446
- console.error(`Error: No cluster found matching stage "${stage}" and component "${components[0]}".`);
447
- console.error('Available clusters:');
448
- listClustersResponse.clusterArns.forEach(arn => {
449
- console.error(` - ${arn.split('/').pop()}`);
450
- });
451
- process.exit(1);
452
- }
453
- clusterArn = matchingCluster;
454
- console.log(`Auto-detected cluster: ${clusterArn.split('/').pop()}`);
455
- }
456
- const listTasksCommand = new ListTasksCommand({
457
- cluster: clusterArn,
458
- desiredStatus: 'RUNNING'
459
- });
460
- const listTasksResponse = await ecsClient.send(listTasksCommand);
461
- if (!listTasksResponse.taskArns || listTasksResponse.taskArns.length === 0) {
462
- console.error('No running tasks found in cluster');
463
- process.exit(1);
464
- }
465
- const describeTasksCommand = new DescribeTasksCommand({
466
- cluster: clusterArn,
467
- tasks: listTasksResponse.taskArns
468
- });
469
- const describeTasksResponse = await ecsClient.send(describeTasksCommand);
470
- let matchingTask;
471
- if (service) {
472
- let servicePrefix;
473
- if (service === 'web') {
474
- servicePrefix = '-web';
475
- }
476
- else if (service === 'worker') {
477
- servicePrefix = '-worker';
478
- }
479
- else {
480
- servicePrefix = `-${service}`;
481
- }
482
- matchingTask = describeTasksResponse.tasks?.find(task => {
483
- const containerName = task.containers?.[0]?.name || '';
484
- return containerName.toLowerCase().includes(servicePrefix.toLowerCase());
485
- });
486
- }
487
- if (!matchingTask) {
488
- if (service) {
489
- console.log(`\nNo running task found matching service: ${service}`);
490
- }
491
- console.log('Available tasks in cluster:\n');
492
- const choices = describeTasksResponse.tasks?.map(task => {
493
- const taskId = task.taskArn?.split('/').pop() || '';
494
- const containerName = task.containers?.[0]?.name || 'unknown';
495
- const status = task.lastStatus || 'unknown';
496
- return {
497
- name: `${containerName} (${taskId.substring(0, 8)}...) - ${status}`,
498
- value: task,
499
- description: `Task: ${taskId}`
500
- };
501
- }) || [];
502
- if (choices.length === 0) {
503
- console.error('No tasks available to select from.');
504
- process.exit(1);
505
- }
506
- matchingTask = await select({
507
- message: 'Select a task to stream logs from:',
508
- choices
509
- });
510
- }
511
- const taskDefinitionArn = matchingTask.taskDefinitionArn;
512
- if (!taskDefinitionArn) {
513
- console.error('Error: Could not find task definition ARN');
514
- process.exit(1);
515
- }
516
- const describeTaskDefCommand = new DescribeTaskDefinitionCommand({
517
- taskDefinition: taskDefinitionArn
518
- });
519
- const taskDefResponse = await ecsClient.send(describeTaskDefCommand);
520
- const containerDef = taskDefResponse.taskDefinition?.containerDefinitions?.[0];
521
- const logConfig = containerDef?.logConfiguration;
522
- if (!logConfig || logConfig.logDriver !== 'awslogs') {
523
- console.error('Error: Task does not use CloudWatch Logs (awslogs driver)');
524
- process.exit(1);
525
- }
526
- const logGroup = logConfig.options?.['awslogs-group'];
527
- if (!logGroup) {
528
- console.error('Error: Could not determine CloudWatch log group');
529
- process.exit(1);
530
- }
531
- const containerName = matchingTask.containers?.[0]?.name || 'unknown';
532
- console.log(`Streaming logs from: ${containerName}`);
533
- console.log(`Log group: ${logGroup}`);
534
- console.log('');
535
- const awsArgs = [
536
- 'logs',
537
- 'tail',
538
- logGroup,
539
- '--since', options.since || '10m'
540
- ];
541
- if (options.follow) {
542
- awsArgs.push('--follow');
543
- }
544
- const awsCommand = spawn('aws', awsArgs, {
545
- stdio: 'inherit',
546
- env: { ...process.env, AWS_REGION: region }
547
- });
548
- awsCommand.on('exit', (code) => {
549
- process.exit(code || 0);
550
- });
551
- }
552
- catch (error) {
553
- console.error('Error:', error.message);
554
- process.exit(1);
555
- }
556
- });
557
- program
558
- .command('github-iam')
559
- .description('Create an IAM Role on AWS for GitHub Actions OIDC authentication for deployments')
560
- .option('-r, --repo <repo>', 'GitHub repository in format owner/repo (auto-detected from git remote)')
561
- .option('-b, --branch <branch>', 'Branch to allow deployments from (use * for all branches)', '*')
562
- .option('--region <region>', 'AWS region', process.env.AWS_REGION || 'us-east-1')
563
- .option('--role-name <name>', 'Name for the IAM role (defaults to github-actions-{project}-sst-deploy)')
564
- .action(async (options) => {
565
- try {
566
- const { branch, region } = options;
567
- let repo = options.repo;
568
- let roleName = options.roleName;
569
- if (!repo) {
570
- const detectedRepo = detectGitHubRepo();
571
- if (detectedRepo) {
572
- repo = detectedRepo;
573
- console.log(`šŸ“¦ Auto-detected repository: ${repo}`);
574
- }
575
- else {
576
- console.error('Error: Could not auto-detect GitHub repository.');
577
- console.error('Please use --repo flag to specify the repository in format owner/repo');
578
- process.exit(1);
579
- }
580
- }
581
- if (!repo.includes('/')) {
582
- console.error('Error: Repository must be in format owner/repo');
583
- process.exit(1);
584
- }
585
- if (!roleName) {
586
- const configPath = findSstConfig();
587
- const projectName = configPath ? extractSstProjectName(configPath) : null;
588
- roleName = projectName
589
- ? `github-actions-${projectName}-sst-deploy`
590
- : 'github-actions-sst-deploy';
591
- console.log(`šŸ·ļø Using role name: ${roleName}`);
592
- }
593
- const [owner, repoName] = repo.split('/');
594
- const iamClient = new IAMClient({ region });
595
- const githubOidcUrl = 'https://token.actions.githubusercontent.com';
596
- const githubOidcArn = `arn:aws:iam::${await getAwsAccountId(iamClient)}:oidc-provider/token.actions.githubusercontent.com`;
597
- console.log(`\nšŸ”§ Setting up GitHub Actions OIDC for ${owner}/${repoName}...\n`);
598
- const oidcProviderArn = await ensureGithubOidcProvider(iamClient, githubOidcUrl);
599
- console.log(`āœ… GitHub OIDC Provider: ${oidcProviderArn}`);
600
- const trustPolicy = buildTrustPolicy(oidcProviderArn, owner, repoName, branch);
601
- try {
602
- const existingRole = await iamClient.send(new GetRoleCommand({ RoleName: roleName }));
603
- console.log(`āš ļø Role "${roleName}" already exists with ARN: ${existingRole.Role?.Arn}`);
604
- console.log(' If you need to update the trust policy, delete the role first and re-run this command.');
605
- }
606
- catch (error) {
607
- if (error.name === 'NoSuchEntityException') {
608
- const createRoleResponse = await iamClient.send(new CreateRoleCommand({
609
- RoleName: roleName,
610
- AssumeRolePolicyDocument: JSON.stringify(trustPolicy),
611
- Description: `GitHub Actions deployment role for ${owner}/${repoName}`
612
- }));
613
- console.log(`āœ… Created IAM Role: ${createRoleResponse.Role?.Arn}`);
614
- await iamClient.send(new AttachRolePolicyCommand({
615
- RoleName: roleName,
616
- PolicyArn: 'arn:aws:iam::aws:policy/AdministratorAccess'
617
- }));
618
- console.log('āœ… Attached AdministratorAccess policy');
619
- }
620
- else {
621
- throw error;
622
- }
623
- }
624
- console.log('\nšŸ“‹ Add the following to your GitHub Actions workflow:\n');
625
- console.log('```yaml');
626
- console.log('permissions:');
627
- console.log(' id-token: write');
628
- console.log(' contents: read');
629
- console.log('');
630
- console.log('jobs:');
631
- console.log(' deploy:');
632
- console.log(' runs-on: ubuntu-latest');
633
- console.log(' steps:');
634
- console.log(' - uses: actions/checkout@v4');
635
- console.log('');
636
- console.log(' - name: Configure AWS Credentials');
637
- console.log(' uses: aws-actions/configure-aws-credentials@v4');
638
- console.log(' with:');
639
- console.log(` role-to-assume: arn:aws:iam::ACCOUNT_ID:role/${roleName}`);
640
- console.log(` aws-region: ${region}`);
641
- console.log('');
642
- console.log(' - name: Deploy with SST');
643
- console.log(' run: npx sst deploy --stage production');
644
- console.log('```\n');
645
- console.log('šŸ’” Replace ACCOUNT_ID with your AWS account ID in the workflow file.');
646
- console.log(`šŸ’” The role allows deployments from: ${branch === '*' ? 'all branches' : `branch "${branch}"`}`);
647
- }
648
- catch (error) {
649
- console.error('Error:', error.message);
650
- process.exit(1);
651
- }
652
- });
653
- async function getAwsAccountId(iamClient) {
654
- const { STSClient, GetCallerIdentityCommand } = await import('@aws-sdk/client-sts');
655
- const stsClient = new STSClient({ region: iamClient.config.region });
656
- const response = await stsClient.send(new GetCallerIdentityCommand({}));
657
- return response.Account;
658
- }
659
- async function ensureGithubOidcProvider(iamClient, githubOidcUrl) {
660
- const requiredAudience = 'sts.amazonaws.com';
661
- const listResponse = await iamClient.send(new ListOpenIDConnectProvidersCommand({}));
662
- const existingProvider = listResponse.OpenIDConnectProviderList?.find(provider => provider.Arn?.includes('token.actions.githubusercontent.com'));
663
- if (existingProvider) {
664
- const providerDetails = await iamClient.send(new GetOpenIDConnectProviderCommand({
665
- OpenIDConnectProviderArn: existingProvider.Arn
666
- }));
667
- const hasRequiredAudience = providerDetails.ClientIDList?.includes(requiredAudience);
668
- if (!hasRequiredAudience) {
669
- console.log(`āš ļø OIDC provider exists but missing "${requiredAudience}" audience. Adding it...`);
670
- await iamClient.send(new AddClientIDToOpenIDConnectProviderCommand({
671
- OpenIDConnectProviderArn: existingProvider.Arn,
672
- ClientID: requiredAudience
673
- }));
674
- console.log(`āœ… Added "${requiredAudience}" audience to OIDC provider`);
675
- }
676
- return existingProvider.Arn;
677
- }
678
- const thumbprint = '6938fd4d98bab03faadb97b34396831e3780aea1';
679
- const createResponse = await iamClient.send(new CreateOpenIDConnectProviderCommand({
680
- Url: githubOidcUrl,
681
- ClientIDList: [requiredAudience],
682
- ThumbprintList: [thumbprint]
683
- }));
684
- return createResponse.OpenIDConnectProviderArn;
685
- }
686
- function buildTrustPolicy(oidcProviderArn, owner, repo, branch) {
687
- const condition = branch === '*'
688
- ? `repo:${owner}/${repo}:*`
689
- : `repo:${owner}/${repo}:ref:refs/heads/${branch}`;
690
- return {
691
- Version: '2012-10-17',
692
- Statement: [
693
- {
694
- Effect: 'Allow',
695
- Principal: {
696
- Federated: oidcProviderArn
697
- },
698
- Action: 'sts:AssumeRoleWithWebIdentity',
699
- Condition: {
700
- StringEquals: {
701
- 'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com'
702
- },
703
- StringLike: {
704
- 'token.actions.githubusercontent.com:sub': condition
705
- }
706
- }
707
- }
708
- ]
709
- };
710
- }
22
+ program.addCommand(initCommand);
23
+ program.addCommand(deployCommand);
24
+ program.addCommand(sshCommand);
25
+ program.addCommand(logsCommand);
26
+ program.addCommand(githubIamCommand);
27
+ program.addCommand(installCommand);
711
28
  program.parse();
712
29
  //# sourceMappingURL=cli.js.map