alchemy 0.28.0 → 0.29.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/lib/alchemy.d.ts +15 -1
- package/lib/alchemy.d.ts.map +1 -1
- package/lib/alchemy.js +42 -8
- package/lib/alchemy.js.map +1 -1
- package/lib/apply.js +3 -9
- package/lib/apply.js.map +1 -1
- package/lib/cloudflare/do-state-store/internal.d.ts +1 -1
- package/lib/cloudflare/do-state-store/internal.d.ts.map +1 -1
- package/lib/cloudflare/do-state-store/internal.js +2 -2
- package/lib/cloudflare/do-state-store/internal.js.map +1 -1
- package/lib/cloudflare/do-state-store/store.d.ts.map +1 -1
- package/lib/cloudflare/do-state-store/store.js +19 -4
- package/lib/cloudflare/do-state-store/store.js.map +1 -1
- package/lib/cloudflare/secrets-store.d.ts.map +1 -1
- package/lib/cloudflare/secrets-store.js +21 -17
- package/lib/cloudflare/secrets-store.js.map +1 -1
- package/lib/cloudflare/worker-assets.d.ts.map +1 -1
- package/lib/cloudflare/worker-assets.js +9 -0
- package/lib/cloudflare/worker-assets.js.map +1 -1
- package/lib/destroy.js +3 -3
- package/lib/destroy.js.map +1 -1
- package/lib/github/comment.d.ts +156 -0
- package/lib/github/comment.d.ts.map +1 -0
- package/lib/github/comment.js +192 -0
- package/lib/github/comment.js.map +1 -0
- package/lib/github/index.d.ts +1 -0
- package/lib/github/index.d.ts.map +1 -1
- package/lib/github/index.js +1 -0
- package/lib/github/index.js.map +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/lib/scope.d.ts +3 -0
- package/lib/scope.d.ts.map +1 -1
- package/lib/scope.js +2 -1
- package/lib/scope.js.map +1 -1
- package/lib/util/cli.d.ts.map +1 -1
- package/lib/util/cli.js +5 -6
- package/lib/util/cli.js.map +1 -1
- package/lib/util/telemetry/client.js +1 -1
- package/lib/util/telemetry/client.js.map +1 -1
- package/package.json +2 -1
- package/src/alchemy.ts +65 -9
- package/src/apply.ts +3 -9
- package/src/cloudflare/do-state-store/internal.ts +2 -1
- package/src/cloudflare/do-state-store/store.ts +26 -4
- package/src/cloudflare/secrets-store.ts +22 -21
- package/src/cloudflare/worker-assets.ts +16 -0
- package/src/destroy.ts +3 -3
- package/src/github/comment.ts +278 -0
- package/src/github/index.ts +1 -0
- package/src/index.ts +1 -1
- package/src/scope.ts +14 -6
- package/src/util/cli.ts +25 -18
- package/src/util/telemetry/client.ts +1 -1
package/src/alchemy.ts
CHANGED
|
@@ -19,6 +19,39 @@ import { logger } from "./util/logger.ts";
|
|
|
19
19
|
import { TelemetryClient } from "./util/telemetry/client.ts";
|
|
20
20
|
import type { LoggerApi } from "./util/cli.ts";
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Parses CLI arguments to extract alchemy options
|
|
24
|
+
*/
|
|
25
|
+
function parseCliArgs(): Partial<AlchemyOptions> {
|
|
26
|
+
const args = process.argv.slice(2);
|
|
27
|
+
const options: Partial<AlchemyOptions> = {};
|
|
28
|
+
|
|
29
|
+
// Parse phase from CLI arguments
|
|
30
|
+
if (args.includes("--destroy")) {
|
|
31
|
+
options.phase = "destroy";
|
|
32
|
+
} else if (args.includes("--read")) {
|
|
33
|
+
options.phase = "read";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Parse quiet flag
|
|
37
|
+
if (args.includes("--quiet")) {
|
|
38
|
+
options.quiet = true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Parse stage argument (--stage my-stage)
|
|
42
|
+
const stageIndex = args.indexOf("--stage");
|
|
43
|
+
if (stageIndex !== -1 && stageIndex + 1 < args.length) {
|
|
44
|
+
options.stage = args[stageIndex + 1];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Get password from environment variables
|
|
48
|
+
if (process.env.ALCHEMY_PASSWORD) {
|
|
49
|
+
options.password = process.env.ALCHEMY_PASSWORD;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return options;
|
|
53
|
+
}
|
|
54
|
+
|
|
22
55
|
/**
|
|
23
56
|
* Type alias for semantic highlighting of `alchemy` as a type keyword
|
|
24
57
|
*/
|
|
@@ -29,9 +62,16 @@ export const alchemy: Alchemy = _alchemy as any;
|
|
|
29
62
|
/**
|
|
30
63
|
* The Alchemy interface provides core functionality and is augmented by providers.
|
|
31
64
|
* Supports both application scoping with secrets and template string interpolation.
|
|
65
|
+
* Automatically parses CLI arguments for common options.
|
|
32
66
|
*
|
|
33
67
|
* @example
|
|
34
|
-
* //
|
|
68
|
+
* // Simple usage with automatic CLI argument parsing
|
|
69
|
+
* const app = await alchemy("my-app");
|
|
70
|
+
* // Now supports: --destroy, --read, --quiet, --stage my-stage
|
|
71
|
+
* // Environment variables: PASSWORD, ALCHEMY_PASSWORD, ALCHEMY_STAGE, USER
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* // Create an application scope with explicit options (overrides CLI args)
|
|
35
75
|
* const app = await alchemy("github:alchemy", {
|
|
36
76
|
* stage: "prod",
|
|
37
77
|
* phase: "up",
|
|
@@ -69,8 +109,15 @@ export interface Alchemy {
|
|
|
69
109
|
/**
|
|
70
110
|
* Creates a new application scope with the given name and options.
|
|
71
111
|
* Used to create and manage resources with proper secret handling.
|
|
112
|
+
* Automatically parses CLI arguments: --destroy, --read, --quiet, --stage <name>
|
|
113
|
+
* Environment variables: PASSWORD, ALCHEMY_PASSWORD, ALCHEMY_STAGE, USER
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* // Simple usage with CLI argument parsing
|
|
117
|
+
* const app = await alchemy("my-app");
|
|
72
118
|
*
|
|
73
119
|
* @example
|
|
120
|
+
* // With explicit options (overrides CLI args)
|
|
74
121
|
* const app = await alchemy("my-app", {
|
|
75
122
|
* stage: "prod",
|
|
76
123
|
* // Required for encrypting/decrypting secrets
|
|
@@ -115,20 +162,29 @@ async function _alchemy(
|
|
|
115
162
|
): Promise<Scope | string | never> {
|
|
116
163
|
if (typeof args[0] === "string") {
|
|
117
164
|
const [appName, options] = args as [string, AlchemyOptions?];
|
|
118
|
-
|
|
165
|
+
|
|
166
|
+
// Parse CLI arguments and merge with provided options (explicit options take precedence)
|
|
167
|
+
const cliOptions = parseCliArgs();
|
|
168
|
+
const mergedOptions = {
|
|
169
|
+
...cliOptions,
|
|
170
|
+
...options,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const phase = isRuntime ? "read" : (mergedOptions?.phase ?? "up");
|
|
119
174
|
const telemetryClient =
|
|
120
|
-
|
|
175
|
+
mergedOptions?.parent?.telemetryClient ??
|
|
121
176
|
TelemetryClient.create({
|
|
122
177
|
phase,
|
|
123
|
-
enabled:
|
|
124
|
-
quiet:
|
|
178
|
+
enabled: mergedOptions?.telemetry ?? true,
|
|
179
|
+
quiet: mergedOptions?.quiet ?? false,
|
|
125
180
|
});
|
|
126
181
|
const root = new Scope({
|
|
127
|
-
...
|
|
182
|
+
...mergedOptions,
|
|
128
183
|
appName,
|
|
129
|
-
stage:
|
|
184
|
+
stage:
|
|
185
|
+
mergedOptions?.stage ?? process.env.ALCHEMY_STAGE ?? process.env.USER,
|
|
130
186
|
phase,
|
|
131
|
-
password:
|
|
187
|
+
password: mergedOptions?.password ?? process.env.ALCHEMY_PASSWORD,
|
|
132
188
|
telemetryClient,
|
|
133
189
|
});
|
|
134
190
|
try {
|
|
@@ -138,7 +194,7 @@ async function _alchemy(
|
|
|
138
194
|
// see Scope.finalize for where we pop the global scope
|
|
139
195
|
Scope.globals.push(root);
|
|
140
196
|
}
|
|
141
|
-
if (
|
|
197
|
+
if (mergedOptions?.phase === "destroy") {
|
|
142
198
|
await destroy(root);
|
|
143
199
|
return process.exit(0);
|
|
144
200
|
}
|
package/src/apply.ts
CHANGED
|
@@ -41,12 +41,6 @@ async function _apply<Out extends Resource>(
|
|
|
41
41
|
const scope = resource[ResourceScope];
|
|
42
42
|
const start = performance.now();
|
|
43
43
|
try {
|
|
44
|
-
logger.task(resource[ResourceFQN], {
|
|
45
|
-
prefix: "SETUP",
|
|
46
|
-
prefixColor: "cyanBright",
|
|
47
|
-
resource: formatFQN(resource[ResourceFQN]),
|
|
48
|
-
message: "Setting up Resource...",
|
|
49
|
-
});
|
|
50
44
|
const quiet = props?.quiet ?? scope.quiet;
|
|
51
45
|
await scope.init();
|
|
52
46
|
let state: State | undefined = (await scope.state.get(
|
|
@@ -112,7 +106,7 @@ async function _apply<Out extends Resource>(
|
|
|
112
106
|
) {
|
|
113
107
|
if (!quiet) {
|
|
114
108
|
logger.task(resource[ResourceFQN], {
|
|
115
|
-
prefix: "
|
|
109
|
+
prefix: "skipped",
|
|
116
110
|
prefixColor: "yellowBright",
|
|
117
111
|
resource: formatFQN(resource[ResourceFQN]),
|
|
118
112
|
message: "Skipped Resource (no changes)",
|
|
@@ -141,7 +135,7 @@ async function _apply<Out extends Resource>(
|
|
|
141
135
|
|
|
142
136
|
if (!quiet) {
|
|
143
137
|
logger.task(resource[ResourceFQN], {
|
|
144
|
-
prefix: phase === "create" ? "
|
|
138
|
+
prefix: phase === "create" ? "creating" : "updating",
|
|
145
139
|
prefixColor: "magenta",
|
|
146
140
|
resource: formatFQN(resource[ResourceFQN]),
|
|
147
141
|
message: `${phase === "create" ? "Creating" : "Updating"} Resource...`,
|
|
@@ -191,7 +185,7 @@ async function _apply<Out extends Resource>(
|
|
|
191
185
|
);
|
|
192
186
|
if (!quiet) {
|
|
193
187
|
logger.task(resource[ResourceFQN], {
|
|
194
|
-
prefix: phase === "create" ? "
|
|
188
|
+
prefix: phase === "create" ? "created" : "updated",
|
|
195
189
|
prefixColor: "greenBright",
|
|
196
190
|
resource: formatFQN(resource[ResourceFQN]),
|
|
197
191
|
message: `${phase === "create" ? "Created" : "Updated"} Resource`,
|
|
@@ -104,6 +104,7 @@ export async function upsertStateStoreWorker(
|
|
|
104
104
|
api: CloudflareApi,
|
|
105
105
|
workerName: string,
|
|
106
106
|
token: string,
|
|
107
|
+
force: boolean,
|
|
107
108
|
) {
|
|
108
109
|
const key = `worker:${workerName}`;
|
|
109
110
|
const cached = cache.get(key);
|
|
@@ -111,7 +112,7 @@ export async function upsertStateStoreWorker(
|
|
|
111
112
|
return;
|
|
112
113
|
}
|
|
113
114
|
const { found, tag } = await getWorkerStatus(api, workerName);
|
|
114
|
-
if (found && tag === TAG) {
|
|
115
|
+
if (found && tag === TAG && !force) {
|
|
115
116
|
cache.set(key, TAG);
|
|
116
117
|
return;
|
|
117
118
|
}
|
|
@@ -88,7 +88,12 @@ export class DOStateStore implements StateStore {
|
|
|
88
88
|
const api = await createCloudflareApi(this.options);
|
|
89
89
|
const [subdomain, _] = await Promise.all([
|
|
90
90
|
getAccountSubdomain(api),
|
|
91
|
-
upsertStateStoreWorker(
|
|
91
|
+
upsertStateStoreWorker(
|
|
92
|
+
api,
|
|
93
|
+
workerName,
|
|
94
|
+
token,
|
|
95
|
+
this.options.worker?.force ?? false,
|
|
96
|
+
),
|
|
92
97
|
]);
|
|
93
98
|
const client = new DOStateStoreClient({
|
|
94
99
|
app: this.scope.appName ?? "alchemy",
|
|
@@ -97,9 +102,26 @@ export class DOStateStore implements StateStore {
|
|
|
97
102
|
token,
|
|
98
103
|
});
|
|
99
104
|
// This ensures the token is correct and the worker is ready to use.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
105
|
+
let last: Response | undefined;
|
|
106
|
+
let delay = 1000;
|
|
107
|
+
for (let i = 0; i < 20; i++) {
|
|
108
|
+
const res = await client.validate();
|
|
109
|
+
if (res.ok) {
|
|
110
|
+
return client;
|
|
111
|
+
}
|
|
112
|
+
if (!last) {
|
|
113
|
+
console.log("Waiting for state store deployment...");
|
|
114
|
+
}
|
|
115
|
+
last = res;
|
|
116
|
+
// Exponential backoff with jitter
|
|
117
|
+
const jitter = Math.random() * 0.1 * delay;
|
|
118
|
+
await new Promise((resolve) => setTimeout(resolve, delay + jitter));
|
|
119
|
+
delay *= 1.5; // Increase the delay for next attempt
|
|
120
|
+
delay = Math.min(delay, 10000); // Cap at 10 seconds
|
|
121
|
+
}
|
|
122
|
+
throw new Error(
|
|
123
|
+
`Failed to access state store: ${last?.status} ${last?.statusText}`,
|
|
124
|
+
);
|
|
103
125
|
}
|
|
104
126
|
|
|
105
127
|
private async getClient() {
|
|
@@ -220,33 +220,34 @@ const _SecretsStore = Resource("cloudflare::SecretsStore", async function <
|
|
|
220
220
|
|
|
221
221
|
await insertSecrets(api, storeId, props);
|
|
222
222
|
} else {
|
|
223
|
-
|
|
223
|
+
// If adopt is true, first check if a store with this name already exists
|
|
224
|
+
if (props.adopt) {
|
|
225
|
+
console.log(`Checking for existing secrets store '${name}' to adopt`);
|
|
226
|
+
const existingStore = await findSecretsStoreByName(api, name);
|
|
227
|
+
|
|
228
|
+
if (existingStore) {
|
|
229
|
+
console.log(`Found existing secrets store '${name}', adopting it`);
|
|
230
|
+
storeId = existingStore.id;
|
|
231
|
+
createdAt = existingStore.createdAt || Date.now();
|
|
232
|
+
} else {
|
|
233
|
+
console.log(
|
|
234
|
+
`No existing secrets store '${name}' found, creating new one`,
|
|
235
|
+
);
|
|
236
|
+
const { id } = await createSecretsStore(api, {
|
|
237
|
+
...props,
|
|
238
|
+
name,
|
|
239
|
+
});
|
|
240
|
+
createdAt = Date.now();
|
|
241
|
+
storeId = id;
|
|
242
|
+
}
|
|
243
|
+
} else {
|
|
244
|
+
// Default behavior: create a new store
|
|
224
245
|
const { id } = await createSecretsStore(api, {
|
|
225
246
|
...props,
|
|
226
247
|
name,
|
|
227
248
|
});
|
|
228
249
|
createdAt = Date.now();
|
|
229
250
|
storeId = id;
|
|
230
|
-
} catch (error) {
|
|
231
|
-
if (
|
|
232
|
-
props.adopt &&
|
|
233
|
-
error instanceof Error &&
|
|
234
|
-
error.message.includes("already exists")
|
|
235
|
-
) {
|
|
236
|
-
console.log(`Secrets store '${name}' already exists, adopting it`);
|
|
237
|
-
const existingStore = await findSecretsStoreByName(api, name);
|
|
238
|
-
|
|
239
|
-
if (!existingStore) {
|
|
240
|
-
throw new Error(
|
|
241
|
-
`Failed to find existing secrets store '${name}' for adoption`,
|
|
242
|
-
);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
storeId = existingStore.id;
|
|
246
|
-
createdAt = existingStore.createdAt || Date.now();
|
|
247
|
-
} else {
|
|
248
|
-
throw error;
|
|
249
|
-
}
|
|
250
251
|
}
|
|
251
252
|
|
|
252
253
|
await insertSecrets(api, storeId, props);
|
|
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { AsyncQueue } from "../util/async-queue.ts";
|
|
5
5
|
import { getContentType } from "../util/content-type.ts";
|
|
6
|
+
import { CloudflareApiError } from "./api-error.ts";
|
|
6
7
|
import type { CloudflareApi } from "./api.ts";
|
|
7
8
|
import type { Assets } from "./assets.ts";
|
|
8
9
|
import type { AssetsConfig, WorkerProps } from "./worker.ts";
|
|
@@ -84,6 +85,21 @@ export async function uploadAssets(
|
|
|
84
85
|
const sessionData =
|
|
85
86
|
(await uploadSessionResponse.json()) as UploadSessionResponse;
|
|
86
87
|
|
|
88
|
+
if (!sessionData?.success) {
|
|
89
|
+
if (sessionData?.errors) {
|
|
90
|
+
throw new CloudflareApiError(
|
|
91
|
+
`Failed to start assets upload session:\n${sessionData.errors
|
|
92
|
+
.map((error) => `- ${error.code}: ${error.message}`)
|
|
93
|
+
.join("\n")}`,
|
|
94
|
+
uploadSessionResponse,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
throw new CloudflareApiError(
|
|
98
|
+
`Failed to start assets upload session: ${uploadSessionResponse.statusText}`,
|
|
99
|
+
uploadSessionResponse,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
87
103
|
// If there are no buckets, assets are already uploaded or empty
|
|
88
104
|
if (!sessionData.result.buckets || sessionData.result.buckets.length === 0) {
|
|
89
105
|
return {
|
package/src/destroy.ts
CHANGED
|
@@ -89,8 +89,8 @@ export async function destroy<Type extends string>(
|
|
|
89
89
|
try {
|
|
90
90
|
if (!quiet) {
|
|
91
91
|
logger.task(instance[ResourceFQN], {
|
|
92
|
-
prefix: "
|
|
93
|
-
prefixColor: "
|
|
92
|
+
prefix: "deleting",
|
|
93
|
+
prefixColor: "redBright",
|
|
94
94
|
resource: formatFQN(instance[ResourceFQN]),
|
|
95
95
|
message: "Deleting Resource...",
|
|
96
96
|
});
|
|
@@ -150,7 +150,7 @@ export async function destroy<Type extends string>(
|
|
|
150
150
|
|
|
151
151
|
if (!quiet) {
|
|
152
152
|
logger.task(instance[ResourceFQN], {
|
|
153
|
-
prefix: "
|
|
153
|
+
prefix: "deleted",
|
|
154
154
|
prefixColor: "greenBright",
|
|
155
155
|
resource: formatFQN(instance[ResourceFQN]),
|
|
156
156
|
message: "Deleted Resource",
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import type { Context } from "../context.ts";
|
|
2
|
+
import { Resource } from "../resource.ts";
|
|
3
|
+
import type { Secret } from "../secret.ts";
|
|
4
|
+
import { logger } from "../util/logger.ts";
|
|
5
|
+
import { createGitHubClient, verifyGitHubAuth } from "./client.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Properties for creating or updating a GitHub Comment
|
|
9
|
+
*/
|
|
10
|
+
export interface GitHubCommentProps {
|
|
11
|
+
/**
|
|
12
|
+
* Repository owner (user or organization)
|
|
13
|
+
*/
|
|
14
|
+
owner: string;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Repository name
|
|
18
|
+
*/
|
|
19
|
+
repository: string;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Issue or Pull Request number to comment on
|
|
23
|
+
*/
|
|
24
|
+
issueNumber: number;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Comment body (supports GitHub Markdown)
|
|
28
|
+
*/
|
|
29
|
+
body: string;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether to allow deletion of the comment
|
|
33
|
+
* By default, comments are never deleted to preserve discussion history
|
|
34
|
+
* @default false
|
|
35
|
+
*/
|
|
36
|
+
allowDelete?: boolean;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Optional GitHub API token (overrides environment variable)
|
|
40
|
+
* If not provided, will use GITHUB_TOKEN or GITHUB_ACCESS_TOKEN environment variables
|
|
41
|
+
* Token must have 'repo' scope for private repositories
|
|
42
|
+
* or 'public_repo' scope for public repositories
|
|
43
|
+
*/
|
|
44
|
+
token?: Secret;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Output returned after Comment creation/update
|
|
49
|
+
*/
|
|
50
|
+
export interface GitHubComment
|
|
51
|
+
extends Resource<"github::Comment">,
|
|
52
|
+
Omit<GitHubCommentProps, "token"> {
|
|
53
|
+
/**
|
|
54
|
+
* The ID of the resource
|
|
55
|
+
*/
|
|
56
|
+
id: string;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The numeric ID of the comment in GitHub
|
|
60
|
+
*/
|
|
61
|
+
commentId: number;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* URL to view the comment
|
|
65
|
+
*/
|
|
66
|
+
htmlUrl: string;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Time at which the comment was created/updated
|
|
70
|
+
*/
|
|
71
|
+
updatedAt: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resource for managing GitHub issue and pull request comments
|
|
76
|
+
*
|
|
77
|
+
* By default, comments are never deleted to preserve discussion history.
|
|
78
|
+
* Set `allowDelete: true` to enable deletion when the resource is destroyed.
|
|
79
|
+
*
|
|
80
|
+
* Authentication is handled in the following order:
|
|
81
|
+
* 1. `token` parameter in the resource props (if provided)
|
|
82
|
+
* 2. `GITHUB_ACCESS_TOKEN` environment variable (for actions with admin permissions)
|
|
83
|
+
* 3. `GITHUB_TOKEN` environment variable
|
|
84
|
+
* 4. GitHub CLI token (if gh is installed and authenticated)
|
|
85
|
+
*
|
|
86
|
+
* The token must have the following permissions:
|
|
87
|
+
* - 'repo' scope for private repositories
|
|
88
|
+
* - 'public_repo' scope for public repositories
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ## Create a comment on an issue
|
|
92
|
+
*
|
|
93
|
+
* Create a comment on issue #123 using the default GITHUB_TOKEN
|
|
94
|
+
*
|
|
95
|
+
* ```ts
|
|
96
|
+
* const comment = await GitHubComment("issue-comment", {
|
|
97
|
+
* owner: "my-org",
|
|
98
|
+
* repository: "my-repo",
|
|
99
|
+
* issueNumber: 123,
|
|
100
|
+
* body: "This is a comment created by Alchemy!"
|
|
101
|
+
* });
|
|
102
|
+
* ```
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* ## Create a comment on a pull request
|
|
106
|
+
*
|
|
107
|
+
* Comments work the same way for pull requests
|
|
108
|
+
*
|
|
109
|
+
* ```ts
|
|
110
|
+
* const prComment = await GitHubComment("pr-comment", {
|
|
111
|
+
* owner: "my-org",
|
|
112
|
+
* repository: "my-repo",
|
|
113
|
+
* issueNumber: 456, // PR number
|
|
114
|
+
* body: "## Deployment Status\n\n✅ Successfully deployed to staging!"
|
|
115
|
+
* });
|
|
116
|
+
* ```
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ## Update comment content
|
|
120
|
+
*
|
|
121
|
+
* Comments can be updated by changing the body content
|
|
122
|
+
*
|
|
123
|
+
* ```ts
|
|
124
|
+
* const comment = await GitHubComment("status-comment", {
|
|
125
|
+
* owner: "my-org",
|
|
126
|
+
* repository: "my-repo",
|
|
127
|
+
* issueNumber: 789,
|
|
128
|
+
* body: "🔄 Deployment in progress..."
|
|
129
|
+
* });
|
|
130
|
+
*
|
|
131
|
+
* // Later, update the comment
|
|
132
|
+
* await GitHubComment("status-comment", {
|
|
133
|
+
* owner: "my-org",
|
|
134
|
+
* repository: "my-repo",
|
|
135
|
+
* issueNumber: 789,
|
|
136
|
+
* body: "✅ Deployment completed successfully!"
|
|
137
|
+
* });
|
|
138
|
+
* ```
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* ## Allow comment deletion
|
|
142
|
+
*
|
|
143
|
+
* By default comments are preserved, but you can opt-in to deletion
|
|
144
|
+
*
|
|
145
|
+
* ```ts
|
|
146
|
+
* const comment = await GitHubComment("temp-comment", {
|
|
147
|
+
* owner: "my-org",
|
|
148
|
+
* repository: "my-repo",
|
|
149
|
+
* issueNumber: 123,
|
|
150
|
+
* body: "This comment can be deleted",
|
|
151
|
+
* allowDelete: true
|
|
152
|
+
* });
|
|
153
|
+
* ```
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ## Use custom authentication token
|
|
157
|
+
*
|
|
158
|
+
* Pass a custom GitHub token for authentication
|
|
159
|
+
*
|
|
160
|
+
* ```ts
|
|
161
|
+
* const comment = await GitHubComment("authenticated-comment", {
|
|
162
|
+
* owner: "my-org",
|
|
163
|
+
* repository: "my-repo",
|
|
164
|
+
* issueNumber: 123,
|
|
165
|
+
* body: "Comment with custom token",
|
|
166
|
+
* token: alchemy.secret(process.env.CUSTOM_GITHUB_TOKEN)
|
|
167
|
+
* });
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
export const GitHubComment = Resource(
|
|
171
|
+
"github::Comment",
|
|
172
|
+
async function (
|
|
173
|
+
this: Context<GitHubComment>,
|
|
174
|
+
_id: string,
|
|
175
|
+
props: GitHubCommentProps,
|
|
176
|
+
): Promise<GitHubComment> {
|
|
177
|
+
// Create authenticated Octokit client - will automatically handle token resolution
|
|
178
|
+
const octokit = await createGitHubClient({
|
|
179
|
+
token: props.token?.unencrypted,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Verify authentication and permissions
|
|
183
|
+
if (!this.quiet) {
|
|
184
|
+
await verifyGitHubAuth(octokit, props.owner, props.repository);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (this.phase === "delete") {
|
|
188
|
+
if (this.output?.commentId && props.allowDelete) {
|
|
189
|
+
try {
|
|
190
|
+
// Delete the comment
|
|
191
|
+
await octokit.rest.issues.deleteComment({
|
|
192
|
+
owner: props.owner,
|
|
193
|
+
repo: props.repository,
|
|
194
|
+
comment_id: this.output.commentId,
|
|
195
|
+
});
|
|
196
|
+
} catch (error: any) {
|
|
197
|
+
// Ignore 404 errors (comment already deleted)
|
|
198
|
+
if (error.status === 404) {
|
|
199
|
+
logger.log("Comment doesn't exist, ignoring");
|
|
200
|
+
} else {
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} else if (this.output?.commentId && !props.allowDelete) {
|
|
205
|
+
logger.log(
|
|
206
|
+
"Comment deletion skipped - allowDelete is false (default behavior to preserve discussion history)",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Return void (a deleted resource has no content)
|
|
211
|
+
return this.destroy();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
if (this.phase === "update" && this.output?.commentId) {
|
|
216
|
+
// Update existing comment
|
|
217
|
+
const { data: updatedComment } =
|
|
218
|
+
await octokit.rest.issues.updateComment({
|
|
219
|
+
owner: props.owner,
|
|
220
|
+
repo: props.repository,
|
|
221
|
+
comment_id: this.output.commentId,
|
|
222
|
+
body: props.body,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return this({
|
|
226
|
+
id: `${props.owner}/${props.repository}/issues/${props.issueNumber}/comments/${updatedComment.id}`,
|
|
227
|
+
commentId: updatedComment.id,
|
|
228
|
+
owner: props.owner,
|
|
229
|
+
repository: props.repository,
|
|
230
|
+
issueNumber: props.issueNumber,
|
|
231
|
+
body: props.body,
|
|
232
|
+
allowDelete: props.allowDelete,
|
|
233
|
+
htmlUrl: updatedComment.html_url,
|
|
234
|
+
updatedAt: updatedComment.updated_at,
|
|
235
|
+
});
|
|
236
|
+
} else {
|
|
237
|
+
// Create new comment
|
|
238
|
+
const { data: newComment } = await octokit.rest.issues.createComment({
|
|
239
|
+
owner: props.owner,
|
|
240
|
+
repo: props.repository,
|
|
241
|
+
issue_number: props.issueNumber,
|
|
242
|
+
body: props.body,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
return this({
|
|
246
|
+
id: `${props.owner}/${props.repository}/issues/${props.issueNumber}/comments/${newComment.id}`,
|
|
247
|
+
commentId: newComment.id,
|
|
248
|
+
owner: props.owner,
|
|
249
|
+
repository: props.repository,
|
|
250
|
+
issueNumber: props.issueNumber,
|
|
251
|
+
body: props.body,
|
|
252
|
+
allowDelete: props.allowDelete,
|
|
253
|
+
htmlUrl: newComment.html_url,
|
|
254
|
+
updatedAt: newComment.updated_at,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
} catch (error: any) {
|
|
258
|
+
if (error.status === 403) {
|
|
259
|
+
logger.error(
|
|
260
|
+
"\n⚠️ Error creating/updating GitHub comment: Insufficient permissions.",
|
|
261
|
+
);
|
|
262
|
+
logger.error(
|
|
263
|
+
"Make sure your GitHub token has the required permissions (repo scope for private repos).\n",
|
|
264
|
+
);
|
|
265
|
+
} else if (error.status === 404) {
|
|
266
|
+
logger.error(
|
|
267
|
+
`\n⚠️ Issue or Pull Request #${props.issueNumber} not found in ${props.owner}/${props.repository}`,
|
|
268
|
+
);
|
|
269
|
+
logger.error(
|
|
270
|
+
"Make sure the issue/PR exists and you have access to it\n",
|
|
271
|
+
);
|
|
272
|
+
} else {
|
|
273
|
+
logger.error("Error creating/updating GitHub comment:", error.message);
|
|
274
|
+
}
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
);
|
package/src/github/index.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export type { AlchemyOptions, Phase } from "./alchemy.ts";
|
|
2
2
|
export type * from "./context.ts";
|
|
3
3
|
export * from "./resource.ts";
|
|
4
|
-
export
|
|
4
|
+
export * from "./scope.ts";
|
|
5
5
|
export * from "./secret.ts";
|
|
6
6
|
export * from "./serde.ts";
|
|
7
7
|
export * from "./state.ts";
|
package/src/scope.ts
CHANGED
|
@@ -27,10 +27,15 @@ export interface ScopeOptions {
|
|
|
27
27
|
// TODO: support browser
|
|
28
28
|
const DEFAULT_STAGE = process.env.ALCHEMY_STAGE ?? process.env.USER ?? "dev";
|
|
29
29
|
|
|
30
|
+
declare global {
|
|
31
|
+
var __ALCHEMY_STORAGE__: AsyncLocalStorage<Scope>;
|
|
32
|
+
}
|
|
33
|
+
|
|
30
34
|
export class Scope {
|
|
31
35
|
public static readonly KIND = "alchemy::Scope" as const;
|
|
32
36
|
|
|
33
|
-
public static storage =
|
|
37
|
+
public static storage = (globalThis.__ALCHEMY_STORAGE__ ??=
|
|
38
|
+
new AsyncLocalStorage<Scope>());
|
|
34
39
|
public static globals: Scope[] = [];
|
|
35
40
|
|
|
36
41
|
public static get(): Scope | undefined {
|
|
@@ -98,11 +103,14 @@ export class Scope {
|
|
|
98
103
|
|
|
99
104
|
this.logger = this.quiet
|
|
100
105
|
? createDummyLogger()
|
|
101
|
-
: createLoggerInstance(
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
+
: createLoggerInstance(
|
|
107
|
+
{
|
|
108
|
+
phase: this.phase,
|
|
109
|
+
stage: this.stage,
|
|
110
|
+
appName: this.appName ?? "",
|
|
111
|
+
},
|
|
112
|
+
options.logger,
|
|
113
|
+
);
|
|
106
114
|
|
|
107
115
|
this.stateStore =
|
|
108
116
|
options.stateStore ??
|