@alchemy.run/pkg 0.0.0-placeholder → 2.0.0-beta.79

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 (70) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +5 -0
  3. package/README.md +117 -0
  4. package/dist/Manifest.d.ts +107 -0
  5. package/dist/Manifest.d.ts.map +1 -0
  6. package/dist/Manifest.js +58 -0
  7. package/dist/Manifest.js.map +1 -0
  8. package/dist/Protocol.d.ts +186 -0
  9. package/dist/Protocol.d.ts.map +1 -0
  10. package/dist/Protocol.js +115 -0
  11. package/dist/Protocol.js.map +1 -0
  12. package/dist/Registry/Bindings.d.ts +134 -0
  13. package/dist/Registry/Bindings.d.ts.map +1 -0
  14. package/dist/Registry/Bindings.js +92 -0
  15. package/dist/Registry/Bindings.js.map +1 -0
  16. package/dist/Registry/GitHub.d.ts +96 -0
  17. package/dist/Registry/GitHub.d.ts.map +1 -0
  18. package/dist/Registry/GitHub.js +226 -0
  19. package/dist/Registry/GitHub.js.map +1 -0
  20. package/dist/Registry/Handler.d.ts +53 -0
  21. package/dist/Registry/Handler.d.ts.map +1 -0
  22. package/dist/Registry/Handler.js +375 -0
  23. package/dist/Registry/Handler.js.map +1 -0
  24. package/dist/Registry/KVCache.d.ts +24 -0
  25. package/dist/Registry/KVCache.d.ts.map +1 -0
  26. package/dist/Registry/KVCache.js +42 -0
  27. package/dist/Registry/KVCache.js.map +1 -0
  28. package/dist/Registry/Sweep.d.ts +12 -0
  29. package/dist/Registry/Sweep.d.ts.map +1 -0
  30. package/dist/Registry/Sweep.js +69 -0
  31. package/dist/Registry/Sweep.js.map +1 -0
  32. package/dist/Registry/Tags.d.ts +49 -0
  33. package/dist/Registry/Tags.d.ts.map +1 -0
  34. package/dist/Registry/Tags.js +80 -0
  35. package/dist/Registry/Tags.js.map +1 -0
  36. package/dist/Registry.d.ts +378 -0
  37. package/dist/Registry.d.ts.map +1 -0
  38. package/dist/Registry.js +119 -0
  39. package/dist/Registry.js.map +1 -0
  40. package/dist/cli/bin.d.ts +18 -0
  41. package/dist/cli/bin.d.ts.map +1 -0
  42. package/dist/cli/bin.js +33 -0
  43. package/dist/cli/bin.js.map +1 -0
  44. package/dist/cli/pack.d.ts +169 -0
  45. package/dist/cli/pack.d.ts.map +1 -0
  46. package/dist/cli/pack.js +447 -0
  47. package/dist/cli/pack.js.map +1 -0
  48. package/dist/cli/publish.d.ts +33 -0
  49. package/dist/cli/publish.d.ts.map +1 -0
  50. package/dist/cli/publish.js +76 -0
  51. package/dist/cli/publish.js.map +1 -0
  52. package/dist/index.d.ts +3 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.js +6 -0
  55. package/dist/index.js.map +1 -0
  56. package/migrations/0001_init.sql +19 -0
  57. package/package.json +89 -1
  58. package/src/Manifest.ts +76 -0
  59. package/src/Protocol.ts +163 -0
  60. package/src/Registry/Bindings.ts +134 -0
  61. package/src/Registry/GitHub.ts +365 -0
  62. package/src/Registry/Handler.ts +549 -0
  63. package/src/Registry/KVCache.ts +68 -0
  64. package/src/Registry/Sweep.ts +117 -0
  65. package/src/Registry/Tags.ts +117 -0
  66. package/src/Registry.ts +197 -0
  67. package/src/cli/bin.ts +101 -0
  68. package/src/cli/pack.ts +603 -0
  69. package/src/cli/publish.ts +129 -0
  70. package/src/index.ts +5 -0
