@coderook/cli 0.26.0 → 0.28.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.
@@ -131,10 +131,39 @@ async function commandVersions(parsed) {
131
131
  console.log(dim("Nothing has been saved to this project yet."));
132
132
  return 0;
133
133
  }
134
+ /*
135
+ The same two lanes the website shows.
136
+
137
+ A version is a commit somebody named, so the lanes are a split of one
138
+ history rather than two histories — asking for commits gives the saves
139
+ still waiting on that decision, and asking for versions gives the ones
140
+ the public side can see. Neither contains the other; with no flag you get
141
+ both, which is what this always did.
142
+ */
143
+ const onlyVersions = parsed.flags.get("versions") === true;
144
+ const onlyCommits = parsed.flags.get("commits") === true;
145
+ if (onlyVersions && onlyCommits) {
146
+ console.error(red("Pick one: --versions or --commits, or neither for both."));
147
+ return 1;
148
+ }
149
+ const lane = saved.filter((version) => {
150
+ if (onlyVersions)
151
+ return Boolean(version.name);
152
+ if (onlyCommits)
153
+ return !version.name;
154
+ return true;
155
+ });
156
+ if (!lane.length) {
157
+ console.log(dim(onlyVersions
158
+ ? "Nothing has been made a version yet."
159
+ : "Every save here has been made a version."));
160
+ return 0;
161
+ }
162
+ const withNotes = parsed.flags.get("notes") === true;
134
163
  const asked = Number(parsed.flags.get("limit") ?? 20);
135
164
  const limit = Number.isFinite(asked) && asked > 0 ? asked : 20;
136
165
  console.log(bold(project.name));
