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

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,603 @@
1
+ import * as Config from "effect/Config";
2
+ import * as Console from "effect/Console";
3
+ import * as Data from "effect/Data";
4
+ import * as Effect from "effect/Effect";
5
+ import * as FileSystem from "effect/FileSystem";
6
+ import * as Option from "effect/Option";
7
+ import * as Path from "effect/Path";
8
+ import * as Schema from "effect/Schema";
9
+ import * as SchemaIssue from "effect/SchemaIssue";
10
+ import * as SchemaTransformation from "effect/SchemaTransformation";
11
+ import * as ChildProcess from "effect/unstable/process/ChildProcess";
12
+ import { exec } from "alchemy/Util/exec";
13
+ import { sha256 } from "alchemy/Util/sha256";
14
+ import { packTar, unpackTar, type TarHeader } from "modern-tar";
15
+ import { gunzipSync, gzipSync } from "node:zlib";
16
+ import {
17
+ GroupName,
18
+ MANIFEST_FILE,
19
+ ManifestJson,
20
+ type Manifest,
21
+ type ManifestPackage,
22
+ } from "../Manifest.ts";
23
+ import { manifestArtifactName, tarballUrl } from "../Protocol.ts";
24
+
25
+ export class WorkspaceError extends Data.TaggedError("WorkspaceError")<{
26
+ readonly message: string;
27
+ }> {}
28
+
29
+ const GROUP_SPEC = /^([^[=]+?)\s*(?:\[([^\]]*)\])?\s*=\s*(.+?)\s*$/;
30
+
31
+ /**
32
+ * A `--group` flag, `NAME=GLOB` or `NAME[Collapsed]=GLOB`, e.g.
33
+ * `Alchemy=./packages/*` or `Distilled[Collapsed]=./submodules/distilled/packages/*`.
34
+ * `Collapsed` is the only attribute.
35
+ */
36
+ export const Group = Schema.String.pipe(
37
+ Schema.decodeTo(
38
+ Schema.Struct({
39
+ name: GroupName,
40
+ pattern: Schema.String,
41
+ /** Render the group collapsed in the install comment. */
42
+ collapsed: Schema.Boolean,
43
+ }),
44
+ SchemaTransformation.transformEffect({
45
+ decode: (spec, options) => {
46
+ const match = spec.match(GROUP_SPEC);
47
+ const attributes = (match?.[2] ?? "")
48
+ .split(",")
49
+ .map((attribute) => attribute.trim())
50
+ .filter((attribute) => attribute.length > 0);
51
+ return match &&
52
+ attributes.every((attribute) => attribute === "Collapsed")
53
+ ? Effect.succeed({
54
+ name: match[1]!,
55
+ pattern: match[3]!,
56
+ collapsed: attributes.includes("Collapsed"),
57
+ })
58
+ : Effect.fail(
59
+ new SchemaIssue.InvalidValue(
60
+ { expected: "NAME=GLOB or NAME[Collapsed]=GLOB" },
61
+ spec,
62
+ options,
63
+ ),
64
+ );
65
+ },
66
+ encode: (group) =>
67
+ Effect.succeed(
68
+ `${group.name}${group.collapsed ? "[Collapsed]" : ""}=${group.pattern}`,
69
+ ),
70
+ }),
71
+ ),
72
+ );
73
+ export type Group = typeof Group.Type;
74
+
75
+ /** The subset of `package.json` the CLI reads. Extra keys are preserved on the raw object. */
76
+ export const PackageJson = Schema.Struct({
77
+ name: Schema.optionalKey(Schema.String),
78
+ version: Schema.optionalKey(Schema.String),
79
+ private: Schema.optionalKey(Schema.Boolean),
80
+ });
81
+
82
+ export const DEPENDENCY_SECTIONS = [
83
+ "dependencies",
84
+ "devDependencies",
85
+ "peerDependencies",
86
+ "optionalDependencies",
87
+ ] as const;
88
+
89
+ const DependencyMap = Schema.optionalKey(
90
+ Schema.Record(Schema.String, Schema.String),
91
+ );
92
+
93
+ export const DependencySections = Schema.Struct({
94
+ dependencies: DependencyMap,
95
+ devDependencies: DependencyMap,
96
+ peerDependencies: DependencyMap,
97
+ optionalDependencies: DependencyMap,
98
+ });
99
+
100
+ /**
101
+ * Order packages so every package comes after the packed packages it
102
+ * depends on, grouped into levels that can be packed concurrently. Fails on
103
+ * a cycle, since a tarball cannot link to a dependency that links back.
104
+ */
105
+ export const dependencyLevels = Effect.fn("dependencyLevels")(function* (
106
+ dependencies: ReadonlyMap<string, ReadonlySet<string>>,
107
+ ) {
108
+ const remaining = new Map(
109
+ [...dependencies].map(([name, deps]) => [
110
+ name,
111
+ new Set([...deps].filter((dep) => dependencies.has(dep))),
112
+ ]),
113
+ );
114
+ const levels: string[][] = [];
115
+ while (remaining.size > 0) {
116
+ const ready = [...remaining]
117
+ .filter(([, deps]) => deps.size === 0)
118
+ .map(([name]) => name)
119
+ .sort();
120
+ if (ready.length === 0) {
121
+ return yield* new WorkspaceError({
122
+ message: `Dependency cycle among packed packages: ${[...remaining.keys()].sort().join(", ")}`,
123
+ });
124
+ }
125
+ for (const name of ready) remaining.delete(name);
126
+ for (const deps of remaining.values()) {
127
+ for (const name of ready) deps.delete(name);
128
+ }
129
+ levels.push(ready);
130
+ }
131
+ return levels;
132
+ });
133
+
134
+ export interface WorkspacePackage {
135
+ readonly name: string;
136
+ readonly version: string;
137
+ /** Relative to the workspace root, POSIX separators. */
138
+ readonly dir: string;
139
+ readonly absDir: string;
140
+ readonly group: string;
141
+ }
142
+
143
+ /**
144
+ * Expand one level of `{a,b,c}` alternatives into plain patterns, so
145
+ * `./packages/{alchemy,pkg}` lists exactly those two directories.
146
+ */
147
+ export const expandBraces = (pattern: string): string[] => {
148
+ const match = pattern.match(/^(.*?)\{([^{}]*)\}(.*)$/);
149
+ if (!match) return [pattern];
150
+ return match[2]!
151
+ .split(",")
152
+ .map((alternative) => alternative.trim())
153
+ .filter((alternative) => alternative.length > 0)
154
+ .flatMap((alternative) =>
155
+ expandBraces(`${match[1]}${alternative}${match[3]}`),
156
+ );
157
+ };
158
+
159
+ /**
160
+ * Expand a directory glob. `*` is supported as a whole path segment and
161
+ * `{a,b}` as a list of alternatives, which covers `./packages/*` and
162
+ * `./submodules/x/packages/{core,aws}`. Matches are directories only.
163
+ */
164
+ const expand = Effect.fn("expandGlob")(function* (cwd: string, glob: string) {
165
+ const results: string[] = [];
166
+ for (const pattern of expandBraces(glob)) {
167
+ results.push(...(yield* expandPattern(cwd, pattern)));
168
+ }
169
+ return [...new Set(results)];
170
+ });
171
+
172
+ const expandPattern = Effect.fn("expandPattern")(function* (
173
+ cwd: string,
174
+ pattern: string,
175
+ ) {
176
+ const fs = yield* FileSystem.FileSystem;
177
+ const path = yield* Path.Path;
178
+ if (pattern.includes("**")) {
179
+ return yield* new WorkspaceError({
180
+ message: `Unsupported pattern ${JSON.stringify(pattern)}: only a single "*" segment is supported`,
181
+ });
182
+ }
183
+ const segments = pattern.split("/").filter((s) => s !== "" && s !== ".");
184
+ let current: string[] = [cwd];
185
+ for (const segment of segments) {
186
+ const next: string[] = [];
187
+ for (const base of current) {
188
+ if (segment === "*") {
189
+ const entries = yield* fs.readDirectory(base);
190
+ for (const entry of entries.sort()) {
191
+ const candidate = path.join(base, entry);
192
+ const stat = yield* fs.stat(candidate);
193
+ if (stat.type === "Directory") next.push(candidate);
194
+ }
195
+ } else if (segment.includes("*")) {
196
+ return yield* new WorkspaceError({
197
+ message: `Unsupported pattern ${JSON.stringify(pattern)}: "*" must be a whole path segment`,
198
+ });
199
+ } else {
200
+ const candidate = path.join(base, segment);
201
+ if (yield* fs.exists(candidate)) next.push(candidate);
202
+ }
203
+ }
204
+ current = next;
205
+ }
206
+ return current;
207
+ });
208
+
209
+ /**
210
+ * Discover publishable packages under each group's pattern, in the order the
211
+ * groups and their directories were given, which is the order they are
212
+ * listed in. Private packages and directories without a named
213
+ * `package.json` are skipped. A package name appearing under two groups is
214
+ * an error.
215
+ */
216
+ export const discover = Effect.fn("discoverPackages")(function* (
217
+ cwd: string,
218
+ groups: ReadonlyArray<Group>,
219
+ ) {
220
+ const fs = yield* FileSystem.FileSystem;
221
+ const path = yield* Path.Path;
222
+ const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(PackageJson));
223
+ const found = new Map<string, WorkspacePackage>();
224
+ for (const group of groups) {
225
+ for (const absDir of yield* expand(cwd, group.pattern)) {
226
+ const manifestPath = path.join(absDir, "package.json");
227
+ if (!(yield* fs.exists(manifestPath))) continue;
228
+ const manifest = yield* decode(yield* fs.readFileString(manifestPath));
229
+ if (manifest.private || manifest.name === undefined) continue;
230
+ const existing = found.get(manifest.name);
231
+ if (existing !== undefined) {
232
+ return yield* new WorkspaceError({
233
+ message: `Package ${manifest.name} found in both ${existing.dir} and ${path.relative(cwd, absDir)}`,
234
+ });
235
+ }
236
+ found.set(manifest.name, {
237
+ name: manifest.name,
238
+ version: manifest.version ?? "0.0.0",
239
+ dir: path.relative(cwd, absDir).split(path.sep).join("/"),
240
+ absDir,
241
+ group: group.name,
242
+ });
243
+ }
244
+ }
245
+ return [...found.values()];
246
+ });
247
+
248
+ export class GitError extends Data.TaggedError("GitError")<{
249
+ readonly args: ReadonlyArray<string>;
250
+ readonly cwd: string;
251
+ readonly exitCode: number;
252
+ readonly stderr: string;
253
+ }> {
254
+ override get message() {
255
+ return `git ${this.args.join(" ")} in ${this.cwd} exited with ${this.exitCode}: ${this.stderr.trim()}`;
256
+ }
257
+ }
258
+
259
+ /** Run `git` in `cwd` and return trimmed stdout. */
260
+ export const git = Effect.fn("git")(function* (
261
+ cwd: string,
262
+ args: ReadonlyArray<string>,
263
+ ) {
264
+ const { exitCode, stdout, stderr } = yield* exec(
265
+ ChildProcess.make("git", [...args], { cwd, shell: false }),
266
+ ).pipe(Effect.scoped);
267
+ if (exitCode !== 0) {
268
+ return yield* new GitError({ args, cwd, exitCode, stderr });
269
+ }
270
+ return stdout.trim();
271
+ });
272
+
273
+ /** Absolute path of the repository (or submodule) that owns `cwd`. */
274
+ export const toplevel = (cwd: string) =>
275
+ git(cwd, ["rev-parse", "--show-toplevel"]);
276
+
277
+ /** Full HEAD SHA of the repository that owns `cwd`. */
278
+ const gitHead = (cwd: string) => git(cwd, ["rev-parse", "HEAD"]);
279
+
280
+ export class PackError extends Data.TaggedError("PackError")<{
281
+ readonly dir: string;
282
+ readonly message: string;
283
+ }> {}
284
+
285
+ const PnpmPackOutput = Schema.fromJsonString(
286
+ Schema.Struct({ filename: Schema.String }),
287
+ );
288
+
289
+ /**
290
+ * Rewrite every dependency on a package in `links` to that package's
291
+ * tarball URL. Returns the rewritten manifest text and the rewrites made.
292
+ */
293
+ export const rewriteDependencies = (
294
+ manifestText: string,
295
+ links: ReadonlyMap<string, string>,
296
+ ) =>
297
+ Effect.gen(function* () {
298
+ const manifest = yield* Schema.decodeUnknownEffect(
299
+ Schema.fromJsonString(
300
+ Schema.StructWithRest(DependencySections, [
301
+ Schema.Record(Schema.String, Schema.Unknown),
302
+ ]),
303
+ ),
304
+ )(manifestText);
305
+ // Preserve source key order after validation: it contributes to the tarball hash.
306
+ const rewritten = JSON.parse(manifestText);
307
+ const rewrites: Array<{ section: string; name: string; url: string }> = [];
308
+ for (const section of DEPENDENCY_SECTIONS) {
309
+ const deps = manifest[section];
310
+ if (deps === undefined) continue;
311
+ const next: Record<string, string> = { ...deps };
312
+ for (const name of Object.keys(deps)) {
313
+ const url = links.get(name);
314
+ if (url === undefined) continue;
315
+ next[name] = url;
316
+ rewrites.push({ section, name, url });
317
+ }
318
+ rewritten[section] = next;
319
+ }
320
+ return { text: `${JSON.stringify(rewritten, null, 2)}\n`, rewrites };
321
+ });
322
+
323
+ const EPOCH = new Date(0);
324
+
325
+ /**
326
+ * Normalize tar headers so identical inputs produce identical bytes:
327
+ * fixed mtime, no ownership, entries sorted by path.
328
+ */
329
+ const normalize = (header: TarHeader, size: number): TarHeader => ({
330
+ name: header.name,
331
+ size,
332
+ mode: header.mode,
333
+ type: header.type ?? "file",
334
+ mtime: EPOCH,
335
+ uid: 0,
336
+ gid: 0,
337
+ uname: "",
338
+ gname: "",
339
+ ...(header.linkname !== undefined ? { linkname: header.linkname } : {}),
340
+ });
341
+
342
+ export interface PackedTarball {
343
+ readonly file: string;
344
+ readonly sha256: string;
345
+ readonly size: number;
346
+ readonly rewrites: ReadonlyArray<{
347
+ section: string;
348
+ name: string;
349
+ url: string;
350
+ }>;
351
+ }
352
+
353
+ /**
354
+ * Pack one package with pnpm, rewrite its dependencies on already-packed
355
+ * packages to their tarball URLs, and repack reproducibly into `outDir/file`.
356
+ */
357
+ export const packPackage = Effect.fn("packPackage")(function* (options: {
358
+ readonly absDir: string;
359
+ /** Tarball URL of every already-packed dependency, by package name. */
360
+ readonly links: ReadonlyMap<string, string>;
361
+ readonly outDir: string;
362
+ readonly file: string;
363
+ }) {
364
+ const fs = yield* FileSystem.FileSystem;
365
+ const path = yield* Path.Path;
366
+ const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "pkg-pack-" });
367
+
368
+ const { exitCode, stdout, stderr } = yield* exec(
369
+ ChildProcess.make("pnpm", ["pack", "--json", "--pack-destination", tmp], {
370
+ cwd: options.absDir,
371
+ shell: false,
372
+ }),
373
+ );
374
+ if (exitCode !== 0) {
375
+ return yield* new PackError({
376
+ dir: options.absDir,
377
+ message: `pnpm pack exited with ${exitCode}: ${stderr.trim() || stdout.trim()}`,
378
+ });
379
+ }
380
+ // pnpm may print progress lines before the JSON document.
381
+ const json = stdout.slice(stdout.indexOf("{"));
382
+ const { filename } = yield* Schema.decodeUnknownEffect(PnpmPackOutput)(json);
383
+
384
+ const packed = yield* fs.readFile(path.join(tmp, path.basename(filename)));
385
+ const inflated = yield* Effect.sync(() => gunzipSync(packed));
386
+ const entries = yield* Effect.promise(() => unpackTar(inflated));
387
+
388
+ let rewrites: PackedTarball["rewrites"] = [];
389
+ const normalized: Array<{ header: TarHeader; data: Uint8Array }> = [];
390
+ for (const entry of entries.sort((a, b) =>
391
+ a.header.name.localeCompare(b.header.name),
392
+ )) {
393
+ let data = entry.data ?? new Uint8Array();
394
+ if (entry.header.name === "package/package.json") {
395
+ const text = yield* Effect.sync(() => new TextDecoder().decode(data));
396
+ const result = yield* rewriteDependencies(text, options.links).pipe(
397
+ Effect.mapError(
398
+ (e) => new PackError({ dir: options.absDir, message: String(e) }),
399
+ ),
400
+ );
401
+ rewrites = result.rewrites;
402
+ data = yield* Effect.sync(() => new TextEncoder().encode(result.text));
403
+ }
404
+ normalized.push({ header: normalize(entry.header, data.byteLength), data });
405
+ }
406
+
407
+ const tar = yield* Effect.promise(() => packTar(normalized));
408
+ const bytes = yield* Effect.sync(() => gzipSync(tar, { level: 9 }));
409
+ yield* fs.writeFile(path.join(options.outDir, options.file), bytes);
410
+ return {
411
+ file: options.file,
412
+ sha256: yield* sha256(bytes),
413
+ size: bytes.byteLength,
414
+ rewrites,
415
+ } satisfies PackedTarball;
416
+ });
417
+
418
+ const PullRequestEvent = Schema.fromJsonString(
419
+ Schema.Struct({
420
+ pull_request: Schema.optionalKey(
421
+ Schema.Struct({ head: Schema.Struct({ sha: Schema.String }) }),
422
+ ),
423
+ }),
424
+ );
425
+
426
+ /**
427
+ * The pull request head commit when running under a GitHub Actions
428
+ * `pull_request` event, read from the event payload. `undefined` elsewhere.
429
+ * On that event the default checkout is a synthetic merge commit, which the
430
+ * registry would reject because it does not match the run's head.
431
+ */
432
+ const pullRequestHead = Effect.gen(function* () {
433
+ const event = yield* Config.option(Config.String("GITHUB_EVENT_NAME"));
434
+ const eventPath = yield* Config.option(Config.String("GITHUB_EVENT_PATH"));
435
+ if (
436
+ Option.getOrUndefined(event) !== "pull_request" ||
437
+ Option.isNone(eventPath)
438
+ ) {
439
+ return undefined;
440
+ }
441
+ const fs = yield* FileSystem.FileSystem;
442
+ const payload = yield* fs
443
+ .readFileString(eventPath.value)
444
+ .pipe(Effect.flatMap(Schema.decodeUnknownEffect(PullRequestEvent)));
445
+ return payload.pull_request?.head.sha;
446
+ });
447
+
448
+ export interface PackOptions {
449
+ readonly cwd: string;
450
+ readonly groups: ReadonlyArray<Group>;
451
+ readonly registry: string;
452
+ readonly out: string;
453
+ }
454
+
455
+ /**
456
+ * Tarball file name for a package: `@` dropped and the scope separator
457
+ * replaced by `+`, a character package names cannot contain, so `@a/b-c`
458
+ * and `@a-b/c` get distinct files.
459
+ */
460
+ export const tarballFile = (name: string) =>
461
+ `${name.replace(/^@/, "").replace("/", "+")}.tgz`;
462
+
463
+ /**
464
+ * Pack every discovered package into `out` with a manifest describing each
465
+ * tarball. Nothing here talks to the registry.
466
+ */
467
+ export const pack = Effect.fn("pack")(function* (options: PackOptions) {
468
+ const fs = yield* FileSystem.FileSystem;
469
+ const path = yield* Path.Path;
470
+
471
+ const root = yield* toplevel(options.cwd);
472
+ const head = yield* gitHead(root);
473
+ const prHead = yield* pullRequestHead;
474
+ if (prHead !== undefined && prHead !== head) {
475
+ return yield* new WorkspaceError({
476
+ message:
477
+ `HEAD is ${head} but the pull request head is ${prHead}. ` +
478
+ "Check out github.event.pull_request.head.sha before packing so tarballs are addressed by a commit that exists on the pull request.",
479
+ });
480
+ }
481
+ const packages = yield* discover(options.cwd, options.groups);
482
+ if (packages.length === 0) {
483
+ yield* Console.log("No publishable packages matched.");
484
+ return undefined;
485
+ }
486
+
487
+ // Dependencies between packed packages are rewritten to the dependency's
488
+ // immutable tarball URL, so a package is packed only after everything it
489
+ // depends on has a hash.
490
+ const byName = new Map(packages.map((pkg) => [pkg.name, pkg]));
491
+ const dependencies = new Map<string, Set<string>>();
492
+ for (const pkg of packages) {
493
+ const manifest = yield* fs
494
+ .readFileString(path.join(pkg.absDir, "package.json"))
495
+ .pipe(
496
+ Effect.flatMap(
497
+ Schema.decodeUnknownEffect(Schema.fromJsonString(DependencySections)),
498
+ ),
499
+ );
500
+ dependencies.set(
501
+ pkg.name,
502
+ new Set(
503
+ DEPENDENCY_SECTIONS.flatMap((section) =>
504
+ Object.keys(manifest[section] ?? {}),
505
+ ).filter((name) => name !== pkg.name && byName.has(name)),
506
+ ),
507
+ );
508
+ }
509
+ const levels = yield* dependencyLevels(dependencies);
510
+
511
+ // The output directory is replaced wholesale, so it has to be a proper
512
+ // subdirectory of the workspace: `--out .` would otherwise delete it.
513
+ const outDir = path.resolve(options.cwd, options.out);
514
+ const relative = path.relative(options.cwd, outDir);
515
+ if (
516
+ relative === "" ||
517
+ relative.startsWith("..") ||
518
+ path.isAbsolute(relative)
519
+ ) {
520
+ return yield* new WorkspaceError({
521
+ message: `--out must be a subdirectory of ${options.cwd}, got ${outDir}`,
522
+ });
523
+ }
524
+ yield* fs.remove(outDir, { recursive: true, force: true });
525
+ yield* fs.makeDirectory(outDir, { recursive: true });
526
+
527
+ const links = new Map<string, string>();
528
+ const packedByName = new Map<string, ManifestPackage>();
529
+ for (const level of levels) {
530
+ const packedLevel = yield* Effect.forEach(
531
+ level,
532
+ Effect.fn(function* (name) {
533
+ const pkg = byName.get(name)!;
534
+ const packed = yield* packPackage({
535
+ absDir: pkg.absDir,
536
+ links,
537
+ outDir,
538
+ file: tarballFile(pkg.name),
539
+ }).pipe(Effect.scoped);
540
+ const lines = [
541
+ `${pkg.name}@${pkg.version} ${packed.sha256.slice(0, 12)} ${packed.size} bytes`,
542
+ ...packed.rewrites.map((r) => ` ${r.section}.${r.name} -> ${r.url}`),
543
+ ];
544
+ yield* Console.log(lines.join("\n"));
545
+ return {
546
+ name: pkg.name,
547
+ version: pkg.version,
548
+ dir: pkg.dir,
549
+ group: pkg.group,
550
+ file: packed.file,
551
+ sha256: packed.sha256,
552
+ size: packed.size,
553
+ } satisfies ManifestPackage;
554
+ }),
555
+ { concurrency: 4 },
556
+ );
557
+ for (const entry of packedLevel) {
558
+ links.set(
559
+ entry.name,
560
+ tarballUrl(options.registry, entry.name, entry.sha256),
561
+ );
562
+ packedByName.set(entry.name, entry);
563
+ }
564
+ }
565
+ // Packing ran in dependency order; the manifest keeps the listed order.
566
+ const entries = packages.map((pkg) => packedByName.get(pkg.name)!);
567
+
568
+ // One entry per distinct group name, in the order the groups were given.
569
+ const groups = [
570
+ ...new Map(
571
+ options.groups.map((group) => [
572
+ group.name,
573
+ { name: group.name, collapsed: group.collapsed },
574
+ ]),
575
+ ).values(),
576
+ ];
577
+ const manifest: Manifest = {
578
+ version: 1,
579
+ groups,
580
+ registry: options.registry.replace(/\/+$/, ""),
581
+ head,
582
+ packages: entries,
583
+ };
584
+ const manifestText = `${yield* Schema.encodeEffect(ManifestJson)(manifest)}\n`;
585
+ yield* fs.writeFileString(path.join(outDir, MANIFEST_FILE), manifestText);
586
+ yield* Console.log(
587
+ `Packed ${entries.length} package(s) into ${path.relative(options.cwd, outDir) || "."}`,
588
+ );
589
+
590
+ // The workflow uploads the manifest as an artifact under this name; that
591
+ // upload is what proves to the registry that this run vouched for it.
592
+ // Only a JavaScript action receives the runtime token needed to upload,
593
+ // so the CLI cannot do it itself.
594
+ const artifact = manifestArtifactName(yield* sha256(manifestText));
595
+ const stepOutput = yield* Config.option(Config.String("GITHUB_OUTPUT"));
596
+ if (Option.isSome(stepOutput)) {
597
+ yield* fs.writeFileString(stepOutput.value, `artifact-name=${artifact}\n`, {
598
+ flag: "a",
599
+ });
600
+ }
601
+ yield* Console.log(`Manifest artifact name: ${artifact}`);
602
+ return manifest;
603
+ });