@skippercorp/skipper 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -0
- package/package.json +48 -0
- package/src/app/cli.ts +31 -0
- package/src/app/register-commands.ts +37 -0
- package/src/command/a.ts +213 -0
- package/src/command/aws/bootstrap.ts +508 -0
- package/src/command/aws/cloudformation.ts +243 -0
- package/src/command/aws/defaults.ts +103 -0
- package/src/command/aws/deploy-template.ts +308 -0
- package/src/command/aws/deploy.ts +593 -0
- package/src/command/aws/github.ts +358 -0
- package/src/command/aws/index.ts +17 -0
- package/src/command/aws/lambda/eventbridge-handler.ts +83 -0
- package/src/command/aws/lambda/handler.ts +521 -0
- package/src/command/aws/lambda/types.ts +86 -0
- package/src/command/aws/network.ts +51 -0
- package/src/command/aws/run.ts +566 -0
- package/src/command/aws/template.ts +406 -0
- package/src/command/aws/verify-issue-subscription.ts +782 -0
- package/src/command/clone.ts +67 -0
- package/src/command/rm.ts +126 -0
- package/src/command/run.ts +43 -0
- package/src/index.ts +16 -0
- package/src/shared/command/interactive.ts +120 -0
- package/src/shared/validation/parse-json.ts +59 -0
- package/src/worker/aws-params.ts +54 -0
- package/src/worker/contract.ts +324 -0
- package/src/worker/github-events.ts +57 -0
- package/src/worker/load.ts +86 -0
- package/src/worker/route.ts +91 -0
- package/src/worker/serialize.ts +175 -0
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CloudFormationClient,
|
|
3
|
+
DescribeStackResourceCommand,
|
|
4
|
+
DescribeStacksCommand,
|
|
5
|
+
type Output,
|
|
6
|
+
} from "@aws-sdk/client-cloudformation";
|
|
7
|
+
import {
|
|
8
|
+
DescribeTasksCommand,
|
|
9
|
+
ECSClient,
|
|
10
|
+
RunTaskCommand,
|
|
11
|
+
type RunTaskCommandInput,
|
|
12
|
+
} from "@aws-sdk/client-ecs";
|
|
13
|
+
import type { Command } from "commander";
|
|
14
|
+
import { parseGitHubRepoFromRemote } from "./github.js";
|
|
15
|
+
import { resolveDeployDefaults } from "./defaults.js";
|
|
16
|
+
|
|
17
|
+
const DEFAULT_BEDROCK_MODEL = "eu.anthropic.claude-sonnet-4-6";
|
|
18
|
+
|
|
19
|
+
type AgentType = "claude" | "opencode";
|
|
20
|
+
|
|
21
|
+
type RunOptions = {
|
|
22
|
+
service?: string;
|
|
23
|
+
env?: string;
|
|
24
|
+
profile?: string;
|
|
25
|
+
stackName?: string;
|
|
26
|
+
githubRepo?: string;
|
|
27
|
+
agent?: string;
|
|
28
|
+
model?: string;
|
|
29
|
+
wait?: boolean;
|
|
30
|
+
timeoutMinutes: number;
|
|
31
|
+
dryRun?: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type RunContext = {
|
|
35
|
+
service: string;
|
|
36
|
+
env: string;
|
|
37
|
+
region: string;
|
|
38
|
+
stackName: string;
|
|
39
|
+
repositoryUrl: string;
|
|
40
|
+
prompt: string;
|
|
41
|
+
agent?: AgentType;
|
|
42
|
+
model?: string;
|
|
43
|
+
wait: boolean;
|
|
44
|
+
timeoutMinutes: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type StackRunResources = {
|
|
48
|
+
clusterArn: string;
|
|
49
|
+
taskDefinitionArn: string;
|
|
50
|
+
securityGroupId: string;
|
|
51
|
+
subnetIds: string[];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Register AWS run command.
|
|
56
|
+
*
|
|
57
|
+
* @since 1.0.0
|
|
58
|
+
* @category AWS.Run
|
|
59
|
+
*/
|
|
60
|
+
export function registerAwsRunCommand(program: Command): void {
|
|
61
|
+
program
|
|
62
|
+
.command("run")
|
|
63
|
+
.description("Run prompt in ECS task from bootstrap stack")
|
|
64
|
+
.argument("<prompt...>", "Prompt text for selected agent")
|
|
65
|
+
.option("--service <name>", "Service name (default: current directory)")
|
|
66
|
+
.option("--env <name>", "Environment (default: AWS_PROFILE or sandbox)")
|
|
67
|
+
.option("--profile <profile>", "AWS profile")
|
|
68
|
+
.option(
|
|
69
|
+
"--stack-name <name>",
|
|
70
|
+
"CloudFormation stack name (default: <service>-<env>-bootstrap)",
|
|
71
|
+
)
|
|
72
|
+
.option(
|
|
73
|
+
"--github-repo <owner/repo|url>",
|
|
74
|
+
"GitHub repo to clone (default: current git repo)",
|
|
75
|
+
)
|
|
76
|
+
.option("--agent <name>", "ECS agent runtime override (claude|opencode)")
|
|
77
|
+
.option("--model <id>", `Claude Bedrock model id (default: ${DEFAULT_BEDROCK_MODEL})`)
|
|
78
|
+
.option("--wait", "Wait for ECS task to stop")
|
|
79
|
+
.option("--timeout-minutes <n>", "Wait timeout minutes", parseNumber, 30)
|
|
80
|
+
.option("--dry-run", "Print runTask payload only")
|
|
81
|
+
.action(handleRunAction);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Execute aws run action.
|
|
86
|
+
*
|
|
87
|
+
* @since 1.0.0
|
|
88
|
+
* @category AWS.Run
|
|
89
|
+
*/
|
|
90
|
+
async function handleRunAction(
|
|
91
|
+
promptParts: string[],
|
|
92
|
+
options: RunOptions,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
const context = await buildRunContext(promptParts, options);
|
|
95
|
+
await runWithProfile(options.profile, async () => {
|
|
96
|
+
await executeRun(context, options.dryRun ?? false);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build normalized run context.
|
|
102
|
+
*
|
|
103
|
+
* @since 1.0.0
|
|
104
|
+
* @category AWS.Run
|
|
105
|
+
*/
|
|
106
|
+
async function buildRunContext(
|
|
107
|
+
promptParts: string[],
|
|
108
|
+
options: RunOptions,
|
|
109
|
+
): Promise<RunContext> {
|
|
110
|
+
const defaults = resolveDeployDefaults();
|
|
111
|
+
const service = options.service ?? defaults.service;
|
|
112
|
+
const env = options.env ?? defaults.env;
|
|
113
|
+
const region = defaults.region;
|
|
114
|
+
const prompt = promptParts.join(" ").trim();
|
|
115
|
+
if (!isSimpleName(service)) throw new Error("service must match [a-zA-Z0-9-]+");
|
|
116
|
+
if (!isSimpleName(env)) throw new Error("env must match [a-zA-Z0-9-]+");
|
|
117
|
+
if (!prompt) throw new Error("prompt required");
|
|
118
|
+
const repo = options.githubRepo ?? (await resolveRunGithubRepo());
|
|
119
|
+
if (!repo) {
|
|
120
|
+
throw new Error("github repo not found from git remotes/env; pass --github-repo");
|
|
121
|
+
}
|
|
122
|
+
const agent =
|
|
123
|
+
options.agent || process.env.SKIPPER_AWS_AGENT
|
|
124
|
+
? parseAgentType(options.agent ?? process.env.SKIPPER_AWS_AGENT ?? "")
|
|
125
|
+
: undefined;
|
|
126
|
+
return {
|
|
127
|
+
service,
|
|
128
|
+
env,
|
|
129
|
+
region,
|
|
130
|
+
stackName: options.stackName ?? buildDefaultRunStackName(service, env),
|
|
131
|
+
repositoryUrl: toGitHubCloneUrl(repo),
|
|
132
|
+
prompt,
|
|
133
|
+
agent,
|
|
134
|
+
model: options.model?.trim() || DEFAULT_BEDROCK_MODEL,
|
|
135
|
+
wait: options.wait ?? false,
|
|
136
|
+
timeoutMinutes: options.timeoutMinutes,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Execute run flow.
|
|
142
|
+
*
|
|
143
|
+
* @since 1.0.0
|
|
144
|
+
* @category AWS.Run
|
|
145
|
+
*/
|
|
146
|
+
async function executeRun(context: RunContext, dryRun: boolean): Promise<void> {
|
|
147
|
+
console.log(`Resolving stack ${context.stackName} in ${context.region}...`);
|
|
148
|
+
const cf = new CloudFormationClient({ region: context.region });
|
|
149
|
+
const resources = await resolveRunResources(cf, context.stackName);
|
|
150
|
+
const input = buildRunTaskInput(context, resources);
|
|
151
|
+
if (dryRun) {
|
|
152
|
+
console.log(
|
|
153
|
+
JSON.stringify(
|
|
154
|
+
{
|
|
155
|
+
stackName: context.stackName,
|
|
156
|
+
region: context.region,
|
|
157
|
+
request: input,
|
|
158
|
+
},
|
|
159
|
+
null,
|
|
160
|
+
2,
|
|
161
|
+
),
|
|
162
|
+
);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const ecs = new ECSClient({ region: context.region });
|
|
166
|
+
console.log("Starting ECS task...");
|
|
167
|
+
const response = await ecs.send(new RunTaskCommand(input));
|
|
168
|
+
if ((response.failures?.length ?? 0) > 0) {
|
|
169
|
+
const details = response.failures
|
|
170
|
+
?.map((failure) => `${failure.reason ?? "unknown"}:${failure.detail ?? ""}`)
|
|
171
|
+
.join(", ");
|
|
172
|
+
throw new Error(`ecs runTask failed ${details}`);
|
|
173
|
+
}
|
|
174
|
+
const taskArn = response.tasks?.[0]?.taskArn;
|
|
175
|
+
if (!taskArn) throw new Error("ecs runTask created no tasks");
|
|
176
|
+
console.log(`Started ECS task: ${taskArn}`);
|
|
177
|
+
if (!context.wait) {
|
|
178
|
+
console.log("Task launched (use --wait to poll until STOPPED)");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
await waitForTaskStop(ecs, resources.clusterArn, taskArn, context.timeoutMinutes);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Resolve required stack outputs for run command.
|
|
186
|
+
*
|
|
187
|
+
* @since 1.0.0
|
|
188
|
+
* @category AWS.Run
|
|
189
|
+
*/
|
|
190
|
+
async function resolveRunResources(
|
|
191
|
+
client: CloudFormationClient,
|
|
192
|
+
stackName: string,
|
|
193
|
+
): Promise<StackRunResources> {
|
|
194
|
+
const res = await client.send(new DescribeStacksCommand({ StackName: stackName }));
|
|
195
|
+
const stack = res.Stacks?.[0];
|
|
196
|
+
if (!stack) throw new Error(`stack not found: ${stackName}`);
|
|
197
|
+
const outputs = stack.Outputs ?? [];
|
|
198
|
+
assertRunStackHasEcsOutputs(outputs);
|
|
199
|
+
const parameters = stack.Parameters ?? [];
|
|
200
|
+
const securityGroupId =
|
|
201
|
+
readOptionalOutput(outputs, "EcsSecurityGroupId") ??
|
|
202
|
+
(await readSecurityGroupFromResource(client, stackName));
|
|
203
|
+
const subnetIdsCsv =
|
|
204
|
+
readOptionalOutput(outputs, "EcsSubnetIdsCsv") ??
|
|
205
|
+
readRequiredParameter(parameters, "SubnetIds");
|
|
206
|
+
return {
|
|
207
|
+
clusterArn: readRequiredOutput(outputs, "EcsClusterArn"),
|
|
208
|
+
taskDefinitionArn: readRequiredOutput(outputs, "EcsTaskDefinitionArn"),
|
|
209
|
+
securityGroupId,
|
|
210
|
+
subnetIds: parseSubnetIdsCsv(subnetIdsCsv),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Validate stack has ECS outputs for aws run.
|
|
216
|
+
*
|
|
217
|
+
* @since 1.0.0
|
|
218
|
+
* @category AWS.Run
|
|
219
|
+
*/
|
|
220
|
+
export function assertRunStackHasEcsOutputs(outputs: Output[]): void {
|
|
221
|
+
const missing = ["EcsClusterArn", "EcsTaskDefinitionArn"].filter(
|
|
222
|
+
(key) => readOptionalOutput(outputs, key) === undefined,
|
|
223
|
+
);
|
|
224
|
+
if (missing.length === 0) return;
|
|
225
|
+
if (readOptionalOutput(outputs, "EventBusArn")) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
"bootstrap stack missing ECS outputs; re-run aws bootstrap to include ECS task resources",
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
throw new Error(`Missing CloudFormation output: ${missing[0]}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Build default stack name for aws run.
|
|
235
|
+
*
|
|
236
|
+
* @since 1.0.0
|
|
237
|
+
* @category AWS.Run
|
|
238
|
+
*/
|
|
239
|
+
export function buildDefaultRunStackName(service: string, env: string): string {
|
|
240
|
+
return `${service}-${env}-bootstrap`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Build ECS runTask command input.
|
|
245
|
+
*
|
|
246
|
+
* @since 1.0.0
|
|
247
|
+
* @category AWS.Run
|
|
248
|
+
*/
|
|
249
|
+
export function buildRunTaskInput(
|
|
250
|
+
context: RunContext,
|
|
251
|
+
resources: StackRunResources,
|
|
252
|
+
): RunTaskCommandInput {
|
|
253
|
+
const environment = [
|
|
254
|
+
{ name: "REPOSITORY_URL", value: context.repositoryUrl },
|
|
255
|
+
{ name: "PROMPT", value: context.prompt },
|
|
256
|
+
{ name: "CLAUDE_CODE_USE_BEDROCK", value: "1" },
|
|
257
|
+
{ name: "AWS_REGION", value: context.region },
|
|
258
|
+
{ name: "AWS_DEFAULT_REGION", value: context.region },
|
|
259
|
+
{ name: "ANTHROPIC_MODEL", value: context.model ?? DEFAULT_BEDROCK_MODEL },
|
|
260
|
+
{
|
|
261
|
+
name: "ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
262
|
+
value: context.model ?? DEFAULT_BEDROCK_MODEL,
|
|
263
|
+
},
|
|
264
|
+
];
|
|
265
|
+
if (context.agent) {
|
|
266
|
+
environment.push({ name: "ECS_AGENT", value: context.agent });
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
cluster: resources.clusterArn,
|
|
270
|
+
taskDefinition: resources.taskDefinitionArn,
|
|
271
|
+
launchType: "FARGATE",
|
|
272
|
+
networkConfiguration: {
|
|
273
|
+
awsvpcConfiguration: {
|
|
274
|
+
subnets: resources.subnetIds,
|
|
275
|
+
securityGroups: [resources.securityGroupId],
|
|
276
|
+
assignPublicIp: "ENABLED",
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
overrides: {
|
|
280
|
+
containerOverrides: [{ name: "webhook", environment }],
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Convert owner/repo or URL to Git clone URL.
|
|
287
|
+
*
|
|
288
|
+
* @since 1.0.0
|
|
289
|
+
* @category AWS.Run
|
|
290
|
+
*/
|
|
291
|
+
export function toGitHubCloneUrl(value: string): string {
|
|
292
|
+
const trimmed = value.trim();
|
|
293
|
+
if (trimmed.startsWith("https://") || trimmed.startsWith("ssh://") || trimmed.startsWith("git@")) {
|
|
294
|
+
const withoutGitSuffix = trimmed.replace(/\.git$/i, "");
|
|
295
|
+
if (withoutGitSuffix.startsWith("git@")) {
|
|
296
|
+
const match = withoutGitSuffix.match(/^git@github\.com:([^/]+)\/([^/]+)$/i);
|
|
297
|
+
if (!match) throw new Error(`invalid github repo: ${value}`);
|
|
298
|
+
return `https://github.com/${match[1]}/${match[2]}.git`;
|
|
299
|
+
}
|
|
300
|
+
const match = withoutGitSuffix.match(
|
|
301
|
+
/^https:\/\/github\.com\/([^/]+)\/([^/]+)$/i,
|
|
302
|
+
);
|
|
303
|
+
if (!match) throw new Error(`invalid github repo: ${value}`);
|
|
304
|
+
return `https://github.com/${match[1]}/${match[2]}.git`;
|
|
305
|
+
}
|
|
306
|
+
const normalized = trimmed.replace(/\.git$/i, "").replace(/^\/+|\/+$/g, "");
|
|
307
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalized)) {
|
|
308
|
+
throw new Error(`invalid github repo: ${value}`);
|
|
309
|
+
}
|
|
310
|
+
return `https://github.com/${normalized}.git`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Read required CloudFormation output by key.
|
|
315
|
+
*
|
|
316
|
+
* @since 1.0.0
|
|
317
|
+
* @category AWS.Run
|
|
318
|
+
*/
|
|
319
|
+
export function readRequiredOutput(outputs: Output[], key: string): string {
|
|
320
|
+
const output = readOptionalOutput(outputs, key);
|
|
321
|
+
if (!output) {
|
|
322
|
+
throw new Error(`Missing CloudFormation output: ${key}`);
|
|
323
|
+
}
|
|
324
|
+
return output;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Read optional CloudFormation output by key.
|
|
329
|
+
*
|
|
330
|
+
* @since 1.0.0
|
|
331
|
+
* @category AWS.Run
|
|
332
|
+
*/
|
|
333
|
+
function readOptionalOutput(outputs: Output[], key: string): string | undefined {
|
|
334
|
+
return outputs.find((entry) => entry.OutputKey === key)?.OutputValue;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Parse subnet CSV from stack output.
|
|
339
|
+
*
|
|
340
|
+
* @since 1.0.0
|
|
341
|
+
* @category AWS.Run
|
|
342
|
+
*/
|
|
343
|
+
export function parseSubnetIdsCsv(value: string): string[] {
|
|
344
|
+
const subnetIds = value
|
|
345
|
+
.split(",")
|
|
346
|
+
.map((entry) => entry.trim())
|
|
347
|
+
.filter((entry) => entry.length > 0);
|
|
348
|
+
if (subnetIds.length === 0) {
|
|
349
|
+
throw new Error("subnets output empty");
|
|
350
|
+
}
|
|
351
|
+
return subnetIds;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Poll ECS task until STOPPED.
|
|
356
|
+
*
|
|
357
|
+
* @since 1.0.0
|
|
358
|
+
* @category AWS.Run
|
|
359
|
+
*/
|
|
360
|
+
async function waitForTaskStop(
|
|
361
|
+
client: ECSClient,
|
|
362
|
+
clusterArn: string,
|
|
363
|
+
taskArn: string,
|
|
364
|
+
timeoutMinutes: number,
|
|
365
|
+
): Promise<void> {
|
|
366
|
+
const deadline = Date.now() + timeoutMinutes * 60_000;
|
|
367
|
+
while (Date.now() < deadline) {
|
|
368
|
+
const res = await client.send(
|
|
369
|
+
new DescribeTasksCommand({
|
|
370
|
+
cluster: clusterArn,
|
|
371
|
+
tasks: [taskArn],
|
|
372
|
+
}),
|
|
373
|
+
);
|
|
374
|
+
const task = res.tasks?.[0];
|
|
375
|
+
const status = task?.lastStatus ?? "UNKNOWN";
|
|
376
|
+
if (status === "STOPPED") {
|
|
377
|
+
const container = task?.containers?.[0];
|
|
378
|
+
const outcome = describeTaskStopOutcome(
|
|
379
|
+
taskArn,
|
|
380
|
+
container?.exitCode,
|
|
381
|
+
task?.stoppedReason,
|
|
382
|
+
container?.reason,
|
|
383
|
+
);
|
|
384
|
+
if (outcome.success) {
|
|
385
|
+
console.log(outcome.message);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
throw new Error(outcome.message);
|
|
389
|
+
}
|
|
390
|
+
await Bun.sleep(5000);
|
|
391
|
+
}
|
|
392
|
+
throw new Error(`Timed out waiting for task ${taskArn}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Derive task stop outcome from ECS status details.
|
|
397
|
+
*
|
|
398
|
+
* @since 1.0.0
|
|
399
|
+
* @category AWS.Run
|
|
400
|
+
*/
|
|
401
|
+
export function describeTaskStopOutcome(
|
|
402
|
+
taskArn: string,
|
|
403
|
+
exitCode: number | undefined,
|
|
404
|
+
stoppedReason: string | undefined,
|
|
405
|
+
containerReason: string | undefined,
|
|
406
|
+
): { success: boolean; message: string } {
|
|
407
|
+
if (exitCode === 0) {
|
|
408
|
+
return {
|
|
409
|
+
success: true,
|
|
410
|
+
message: `Task success (${taskArn}) exitCode=0`,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
const reasonDetails = [
|
|
414
|
+
stoppedReason ? `stoppedReason=${stoppedReason}` : undefined,
|
|
415
|
+
containerReason ? `containerReason=${containerReason}` : undefined,
|
|
416
|
+
].filter((entry): entry is string => entry !== undefined);
|
|
417
|
+
const suffix = reasonDetails.length > 0 ? ` ${reasonDetails.join(" ")}` : "";
|
|
418
|
+
return {
|
|
419
|
+
success: false,
|
|
420
|
+
message: `Task failed (${taskArn}) exitCode=${exitCode ?? "unknown"}${suffix}`,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Parse positive number option.
|
|
426
|
+
*
|
|
427
|
+
* @since 1.0.0
|
|
428
|
+
* @category AWS.Run
|
|
429
|
+
*/
|
|
430
|
+
function parseNumber(value: string): number {
|
|
431
|
+
const parsed = Number.parseInt(value, 10);
|
|
432
|
+
if (Number.isNaN(parsed) || parsed <= 0) {
|
|
433
|
+
throw new Error("timeout must be positive integer");
|
|
434
|
+
}
|
|
435
|
+
return parsed;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Validate simple slug-like value.
|
|
440
|
+
*
|
|
441
|
+
* @since 1.0.0
|
|
442
|
+
* @category AWS.Run
|
|
443
|
+
*/
|
|
444
|
+
function isSimpleName(value: string): boolean {
|
|
445
|
+
return /^[a-zA-Z0-9-]+$/.test(value);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Parse ECS agent type.
|
|
450
|
+
*
|
|
451
|
+
* @since 1.0.0
|
|
452
|
+
* @category AWS.Run
|
|
453
|
+
*/
|
|
454
|
+
function parseAgentType(value: string): AgentType {
|
|
455
|
+
const normalized = value.trim().toLowerCase();
|
|
456
|
+
if (normalized === "claude" || normalized === "opencode") {
|
|
457
|
+
return normalized;
|
|
458
|
+
}
|
|
459
|
+
throw new Error("agent must be claude or opencode");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Read required CloudFormation stack parameter.
|
|
464
|
+
*
|
|
465
|
+
* @since 1.0.0
|
|
466
|
+
* @category AWS.Run
|
|
467
|
+
*/
|
|
468
|
+
function readRequiredParameter(
|
|
469
|
+
parameters: Array<{ ParameterKey?: string; ParameterValue?: string }>,
|
|
470
|
+
key: string,
|
|
471
|
+
): string {
|
|
472
|
+
const value = parameters.find((entry) => entry.ParameterKey === key)?.ParameterValue;
|
|
473
|
+
if (!value) {
|
|
474
|
+
throw new Error(`Missing CloudFormation parameter: ${key}`);
|
|
475
|
+
}
|
|
476
|
+
return value;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Read task security group id from stack resource.
|
|
481
|
+
*
|
|
482
|
+
* @since 1.0.0
|
|
483
|
+
* @category AWS.Run
|
|
484
|
+
*/
|
|
485
|
+
async function readSecurityGroupFromResource(
|
|
486
|
+
client: CloudFormationClient,
|
|
487
|
+
stackName: string,
|
|
488
|
+
): Promise<string> {
|
|
489
|
+
const res = await client.send(
|
|
490
|
+
new DescribeStackResourceCommand({
|
|
491
|
+
StackName: stackName,
|
|
492
|
+
LogicalResourceId: "WebhookTaskSecurityGroup",
|
|
493
|
+
}),
|
|
494
|
+
);
|
|
495
|
+
const id = res.StackResourceDetail?.PhysicalResourceId;
|
|
496
|
+
if (!id) {
|
|
497
|
+
throw new Error("Missing CloudFormation output: EcsSecurityGroupId");
|
|
498
|
+
}
|
|
499
|
+
return id;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Resolve GitHub repo without gh interactive fallback.
|
|
504
|
+
*
|
|
505
|
+
* @since 1.0.0
|
|
506
|
+
* @category AWS.Run
|
|
507
|
+
*/
|
|
508
|
+
async function resolveRunGithubRepo(
|
|
509
|
+
cwd = process.cwd(),
|
|
510
|
+
env: Record<string, string | undefined> = process.env,
|
|
511
|
+
): Promise<string | undefined> {
|
|
512
|
+
const fromGit = await resolveFromGitRemotes(cwd);
|
|
513
|
+
if (fromGit) return fromGit;
|
|
514
|
+
const fromEnv = env.GITHUB_REPOSITORY ?? env.SKIPPER_GITHUB_REPO;
|
|
515
|
+
return fromEnv?.trim() || undefined;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Resolve owner/repo from local git remotes.
|
|
520
|
+
*
|
|
521
|
+
* @since 1.0.0
|
|
522
|
+
* @category AWS.Run
|
|
523
|
+
*/
|
|
524
|
+
async function resolveFromGitRemotes(cwd: string): Promise<string | undefined> {
|
|
525
|
+
const remotesRaw = await Bun.$`git remote`.cwd(cwd).nothrow().text();
|
|
526
|
+
const remotes = remotesRaw
|
|
527
|
+
.split("\n")
|
|
528
|
+
.map((name) => name.trim())
|
|
529
|
+
.filter((name) => name.length > 0);
|
|
530
|
+
const orderedRemotes = [
|
|
531
|
+
...remotes.filter((name) => name === "origin"),
|
|
532
|
+
...remotes.filter((name) => name !== "origin"),
|
|
533
|
+
];
|
|
534
|
+
for (const remote of orderedRemotes) {
|
|
535
|
+
const url = await Bun.$`git remote get-url ${remote}`.cwd(cwd).nothrow().text();
|
|
536
|
+
const parsed = parseGitHubRepoFromRemote(url.trim());
|
|
537
|
+
if (parsed) return parsed;
|
|
538
|
+
}
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Run body with optional AWS profile override.
|
|
544
|
+
*
|
|
545
|
+
* @since 1.0.0
|
|
546
|
+
* @category AWS.Run
|
|
547
|
+
*/
|
|
548
|
+
async function runWithProfile(
|
|
549
|
+
profile: string | undefined,
|
|
550
|
+
run: () => Promise<void>,
|
|
551
|
+
): Promise<void> {
|
|
552
|
+
const previousProfile = process.env.AWS_PROFILE;
|
|
553
|
+
if (profile) {
|
|
554
|
+
process.env.AWS_PROFILE = profile;
|
|
555
|
+
}
|
|
556
|
+
try {
|
|
557
|
+
await run();
|
|
558
|
+
} finally {
|
|
559
|
+
if (!profile) return;
|
|
560
|
+
if (previousProfile === undefined) {
|
|
561
|
+
delete process.env.AWS_PROFILE;
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
process.env.AWS_PROFILE = previousProfile;
|
|
565
|
+
}
|
|
566
|
+
}
|