@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.
@@ -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
+ }