@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,358 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
isRecord,
|
|
4
|
+
parseJson,
|
|
5
|
+
readOptionalString,
|
|
6
|
+
} from "../../shared/validation/parse-json.js";
|
|
7
|
+
|
|
8
|
+
type GithubHook = {
|
|
9
|
+
id?: number;
|
|
10
|
+
config?: {
|
|
11
|
+
url?: string;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type GithubHookWithId = GithubHook & { id: number };
|
|
16
|
+
|
|
17
|
+
export type UpsertGithubWebhookInput = {
|
|
18
|
+
repo: string;
|
|
19
|
+
webhookUrl: string;
|
|
20
|
+
events?: string[];
|
|
21
|
+
secret?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type UpsertGithubWebhookResult = {
|
|
25
|
+
action: "created" | "updated";
|
|
26
|
+
id: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Convert repo into deterministic prefix-friendly slug.
|
|
31
|
+
*
|
|
32
|
+
* @since 1.0.0
|
|
33
|
+
* @category AWS.GitHub
|
|
34
|
+
*/
|
|
35
|
+
export function toRepositoryPrefix(repo: string): string {
|
|
36
|
+
const normalized = normalizeRepo(repo).toLowerCase();
|
|
37
|
+
const prefix = normalized
|
|
38
|
+
.replace(/[\/_.]+/g, "-")
|
|
39
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
40
|
+
.replace(/-+/g, "-")
|
|
41
|
+
.replace(/^-+|-+$/g, "");
|
|
42
|
+
if (!prefix) {
|
|
43
|
+
throw new Error(`invalid github repo: ${repo}`);
|
|
44
|
+
}
|
|
45
|
+
return prefix;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve target GitHub repo name.
|
|
50
|
+
*
|
|
51
|
+
* @since 1.0.0
|
|
52
|
+
* @category AWS.GitHub
|
|
53
|
+
*/
|
|
54
|
+
export async function resolveGithubRepo(
|
|
55
|
+
explicitRepo?: string,
|
|
56
|
+
env: Record<string, string | undefined> = process.env,
|
|
57
|
+
cwd = process.cwd(),
|
|
58
|
+
): Promise<string | undefined> {
|
|
59
|
+
if (explicitRepo) return normalizeRepo(explicitRepo);
|
|
60
|
+
|
|
61
|
+
const fromCurrentRepo = await resolveFromGitRemote(cwd);
|
|
62
|
+
if (fromCurrentRepo) return fromCurrentRepo;
|
|
63
|
+
|
|
64
|
+
const fromEnv = env.GITHUB_REPOSITORY ?? env.SKIPPER_GITHUB_REPO;
|
|
65
|
+
if (fromEnv) return normalizeRepo(fromEnv);
|
|
66
|
+
|
|
67
|
+
const inferredFromGh = await inferFromGhLoginAndCwd(cwd);
|
|
68
|
+
if (inferredFromGh) return inferredFromGh;
|
|
69
|
+
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Create or update webhook for repository.
|
|
75
|
+
*
|
|
76
|
+
* @since 1.0.0
|
|
77
|
+
* @category AWS.GitHub
|
|
78
|
+
*/
|
|
79
|
+
export async function upsertGithubWebhook(
|
|
80
|
+
input: UpsertGithubWebhookInput,
|
|
81
|
+
): Promise<UpsertGithubWebhookResult> {
|
|
82
|
+
const repo = normalizeRepo(input.repo);
|
|
83
|
+
const webhookUrl = normalizeUrl(input.webhookUrl);
|
|
84
|
+
const events = input.events && input.events.length > 0 ? input.events : ["*"];
|
|
85
|
+
|
|
86
|
+
const hooksJson = await runGhApi([`repos/${repo}/hooks`]);
|
|
87
|
+
const hooks = parseHooks(hooksJson);
|
|
88
|
+
const existing = hooks.find(
|
|
89
|
+
(hook) => normalizeUrl(hook.config?.url) === webhookUrl,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
if (existing?.id !== undefined) {
|
|
93
|
+
const updatedJson = await runGhApi([
|
|
94
|
+
"--method",
|
|
95
|
+
"PATCH",
|
|
96
|
+
`repos/${repo}/hooks/${existing.id}`,
|
|
97
|
+
...buildWebhookFormArgs({ webhookUrl, events, secret: input.secret }),
|
|
98
|
+
]);
|
|
99
|
+
const updated = parseHook(updatedJson);
|
|
100
|
+
return {
|
|
101
|
+
action: "updated",
|
|
102
|
+
id: updated.id,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const createdJson = await runGhApi([
|
|
107
|
+
"--method",
|
|
108
|
+
"POST",
|
|
109
|
+
`repos/${repo}/hooks`,
|
|
110
|
+
"-f",
|
|
111
|
+
"name=web",
|
|
112
|
+
...buildWebhookFormArgs({ webhookUrl, events, secret: input.secret }),
|
|
113
|
+
]);
|
|
114
|
+
const created = parseHook(createdJson);
|
|
115
|
+
return {
|
|
116
|
+
action: "created",
|
|
117
|
+
id: created.id,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
type WebhookFormArgsInput = {
|
|
122
|
+
webhookUrl: string;
|
|
123
|
+
events: string[];
|
|
124
|
+
secret?: string;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Build `gh api` form args for webhook.
|
|
129
|
+
*
|
|
130
|
+
* @since 1.0.0
|
|
131
|
+
* @category AWS.GitHub
|
|
132
|
+
*/
|
|
133
|
+
function buildWebhookFormArgs(input: WebhookFormArgsInput): string[] {
|
|
134
|
+
const args = [
|
|
135
|
+
"-F",
|
|
136
|
+
"active=true",
|
|
137
|
+
"-f",
|
|
138
|
+
`config[url]=${input.webhookUrl}`,
|
|
139
|
+
"-f",
|
|
140
|
+
"config[content_type]=json",
|
|
141
|
+
"-f",
|
|
142
|
+
"config[insecure_ssl]=0",
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
for (const event of input.events) {
|
|
146
|
+
args.push("-f", `events[]=${event}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (input.secret && input.secret.length > 0) {
|
|
150
|
+
args.push("-f", `config[secret]=${input.secret}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return args;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Execute `gh api` command.
|
|
158
|
+
*
|
|
159
|
+
* @since 1.0.0
|
|
160
|
+
* @category AWS.GitHub
|
|
161
|
+
*/
|
|
162
|
+
async function runGhApi(args: string[]): Promise<string> {
|
|
163
|
+
try {
|
|
164
|
+
const proc = Bun.spawn(["gh", "api", ...args], {
|
|
165
|
+
stdout: "pipe",
|
|
166
|
+
stderr: "pipe",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
170
|
+
new Response(proc.stdout).text(),
|
|
171
|
+
new Response(proc.stderr).text(),
|
|
172
|
+
proc.exited,
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
if (code !== 0) {
|
|
176
|
+
throw new Error(stderr.trim() || `gh api failed (${code})`);
|
|
177
|
+
}
|
|
178
|
+
return stdout;
|
|
179
|
+
} catch (error) {
|
|
180
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
181
|
+
throw new Error(`GitHub API error: ${message}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Parse hooks list response.
|
|
187
|
+
*
|
|
188
|
+
* @since 1.0.0
|
|
189
|
+
* @category AWS.GitHub
|
|
190
|
+
*/
|
|
191
|
+
function parseHooks(raw: string): GithubHook[] {
|
|
192
|
+
const parsed = parseJson(raw, isHookArray, "GitHub hooks list");
|
|
193
|
+
return parsed;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Check hooks array shape.
|
|
198
|
+
*
|
|
199
|
+
* @since 1.0.0
|
|
200
|
+
* @category AWS.GitHub
|
|
201
|
+
*/
|
|
202
|
+
function isHookArray(value: unknown): value is GithubHook[] {
|
|
203
|
+
if (!Array.isArray(value)) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
return value.every(isGithubHook);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Parse webhook object response.
|
|
211
|
+
*
|
|
212
|
+
* @since 1.0.0
|
|
213
|
+
* @category AWS.GitHub
|
|
214
|
+
*/
|
|
215
|
+
function parseHook(raw: string): GithubHookWithId {
|
|
216
|
+
const hook = parseJson(raw, isGithubHook, "GitHub webhook response");
|
|
217
|
+
if (hook.id === undefined) {
|
|
218
|
+
throw new Error("GitHub webhook id missing");
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
...hook,
|
|
222
|
+
id: hook.id,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Validate GitHub hook shape.
|
|
228
|
+
*
|
|
229
|
+
* @since 1.0.0
|
|
230
|
+
* @category AWS.GitHub
|
|
231
|
+
*/
|
|
232
|
+
function isGithubHook(value: unknown): value is GithubHook {
|
|
233
|
+
if (!isRecord(value)) return false;
|
|
234
|
+
if (value.id !== undefined && typeof value.id !== "number") return false;
|
|
235
|
+
const config = value.config;
|
|
236
|
+
if (config === undefined) return true;
|
|
237
|
+
if (!isRecord(config)) return false;
|
|
238
|
+
const url = readOptionalString(config, "url");
|
|
239
|
+
return config.url === undefined || typeof url === "string";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Normalize repo input to owner/repo.
|
|
244
|
+
*
|
|
245
|
+
* @since 1.0.0
|
|
246
|
+
* @category AWS.GitHub
|
|
247
|
+
*/
|
|
248
|
+
function normalizeRepo(value: string): string {
|
|
249
|
+
const trimmed = value.trim();
|
|
250
|
+
const fromRemote = parseGitHubRepoFromRemote(trimmed);
|
|
251
|
+
const repo = fromRemote ?? trimmed.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
252
|
+
const clean = repo.replace(/\.git$/i, "");
|
|
253
|
+
|
|
254
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(clean)) {
|
|
255
|
+
throw new Error(`github repo must be owner/repo: ${value}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return clean;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Normalize URL for matching.
|
|
263
|
+
*
|
|
264
|
+
* @since 1.0.0
|
|
265
|
+
* @category AWS.GitHub
|
|
266
|
+
*/
|
|
267
|
+
function normalizeUrl(url?: string): string {
|
|
268
|
+
if (!url) return "";
|
|
269
|
+
return url.trim().replace(/\/+$/, "");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Parse owner/repo from GitHub remote URL.
|
|
274
|
+
*
|
|
275
|
+
* @since 1.0.0
|
|
276
|
+
* @category AWS.GitHub
|
|
277
|
+
*/
|
|
278
|
+
export function parseGitHubRepoFromRemote(url: string): string | undefined {
|
|
279
|
+
const trimmed = url.trim();
|
|
280
|
+
if (!trimmed) return undefined;
|
|
281
|
+
|
|
282
|
+
const ssh = trimmed.match(/^git@github\.com:([^/]+\/[^/]+?)(?:\.git)?\/?$/i);
|
|
283
|
+
if (ssh?.[1]) return ssh[1];
|
|
284
|
+
|
|
285
|
+
const https = trimmed.match(
|
|
286
|
+
/^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?\/?$/i,
|
|
287
|
+
);
|
|
288
|
+
if (https?.[1]) return https[1];
|
|
289
|
+
|
|
290
|
+
const sshUrl = trimmed.match(
|
|
291
|
+
/^ssh:\/\/git@github\.com\/([^/]+\/[^/]+?)(?:\.git)?\/?$/i,
|
|
292
|
+
);
|
|
293
|
+
if (sshUrl?.[1]) return sshUrl[1];
|
|
294
|
+
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Resolve repo from local git remotes.
|
|
300
|
+
*
|
|
301
|
+
* @since 1.0.0
|
|
302
|
+
* @category AWS.GitHub
|
|
303
|
+
*/
|
|
304
|
+
async function resolveFromGitRemote(cwd: string): Promise<string | undefined> {
|
|
305
|
+
const origin = await getGitRemoteUrl("origin", cwd);
|
|
306
|
+
const parsedOrigin = origin ? parseGitHubRepoFromRemote(origin) : undefined;
|
|
307
|
+
if (parsedOrigin) return normalizeRepo(parsedOrigin);
|
|
308
|
+
|
|
309
|
+
const remotesRaw = await Bun.$`git remote`.cwd(cwd).nothrow().text();
|
|
310
|
+
const remotes = remotesRaw
|
|
311
|
+
.split("\n")
|
|
312
|
+
.map((name) => name.trim())
|
|
313
|
+
.filter((name) => name.length > 0 && name !== "origin");
|
|
314
|
+
|
|
315
|
+
for (const remote of remotes) {
|
|
316
|
+
const url = await getGitRemoteUrl(remote, cwd);
|
|
317
|
+
if (!url) continue;
|
|
318
|
+
const parsed = parseGitHubRepoFromRemote(url);
|
|
319
|
+
if (parsed) return normalizeRepo(parsed);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Get git remote URL value.
|
|
327
|
+
*
|
|
328
|
+
* @since 1.0.0
|
|
329
|
+
* @category AWS.GitHub
|
|
330
|
+
*/
|
|
331
|
+
async function getGitRemoteUrl(
|
|
332
|
+
remote: string,
|
|
333
|
+
cwd: string,
|
|
334
|
+
): Promise<string | undefined> {
|
|
335
|
+
const url = await Bun.$`git remote get-url ${remote}`.cwd(cwd).nothrow().text();
|
|
336
|
+
const trimmed = url.trim();
|
|
337
|
+
if (!trimmed) return undefined;
|
|
338
|
+
return trimmed;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Infer repo from `gh` login and cwd.
|
|
343
|
+
*
|
|
344
|
+
* @since 1.0.0
|
|
345
|
+
* @category AWS.GitHub
|
|
346
|
+
*/
|
|
347
|
+
async function inferFromGhLoginAndCwd(
|
|
348
|
+
cwd: string,
|
|
349
|
+
): Promise<string | undefined> {
|
|
350
|
+
const repoName = basename(cwd).trim();
|
|
351
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(repoName)) return undefined;
|
|
352
|
+
|
|
353
|
+
const owner = await Bun.$`gh api user --jq .login`.cwd(cwd).nothrow().text();
|
|
354
|
+
const cleanOwner = owner.trim();
|
|
355
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(cleanOwner)) return undefined;
|
|
356
|
+
|
|
357
|
+
return normalizeRepo(`${cleanOwner}/${repoName}`);
|
|
358
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { registerAwsBootstrapCommand } from "./bootstrap.js";
|
|
3
|
+
import { registerAwsDeployCommand } from "./deploy.js";
|
|
4
|
+
import { registerAwsRunCommand } from "./run.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Register AWS command namespace.
|
|
8
|
+
*
|
|
9
|
+
* @since 1.0.0
|
|
10
|
+
* @category CLI
|
|
11
|
+
*/
|
|
12
|
+
export function registerAwsCommand(program: Command) {
|
|
13
|
+
const aws = program.command("aws").description("AWS commands");
|
|
14
|
+
registerAwsBootstrapCommand(aws);
|
|
15
|
+
registerAwsDeployCommand(aws);
|
|
16
|
+
registerAwsRunCommand(aws);
|
|
17
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
type EventBridgeEvent = {
|
|
2
|
+
detail?: unknown;
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
type SQSEvent = {
|
|
6
|
+
Records?: Array<{
|
|
7
|
+
body: string;
|
|
8
|
+
}>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Handle EventBridge event by adapting to queue shape.
|
|
13
|
+
*
|
|
14
|
+
* @since 1.0.0
|
|
15
|
+
* @category AWS.Lambda
|
|
16
|
+
*/
|
|
17
|
+
export async function handler(event: EventBridgeEvent): Promise<void> {
|
|
18
|
+
const { handler: queueHandler } = await import("./handler.js");
|
|
19
|
+
await queueHandler(toSqsEvent(event));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Convert EventBridge detail to SQS event payload.
|
|
24
|
+
*
|
|
25
|
+
* @since 1.0.0
|
|
26
|
+
* @category AWS.Lambda
|
|
27
|
+
*/
|
|
28
|
+
export function toSqsEvent(event: EventBridgeEvent): SQSEvent {
|
|
29
|
+
return {
|
|
30
|
+
Records: [
|
|
31
|
+
{
|
|
32
|
+
body: JSON.stringify(readQueueEnvelope(event.detail)),
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read queue envelope fields from EventBridge detail.
|
|
40
|
+
*
|
|
41
|
+
* @since 1.0.0
|
|
42
|
+
* @category AWS.Lambda
|
|
43
|
+
*/
|
|
44
|
+
function readQueueEnvelope(detail: unknown): {
|
|
45
|
+
rawBodyB64?: string;
|
|
46
|
+
headers?: Record<string, string | undefined>;
|
|
47
|
+
} {
|
|
48
|
+
if (!isRecord(detail)) {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
const rawBodyB64 = typeof detail.rawBodyB64 === "string" ? detail.rawBodyB64 : undefined;
|
|
52
|
+
const headers = isHeaderMap(detail.headers) ? detail.headers : undefined;
|
|
53
|
+
return {
|
|
54
|
+
rawBodyB64,
|
|
55
|
+
headers,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check plain object value.
|
|
61
|
+
*
|
|
62
|
+
* @since 1.0.0
|
|
63
|
+
* @category AWS.Lambda
|
|
64
|
+
*/
|
|
65
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
66
|
+
return typeof value === "object" && value !== null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check header map string values.
|
|
71
|
+
*
|
|
72
|
+
* @since 1.0.0
|
|
73
|
+
* @category AWS.Lambda
|
|
74
|
+
*/
|
|
75
|
+
function isHeaderMap(value: unknown): value is Record<string, string | undefined> {
|
|
76
|
+
if (!isRecord(value)) return false;
|
|
77
|
+
for (const headerValue of Object.values(value)) {
|
|
78
|
+
if (headerValue !== undefined && typeof headerValue !== "string") {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|