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.
Files changed (56) hide show
  1. package/lib/alchemy.d.ts +15 -1
  2. package/lib/alchemy.d.ts.map +1 -1
  3. package/lib/alchemy.js +42 -8
  4. package/lib/alchemy.js.map +1 -1
  5. package/lib/apply.js +3 -9
  6. package/lib/apply.js.map +1 -1
  7. package/lib/cloudflare/do-state-store/internal.d.ts +1 -1
  8. package/lib/cloudflare/do-state-store/internal.d.ts.map +1 -1
  9. package/lib/cloudflare/do-state-store/internal.js +2 -2
  10. package/lib/cloudflare/do-state-store/internal.js.map +1 -1
  11. package/lib/cloudflare/do-state-store/store.d.ts.map +1 -1
  12. package/lib/cloudflare/do-state-store/store.js +19 -4
  13. package/lib/cloudflare/do-state-store/store.js.map +1 -1
  14. package/lib/cloudflare/secrets-store.d.ts.map +1 -1
  15. package/lib/cloudflare/secrets-store.js +21 -17
  16. package/lib/cloudflare/secrets-store.js.map +1 -1
  17. package/lib/cloudflare/worker-assets.d.ts.map +1 -1
  18. package/lib/cloudflare/worker-assets.js +9 -0
  19. package/lib/cloudflare/worker-assets.js.map +1 -1
  20. package/lib/destroy.js +3 -3
  21. package/lib/destroy.js.map +1 -1
  22. package/lib/github/comment.d.ts +156 -0
  23. package/lib/github/comment.d.ts.map +1 -0
  24. package/lib/github/comment.js +192 -0
  25. package/lib/github/comment.js.map +1 -0
  26. package/lib/github/index.d.ts +1 -0
  27. package/lib/github/index.d.ts.map +1 -1
  28. package/lib/github/index.js +1 -0
  29. package/lib/github/index.js.map +1 -1
  30. package/lib/index.d.ts +1 -1
  31. package/lib/index.d.ts.map +1 -1
  32. package/lib/index.js +1 -0
  33. package/lib/index.js.map +1 -1
  34. package/lib/scope.d.ts +3 -0
  35. package/lib/scope.d.ts.map +1 -1
  36. package/lib/scope.js +2 -1
  37. package/lib/scope.js.map +1 -1
  38. package/lib/util/cli.d.ts.map +1 -1
  39. package/lib/util/cli.js +5 -6
  40. package/lib/util/cli.js.map +1 -1
  41. package/lib/util/telemetry/client.js +1 -1
  42. package/lib/util/telemetry/client.js.map +1 -1
  43. package/package.json +2 -1
  44. package/src/alchemy.ts +65 -9
  45. package/src/apply.ts +3 -9
  46. package/src/cloudflare/do-state-store/internal.ts +2 -1
  47. package/src/cloudflare/do-state-store/store.ts +26 -4
  48. package/src/cloudflare/secrets-store.ts +22 -21
  49. package/src/cloudflare/worker-assets.ts +16 -0
  50. package/src/destroy.ts +3 -3
  51. package/src/github/comment.ts +278 -0
  52. package/src/github/index.ts +1 -0
  53. package/src/index.ts +1 -1
  54. package/src/scope.ts +14 -6
  55. package/src/util/cli.ts +25 -18
  56. 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
- * // Create an application scope with stage and secret handling
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
- const phase = isRuntime ? "read" : (options?.phase ?? "up");
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
- options?.parent?.telemetryClient ??
175
+ mergedOptions?.parent?.telemetryClient ??
121
176
  TelemetryClient.create({
122
177
  phase,
123
- enabled: options?.telemetry ?? true,
124
- quiet: options?.quiet ?? false,
178
+ enabled: mergedOptions?.telemetry ?? true,
179
+ quiet: mergedOptions?.quiet ?? false,
125
180
  });
126
181
  const root = new Scope({
127
- ...options,
182
+ ...mergedOptions,
128
183
  appName,
129
- stage: options?.stage ?? process.env.ALCHEMY_STAGE,
184
+ stage:
185
+ mergedOptions?.stage ?? process.env.ALCHEMY_STAGE ?? process.env.USER,
130
186
  phase,
131
- password: options?.password ?? process.env.ALCHEMY_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 (options?.phase === "destroy") {
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: "SKIPPED",
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" ? "CREATING" : "UPDATING",
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" ? "CREATED" : "UPDATED",
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(api, workerName, token),
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
- // RPC methods are retried, which is what we want here in case the worker is not ready yet.
101
- await client.rpc("validate", null);
102
- return client;
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
- try {
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: "DELETING",
93
- prefixColor: "magenta",
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: "DELETED",
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
+ );
@@ -1,2 +1,3 @@
1
+ export * from "./comment.ts";
1
2
  export * from "./repository-environment.ts";
2
3
  export * from "./secret.ts";
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 type * from "./scope.ts";
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 = new AsyncLocalStorage<Scope>();
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
- phase: this.phase,
103
- stage: this.stage,
104
- appName: this.appName ?? "",
105
- }, options.logger);
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 ??