@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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/cli/src/api.js +156 -1
- package/dist/cli/src/attach_command.js +217 -0
- package/dist/cli/src/cli.js +238 -27
- package/dist/cli/src/git_remote.js +100 -15
- package/dist/cli/src/project_commands.js +46 -5
- package/dist/cli/src/service_commands.js +201 -1
- package/dist/cli/src/version_commands.js +163 -4
- package/dist/desktop-app/src/main/download.js +15 -6
- package/dist/desktop-app/src/main/tracks.js +171 -6
- package/dist/desktop-app/src/main/upload.js +4 -0
- package/dist/desktop-app/src/main/worktree.js +26 -8
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
159
|
-
|
|
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 (
|
|
169
|
-
console.log(dim(` … ${
|
|
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.
|
|
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) => `[2m${value}[0m`;
|
|
|
34
39
|
const bold = (value) => `[1m${value}[0m`;
|
|
35
40
|
const red = (value) => `[31m${value}[0m`;
|
|
36
41
|
const accent = (value) => `[33m${value}[0m`;
|
|
42
|
+
const green = (value) => `[32m${value}[0m`;
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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", "
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|