137
- for (const version of saved.slice(0, limit)) {
166
+ for (const version of lane.slice(0, limit)) {
138
167
  const when = version.createdAt
139
168
  ? new Date(version.createdAt).toLocaleString()
140
169
  : "";
@@ -155,8 +184,11 @@ async function commandVersions(parsed) {
155
184
  version.state === "held" ? red("held") : "",
156
185
  version.state === "declined" ? dim("declined") : "",
157
186
  version.removedAt ? dim("taken down") : "",
158
- version.visibility === "private" ? dim("private") : "",
159
- version.visibility === "unlisted" ? dim("link only") : "",
187
+ /*
188
+ Said only of a version, because only a version is offered to anybody.
189
+ A commit nobody has promoted is not hidden — it was never on offer.
190
+ */
191
+ version.name && version.visibility === "private" ? dim("hidden") : "",
160
192
  ...version.labels.map((label) => accent(label.name)),
161
193
  ].filter(Boolean);
162
194
  console.log(` ${accent(`v${version.sequence}`).padEnd(16)} ${when.padEnd(22)}` +
@@ -164,9 +196,18 @@ async function commandVersions(parsed) {
164
196
  (marks.length ? ` ${marks.join(" ")}` : ""));
165
197
  if (version.message)
166
198
  console.log(` ${dim(version.message)}`);
199
+ /*
200
+ Indented under the save it belongs to rather than printed flat, so a
201
+ note of several paragraphs still reads as part of one row.
202
+ */
203
+ if (withNotes && version.notes?.trim()) {
204
+ for (const line of version.notes.trim().split(/\r?\n/)) {
205
+ console.log(` ${line}`);
206
+ }
207
+ }
167
208
  }
168
- if (saved.length > limit) {
169
- console.log(dim(` … ${saved.length - limit} older. Use --limit to see more.`));
209
+ if (lane.length > limit) {
210
+ console.log(dim(` … ${lane.length - limit} older. Use --limit to see more.`));
170
211
  }
171
212
  return 0;
172
213
  }
@@ -21,6 +21,7 @@ exports.commandRuns = commandRuns;
21
21
  exports.commandLogs = commandLogs;
22
22
  exports.commandTokens = commandTokens;
23
23
  exports.commandDelete = commandDelete;
24
+ exports.commandHooks = commandHooks;
24
25
  const node_process_1 = __importDefault(require("node:process"));
25
26
  const promises_1 = require("node:readline/promises");
26
27
  const api_js_1 = require("./api.js");
@@ -51,11 +52,47 @@ async function commandIssues(parsed) {
51
52
  }
52
53
  if (typeof title === "string" && title.trim()) {
53
54
  const body = parsed.flags.get("body");
55
+ /*
56
+ Labels at the moment it is opened, rather than in a second command
57
+ afterwards. The service has always taken them here; nothing had ever
58
+ sent any, so every issue arrived unlabelled and the label filter on the
59
+ website had nothing to filter by until somebody went back and tidied.
60
+ */
61
+ const named = parsed.flags.get("labels");
62
+ const labels = typeof named === "string"
63
+ ? named
64
+ .split(",")
65
+ .map((one) => one.trim())
66
+ .filter(Boolean)
67
+ : [];
68
+ /*
69
+ And which save it is about, when it is about one. "It broke" and "it
70
+ broke in v41" are different reports, and only the first could be
71
+ written down.
72
+ */
73
+ const about = parsed.flags.get("version");
74
+ let versionId;
75
+ if (typeof about === "string" && about.trim()) {
76
+ const wanted = about.trim().replace(/^v/i, "");
77
+ const all = await (0, api_js_1.versions)(project.id);
78
+ const found = all.find((one) => String(one.sequence) === wanted);
79
+ if (!found) {
80
+ console.error(red(`${project.name} has no version ${about.trim()}.`));
81
+ return 1;
82
+ }
83
+ versionId = found.id;
84
+ }
54
85
  const created = await (0, api_js_1.createIssue)(project.id, {
55
86
  title: title.trim(),
56
87
  body: typeof body === "string" ? body : "",
88
+ labels,
89
+ ...(versionId ? { versionId } : {}),
57
90
  });
58
91
  console.log(green(`Opened issue #${created.number} on ${project.name}.`));
92
+ if (labels.length)
93
+ console.log(dim(` labelled ${labels.join(", ")}`));
94
+ if (versionId)
95
+ console.log(dim(` about v${about}`));
59
96
  return 0;
60
97
  }
61
98
  const found = await (0, api_js_1.issues)(project.id);
@@ -216,10 +253,67 @@ async function commandWatch(parsed) {
216
253
  }
217
254
  /** The automations a project has, and how they have been going. */
218
255
  async function commandWorkflows(parsed) {
219
- const project = await (0, project_commands_js_1.resolveProject)(parsed.positional[0]);
256
+ const project = await (0, project_commands_js_1.resolveProject)(typeof parsed.flags.get("project") === "string"
257
+ ? String(parsed.flags.get("project"))
258
+ : parsed.positional[0]);
220
259
  if (!project)
221
260
  return 1;
222
261
  const found = (await (0, api_js_1.workflows)(project.id)).filter((workflow) => !workflow.archivedAt);
262
+ /*
263
+ Setting, before printing. `cbx actions --ship Build` reads as a sentence
264
+ and then shows the list it has just changed, which is the same shape
265
+ `cbx notes` uses: one command, print by default, flags to write.
266
+ */
267
+ const ship = parsed.flags.get("ship");
268
+ const stop = parsed.flags.get("no-ship");
269
+ if (ship !== undefined || stop !== undefined) {
270
+ const wanted = ship !== undefined;
271
+ const named = wanted ? ship : stop;
272
+ if (typeof named !== "string" || !named.trim()) {
273
+ console.error(red("Which action?") + dim(` cbx actions ${wanted ? "--ship" : "--no-ship"} "Build"`));
274
+ return 1;
275
+ }
276
+ const target = found.find((workflow) => workflow.name.toLowerCase() === named.trim().toLowerCase());
277
+ if (!target) {
278
+ console.error(red(`${project.name} has no action called ${named.trim()}.`));
279
+ if (found.length) {
280
+ console.error(dim(` It has: ${found.map((one) => one.name).join(", ")}`));
281
+ }
282
+ return 1;
283
+ }
284
+ if (wanted && !target.artifactPaths.length) {
285
+ /*
286
+ Refused rather than set. Shipping what a run collected means nothing
287
+ when the run collects nothing, and a flag that reported success and
288
+ then never produced a download would be indistinguishable from the
289
+ feature being broken.
290
+ */
291
+ console.error(red(`${target.name} keeps no files, so it has nothing to ship.`));
292
+ console.error(dim(" Give it something to keep on the Actions screen first, e.g. dist/*.exe"));
293
+ return 1;
294
+ }
295
+ try {
296
+ await (0, api_js_1.changeWorkflow)(project.id, target.id, { attachArtifacts: wanted });
297
+ }
298
+ catch (error) {
299
+ console.error(red(error instanceof Error ? error.message : String(error)));
300
+ return 1;
301
+ }
302
+ target.attachArtifacts = wanted;
303
+ console.log(wanted
304
+ ? green(`${target.name} will attach what it builds to the version it ran against.`)
305
+ : green(`${target.name} will keep its files without attaching them.`));
306
+ if (wanted) {
307
+ /*
308
+ Said plainly, because attaching is publishing. An attachment on a
309
+ public version is a download anybody can take, and somebody turning
310
+ this on for a workflow that builds an internal tool deserves to hear
311
+ that before the next run rather than after it.
312
+ */
313
+ console.log(dim(" Anything it attaches to a public version is public the moment it lands."));
314
+ }
315
+ console.log("");
316
+ }
223
317
  if (!found.length) {
224
318
  console.log(dim("No actions on this project."));
225
319
  return 0;
@@ -232,6 +326,12 @@ async function commandWorkflows(parsed) {
232
326
  console.log(` ${workflow.name.padEnd(24)} ${dim(workflow.trigger).padEnd(20)} ` +
233
327
  `${dim(workflow.runsOn).padEnd(18)} ${health}`);
234
328
  console.log(` ${dim(workflow.command)}`);
329
+ if (workflow.artifactPaths.length) {
330
+ console.log(` ${dim("keeps")} ${dim(workflow.artifactPaths.join(", "))}` +
331
+ (workflow.attachArtifacts
332
+ ? ` ${accent("→ downloads on the version")}`
333
+ : ""));
334
+ }
235
335
  }
236
336
  return 0;
237
337
  }
@@ -399,3 +499,103 @@ async function commandDelete(parsed) {
399
499
  console.log(green(`Deleted ${project.name}.`));
400
500
  return 0;
401
501
  }
502
+ /**
503
+ * Where a project tells something else that something happened.
504
+ *
505
+ * The service has been able to do this since Merge Tracks shipped — queue an
506
+ * event, sign it, deliver it after the request that caused it — and nothing
507
+ * could reach it. A project could not be told to notify a build box, a chat
508
+ * room or a status page, because no client asked.
509
+ */
510
+ async function commandHooks(parsed) {
511
+ const project = await (0, project_commands_js_1.resolveProject)(typeof parsed.flags.get("project") === "string"
512
+ ? String(parsed.flags.get("project"))
513
+ : parsed.positional[0]);
514
+ if (!project)
515
+ return 1;
516
+ const adding = parsed.flags.get("add");
517
+ if (adding !== undefined) {
518
+ if (typeof adding !== "string" || !adding.trim()) {
519
+ console.error(red("Which address?") + dim(" cbx hooks --add https://example.com/hook"));
520
+ return 1;
521
+ }
522
+ const url = adding.trim();
523
+ if (!/^https:\/\//i.test(url)) {
524
+ /*
525
+ Refused here as well as by the service. A webhook carries a signed
526
+ payload about a private project, and http would put it on the wire in
527
+ the clear — worth saying before the round trip rather than after.
528
+ */
529
+ console.error(red("A webhook address has to be https."));
530
+ return 1;
531
+ }
532
+ const named = parsed.flags.get("events");
533
+ const events = typeof named === "string"
534
+ ? named.split(",").map((one) => one.trim()).filter(Boolean)
535
+ : [];
536
+ try {
537
+ const made = await (0, api_js_1.addWebhook)(project.id, url, events);
538
+ console.log(green(`${project.name} will notify ${url}.`));
539
+ console.log("");
540
+ /*
541
+ Printed once because it exists once. The service generates it and no
542
+ route ever hands it back, so this is the only moment anybody can
543
+ write it down — and saying so is the difference between a person
544
+ copying it and a person losing it.
545
+ */
546
+ console.log(` ${bold("Signing secret")} ${made.secret}`);
547
+ console.log(dim(" Written once and never again. Use it to check the signature on"));
548
+ console.log(dim(" what arrives, so nobody else can post to your endpoint."));
549
+ if (events.length)
550
+ console.log(dim(` Sending: ${events.join(", ")}`));
551
+ else
552
+ console.log(dim(" Sending: everything this project announces"));
553
+ }
554
+ catch (error) {
555
+ console.error(red(error instanceof Error ? error.message : String(error)));
556
+ return 1;
557
+ }
558
+ return 0;
559
+ }
560
+ const removing = parsed.flags.get("remove");
561
+ if (removing !== undefined) {
562
+ if (typeof removing !== "string" || !removing.trim()) {
563
+ console.error(red("Which one?") + dim(" cbx hooks --remove <id>"));
564
+ return 1;
565
+ }
566
+ const wanted = removing.trim();
567
+ const found = (await (0, api_js_1.webhooks)(project.id)).find((one) => one.id === wanted || one.url === wanted);
568
+ if (!found) {
569
+ console.error(red(`${project.name} has no webhook called ${wanted}.`));
570
+ return 1;
571
+ }
572
+ await (0, api_js_1.removeWebhook)(project.id, found.id);
573
+ console.log(green(`${found.url} will not be notified any more.`));
574
+ return 0;
575
+ }
576
+ const found = await (0, api_js_1.webhooks)(project.id);
577
+ if (!found.length) {
578
+ console.log(dim("This project notifies nothing."));
579
+ console.log(dim(" cbx hooks --add https://example.com/coderook"));
580
+ return 0;
581
+ }
582
+ console.log(bold(project.name));
583
+ for (const hook of found) {
584
+ /*
585
+ A run of failures shown rather than hidden. A webhook that has been
586
+ failing for a week looks exactly like one that works until somebody
587
+ checks the receiving end, which is the wrong place to find out.
588
+ */
589
+ const health = !hook.active
590
+ ? red("off")
591
+ : hook.consecutiveFailures
592
+ ? red(`${hook.consecutiveFailures} failed in a row`)
593
+ : hook.lastDeliveredAt
594
+ ? green("delivering")
595
+ : dim("nothing sent yet");
596
+ console.log(` ${hook.url}`);
597
+ console.log(` ${dim(hook.id)} ${health}` +
598
+ (hook.events.length ? ` ${dim(hook.events.join(", "))}` : ` ${dim("everything")}`));
599
+ }
600
+ return 0;
601
+ }
@@ -16,6 +16,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
16
16
  };
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.split = split;
19
+ exports.commandNotes = commandNotes;
19
20
  exports.commandVersion = commandVersion;
20
21
  exports.commandLabels = commandLabels;
21
22
  exports.commandHeld = commandHeld;
@@ -26,6 +27,10 @@ exports.commandUndo = commandUndo;
26
27
  const api_js_1 = require("./api.js");
27
28
  const project_commands_js_1 = require("./project_commands.js");
28
29
  const promises_1 = require("node:readline/promises");
30
+ const node_child_process_1 = require("node:child_process");
31
+ const node_fs_1 = require("node:fs");
32
+ const node_os_1 = require("node:os");
33
+ const node_path_1 = __importDefault(require("node:path"));
29
34
  const node_process_1 = __importDefault(require("node:process"));
30
35
  /* Written out rather than imported: the same four escapes every other command
31
36
  file in here declares for itself, and a shared module for four one-line
@@ -34,6 +39,7 @@ const dim = (value) => `${value}`;
34
39
  const bold = (value) => `${value}`;
35
40
  const red = (value) => `${value}`;
36
41
  const accent = (value) => `${value}`;
42
+ const green = (value) => `${value}`;
37
43
  /**
38
44
  * Which of `[n] [project]` the arguments actually were.
39
45
  *
@@ -112,6 +118,148 @@ function colourOf(given) {
112
118
  function paint(label) {
113
119
  return accent(label.name);
114
120
  }
121
+ /**
122
+ * Release info, from wherever the writer keeps it.
123
+ *
124
+ * `--notes "..."` is fine for a sentence and hopeless for anything real: a
125
+ * release note has paragraphs and lists in it, and the shell trick people
126
+ * reach for — `--notes "$(cat NOTES.md)"` — is not a thing in PowerShell or
127
+ * cmd, which is most of this product's users. So the text can come from a
128
+ * file, or from the editor already set up for writing commit messages.
129
+ *
130
+ * Returns undefined when none of them were asked for, which is different from
131
+ * an empty string: one means leave it alone, the other means clear it.
132
+ */
133
+ function notesFrom(parsed) {
134
+ const inline = parsed.flags.get("notes");
135
+ const file = parsed.flags.get("notes-file");
136
+ const edit = parsed.flags.get("edit") === true;
137
+ const chosen = [
138
+ typeof inline === "string",
139
+ typeof file === "string",
140
+ edit,
141
+ ].filter(Boolean).length;
142
+ if (chosen > 1) {
143
+ throw new Error("Use one of --notes, --notes-file or --edit, not several.");
144
+ }
145
+ if (typeof inline === "string")
146
+ return inline;
147
+ if (typeof file === "string") {
148
+ try {
149
+ return (0, node_fs_1.readFileSync)(file, "utf8");
150
+ }
151
+ catch {
152
+ throw new Error(`Could not read ${file}.`);
153
+ }
154
+ }
155
+ /*
156
+ --edit is answered by the caller, which has the existing text to seed the
157
+ editor with. Nothing to report from here.
158
+ */
159
+ return undefined;
160
+ }
161
+ /**
162
+ * Write in the editor this person already uses, the way git does.
163
+ *
164
+ * The comment lines are stripped, so the instructions at the bottom of the
165
+ * file cannot end up published as part of the release. Quitting without
166
+ * saving leaves the notes untouched rather than clearing them, because an
167
+ * empty buffer is far more often a change of mind than an instruction.
168
+ */
169
+ function writeInEditor(existing) {
170
+ const editor = node_process_1.default.env.CODEROOK_EDITOR ??
171
+ node_process_1.default.env.VISUAL ??
172
+ node_process_1.default.env.EDITOR ??
173
+ (node_process_1.default.platform === "win32" ? "notepad" : "nano");
174
+ const directory = (0, node_fs_1.mkdtempSync)(node_path_1.default.join((0, node_os_1.tmpdir)(), "cbx-notes-"));
175
+ const file = node_path_1.default.join(directory, "RELEASE_NOTES.md");
176
+ try {
177
+ (0, node_fs_1.writeFileSync)(file, `${existing}\n\n` +
178
+ "# Write what changed in this version, in Markdown.\n" +
179
+ "# Lines starting with # in the first column are removed.\n" +
180
+ "# Save an empty file to leave the notes as they were.\n", "utf8");
181
+ /*
182
+ No shell. Handing the arguments to one concatenates them into a command
183
+ line without escaping, which makes the editor setting — and the path
184
+ beside it — a place to hide a second command. An editor is a program
185
+ and some arguments, so it is split here and run directly, which also
186
+ means a path with a space in it survives.
187
+ */
188
+ const parts = editor.match(/"[^"]+"|\S+/g) ?? [editor];
189
+ const unquote = (value) => value.replace(/^"|"$/g, "");
190
+ const run = (0, node_child_process_1.spawnSync)(unquote(parts[0] ?? editor), [...parts.slice(1).map(unquote), file], { stdio: "inherit" });
191
+ if (run.status !== 0)
192
+ return null;
193
+ const written = (0, node_fs_1.readFileSync)(file, "utf8")
194
+ .split(/\r?\n/)
195
+ .filter((line) => !/^#\s/.test(line) && line !== "#")
196
+ .join("\n")
197
+ .trim();
198
+ return written ? written : null;
199
+ }
200
+ catch {
201
+ return null;
202
+ }
203
+ finally {
204
+ (0, node_fs_1.rmSync)(directory, { recursive: true, force: true });
205
+ }
206
+ }
207
+ /**
208
+ * Read or write what a version says about itself.
209
+ *
210
+ * The website grew a place to write this and the terminal could only set it
211
+ * as a one-line flag while promoting, and could not read it back at all —
212
+ * `cbx releases` prints the first line and stops. Somebody working here could
213
+ * publish release info but never check what they had published.
214
+ */
215
+ async function commandNotes(parsed) {
216
+ const which = split(parsed);
217
+ const project = await (0, project_commands_js_1.resolveProject)(which.project);
218
+ if (!project)
219
+ return 1;
220
+ const target = await pick(project.id, which.n);
221
+ if (!target)
222
+ return 1;
223
+ if (parsed.flags.get("clear") === true) {
224
+ await (0, api_js_1.changeVersion)(project.id, target.id, { notes: null });
225
+ console.log(green(`Cleared the notes on v${target.sequence}.`));
226
+ return 0;
227
+ }
228
+ let next;
229
+ try {
230
+ next = notesFrom(parsed);
231
+ }
232
+ catch (error) {
233
+ console.error(red(error instanceof Error ? error.message : String(error)));
234
+ return 1;
235
+ }
236
+ if (parsed.flags.get("edit") === true) {
237
+ const written = writeInEditor(target.notes ?? "");
238
+ if (written === null) {
239
+ console.log(dim("Left as it was."));
240
+ return 0;
241
+ }
242
+ next = written;
243
+ }
244
+ if (next === undefined) {
245
+ /* Nothing to write, so this is a read. */
246
+ const text = (target.notes ?? "").trim();
247
+ if (!text) {
248
+ console.log(dim(`v${target.sequence} has no notes.`) +
249
+ dim(" Write some with --edit, --notes-file or --notes"));
250
+ return 0;
251
+ }
252
+ console.log(bold(`v${target.sequence}${target.name ? ` ${target.name}` : ""}`));
253
+ console.log(text);
254
+ return 0;
255
+ }
256
+ const text = (next ?? "").trim();
257
+ await (0, api_js_1.changeVersion)(project.id, target.id, { notes: text ? next : null });
258
+ console.log(green(text
259
+ ? `Wrote the notes on v${target.sequence}.`
260
+ : `Cleared the notes on v${target.sequence}.`));
261
+ return 0;
262
+ }
115
263
  async function commandVersion(parsed) {
116
264
  const which = split(parsed);
117
265
  const project = await (0, project_commands_js_1.resolveProject)(which.project);
@@ -126,16 +274,27 @@ async function commandVersion(parsed) {
126
274
  patch.name = name;
127
275
  if (parsed.flags.get("unname") === true)
128
276
  patch.name = null;
129
- const notes = parsed.flags.get("notes");
130
- if (typeof notes === "string")
131
- patch.notes = notes;
277
+ try {
278
+ const notes = notesFrom(parsed);
279
+ if (notes !== undefined)
280
+ patch.notes = notes;
281
+ }
282
+ catch (error) {
283
+ console.error(red(error instanceof Error ? error.message : String(error)));
284
+ return 1;
285
+ }
286
+ if (parsed.flags.get("edit") === true) {
287
+ const written = writeInEditor(target.notes ?? "");
288
+ if (written !== null)
289
+ patch.notes = written;
290
+ }
132
291
  if (parsed.flags.get("hide") === true)
133
292
  patch.visibility = "private";
134
293
  if (parsed.flags.get("show") === true)
135
294
  patch.visibility = "public";
136
295
  const visibility = parsed.flags.get("visibility");
137
296
  if (typeof visibility === "string") {
138
- if (!["private", "unlisted", "public"].includes(visibility)) {
297
+ if (!["private", "public"].includes(visibility)) {
139
298
  console.error(red("Visibility is private, unlisted or public."));
140
299
  return 1;
141
300
  }
@@ -229,6 +229,7 @@ class Downloader {
229
229
  ...(row.objectId ? { objectId: String(row.objectId) } : {}),
230
230
  sha256: String(row.sha256 ?? ""),
231
231
  sourceSize: Number(row.sourceSize ?? 0),
232
+ ...(row.sealed === true ? { sealed: true } : {}),
232
233
  ...(packed?.objectId
233
234
  ? {
234
235
  pack: {
@@ -298,7 +299,7 @@ class Downloader {
298
299
  const packed = new Map();
299
300
  for (const file of files) {
300
301
  const id = file.pack?.objectId;
301
- if (!id)
302
+ if (!id || file.sealed)
302
303
  continue;
303
304
  const group = packed.get(id) ?? [];
304
305
  group.push(file);
@@ -323,7 +324,7 @@ class Downloader {
323
324
  for (const file of files) {
324
325
  this.check();
325
326
  progress(file.path);
326
- const packId = file.pack?.objectId;
327
+ const packId = file.sealed ? undefined : file.pack?.objectId;
327
328
  if (packId && restoredPacks.has(packId))
328
329
  continue;
329
330
  if (packId) {
@@ -372,7 +373,14 @@ class Downloader {
372
373
  }
373
374
  async fetchInto(repositoryId, versionId, file, target) {
374
375
  const whole = (0, node_crypto_1.createHash)("sha256");
375
- const pieces = file.chunks ?? [];
376
+ /*
377
+ A sealed file is read whole, by path, whatever shape it is stored in.
378
+
379
+ Its pieces and its object are the covered bytes; only the per-path route
380
+ puts the real value back. Reading it any other way fetches something
381
+ that cannot match the digest this file was listed with.
382
+ */
383
+ const pieces = file.sealed ? [] : (file.chunks ?? []);
376
384
  // Captured so the generators below do not need `this` rebound.
377
385
  const request = (route) => this.request(route);
378
386
  const check = () => this.check();
@@ -399,7 +407,7 @@ class Downloader {
399
407
  }
400
408
  })()
401
409
  : (async function* () {
402
- const reply = await request(file.objectId
410
+ const reply = await request(file.objectId && !file.sealed
403
411
  ? `/v1/repositories/${repositoryId}/objects/${file.objectId}`
404
412
  : `/v1/repositories/${repositoryId}/versions/${versionId}/file` +
405
413
  `?path=${encodeURIComponent(file.path)}`);
@@ -456,7 +464,8 @@ class Downloader {
456
464
  let bytes = 0;
457
465
  const packed = new Map();
458
466
  for (const file of files) {
459
- const id = file.pack?.objectId;
467
+ /* Sealed members never come out of the pack — see `sealed` above. */
468
+ const id = file.sealed ? undefined : file.pack?.objectId;
460
469
  if (!id)
461
470
  continue;
462
471
  const group = packed.get(id) ?? [];
@@ -479,7 +488,7 @@ class Downloader {
479
488
  if (parts.some((part) => part === ".." || part.includes("\0"))) {
480
489
  throw new Error(`That version contains an unsafe path: ${file.path}`);
481
490
  }
482
- const packId = file.pack?.objectId;
491
+ const packId = file.sealed ? undefined : file.pack?.objectId;
483
492
  if (packId && restoredPacks.has(packId))
484
493
  continue;
485
494
  if (packId) {