@@ -0,0 +1,134 @@
1
+ import * as Cloudflare from "alchemy/Cloudflare";
2
+ import type * as Config from "effect/Config";
3
+ import * as Context from "effect/Context";
4
+ import * as Duration from "effect/Duration";
5
+ import type * as Redacted from "effect/Redacted";
6
+ import * as Schema from "effect/Schema";
7
+
8
+ /**
9
+ * What the registry Worker binds: its three resources, the settings binding
10
+ * that carries the policy into the isolate, and the credential bindings.
11
+ * Shared by `Registry.ts` (which declares the Worker) and the runtime
12
+ * modules in this folder.
13
+ */
14
+
15
+ /**
16
+ * Registry policy. The `Type` side is what the Worker runs against
17
+ * (`Duration`, `ByteSize`); the `Encoded` side is plain JSON, which is how
18
+ * the policy crosses into the isolate as a Worker binding.
19
+ */
20
+ export const Policy = Schema.Struct({
21
+ /**
22
+ * Repositories allowed to publish, as `owner/name`. A publication may
23
+ * contain any package; every package gets the commit, short commit,
24
+ * `branch:` and `pr:` tags of the run that produced it.
25
+ */
26
+ repos: Schema.Array(Schema.String),
27
+ /**
28
+ * How long a publication lives: from the push for branch publications,
29
+ * from close or merge for pull request publications. Publishing the same
30
+ * content again refreshes the clock.
31
+ */
32
+ ttl: Schema.DurationFromMillis,
33
+ /** Upper bound on a single tarball. Absent means unlimited. */
34
+ maxPackageSize: Schema.optionalKey(Schema.ByteSizeFromNumber),
35
+ });
36
+ export type Policy = typeof Policy.Type;
37
+
38
+ /**
39
+ * Everything the Worker needs that is plain data. Encoded into the
40
+ * {@link SETTINGS_ENV} binding at deploy time and decoded back inside the
41
+ * isolate, so the deploy-time and runtime views of the registry are the
42
+ * same value by construction.
43
+ */
44
+ export const Settings = Schema.Struct({
45
+ policy: Policy,
46
+ /**
47
+ * Hostname to package scope. Requests on an aliased host resolve an
48
+ * unscoped name under that scope, so `pkg.distilled.cloud/core/<sha>`
49
+ * serves `@distilled.cloud/core`.
50
+ */
51
+ aliases: Schema.Record(Schema.String, Schema.String),
52
+ /** Cron expression for the expiry sweep. */
53
+ cron: Schema.String,
54
+ github: Schema.Struct({
55
+ apiUrl: Schema.String,
56
+ }),
57
+ });
58
+ export type Settings = typeof Settings.Type;
59
+
60
+ /**
61
+ * Worker props the registry forwards verbatim. `main`, `env`, and `crons`
62
+ * are owned by the registry itself.
63
+ */
64
+ export type WorkerOverrides = Omit<
65
+ Cloudflare.WorkerProps,
66
+ "main" | "env" | "crons"
67
+ >;
68
+
69
+ export interface GitHubCredentials {
70
+ /** The App id. Read from the deploy environment, bound to the Worker. */
71
+ readonly appId: Config.Config<string>;
72
+ /** The App's private key PEM. Bound to the Worker as a secret. */
73
+ readonly privateKey: Config.Config<Redacted.Redacted<string>>;
74
+ }
75
+
76
+ /**
77
+ * The resolved registry configuration, provided to the Worker's Init phase.
78
+ * At plan time it is the value `PkgRegistry(id, options)` built from the
79
+ * caller's options; inside the isolate it is rebuilt from the Worker's
80
+ * bindings (see `RegistryConfigFromEnv` in `Registry.ts`). Either way the
81
+ * handler code sees one shape.
82
+ */
83
+ export class RegistryConfig extends Context.Service<
84
+ RegistryConfig,
85
+ Settings & {
86
+ readonly worker: WorkerOverrides;
87
+ readonly github: Settings["github"] & GitHubCredentials;
88
+ }
89
+ >()("@alchemy.run/pkg/RegistryConfig") {}
90
+
91
+ /** Worker `json` binding carrying the encoded {@link Settings}. */
92
+ export const SETTINGS_ENV = "PKG_SETTINGS";
93
+
94
+ /** Worker env var the GitHub App id is bound under. */
95
+ export const APP_ID_ENV = "PKG_GITHUB_APP_ID";
96
+
97
+ /** Worker secret the GitHub App private key PEM is bound under. */
98
+ export const PRIVATE_KEY_ENV = "PKG_GITHUB_APP_PRIVATE_KEY";
99
+
100
+ export const DEFAULT_TTL = Duration.weeks(1);
101
+ export const DEFAULT_CRON = "0 * * * *";
102
+ export const DEFAULT_API_URL = "https://api.github.com";
103
+
104
+ /** Marker that identifies the sticky install comment. */
105
+ export const COMMENT_MARKER = "<!-- pkg-preview-comment -->";
106
+
107
+ /** Uploaded tarballs no tag points at are deleted once older than this. */
108
+ export const ORPHAN_GRACE = Duration.hours(24);
109
+
110
+ /** Tags tied to pull requests are re-checked when due within this window. */
111
+ export const SWEEP_LOOKAHEAD = Duration.hours(2);
112
+
113
+ /** Content-addressed tarballs, keyed `<encoded name>/<sha256>.tgz`. */
114
+ export const Bucket = Cloudflare.R2.Bucket("Bucket", { forceDestroy: true });
115
+
116
+ export const tarballKey = (name: string, sha256: string) =>
117
+ `${encodeURIComponent(name)}/${sha256}.tgz`;
118
+
119
+ // The migrations ship inside this package. `import.meta.url` is a file URL
120
+ // during plan/deploy and absent or opaque inside the isolate, where the
121
+ // resource declaration is only evaluated for its binding.
122
+ const migrations =
123
+ typeof import.meta.url === "string" && import.meta.url.startsWith("file:")
124
+ ? decodeURIComponent(new URL("../../migrations", import.meta.url).pathname)
125
+ : undefined;
126
+
127
+ /** Publications, tags, and tarball bookkeeping. */
128
+ export const Database = Cloudflare.D1.Database("Database", { migrations });
129
+
130
+ /**
131
+ * Resolved runs and installation tokens, shared by every isolate of the
132
+ * Worker. Everything in it is re-derivable from GitHub; see `KVCache.ts`.
133
+ */
134
+ export const Cache = Cloudflare.KV.Namespace("Cache");
@@ -0,0 +1,365 @@
1
+ import * as Github from "@distilled.cloud/github";
2
+ import * as Actions from "@distilled.cloud/github/actions";
3
+ import * as Apps from "@distilled.cloud/github/apps";
4
+ import * as Checks from "@distilled.cloud/github/checks";
5
+ import * as Issues from "@distilled.cloud/github/issues";
6
+ import * as Pulls from "@distilled.cloud/github/pulls";
7
+ import * as Repos from "@distilled.cloud/github/repos";
8
+ import * as Arr from "effect/Array";
9
+ import * as Clock from "effect/Clock";
10
+ import * as Context from "effect/Context";
11
+ import * as Data from "effect/Data";
12
+ import * as DateTime from "effect/DateTime";
13
+ import * as Duration from "effect/Duration";
14
+ import * as Effect from "effect/Effect";
15
+ import * as Encoding from "effect/Encoding";
16
+ import * as Layer from "effect/Layer";
17
+ import * as Option from "effect/Option";
18
+ import * as Redacted from "effect/Redacted";
19
+ import * as Schema from "effect/Schema";
20
+ import * as Stream from "effect/Stream";
21
+ import * as HttpClient from "effect/unstable/http/HttpClient";
22
+ import * as crypto from "node:crypto";
23
+ import { COMMENT_MARKER, RegistryConfig } from "./Bindings.ts";
24
+ import * as KVCache from "./KVCache.ts";
25
+
26
+ export class CryptoError extends Data.TaggedError("CryptoError")<{
27
+ readonly message: string;
28
+ }> {}
29
+
30
+ export class AppNotInstalled extends Data.TaggedError("AppNotInstalled")<{
31
+ readonly repo: string;
32
+ }> {
33
+ override get message() {
34
+ return `The GitHub App is not installed on ${this.repo}`;
35
+ }
36
+ }
37
+
38
+ /** Import an RSA private key PEM (PKCS#1 or PKCS#8) for RS256 signing. */
39
+ export const importPrivateKey = (pem: string) =>
40
+ Effect.try({
41
+ try: () => crypto.createPrivateKey({ key: pem }),
42
+ catch: (cause) =>
43
+ new CryptoError({ message: `invalid private key: ${cause}` }),
44
+ });
45
+
46
+ /** Sign a compact RS256 JWT, used to authenticate as the GitHub App. */
47
+ export const signJwt = (
48
+ claims: Record<string, unknown>,
49
+ key: crypto.KeyObject,
50
+ ) =>
51
+ Effect.try({
52
+ try: () => {
53
+ const header = Encoding.encodeBase64Url(
54
+ JSON.stringify({ alg: "RS256", typ: "JWT" }),
55
+ );
56
+ const payload = Encoding.encodeBase64Url(JSON.stringify(claims));
57
+ const input = `${header}.${payload}`;
58
+ const signature = crypto.sign("sha256", Buffer.from(input), key);
59
+ return `${input}.${Encoding.encodeBase64Url(new Uint8Array(signature))}`;
60
+ },
61
+ catch: (cause) => new CryptoError({ message: `signing failed: ${cause}` }),
62
+ });
63
+
64
+ /**
65
+ * Install commands per package, grouped. A group marked `collapsed`
66
+ * renders as a closed `<details>` block so a long list of secondary
67
+ * packages stays out of the way.
68
+ */
69
+ export const renderInstalls = (
70
+ packages: ReadonlyArray<{ name: string; group: string; url: string }>,
71
+ groups: ReadonlyArray<{ name: string; collapsed: boolean }>,
72
+ ) => {
73
+ const collapsed = new Set(
74
+ groups.filter((g) => g.collapsed).map((g) => g.name),
75
+ );
76
+ // One code block per package so each command has its own copy button, in
77
+ // the order the manifest lists them, which is the order they were given
78
+ // to `pkg pack`. Blank lines around the markdown inside `<details>` are
79
+ // what make GitHub render it.
80
+ return Object.entries(Arr.groupBy(packages, (pkg) => pkg.group))
81
+ .flatMap(([group, members]) => {
82
+ const installs = members.flatMap(({ url }) => [
83
+ "```sh",
84
+ `pnpm install ${url}`,
85
+ "```",
86
+ "",
87
+ ]);
88
+ return collapsed.has(group)
89
+ ? [
90
+ "<details>",
91
+ `<summary><b>${group}</b> (${members.length})</summary>`,
92
+ "",
93
+ ...installs,
94
+ "</details>",
95
+ "",
96
+ ]
97
+ : [`### ${group}`, "", ...installs];
98
+ })
99
+ .join("\n");
100
+ };
101
+
102
+ /**
103
+ * GitHub's `<relative-time>` element, rendered as a live relative time in
104
+ * comments, with a plain UTC fallback like `Sep 7, 2026 at 2:42 PM UTC`.
105
+ */
106
+ const relativeTime = (millis: number) => {
107
+ const date = DateTime.makeUnsafe(millis);
108
+ const label = DateTime.formatUtc(date, {
109
+ locale: "en-US",
110
+ dateStyle: "medium",
111
+ timeStyle: "short",
112
+ });
113
+ return `<relative-time datetime="${DateTime.formatIso(date)}">${label} UTC</relative-time>`;
114
+ };
115
+
116
+ export const renderComment = (
117
+ packages: ReadonlyArray<{ name: string; group: string; url: string }>,
118
+ groups: ReadonlyArray<{ name: string; collapsed: boolean }>,
119
+ times: { readonly publishedAt: number; readonly expiresAt: number },
120
+ ) =>
121
+ [
122
+ COMMENT_MARKER,
123
+ "",
124
+ "Install the packages built from this commit:",
125
+ "",
126
+ renderInstalls(packages, groups),
127
+ `Published ${relativeTime(times.publishedAt)}. Expires ${relativeTime(times.expiresAt)}, extended while this pull request is open.`,
128
+ ].join("\n");
129
+
130
+ /** Render any GitHub operation failure for logs and error responses. */
131
+ export const describe = (error: {
132
+ readonly _tag: string;
133
+ readonly message?: string | undefined;
134
+ }) => (error.message ? `${error._tag}: ${error.message}` : error._tag);
135
+
136
+ const USER_AGENT = "alchemy-pkg";
137
+
138
+ const split = (repo: string) => {
139
+ const [owner, name] = repo.split("/");
140
+ return { owner: owner ?? "", repo: name ?? "" };
141
+ };
142
+
143
+ /**
144
+ * Installation tokens are refreshed two minutes before GitHub expires them:
145
+ * one for clock skew, one for the minute KV's edge cache may keep serving an
146
+ * expired entry.
147
+ */
148
+ const TOKEN_MARGIN = Duration.minutes(2);
149
+
150
+ /**
151
+ * The registry's GitHub App: every operation runs as the App's installation
152
+ * on the repository in question. Installation tokens live in the KV cache,
153
+ * so one mint per repository serves every isolate for about an hour.
154
+ */
155
+ const make = Effect.gen(function* () {
156
+ const config = yield* RegistryConfig;
157
+ const http = yield* HttpClient.HttpClient;
158
+
159
+ /** Run a distilled GitHub operation as the given bearer token. */
160
+ const as = <A, E, R>(token: string, operation: Effect.Effect<A, E, R>) =>
161
+ operation.pipe(
162
+ Effect.provideService(
163
+ Github.Credentials,
164
+ Effect.succeed({
165
+ token: Redacted.make(token),
166
+ apiBaseUrl: config.github.apiUrl,
167
+ userAgent: USER_AGENT,
168
+ }),
169
+ ),
170
+ Effect.provideService(HttpClient.HttpClient, http),
171
+ );
172
+
173
+ // The credentials are read here, when a token is minted, never during the
174
+ // Worker's Init: `Registry.ts` binds them explicitly, so plan time never
175
+ // resolves them. Importing the key is a sub-millisecond step next to the
176
+ // two GitHub calls a mint makes, so nothing is kept between mints.
177
+ const appJwt = Effect.gen(function* () {
178
+ const pem = yield* config.github.privateKey;
179
+ const key = yield* importPrivateKey(Redacted.value(pem));
180
+ const now = Math.floor((yield* Clock.currentTimeMillis) / 1000);
181
+ return yield* signJwt(
182
+ { iat: now - 60, exp: now + 540, iss: yield* config.github.appId },
183
+ key,
184
+ );
185
+ });
186
+
187
+ // Failures are not kept: the next call retries the mint.
188
+ const tokens = KVCache.make(
189
+ (repo: string) => `token:${repo}`,
190
+ Schema.String,
191
+ (repo) =>
192
+ Effect.gen(function* () {
193
+ const jwt = yield* appJwt;
194
+ const installation = yield* as(
195
+ jwt,
196
+ Apps.getRepoInstallation(split(repo)),
197
+ ).pipe(
198
+ Effect.catchTag("NotFound", () => new AppNotInstalled({ repo })),
199
+ );
200
+ const access = yield* as(
201
+ jwt,
202
+ Apps.createInstallationAccessToken({
203
+ installation_id: installation.id,
204
+ }),
205
+ );
206
+ const now = yield* Clock.currentTimeMillis;
207
+ return {
208
+ value: access.token,
209
+ ttl: Duration.subtract(
210
+ Duration.millis(Math.max(0, Date.parse(access.expires_at) - now)),
211
+ TOKEN_MARGIN,
212
+ ),
213
+ };
214
+ }),
215
+ );
216
+
217
+ /** A GitHub operation run as the App's installation on `repo`. */
218
+ const asInstallation = <A, E, R>(
219
+ repo: string,
220
+ operation: Effect.Effect<A, E, R>,
221
+ ) => Effect.flatMap(tokens(repo), (token) => as(token, operation));
222
+
223
+ return {
224
+ getRun: (repo: string, runId: number) =>
225
+ asInstallation(
226
+ repo,
227
+ Actions.getWorkflowRun({ ...split(repo), run_id: runId }),
228
+ ),
229
+
230
+ /**
231
+ * Artifacts named `name` uploaded to a run so far, including by jobs
232
+ * still in progress.
233
+ */
234
+ listRunArtifacts: (repo: string, runId: number, name: string) =>
235
+ asInstallation(
236
+ repo,
237
+ Actions.listWorkflowRunArtifacts({
238
+ ...split(repo),
239
+ run_id: runId,
240
+ name,
241
+ per_page: 100,
242
+ }),
243
+ ).pipe(Effect.map((page) => page.artifacts)),
244
+
245
+ /**
246
+ * Pull requests against `repo` whose head is `sha` in `headRepo`, the
247
+ * repository the commit was pushed to.
248
+ */
249
+ pullRequestsForCommit: (repo: string, headRepo: string, sha: string) =>
250
+ asInstallation(
251
+ repo,
252
+ Repos.listPullRequestsAssociatedWithCommit({
253
+ ...split(repo),
254
+ commit_sha: sha,
255
+ per_page: 100,
256
+ }),
257
+ ).pipe(
258
+ Effect.map((pulls) =>
259
+ pulls.filter(
260
+ (pr) =>
261
+ pr.head.sha === sha &&
262
+ pr.head.repo?.full_name === headRepo &&
263
+ pr.base.repo.full_name === repo,
264
+ ),
265
+ ),
266
+ ),
267
+
268
+ getPullRequest: (repo: string, number: number) =>
269
+ asInstallation(repo, Pulls.get({ ...split(repo), pull_number: number })),
270
+
271
+ /**
272
+ * Publish a completed check run on `headSha`. GitHub shows the newest run
273
+ * per name and App, so re-publishing the same commit simply supersedes it.
274
+ */
275
+ createCheckRun: (
276
+ repo: string,
277
+ input: {
278
+ readonly headSha: string;
279
+ readonly name: string;
280
+ readonly title: string;
281
+ readonly summary: string;
282
+ readonly detailsUrl: string;
283
+ },
284
+ ) =>
285
+ Effect.flatMap(DateTime.now, (now) =>
286
+ asInstallation(
287
+ repo,
288
+ Checks.create({
289
+ ...split(repo),
290
+ name: input.name,
291
+ head_sha: input.headSha,
292
+ status: "completed",
293
+ conclusion: "success",
294
+ completed_at: DateTime.formatIso(now),
295
+ details_url: input.detailsUrl,
296
+ output: { title: input.title, summary: input.summary },
297
+ }),
298
+ ),
299
+ ),
300
+
301
+ /**
302
+ * Create or update the comment on `issue` whose body starts with
303
+ * `marker`, looking through the first five pages of comments.
304
+ */
305
+ upsertComment: (
306
+ repo: string,
307
+ issue: number,
308
+ marker: string,
309
+ body: string,
310
+ ) =>
311
+ Effect.gen(function* () {
312
+ const comments = Stream.paginate(1, (page) =>
313
+ asInstallation(
314
+ repo,
315
+ Issues.listComments({
316
+ ...split(repo),
317
+ issue_number: issue,
318
+ per_page: 100,
319
+ page,
320
+ }),
321
+ ).pipe(
322
+ Effect.map((comments) => [
323
+ comments,
324
+ comments.length < 100 || page >= 5
325
+ ? Option.none()
326
+ : Option.some(page + 1),
327
+ ]),
328
+ ),
329
+ );
330
+ const existing = yield* comments.pipe(
331
+ Stream.filter((comment) => comment.body?.startsWith(marker) === true),
332
+ Stream.take(1),
333
+ Stream.runCollect,
334
+ Effect.map(Arr.head),
335
+ );
336
+ yield* Option.match(existing, {
337
+ onNone: () =>
338
+ asInstallation(
339
+ repo,
340
+ Issues.createComment({
341
+ ...split(repo),
342
+ issue_number: issue,
343
+ body,
344
+ }),
345
+ ),
346
+ onSome: (comment) =>
347
+ asInstallation(
348
+ repo,
349
+ Issues.updateComment({
350
+ ...split(repo),
351
+ comment_id: comment.id,
352
+ body,
353
+ }),
354
+ ),
355
+ });
356
+ }),
357
+ };
358
+ });
359
+
360
+ export class GitHubApp extends Context.Service<
361
+ GitHubApp,
362
+ Effect.Success<typeof make>
363
+ >()("@alchemy.run/pkg/GitHubApp") {}
364
+
365
+ export const GitHubAppLive = Layer.effect(GitHubApp, make);