alchemy 0.1.13 → 0.1.15
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/lib/aws/account-id.d.ts +5 -0
- package/lib/aws/account-id.d.ts.map +1 -0
- package/lib/aws/account-id.js +10 -0
- package/lib/aws/account-id.js.map +1 -0
- package/lib/aws/index.d.ts +1 -0
- package/lib/aws/index.d.ts.map +1 -1
- package/lib/aws/index.js +1 -0
- package/lib/aws/index.js.map +1 -1
- package/lib/aws/oidc/github-oidc-provider.d.ts +11 -0
- package/lib/aws/oidc/github-oidc-provider.d.ts.map +1 -0
- package/lib/aws/oidc/github-oidc-provider.js +22 -0
- package/lib/aws/oidc/github-oidc-provider.js.map +1 -0
- package/lib/aws/oidc/index.d.ts +3 -0
- package/lib/aws/oidc/index.d.ts.map +1 -0
- package/lib/aws/oidc/index.js +3 -0
- package/lib/aws/oidc/index.js.map +1 -0
- package/lib/aws/oidc/oidc-provider.d.ts +60 -0
- package/lib/aws/oidc/oidc-provider.d.ts.map +1 -0
- package/lib/aws/oidc/oidc-provider.js +140 -0
- package/lib/aws/oidc/oidc-provider.js.map +1 -0
- package/lib/aws/queue.d.ts +24 -0
- package/lib/aws/queue.d.ts.map +1 -1
- package/lib/aws/queue.js +6 -1
- package/lib/aws/queue.js.map +1 -1
- package/lib/aws/role.d.ts.map +1 -1
- package/lib/aws/role.js +49 -1
- package/lib/aws/role.js.map +1 -1
- package/lib/aws/ses.d.ts.map +1 -1
- package/lib/aws/ses.js +20 -28
- package/lib/aws/ses.js.map +1 -1
- package/lib/aws/table.d.ts.map +1 -1
- package/lib/aws/table.js +113 -79
- package/lib/aws/table.js.map +1 -1
- package/lib/cloudflare/bindings.d.ts +7 -2
- package/lib/cloudflare/bindings.d.ts.map +1 -1
- package/lib/cloudflare/durable-object-namespace.d.ts +2 -5
- package/lib/cloudflare/durable-object-namespace.d.ts.map +1 -1
- package/lib/cloudflare/durable-object-namespace.js +1 -6
- package/lib/cloudflare/durable-object-namespace.js.map +1 -1
- package/lib/cloudflare/kv-namespace.d.ts +3 -0
- package/lib/cloudflare/kv-namespace.d.ts.map +1 -1
- package/lib/cloudflare/kv-namespace.js +28 -7
- package/lib/cloudflare/kv-namespace.js.map +1 -1
- package/lib/cloudflare/static-site.d.ts.map +1 -1
- package/lib/cloudflare/static-site.js +12 -30
- package/lib/cloudflare/static-site.js.map +1 -1
- package/lib/cloudflare/worker.d.ts +6 -3
- package/lib/cloudflare/worker.d.ts.map +1 -1
- package/lib/cloudflare/worker.js +82 -41
- package/lib/cloudflare/worker.js.map +1 -1
- package/lib/github/client.d.ts +34 -0
- package/lib/github/client.d.ts.map +1 -0
- package/lib/github/client.js +91 -0
- package/lib/github/client.js.map +1 -0
- package/lib/github/index.d.ts +2 -0
- package/lib/github/index.d.ts.map +1 -0
- package/lib/github/index.js +2 -0
- package/lib/github/index.js.map +1 -0
- package/lib/github/secret.d.ts +46 -0
- package/lib/github/secret.d.ts.map +1 -0
- package/lib/github/secret.js +103 -0
- package/lib/github/secret.js.map +1 -0
- package/lib/utils/retry.d.ts +13 -0
- package/lib/utils/retry.d.ts.map +1 -0
- package/lib/utils/retry.js +32 -0
- package/lib/utils/retry.js.map +1 -0
- package/package.json +10 -3
- package/src/aws/account-id.ts +11 -0
- package/src/aws/index.ts +1 -0
- package/src/aws/oidc/github-oidc-provider.ts +34 -0
- package/src/aws/oidc/index.ts +2 -0
- package/src/aws/oidc/oidc-provider.ts +253 -0
- package/src/aws/queue.ts +23 -1
- package/src/aws/role.ts +69 -0
- package/src/aws/ses.ts +27 -31
- package/src/aws/table.ts +143 -90
- package/src/cloudflare/bindings.ts +8 -2
- package/src/cloudflare/durable-object-namespace.ts +4 -10
- package/src/cloudflare/kv-namespace.ts +45 -15
- package/src/cloudflare/static-site.ts +13 -32
- package/src/cloudflare/worker.ts +117 -62
- package/src/github/client.ts +115 -0
- package/src/github/index.ts +1 -0
- package/src/github/secret.ts +172 -0
- package/src/utils/retry.ts +37 -0
- package/README.md +0 -324
package/src/cloudflare/worker.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import * as fs from "fs/promises";
|
|
2
2
|
import { apply } from "../apply";
|
|
3
3
|
import { Bundle, type BundleProps } from "../esbuild";
|
|
4
|
+
import type { Resolved } from "../output";
|
|
4
5
|
import { type Context, Resource } from "../resource";
|
|
6
|
+
import { withExponentialBackoff } from "../utils/retry";
|
|
5
7
|
import { type CloudflareApi, createCloudflareApi } from "./api";
|
|
6
|
-
import type {
|
|
8
|
+
import type { Bindings, WorkerBindingSpec } from "./bindings";
|
|
7
9
|
import type { DurableObjectNamespace } from "./durable-object-namespace";
|
|
8
10
|
import { isDurableObjectNamespace } from "./durable-object-namespace";
|
|
11
|
+
import { isKVNamespace } from "./kv-namespace";
|
|
9
12
|
import type { WorkerScriptMetadata } from "./worker-metadata";
|
|
10
13
|
import type { SingleStepMigration } from "./worker-migration";
|
|
11
14
|
/**
|
|
@@ -54,7 +57,7 @@ export interface WorkerProps {
|
|
|
54
57
|
/**
|
|
55
58
|
* Bindings to attach to the worker
|
|
56
59
|
*/
|
|
57
|
-
bindings?:
|
|
60
|
+
bindings?: Bindings;
|
|
58
61
|
|
|
59
62
|
/**
|
|
60
63
|
* Environment variables to attach to the worker
|
|
@@ -94,6 +97,8 @@ export interface WorkerProps {
|
|
|
94
97
|
* Output returned after Worker creation/update
|
|
95
98
|
*/
|
|
96
99
|
export interface WorkerOutput extends Omit<WorkerProps, "url"> {
|
|
100
|
+
type: "service";
|
|
101
|
+
|
|
97
102
|
/**
|
|
98
103
|
* The ID of the worker
|
|
99
104
|
*/
|
|
@@ -118,7 +123,11 @@ export interface WorkerOutput extends Omit<WorkerProps, "url"> {
|
|
|
118
123
|
/**
|
|
119
124
|
* The bindings that were created
|
|
120
125
|
*/
|
|
121
|
-
bindings:
|
|
126
|
+
bindings: Bindings;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function isWorker(resource: any): resource is Resolved<Worker> {
|
|
130
|
+
return resource && typeof resource === "object" && resource.type === "worker";
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
export class Worker extends Resource(
|
|
@@ -144,7 +153,7 @@ export class Worker extends Resource(
|
|
|
144
153
|
await assertWorkerDoesNotExist(ctx, api, workerName);
|
|
145
154
|
}
|
|
146
155
|
|
|
147
|
-
const oldBindings = await ctx.get<
|
|
156
|
+
const oldBindings = await ctx.get<Bindings>("bindings");
|
|
148
157
|
|
|
149
158
|
const scriptMetadata = await prepareWorkerMetadata(ctx, oldBindings, props);
|
|
150
159
|
|
|
@@ -174,12 +183,13 @@ export class Worker extends Resource(
|
|
|
174
183
|
|
|
175
184
|
// Construct the output
|
|
176
185
|
const output: WorkerOutput = {
|
|
186
|
+
type: "service",
|
|
177
187
|
id: workerName,
|
|
178
188
|
name: workerName,
|
|
179
189
|
script: scriptContent,
|
|
180
190
|
format: props.format || "esm", // Include format in the output
|
|
181
191
|
routes: props.routes || [],
|
|
182
|
-
bindings: props.bindings
|
|
192
|
+
bindings: props.bindings ?? {},
|
|
183
193
|
env: props.env,
|
|
184
194
|
observability: scriptMetadata.observability,
|
|
185
195
|
createdAt: now,
|
|
@@ -267,58 +277,82 @@ async function putWorker(
|
|
|
267
277
|
scriptContent: string,
|
|
268
278
|
scriptMetadata: WorkerMetadata,
|
|
269
279
|
) {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
280
|
+
return withExponentialBackoff(
|
|
281
|
+
async () => {
|
|
282
|
+
const scriptName =
|
|
283
|
+
scriptMetadata.main_module ?? scriptMetadata.body_part!;
|
|
284
|
+
|
|
285
|
+
// Create FormData for the upload
|
|
286
|
+
const formData = new FormData();
|
|
287
|
+
|
|
288
|
+
// Add the actual script content as a named file part
|
|
289
|
+
formData.append(
|
|
290
|
+
scriptName,
|
|
291
|
+
new Blob([scriptContent], {
|
|
292
|
+
type: scriptMetadata.main_module
|
|
293
|
+
? "application/javascript+module"
|
|
294
|
+
: "application/javascript",
|
|
295
|
+
}),
|
|
296
|
+
scriptName,
|
|
297
|
+
);
|
|
285
298
|
|
|
286
|
-
|
|
299
|
+
// console.log("Script Metadata:", JSON.stringify(scriptMetadata, null, 2));
|
|
287
300
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
301
|
+
// Add metadata as JSON
|
|
302
|
+
formData.append(
|
|
303
|
+
"metadata",
|
|
304
|
+
new Blob([JSON.stringify(scriptMetadata)], {
|
|
305
|
+
type: "application/json",
|
|
306
|
+
}),
|
|
307
|
+
);
|
|
295
308
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
309
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
310
|
+
|
|
311
|
+
// Upload worker script with bindings
|
|
312
|
+
const uploadResponse = await api.put(
|
|
313
|
+
`/accounts/${api.accountId}/workers/scripts/${workerName}`,
|
|
314
|
+
formData,
|
|
315
|
+
{
|
|
316
|
+
headers: {
|
|
317
|
+
"Content-Type": "multipart/form-data",
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
// Check if the upload was successful
|
|
323
|
+
if (!uploadResponse.ok) {
|
|
324
|
+
const errorData: any = await uploadResponse
|
|
325
|
+
.json()
|
|
326
|
+
.catch(() => ({ errors: [{ message: uploadResponse.statusText }] }));
|
|
327
|
+
|
|
328
|
+
const errorMessage = `Error (HTTP ${uploadResponse.status}) uploading worker script '${workerName}': ${errorData.errors?.[0]?.message || uploadResponse.statusText}`;
|
|
329
|
+
|
|
330
|
+
if (
|
|
331
|
+
uploadResponse.status === 400 &&
|
|
332
|
+
errorMessage.includes("not found")
|
|
333
|
+
) {
|
|
334
|
+
throw new NotFoundError(errorMessage);
|
|
335
|
+
}
|
|
336
|
+
throw new Error(errorMessage);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return formData;
|
|
304
340
|
},
|
|
341
|
+
(err) => err instanceof NotFoundError,
|
|
342
|
+
10,
|
|
343
|
+
100,
|
|
305
344
|
);
|
|
345
|
+
}
|
|
306
346
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
.catch(() => ({ errors: [{ message: uploadResponse.statusText }] }));
|
|
312
|
-
throw new Error(
|
|
313
|
-
`Error uploading worker script '${workerName}': ${errorData.errors?.[0]?.message || uploadResponse.statusText}`,
|
|
314
|
-
);
|
|
347
|
+
class NotFoundError extends Error {
|
|
348
|
+
constructor(message: string) {
|
|
349
|
+
super(message);
|
|
350
|
+
this.name = "NotFoundError";
|
|
315
351
|
}
|
|
316
|
-
|
|
317
|
-
return formData;
|
|
318
352
|
}
|
|
319
353
|
|
|
320
354
|
interface WorkerMetadata {
|
|
321
|
-
bindings:
|
|
355
|
+
bindings: WorkerBindingSpec[];
|
|
322
356
|
observability: {
|
|
323
357
|
enabled: boolean;
|
|
324
358
|
};
|
|
@@ -330,12 +364,12 @@ interface WorkerMetadata {
|
|
|
330
364
|
|
|
331
365
|
async function prepareWorkerMetadata(
|
|
332
366
|
ctx: Context<WorkerOutput>,
|
|
333
|
-
oldBindings:
|
|
367
|
+
oldBindings: Bindings | undefined,
|
|
334
368
|
props: WorkerProps,
|
|
335
369
|
): Promise<WorkerMetadata> {
|
|
336
370
|
// Prepare metadata with bindings
|
|
337
371
|
const meta: WorkerMetadata = {
|
|
338
|
-
bindings: []
|
|
372
|
+
bindings: [],
|
|
339
373
|
observability: {
|
|
340
374
|
enabled: props.observability?.enabled !== false,
|
|
341
375
|
},
|
|
@@ -349,30 +383,51 @@ async function prepareWorkerMetadata(
|
|
|
349
383
|
},
|
|
350
384
|
};
|
|
351
385
|
|
|
386
|
+
const bindings = (props.bindings ?? {}) as Resolved<Bindings>;
|
|
387
|
+
|
|
352
388
|
// Convert bindings to the format expected by the API
|
|
353
|
-
for (const binding of
|
|
389
|
+
for (const [bindingName, binding] of Object.entries(bindings)) {
|
|
354
390
|
// Create a copy of the binding to avoid modifying the original
|
|
355
|
-
const bindingData = { ...binding };
|
|
356
391
|
|
|
357
|
-
if (
|
|
358
|
-
|
|
359
|
-
|
|
392
|
+
if (isKVNamespace(binding)) {
|
|
393
|
+
meta.bindings.push({
|
|
394
|
+
type: "kv_namespace",
|
|
395
|
+
name: bindingName,
|
|
396
|
+
namespace_id: binding.id,
|
|
397
|
+
});
|
|
398
|
+
} else if (typeof binding === "string") {
|
|
399
|
+
meta.bindings.push({
|
|
400
|
+
type: "plain_text",
|
|
401
|
+
name: bindingName,
|
|
402
|
+
text: binding,
|
|
403
|
+
});
|
|
404
|
+
} else if (binding.type === "service") {
|
|
405
|
+
meta.bindings.push({
|
|
406
|
+
type: "service",
|
|
407
|
+
name: bindingName,
|
|
408
|
+
service: binding.id,
|
|
409
|
+
});
|
|
410
|
+
} else if (isDurableObjectNamespace(binding)) {
|
|
411
|
+
const stableId = binding.id;
|
|
412
|
+
const className = binding.className;
|
|
360
413
|
|
|
361
414
|
meta.bindings.push({
|
|
362
415
|
type: "durable_object_namespace",
|
|
363
|
-
name:
|
|
416
|
+
name: bindingName,
|
|
364
417
|
class_name: className,
|
|
365
|
-
script_name:
|
|
366
|
-
environment:
|
|
367
|
-
namespace_id:
|
|
418
|
+
script_name: binding.scriptName,
|
|
419
|
+
environment: binding.environment,
|
|
420
|
+
namespace_id: binding.namespaceId,
|
|
368
421
|
});
|
|
369
422
|
|
|
370
|
-
const oldBinding: DurableObjectNamespace | undefined =
|
|
423
|
+
const oldBinding: DurableObjectNamespace | undefined = Object.values(
|
|
424
|
+
oldBindings ?? {},
|
|
425
|
+
)
|
|
371
426
|
?.filter(isDurableObjectNamespace)
|
|
372
427
|
?.find((b) => b.id === stableId);
|
|
373
428
|
|
|
374
429
|
if (!oldBinding) {
|
|
375
|
-
if (
|
|
430
|
+
if (binding.sqlite) {
|
|
376
431
|
meta.migrations!.new_sqlite_classes!.push(className);
|
|
377
432
|
} else {
|
|
378
433
|
meta.migrations!.new_classes!.push(className);
|
|
@@ -384,8 +439,8 @@ async function prepareWorkerMetadata(
|
|
|
384
439
|
});
|
|
385
440
|
}
|
|
386
441
|
} else {
|
|
387
|
-
//
|
|
388
|
-
|
|
442
|
+
// @ts-expect-error - we should never reach here
|
|
443
|
+
throw new Error(`Unsupported binding type: ${binding.type}`);
|
|
389
444
|
}
|
|
390
445
|
}
|
|
391
446
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Octokit } from "@octokit/rest";
|
|
2
|
+
import { exec } from "child_process";
|
|
3
|
+
import { promisify } from "util";
|
|
4
|
+
|
|
5
|
+
// Convert exec to promise-based
|
|
6
|
+
const execAsync = promisify(exec);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Try to get a GitHub token from the GitHub CLI
|
|
10
|
+
*/
|
|
11
|
+
export async function getGitHubTokenFromCLI(): Promise<string | null> {
|
|
12
|
+
try {
|
|
13
|
+
// Check if GitHub CLI is installed
|
|
14
|
+
const { stdout: ghVersion } = await execAsync("gh --version");
|
|
15
|
+
if (!ghVersion) return null;
|
|
16
|
+
|
|
17
|
+
// Get the auth token
|
|
18
|
+
const { stdout: token } = await execAsync("gh auth token");
|
|
19
|
+
return token?.trim() || null;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get GitHub authentication token with the following priority:
|
|
27
|
+
* 1. Explicit token provided in props
|
|
28
|
+
* 2. GITHUB_ACCESS_TOKEN environment variable (for actions with admin permissions)
|
|
29
|
+
* 3. GITHUB_TOKEN environment variable
|
|
30
|
+
* 4. GitHub CLI token (if gh is installed and authenticated)
|
|
31
|
+
*
|
|
32
|
+
* @param token Optional token to use
|
|
33
|
+
* @returns The resolved token or null if not available
|
|
34
|
+
*/
|
|
35
|
+
export async function getGitHubToken(token?: string): Promise<string | null> {
|
|
36
|
+
return (
|
|
37
|
+
token ||
|
|
38
|
+
process.env.GITHUB_ACCESS_TOKEN ||
|
|
39
|
+
process.env.GITHUB_TOKEN ||
|
|
40
|
+
(await getGitHubTokenFromCLI())
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create an authenticated Octokit client
|
|
46
|
+
*
|
|
47
|
+
* @param options Options for creating the client
|
|
48
|
+
* @returns An authenticated Octokit client
|
|
49
|
+
*/
|
|
50
|
+
export async function createGitHubClient(
|
|
51
|
+
options: { token?: string } = {},
|
|
52
|
+
): Promise<Octokit> {
|
|
53
|
+
const token = await getGitHubToken(options.token);
|
|
54
|
+
|
|
55
|
+
return new Octokit({
|
|
56
|
+
auth: token,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Verifies GitHub authentication and provides helpful error messages
|
|
62
|
+
*
|
|
63
|
+
* @param octokit Octokit client
|
|
64
|
+
* @param owner Repository owner
|
|
65
|
+
* @param repo Repository name
|
|
66
|
+
*/
|
|
67
|
+
export async function verifyGitHubAuth(
|
|
68
|
+
octokit: Octokit,
|
|
69
|
+
owner: string,
|
|
70
|
+
repo: string,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
try {
|
|
73
|
+
// Make a test request to check authentication
|
|
74
|
+
await octokit.rest.repos.get({
|
|
75
|
+
owner,
|
|
76
|
+
repo,
|
|
77
|
+
});
|
|
78
|
+
} catch (error: any) {
|
|
79
|
+
if (error.status === 401) {
|
|
80
|
+
console.error(
|
|
81
|
+
"\n⚠️ GitHub authentication failed. Please try one of the following:",
|
|
82
|
+
);
|
|
83
|
+
console.error(
|
|
84
|
+
"1. Run 'gh auth login' to authenticate with the GitHub CLI",
|
|
85
|
+
);
|
|
86
|
+
console.error(
|
|
87
|
+
"2. Set the GITHUB_TOKEN environment variable with a personal access token",
|
|
88
|
+
);
|
|
89
|
+
console.error("3. Pass a token directly to the constructor");
|
|
90
|
+
console.error(
|
|
91
|
+
"\nTo create a token, visit: https://github.com/settings/tokens",
|
|
92
|
+
);
|
|
93
|
+
console.error(
|
|
94
|
+
"Required scopes: 'repo' for private repos or 'public_repo' for public repos\n",
|
|
95
|
+
);
|
|
96
|
+
throw new Error("GitHub authentication failed");
|
|
97
|
+
} else if (error.status === 403) {
|
|
98
|
+
console.error(
|
|
99
|
+
"\n⚠️ Insufficient permissions. You need admin access to the repository.",
|
|
100
|
+
);
|
|
101
|
+
console.error(
|
|
102
|
+
"Make sure your token has the 'repo' scope for private repos or 'public_repo' for public repos\n",
|
|
103
|
+
);
|
|
104
|
+
throw new Error("Insufficient GitHub permissions");
|
|
105
|
+
} else if (error.status === 404) {
|
|
106
|
+
console.error(`\n⚠️ Repository not found: ${owner}/${repo}`);
|
|
107
|
+
console.error(
|
|
108
|
+
"Make sure the repository exists and you have access to it\n",
|
|
109
|
+
);
|
|
110
|
+
throw new Error("GitHub repository not found");
|
|
111
|
+
} else {
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./secret";
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import sodium from "libsodium-wrappers";
|
|
2
|
+
import { type Context, Resource } from "../resource";
|
|
3
|
+
import { createGitHubClient, verifyGitHubAuth } from "./client";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Properties for creating or updating a GitHub Secret
|
|
7
|
+
*/
|
|
8
|
+
export interface GitHubSecretProps {
|
|
9
|
+
/**
|
|
10
|
+
* Repository owner (user or organization)
|
|
11
|
+
*/
|
|
12
|
+
owner: string;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Repository name
|
|
16
|
+
*/
|
|
17
|
+
repository: string;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Secret name
|
|
21
|
+
*/
|
|
22
|
+
name: string;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Secret value (will be stored securely on GitHub)
|
|
26
|
+
*/
|
|
27
|
+
value: string;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Optional GitHub API token (overrides environment variable)
|
|
31
|
+
*/
|
|
32
|
+
token?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Output returned after Secret creation/update
|
|
37
|
+
*/
|
|
38
|
+
export interface GitHubSecretOutput extends Omit<GitHubSecretProps, "value"> {
|
|
39
|
+
/**
|
|
40
|
+
* The ID of the resource
|
|
41
|
+
*/
|
|
42
|
+
id: string;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Time at which the object was created/updated
|
|
46
|
+
*/
|
|
47
|
+
updatedAt: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Encrypt a value for GitHub using libsodium
|
|
52
|
+
* Based on GitHub's documentation for encrypting secrets
|
|
53
|
+
*
|
|
54
|
+
* @param value - The secret value to encrypt
|
|
55
|
+
* @param publicKey - The base64-encoded public key from GitHub
|
|
56
|
+
* @returns The base64-encoded encrypted value
|
|
57
|
+
*/
|
|
58
|
+
async function encryptValue(value: string, publicKey: string): Promise<string> {
|
|
59
|
+
// Initialize libsodium
|
|
60
|
+
await sodium.ready;
|
|
61
|
+
|
|
62
|
+
// Convert the public key from base64 to binary
|
|
63
|
+
const binKey = sodium.from_base64(publicKey, sodium.base64_variants.ORIGINAL);
|
|
64
|
+
|
|
65
|
+
// Convert the message to a Uint8Array
|
|
66
|
+
const binMessage = sodium.from_string(value);
|
|
67
|
+
|
|
68
|
+
// Encrypt the message with the public key using libsodium's sealed box
|
|
69
|
+
const encryptedBin = sodium.crypto_box_seal(binMessage, binKey);
|
|
70
|
+
|
|
71
|
+
// Convert the encrypted message to base64
|
|
72
|
+
return sodium.to_base64(encryptedBin, sodium.base64_variants.ORIGINAL);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resource for managing GitHub repository secrets
|
|
77
|
+
*/
|
|
78
|
+
export class GitHubSecret extends Resource(
|
|
79
|
+
"github::Secret",
|
|
80
|
+
async (
|
|
81
|
+
ctx: Context<GitHubSecretOutput>,
|
|
82
|
+
props: GitHubSecretProps,
|
|
83
|
+
): Promise<GitHubSecretOutput | void> => {
|
|
84
|
+
// Create authenticated Octokit client - will automatically handle token resolution
|
|
85
|
+
const octokit = await createGitHubClient({ token: props.token });
|
|
86
|
+
|
|
87
|
+
// Verify authentication and permissions
|
|
88
|
+
if (!ctx.quiet) {
|
|
89
|
+
await verifyGitHubAuth(octokit, props.owner, props.repository);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (ctx.event === "delete") {
|
|
93
|
+
if (ctx.output?.id) {
|
|
94
|
+
try {
|
|
95
|
+
// Delete the secret
|
|
96
|
+
await octokit.rest.actions.deleteRepoSecret({
|
|
97
|
+
owner: props.owner,
|
|
98
|
+
repo: props.repository,
|
|
99
|
+
secret_name: props.name,
|
|
100
|
+
});
|
|
101
|
+
} catch (error: any) {
|
|
102
|
+
// Ignore 404 errors (secret already deleted)
|
|
103
|
+
if (error.status === 404) {
|
|
104
|
+
console.log("Secret doesn't exist, ignoring");
|
|
105
|
+
} else {
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Return void (a deleted resource has no content)
|
|
112
|
+
return undefined;
|
|
113
|
+
} else {
|
|
114
|
+
try {
|
|
115
|
+
// Get the repository's public key for encrypting secrets
|
|
116
|
+
const { data: publicKey } = await octokit.rest.actions.getRepoPublicKey(
|
|
117
|
+
{
|
|
118
|
+
owner: props.owner,
|
|
119
|
+
repo: props.repository,
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Encrypt the secret value using libsodium
|
|
124
|
+
const encryptedValue = await encryptValue(props.value, publicKey.key);
|
|
125
|
+
|
|
126
|
+
// Create or update the secret with the encrypted value and key_id
|
|
127
|
+
const response = await octokit.rest.actions.createOrUpdateRepoSecret({
|
|
128
|
+
owner: props.owner,
|
|
129
|
+
repo: props.repository,
|
|
130
|
+
secret_name: props.name,
|
|
131
|
+
encrypted_value: encryptedValue,
|
|
132
|
+
key_id: publicKey.key_id,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Log success
|
|
136
|
+
if (!ctx.quiet) {
|
|
137
|
+
console.log(`Successfully created/updated secret: ${props.name}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// GitHub doesn't return the secret details on create/update, so we need to construct it
|
|
141
|
+
const output: GitHubSecretOutput = {
|
|
142
|
+
id: `${props.owner}/${props.repository}/${props.name}`,
|
|
143
|
+
owner: props.owner,
|
|
144
|
+
repository: props.repository,
|
|
145
|
+
name: props.name,
|
|
146
|
+
token: props.token,
|
|
147
|
+
updatedAt: new Date().toISOString(),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return output;
|
|
151
|
+
} catch (error: any) {
|
|
152
|
+
if (
|
|
153
|
+
error.status === 403 &&
|
|
154
|
+
error.message?.includes("Must have admin rights")
|
|
155
|
+
) {
|
|
156
|
+
console.error(
|
|
157
|
+
"\n⚠️ Error creating/updating GitHub secret: You must have admin rights to the repository.",
|
|
158
|
+
);
|
|
159
|
+
console.error(
|
|
160
|
+
"Make sure your GitHub token has the required permissions (repo scope for private repos).\n",
|
|
161
|
+
);
|
|
162
|
+
} else {
|
|
163
|
+
console.error(
|
|
164
|
+
"Error creating/updating GitHub secret:",
|
|
165
|
+
error.message,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
) {}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility function for exponential backoff retry
|
|
3
|
+
* Retries an operation with exponential backoff when a retryable error occurs
|
|
4
|
+
*
|
|
5
|
+
* @param operation The async operation to execute and potentially retry
|
|
6
|
+
* @param isRetryable Function to determine if an error should trigger a retry
|
|
7
|
+
* @param maxAttempts Maximum number of attempts before giving up
|
|
8
|
+
* @param initialDelayMs Initial delay in milliseconds before first retry
|
|
9
|
+
* @returns Result of the operation
|
|
10
|
+
* @throws The last error encountered if all retries fail
|
|
11
|
+
*/
|
|
12
|
+
export async function withExponentialBackoff<T>(
|
|
13
|
+
operation: () => Promise<T>,
|
|
14
|
+
isRetryable: (error: any) => boolean,
|
|
15
|
+
maxAttempts = 5,
|
|
16
|
+
initialDelayMs = 100,
|
|
17
|
+
): Promise<T> {
|
|
18
|
+
let attempt = 0;
|
|
19
|
+
let delay = initialDelayMs;
|
|
20
|
+
|
|
21
|
+
while (true) {
|
|
22
|
+
try {
|
|
23
|
+
return await operation();
|
|
24
|
+
} catch (error) {
|
|
25
|
+
attempt++;
|
|
26
|
+
if (attempt >= maxAttempts || !isRetryable(error)) {
|
|
27
|
+
console.warn("Failed after ", attempt, " attempts");
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Exponential backoff with jitter
|
|
32
|
+
const jitter = Math.random() * 0.1 * delay;
|
|
33
|
+
await new Promise((resolve) => setTimeout(resolve, delay + jitter));
|
|
34
|
+
delay *= 2; // Double the delay for next attempt
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|