@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/Dockerfile.web +1 -1
- package/dist/bin/cli.js +13 -696
- package/dist/bin/cli.js.map +1 -1
- package/dist/bin/commands/deploy.d.ts +2 -0
- package/dist/bin/commands/deploy.js +32 -0
- package/dist/bin/commands/deploy.js.map +1 -0
- package/dist/bin/commands/github-iam.d.ts +2 -0
- package/dist/bin/commands/github-iam.js +100 -0
- package/dist/bin/commands/github-iam.js.map +1 -0
- package/dist/bin/commands/init.d.ts +2 -0
- package/dist/bin/commands/init.js +123 -0
- package/dist/bin/commands/init.js.map +1 -0
- package/dist/bin/commands/install.d.ts +2 -0
- package/dist/bin/commands/install.js +64 -0
- package/dist/bin/commands/install.js.map +1 -0
- package/dist/bin/commands/logs.d.ts +2 -0
- package/dist/bin/commands/logs.js +70 -0
- package/dist/bin/commands/logs.js.map +1 -0
- package/dist/bin/commands/ssh.d.ts +2 -0
- package/dist/bin/commands/ssh.js +46 -0
- package/dist/bin/commands/ssh.js.map +1 -0
- package/dist/bin/utils/ecs.d.ts +7 -0
- package/dist/bin/utils/ecs.js +108 -0
- package/dist/bin/utils/ecs.js.map +1 -0
- package/dist/bin/utils/git.d.ts +1 -0
- package/dist/bin/utils/git.js +19 -0
- package/dist/bin/utils/git.js.map +1 -0
- package/dist/bin/utils/iam.d.ts +4 -0
- package/dist/bin/utils/iam.js +60 -0
- package/dist/bin/utils/iam.js.map +1 -0
- package/dist/bin/utils/sst-config.d.ts +5 -0
- package/dist/bin/utils/sst-config.js +74 -0
- package/dist/bin/utils/sst-config.js.map +1 -0
- package/package.json +3 -2
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, '..', '
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|