@stealthscale/tool-cli 0.1.0

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.
@@ -0,0 +1,740 @@
1
+ import { defineCommand } from "citty";
2
+ import { dependencyClosure, workspaceManifests, workspaceRoot } from "@stealthscale/tool-workspace";
3
+ import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { spawn } from "node:child_process";
7
+ import { createServer } from "node:net";
8
+ import { looseObject, number, optional, parse, record, string, unknown } from "@stealthscale/core-schema";
9
+ //#region src/release/changesets.ts
10
+ /**
11
+ * @fileoverview Tells changesets/action what a run published. The action hands the publish
12
+ * script a file path and reads JSON lines back from it, one per tag, and creates the git tag
13
+ * and the GitHub release from each. It reads nothing the script prints.
14
+ */
15
+ /**
16
+ * Builds the entry for one published package.
17
+ *
18
+ * @param {Manifest} manifest - The package that went out.
19
+ * @returns {TagEvent} The entry, naming the package and its tag.
20
+ */
21
+ function tagEvent(manifest) {
22
+ return {
23
+ packageName: manifest.name,
24
+ tag: `${manifest.name}@${manifest.version}`,
25
+ type: "git-tag"
26
+ };
27
+ }
28
+ /**
29
+ * Writes the entries where changesets/action reads them, one JSON object per line.
30
+ *
31
+ * A run that published nothing still writes the file, empty. The action reports a file it
32
+ * cannot read as a warning and then creates no tag for anything, so an empty file and a
33
+ * missing one differ.
34
+ *
35
+ * @param {string} file - The path the action handed the run, from `CHANGESETS_OUTPUT`.
36
+ * @param {readonly TagEvent[]} events - One entry per published package.
37
+ */
38
+ function writeTagEvents(file, events) {
39
+ writeFileSync(file, events.map((event) => `${JSON.stringify(event)}\n`).join(""));
40
+ }
41
+ //#endregion
42
+ //#region src/report/report.ts
43
+ /**
44
+ * Matches a stack frame, which says where a tool was when it failed rather than why.
45
+ */
46
+ const STACK_FRAME = /^\s+at\s/u;
47
+ /**
48
+ * Builds a step that went as it should.
49
+ *
50
+ * @param {string} name - The stage and its subject.
51
+ * @param {string} [detail] - Text worth reading even so, such as a checker's warnings.
52
+ * Default: nothing.
53
+ * @returns {Step} The step.
54
+ */
55
+ function passed(name, detail = "") {
56
+ return {
57
+ detail,
58
+ name,
59
+ ok: true
60
+ };
61
+ }
62
+ /**
63
+ * Builds a step that did not go as it should.
64
+ *
65
+ * @param {string} name - The stage and its subject.
66
+ * @param {string} detail - The reason: the command's last lines, or what was found wanting.
67
+ * @returns {Step} The step.
68
+ */
69
+ function failed(name, detail) {
70
+ return {
71
+ detail,
72
+ name,
73
+ ok: false
74
+ };
75
+ }
76
+ /**
77
+ * Lists the steps of a run that failed.
78
+ *
79
+ * @param {Report} report - The run.
80
+ * @returns {Step[]} The failed steps, in order.
81
+ */
82
+ function failures(report) {
83
+ return report.steps.filter((step) => !step.ok);
84
+ }
85
+ /**
86
+ * Keeps the end of what a command wrote, which is where the reason usually is. Stack frames
87
+ * go first: a bundler's failure is one line of cause over thirty lines of frames.
88
+ *
89
+ * @param {string} output - Everything the command wrote, both streams together.
90
+ * @param {number} [keep] - How many lines to keep at most. Default: 20.
91
+ * @returns {string} The last lines, with blank lines at either end dropped.
92
+ */
93
+ function lastLines(output, keep = 20) {
94
+ const lines = output.split("\n").filter((line) => !STACK_FRAME.test(line));
95
+ const first = lines.findIndex((line) => line.trim() !== "");
96
+ if (first === -1) return "";
97
+ const last = lines.findLastIndex((line) => line.trim() !== "") + 1;
98
+ return lines.slice(Math.max(first, last - keep), last).join("\n");
99
+ }
100
+ /**
101
+ * Renders a report as text: one line per step, a step's detail indented under it, then the
102
+ * count and, when something failed, where to look.
103
+ *
104
+ * @param {Report} report - The run.
105
+ * @returns {string} The text, ending in a newline.
106
+ */
107
+ function render(report) {
108
+ const lines = report.steps.flatMap((step) => [`${step.ok ? "ok " : "FAIL"} ${step.name}`, ...step.detail === "" ? [] : step.detail.split("\n").map((line) => ` ${line}`)]);
109
+ const count = failures(report).length;
110
+ const summary = count === 0 ? `${report.steps.length} steps, all passed` : `${report.steps.length} steps, ${count} failed; the run's files are in ${report.workdir}`;
111
+ return [
112
+ ...lines,
113
+ "",
114
+ summary,
115
+ ""
116
+ ].join("\n");
117
+ }
118
+ //#endregion
119
+ //#region src/shell/shell.ts
120
+ /**
121
+ * @fileoverview Runs the commands the `stealth` tool needs: bun, npm and a registry, as real
122
+ * processes that the tool owns. A specification hands the tool a shell that records instead.
123
+ */
124
+ /**
125
+ * Describes what a listening server answers for its address, in the one field the tool reads.
126
+ */
127
+ const BOUND_ADDRESS = looseObject({ port: number() });
128
+ /**
129
+ * Builds this process's environment with the command's own additions on top.
130
+ *
131
+ * @param {RunOptions} options - The options the command was given.
132
+ * @returns {Readonly<Record<string, string | undefined>>} This process's variables, with the
133
+ * command's additions written over them.
134
+ */
135
+ function environment(options) {
136
+ return {
137
+ ...process.env,
138
+ ...options.env
139
+ };
140
+ }
141
+ /**
142
+ * Runs one command to completion.
143
+ *
144
+ * @param {string} file - The executable.
145
+ * @param {readonly string[]} args - The arguments.
146
+ * @param {RunOptions} options - The working directory and the environment.
147
+ * @returns {Promise<CommandOutcome>} The exit code and both streams.
148
+ */
149
+ function run(file, args, options) {
150
+ return new Promise((resolve) => {
151
+ const child = spawn(file, [...args], {
152
+ cwd: options.cwd,
153
+ env: environment(options),
154
+ stdio: [
155
+ "ignore",
156
+ "pipe",
157
+ "pipe"
158
+ ]
159
+ });
160
+ let stdout = "";
161
+ let stderr = "";
162
+ child.stdout.on("data", (chunk) => {
163
+ stdout += chunk.toString();
164
+ });
165
+ child.stderr.on("data", (chunk) => {
166
+ stderr += chunk.toString();
167
+ });
168
+ child.on("error", (error) => {
169
+ resolve({
170
+ code: 1,
171
+ stderr: error.message,
172
+ stdout
173
+ });
174
+ });
175
+ child.on("close", (code) => {
176
+ resolve({
177
+ code: code ?? 1,
178
+ stderr,
179
+ stdout
180
+ });
181
+ });
182
+ });
183
+ }
184
+ /**
185
+ * Sets how long a stopped process is given to leave on `SIGTERM` before it is killed.
186
+ */
187
+ const GRACE_MS = 1e3;
188
+ /**
189
+ * Starts one long-lived process, owned by its pid.
190
+ *
191
+ * The log file is opened here and closed when the process exits, so a tool that starts and
192
+ * stops several registries over a run does not run out of descriptors.
193
+ *
194
+ * @param {string} file - The executable.
195
+ * @param {readonly string[]} args - The arguments.
196
+ * @param {StartOptions} options - The working directory, the environment and the log file.
197
+ * @returns {StartedProcess} The process.
198
+ */
199
+ function start(file, args, options) {
200
+ const log = openSync(options.log, "a");
201
+ const child = spawn(file, [...args], {
202
+ cwd: options.cwd,
203
+ env: environment(options),
204
+ stdio: [
205
+ "ignore",
206
+ log,
207
+ log
208
+ ]
209
+ });
210
+ /**
211
+ * Waits for the process to end, however it ends, and closes its log.
212
+ *
213
+ * A process that never spawned reports `error` and one that ran reports `exit`, so the
214
+ * wait settles on either; a promise settles once, so the log is closed once.
215
+ *
216
+ * @returns {Promise<void>} Resolves after the exit, with the log closed.
217
+ */
218
+ async function untilClosed() {
219
+ await new Promise((resolve) => {
220
+ child.once("exit", () => {
221
+ resolve();
222
+ });
223
+ child.once("error", () => {
224
+ resolve();
225
+ });
226
+ });
227
+ closeSync(log);
228
+ }
229
+ const exited = untilClosed();
230
+ return {
231
+ pid: child.pid ?? 0,
232
+ /**
233
+ * Asks the process to leave, kills it if it will not, and waits for the exit.
234
+ *
235
+ * @returns {Promise<void>} Resolves after the exit.
236
+ */
237
+ stop: async () => {
238
+ child.kill("SIGTERM");
239
+ const killer = setTimeout(() => {
240
+ child.kill("SIGKILL");
241
+ }, GRACE_MS);
242
+ try {
243
+ await exited;
244
+ } finally {
245
+ clearTimeout(killer);
246
+ }
247
+ }
248
+ };
249
+ }
250
+ /**
251
+ * Asks the operating system for a port and gives it straight back.
252
+ *
253
+ * @returns {Promise<number>} A port that was free when asked.
254
+ */
255
+ function freePort() {
256
+ return new Promise((resolve, reject) => {
257
+ const server = createServer();
258
+ server.once("error", reject);
259
+ server.listen(0, "127.0.0.1", () => {
260
+ const { port } = parse(BOUND_ADDRESS, server.address());
261
+ server.close(() => {
262
+ resolve(port);
263
+ });
264
+ });
265
+ });
266
+ }
267
+ /**
268
+ * Builds the shell the tool runs under: real processes and real ports. `start` returns the
269
+ * very process it spawned, so `stop` never reaches a process somebody else owns.
270
+ *
271
+ * @returns {Shell} A shell backed by real processes and real ports.
272
+ */
273
+ function shell() {
274
+ return {
275
+ freePort,
276
+ run,
277
+ start
278
+ };
279
+ }
280
+ //#endregion
281
+ //#region src/release/ranges.ts
282
+ /**
283
+ * @fileoverview Writes the range a published manifest carries where the workspace wrote the
284
+ * `workspace:` protocol.
285
+ *
286
+ * Bun rewrites the protocol itself, but it takes the version from the lockfile, and nothing
287
+ * updates the version a lockfile records for a workspace package: `changeset version` writes
288
+ * the manifests alone, and `bun install`, `--force` and `--lockfile-only` all answer "no
289
+ * changes". So bun ships the version from before the bump. A theme released at 0.1.0 asked
290
+ * for `core-theme@^0.0.0`, and a caret on a `0.0.x` version excludes every later one, so no
291
+ * consumer could install it.
292
+ *
293
+ * The release knows every version it is publishing, because it read the manifests to decide
294
+ * what to publish. It writes the ranges from those rather than trusting the lockfile.
295
+ */
296
+ /**
297
+ * Names the blocks a manifest declares dependencies in.
298
+ */
299
+ const DEPENDENCY_BLOCKS = [
300
+ "dependencies",
301
+ "devDependencies",
302
+ "optionalDependencies",
303
+ "peerDependencies"
304
+ ];
305
+ /**
306
+ * Reads the workspace protocol and the range written after it.
307
+ */
308
+ const WORKSPACE = /^workspace:(?<written>.*)$/u;
309
+ /**
310
+ * Writes the range that stands in for one workspace protocol.
311
+ *
312
+ * `*` pins the version exactly, which is what a package asking for whatever the workspace
313
+ * holds means once the workspace is not there. `^` and `~` take that operator. Anything else
314
+ * is a range somebody wrote by hand, and it is published as written.
315
+ *
316
+ * @param {string} written - The text after `workspace:`: `*`, `^`, `~` or a range.
317
+ * @param {string} version - The version the depended-on package is being published at.
318
+ * @returns {string} The range to publish.
319
+ */
320
+ function rangeFor(written, version) {
321
+ if (written === "*" || written === "") return version;
322
+ if (written === "^" || written === "~") return `${written}${version}`;
323
+ return written;
324
+ }
325
+ /**
326
+ * Rewrites every workspace protocol in one manifest against the versions being published.
327
+ *
328
+ * A dependency the workspace does not hold is left as it was written, because inventing a
329
+ * range for it would publish a manifest nobody can explain. Packing then fails on the
330
+ * protocol, which is the loud answer.
331
+ *
332
+ * @param {Declared} declared - The manifest, as it is written on disk.
333
+ * @param {ReadonlyMap<string, string>} versions - Each workspace package mapped to the
334
+ * version it is being published at.
335
+ * @returns {Declared} The manifest, with every protocol it could resolve written as a range.
336
+ */
337
+ function resolvedRanges(declared, versions) {
338
+ const rewritten = { ...declared };
339
+ for (const block of DEPENDENCY_BLOCKS) {
340
+ const entries = declared[block];
341
+ if (typeof entries !== "object" || entries === null) continue;
342
+ rewritten[block] = Object.fromEntries(Object.entries(entries).map(([name, range]) => {
343
+ const version = versions.get(name);
344
+ const protocol = typeof range === "string" ? WORKSPACE.exec(range) : null;
345
+ if (version === void 0 || protocol?.groups === void 0) return [name, range];
346
+ return [name, rangeFor(String(protocol.groups["written"]), version)];
347
+ }));
348
+ }
349
+ return rewritten;
350
+ }
351
+ //#endregion
352
+ //#region src/release/packages.ts
353
+ /**
354
+ * @fileoverview Packs a built package into a tarball with bun and publishes the tarball
355
+ * with npm. The tarball comes from bun, because bun rewrites `workspace:^` and `catalog:`
356
+ * into ranges; the upload goes through npm, because npm can attest provenance and bun
357
+ * cannot. The access comes from the manifest's `publishConfig`, which npm reads from the
358
+ * tarball, and so does the registry when the manifest names one.
359
+ */
360
+ /**
361
+ * Matches a glob character in a `files` entry, which names more than one path.
362
+ */
363
+ const GLOB = /[*?[{]/u;
364
+ /**
365
+ * Joins everything a command wrote on both streams.
366
+ *
367
+ * @param {CommandOutcome} outcome - The command's exit code and both streams.
368
+ * @returns {string} Stdout, then stderr.
369
+ */
370
+ function outputOf(outcome) {
371
+ return `${outcome.stdout}\n${outcome.stderr}`;
372
+ }
373
+ /**
374
+ * Lists the paths a manifest's `files` names that are missing from the package. A glob
375
+ * entry names no one path and is not checked; a manifest without `files` names nothing.
376
+ *
377
+ * @param {Manifest} manifest - The package to check.
378
+ * @returns {string[]} The missing paths, relative to the package, in `files` order.
379
+ */
380
+ function missingFiles(manifest) {
381
+ return (manifest.files ?? []).filter((file) => !GLOB.test(file) && !existsSync(join(manifest.directory, file)));
382
+ }
383
+ /**
384
+ * Reads a manifest's text as the object the rewrite works on.
385
+ *
386
+ * It refuses anything that is not an object rather than answering an empty one, because the
387
+ * answer is written back over the file: a manifest this could not read would be replaced by
388
+ * `{}`. It throws before anything is written, so the package is left as it was found.
389
+ *
390
+ * @param {string} text - The manifest as it is written on disk.
391
+ * @returns {Declared} The manifest.
392
+ * @throws {Error} When the text parses to anything but an object.
393
+ */
394
+ function declaredIn(text) {
395
+ const parsed = JSON.parse(text);
396
+ if (typeof parsed !== "object" || parsed === null) throw new Error(`a manifest is an object, and this one parsed to ${typeof parsed}`);
397
+ return Object.fromEntries(Object.entries({ ...parsed }));
398
+ }
399
+ /**
400
+ * Runs one step with the package's manifest holding real ranges instead of the workspace
401
+ * protocol, and writes back what was there however the step ended.
402
+ *
403
+ * The manifest is written rather than the tarball patched, because bun packs what is on
404
+ * disk: with no protocol left there is nothing for it to rewrite out of a stale lockfile.
405
+ * The original text goes back byte for byte, so a manifest a person formatted stays as they
406
+ * wrote it. A caller naming no versions has nothing to write, and the file is left alone.
407
+ *
408
+ * @template Result - The value the step answers with.
409
+ * @param {Manifest} manifest - The package being packed.
410
+ * @param {ReadonlyMap<string, string>} versions - Each workspace package mapped to the
411
+ * version it is being published at.
412
+ * @param {() => Promise<Result>} step - The work to run while the ranges are written.
413
+ * @returns {Promise<Result>} The step's own answer, unchanged.
414
+ */
415
+ async function written(manifest, versions, step) {
416
+ if (versions.size === 0) return step();
417
+ const path = join(manifest.directory, "package.json");
418
+ const before = readFileSync(path, "utf8");
419
+ const after = resolvedRanges(declaredIn(before), versions);
420
+ writeFileSync(path, `${JSON.stringify(after, void 0, 2)}\n`);
421
+ try {
422
+ return await step();
423
+ } finally {
424
+ writeFileSync(path, before);
425
+ }
426
+ }
427
+ /**
428
+ * Packs one built package into the destination directory.
429
+ *
430
+ * @param {Manifest} manifest - The package to pack.
431
+ * @param {string} destination - The directory the tarball goes into.
432
+ * @param {Shell} shell - The shell that runs bun.
433
+ * @param {ReadonlyMap<string, string>} [versions] - Each workspace package mapped to the
434
+ * version it is being published at. Default: none, leaving every range as written.
435
+ * @returns {Promise<PackResult>} The step, and the tarball when there is one.
436
+ */
437
+ async function pack(manifest, destination, shell, versions = /* @__PURE__ */ new Map()) {
438
+ const name = `pack ${manifest.name}`;
439
+ const outcome = await written(manifest, versions, () => shell.run("bun", [
440
+ "pm",
441
+ "pack",
442
+ "--destination",
443
+ destination,
444
+ "--quiet"
445
+ ], { cwd: manifest.directory }));
446
+ const tarball = outcome.stdout.split("\n").map((line) => line.trim()).findLast((line) => line.endsWith(".tgz"));
447
+ if (outcome.code !== 0 || tarball === void 0) return { step: failed(name, lastLines(outputOf(outcome))) };
448
+ return {
449
+ packed: {
450
+ manifest,
451
+ tarball: resolve(destination, tarball)
452
+ },
453
+ step: passed(name, tarball)
454
+ };
455
+ }
456
+ /**
457
+ * Publishes one tarball with npm. The token, when there is one, travels in a user config
458
+ * file npm is pointed at, so nothing else npm reads is touched.
459
+ *
460
+ * The run's registry is passed on the command line rather than the manifest's. Npm reads
461
+ * `publishConfig.registry` out of the tarball and lets it win over the flag, which is the
462
+ * rule the asking side applies as well.
463
+ *
464
+ * @param {string} tarball - The tarball to publish, absolute.
465
+ * @param {string} cwd - The directory npm runs in.
466
+ * @param {PublishOptions} options - How to publish. `PublishOptions` documents every member.
467
+ * @param {Shell} shell - The shell that runs npm.
468
+ * @returns {Promise<CommandOutcome>} Npm's exit code and both streams.
469
+ */
470
+ function publishTarball(tarball, cwd, options, shell) {
471
+ const args = ["publish", tarball];
472
+ if (options.dryRun) args.push("--dry-run");
473
+ if (options.provenance) args.push("--provenance");
474
+ if (options.registry !== void 0) args.push("--registry", options.registry);
475
+ return shell.run("npm", args, {
476
+ cwd,
477
+ env: options.userconfig === void 0 ? void 0 : { NPM_CONFIG_USERCONFIG: options.userconfig }
478
+ });
479
+ }
480
+ //#endregion
481
+ //#region src/release/registry.ts
482
+ /**
483
+ * @fileoverview Asks a registry what it has: which registry npm is configured for, and
484
+ * whether it already holds a version of a package.
485
+ */
486
+ /**
487
+ * Accepts the document a registry answers for a package, in the one field the tool reads.
488
+ */
489
+ const PACKUMENT = looseObject({ versions: optional(record(string(), unknown())) });
490
+ /**
491
+ * Ends a registry URL with the slash bun and npm write.
492
+ *
493
+ * @param {string} url - The registry as npm or a manifest names it.
494
+ * @returns {string} The URL with exactly one trailing slash.
495
+ */
496
+ function withTrailingSlash(url) {
497
+ return url.endsWith("/") ? url : `${url}/`;
498
+ }
499
+ /**
500
+ * Asks npm for the registry it is configured to publish to.
501
+ *
502
+ * Ask this in the directory the publish will run in. Npm reads its configuration, and the
503
+ * manifest beside it, from where it runs, so asking anywhere else can name a registry the
504
+ * publish will not use, and asking in a workspace whose manifest names another package
505
+ * manager makes npm refuse outright.
506
+ *
507
+ * @param {Shell} shell - The shell that runs npm.
508
+ * @param {string} cwd - The directory the publish will run in.
509
+ * @returns {Promise<string>} The registry, with its trailing slash.
510
+ * @throws {Error} When npm names no registry.
511
+ */
512
+ async function configuredRegistry(shell, cwd) {
513
+ const outcome = await shell.run("npm", [
514
+ "config",
515
+ "get",
516
+ "registry"
517
+ ], { cwd });
518
+ const url = outcome.stdout.trim();
519
+ if (outcome.code !== 0 || url === "") throw new Error(`npm names no registry: ${lastLines(`${outcome.stdout}\n${outcome.stderr}`)}`);
520
+ return withTrailingSlash(url);
521
+ }
522
+ /**
523
+ * Returns `true` when the registry already holds this version of the package.
524
+ *
525
+ * @param {string} registry - The registry, with its trailing slash.
526
+ * @param {Manifest} manifest - The package to ask about.
527
+ * @param {typeof fetch} fetchImpl - The fetch that asks the registry.
528
+ * @returns {Promise<boolean>} `true` when that exact version is there.
529
+ * @throws {Error} When the registry answers with anything but the package or a 404.
530
+ */
531
+ async function registryHasVersion(registry, manifest, fetchImpl) {
532
+ const response = await fetchImpl(`${registry}${manifest.name.replace("/", "%2F")}`);
533
+ if (response.status === 404) return false;
534
+ if (!response.ok) throw new Error(`${registry} answered ${response.status} for ${manifest.name}`);
535
+ const { versions } = parse(PACKUMENT, await response.json());
536
+ return manifest.version in (versions ?? {});
537
+ }
538
+ //#endregion
539
+ //#region src/release/release.ts
540
+ /**
541
+ * @fileoverview Publishes every public package of a workspace the registry does not have
542
+ * yet, in dependency order, from a workspace that is already built.
543
+ */
544
+ /**
545
+ * Collects the release set: every public package of the workspace and every workspace
546
+ * package it depends on, dependencies before dependents.
547
+ *
548
+ * @param {string} root - The workspace root.
549
+ * @returns {ReleaseSet} The set, and the private packages it would need and cannot have.
550
+ */
551
+ function releaseSet(root) {
552
+ const manifests = workspaceManifests(root);
553
+ const roots = manifests.filter((manifest) => !manifest.private).map((manifest) => manifest.name);
554
+ const { ordered } = dependencyClosure(roots, manifests);
555
+ return {
556
+ hidden: ordered.filter((manifest) => manifest.private).map((manifest) => manifest.name),
557
+ ordered: ordered.filter((manifest) => !manifest.private)
558
+ };
559
+ }
560
+ /**
561
+ * Releases one package: skips it when the registry has the version, refuses it when a path
562
+ * its `files` names is missing, and packs and publishes it otherwise.
563
+ *
564
+ * @param {Manifest} manifest - The package to release.
565
+ * @param {Publishing} publishing - The registry to ask and the versions going out.
566
+ * `Publishing` documents every member.
567
+ * @param {ReleaseOptions} options - How to publish, and where the tarballs go.
568
+ * @param {Shell} shell - The shell that runs bun and npm.
569
+ * @returns {Promise<Released>} The step, and the tag entry when the package went out.
570
+ */
571
+ async function releaseOne(manifest, publishing, options, shell) {
572
+ const { registry, versions } = publishing;
573
+ const name = `publish ${manifest.name}@${manifest.version}`;
574
+ if (await registryHasVersion(manifest.registry === void 0 ? registry : withTrailingSlash(manifest.registry), manifest, options.fetch)) return { step: passed(name, "already on the registry") };
575
+ const missing = missingFiles(manifest);
576
+ if (missing.length > 0) return { step: failed(name, `not built: ${missing.join(", ")} missing`) };
577
+ const { packed, step } = await pack(manifest, options.tarballs, shell, versions);
578
+ if (packed === void 0) return { step };
579
+ const outcome = await publishTarball(packed.tarball, options.tarballs, options, shell);
580
+ if (outcome.code !== 0) return { step: failed(name, lastLines(`${outcome.stdout}\n${outcome.stderr}`)) };
581
+ if (options.dryRun) return { step: passed(name, "dry run") };
582
+ return {
583
+ step: passed(name, "published"),
584
+ tag: tagEvent(manifest)
585
+ };
586
+ }
587
+ /**
588
+ * Releases the packages one after another, dependencies first, and stops at the first
589
+ * failure: a dependent published without its dependency is a package nobody can install.
590
+ *
591
+ * @param {readonly Manifest[]} ordered - The packages, dependencies first.
592
+ * @param {string} registry - The registry to ask, unless a manifest names its own.
593
+ * @param {ReleaseOptions} options - How to publish, and where the tarballs go.
594
+ * @param {Shell} shell - The shell that runs bun and npm.
595
+ * @returns {Promise<Released[]>} One entry per package, in the order they were tried.
596
+ */
597
+ function releaseAll(ordered, registry, options, shell) {
598
+ const versions = new Map(ordered.map((manifest) => [manifest.name, manifest.version]));
599
+ return ordered.reduce(async (before, manifest) => {
600
+ const done = await before;
601
+ if (done.some(({ step }) => !step.ok)) {
602
+ const name = `publish ${manifest.name}@${manifest.version}`;
603
+ return [...done, { step: failed(name, "not attempted: an earlier publish failed") }];
604
+ }
605
+ return [...done, await releaseOne(manifest, {
606
+ registry,
607
+ versions
608
+ }, options, shell)];
609
+ }, Promise.resolve([]));
610
+ }
611
+ /**
612
+ * Releases every package of the set the registry does not have yet, packed and published in
613
+ * dependency order. It refuses the whole set when the set depends on a private package.
614
+ *
615
+ * @param {ReleaseOptions} options - The registry, how to publish, the workspace and where
616
+ * the tarballs go.
617
+ * @param {Shell} shell - The shell that runs bun and npm.
618
+ * @returns {Promise<Report>} Every step, in order.
619
+ */
620
+ async function release(options, shell) {
621
+ const { hidden, ordered } = releaseSet(options.root);
622
+ if (hidden.length > 0) return {
623
+ steps: [failed("release set", `private, so nothing that depends on them can be installed: ${hidden.join(", ")}`)],
624
+ workdir: options.tarballs
625
+ };
626
+ mkdirSync(options.tarballs, { recursive: true });
627
+ const registry = options.registry === void 0 ? await configuredRegistry(shell, options.tarballs) : withTrailingSlash(options.registry);
628
+ const released = await releaseAll(ordered, registry, options, shell);
629
+ if (options.changesetsOutput !== void 0) writeTagEvents(options.changesetsOutput, released.flatMap(({ tag }) => tag === void 0 ? [] : [tag]));
630
+ return {
631
+ steps: [passed("release set", `${ordered.length} packages to ${registry}`), ...released.map(({ step }) => step)],
632
+ workdir: options.tarballs
633
+ };
634
+ }
635
+ //#endregion
636
+ //#region src/release/command.ts
637
+ /**
638
+ * @fileoverview The `stealth release` command: what it takes from the command line, and how
639
+ * it turns that into a release run. Everything the run touches from outside arrives as a
640
+ * dependency, so a specification drives the whole command without a registry or a process.
641
+ */
642
+ /**
643
+ * Prefixes the directory the tarballs are written to, so a leftover run is recognisable in
644
+ * the temporary directory.
645
+ */
646
+ const TARBALL_PREFIX = "stealth-release-";
647
+ /**
648
+ * What `stealth release` takes from the command line.
649
+ *
650
+ * The registry and the token are absent by default: npm reads its own configuration, and a
651
+ * manifest's `publishConfig` overrides it per package. Naming either here is for a run that
652
+ * publishes somewhere else, such as a local registry in a smoke test.
653
+ */
654
+ const RELEASE_ARGS = {
655
+ "dry-run": {
656
+ default: false,
657
+ description: "Report what would be published, and publish nothing.",
658
+ type: "boolean"
659
+ },
660
+ provenance: {
661
+ default: false,
662
+ description: "Attest provenance through the CI job's token. Npm refuses it elsewhere.",
663
+ type: "boolean"
664
+ },
665
+ registry: {
666
+ description: "Publish to this registry instead of the one npm is configured for.",
667
+ type: "string",
668
+ valueHint: "url"
669
+ },
670
+ tarballs: {
671
+ description: "Write the tarballs here instead of a new temporary directory.",
672
+ type: "string",
673
+ valueHint: "dir"
674
+ },
675
+ userconfig: {
676
+ description: "Read npm's configuration, and so the token, from this file.",
677
+ type: "string",
678
+ valueHint: "file"
679
+ }
680
+ };
681
+ /**
682
+ * Runs a release from the parsed command line and reports it.
683
+ *
684
+ * The exit code is set rather than thrown: a failed publish is an outcome the report already
685
+ * explains, and throwing would print a stack trace over it.
686
+ *
687
+ * @param {ParsedArgs<typeof RELEASE_ARGS>} args - The command line, as citty parsed it.
688
+ * @param {ReleaseDeps} deps - The workspace, the registry, the shell and where to write.
689
+ * @returns {Promise<void>} Resolves once the report is written.
690
+ */
691
+ async function runRelease(args, deps) {
692
+ const report = await release({
693
+ changesetsOutput: deps.changesetsOutput,
694
+ dryRun: args["dry-run"],
695
+ fetch: deps.fetch,
696
+ provenance: args.provenance,
697
+ registry: args.registry,
698
+ root: workspaceRoot(deps.cwd),
699
+ tarballs: args.tarballs ?? mkdtempSync(join(tmpdir(), TARBALL_PREFIX)),
700
+ userconfig: args.userconfig
701
+ }, deps.shell);
702
+ deps.log(render(report));
703
+ if (failures(report).length > 0) process.exitCode = 1;
704
+ }
705
+ /**
706
+ * Builds the release command against the outside world it is given.
707
+ *
708
+ * @param {ReleaseDeps} deps - The workspace, the registry, the shell and where to write.
709
+ * @returns {CommandDef} The command, ready to hand to citty.
710
+ */
711
+ function releaseCommandWith(deps) {
712
+ return defineCommand({
713
+ args: RELEASE_ARGS,
714
+ meta: {
715
+ description: "Publish every public package the registry does not have yet.",
716
+ name: "release"
717
+ },
718
+ /**
719
+ * Runs the release the command line asks for.
720
+ *
721
+ * @param {CommandContext<typeof RELEASE_ARGS>} context - The command line, as citty
722
+ * parsed it.
723
+ * @returns {Promise<void>} Resolves once the report is written.
724
+ */
725
+ run: ({ args }) => runRelease(args, deps)
726
+ });
727
+ }
728
+ /**
729
+ * The release command as the `stealth` bin runs it: this process, the real registry, a real
730
+ * shell and the terminal.
731
+ */
732
+ const releaseCommand = releaseCommandWith({
733
+ changesetsOutput: process.env["CHANGESETS_OUTPUT"],
734
+ cwd: process.cwd(),
735
+ fetch,
736
+ log: process.stdout.write.bind(process.stdout),
737
+ shell: shell()
738
+ });
739
+ //#endregion
740
+ export { tagEvent as _, configuredRegistry as a, missingFiles as c, shell as d, failed as f, render as g, passed as h, releaseSet as i, pack as l, lastLines as m, releaseCommandWith as n, registryHasVersion as o, failures as p, release as r, withTrailingSlash as s, releaseCommand as t, publishTarball as u, writeTagEvents as v };