@coderook/cli 0.10.1 → 0.11.1

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.
@@ -22,6 +22,7 @@ const node_process_1 = __importDefault(require("node:process"));
22
22
  const api_js_1 = require("./api.js");
23
23
  const registry_js_1 = require("./registry.js");
24
24
  const help_js_1 = require("./help.js");
25
+ const progress_js_1 = require("./progress.js");
25
26
  const project_commands_js_1 = require("./project_commands.js");
26
27
  const service_commands_js_1 = require("./service_commands.js");
27
28
  const worktree_js_1 = require("../../desktop-app/src/main/worktree.js");
@@ -74,11 +75,19 @@ function progressLine() {
74
75
  return (text) => {
75
76
  if (!node_process_1.default.stdout.isTTY)
76
77
  return;
77
- const padded = text.padEnd(last);
78
- last = text.length;
78
+ /*
79
+ Measured without the colour codes. Padding to the raw length of a string
80
+ carrying escape sequences overshoots by however many invisible
81
+ characters it holds, which drags the cursor past the end of the line.
82
+ */
83
+ const visible = text.replace(/\[[0-9;]*m/g, "").length;
84
+ const padded = text + " ".repeat(Math.max(0, last - visible));
85
+ last = visible;
79
86
  node_process_1.default.stdout.write(`\r${padded}`);
80
87
  };
81
88
  }
89
+ /** The shared bar, drawn from a percentage and this session's colour setting. */
90
+ const track = (percent) => (0, progress_js_1.bar)(percent / 100, 18, dim);
82
91
  const done = (line) => {
83
92
  if (node_process_1.default.stdout.isTTY) {
84
93
  line("");
@@ -383,8 +392,9 @@ async function commandSubmit(parsed) {
383
392
  */
384
393
  ...(link ? { known: link.local ?? link.manifest ?? {} } : {}),
385
394
  }, (progress) => {
386
- line(` ${String(progress.percent).padStart(3)}% ${progress.stage.padEnd(7)} ` +
387
- `${progress.files}/${progress.totalFiles} ${progress.path.slice(-48)}`);
395
+ line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
396
+ `${progress.stage.padEnd(7)} ${progress.files}/${progress.totalFiles} ` +
397
+ dim(progress.path.slice(-40)));
388
398
  });
389
399
  }
390
400
  catch (error) {
@@ -660,8 +670,9 @@ held, mode = "replace") {
660
670
  }
661
671
  const line = progressLine();
662
672
  const result = await downloader.run(repositoryId, latest.id, destination, (progress) => {
663
- line(` ${String(progress.percent).padStart(3)}% ${progress.files}/${progress.totalFiles}` +
664
- ` ${progress.path.slice(-48)}`);
673
+ line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
674
+ `${progress.files}/${progress.totalFiles} ` +
675
+ dim(progress.path.slice(-40)));
665
676
  },
666
677
  // What this folder received last time, so a file the project has since
667
678
  // dropped is removed while everything untracked is left alone.
@@ -710,7 +721,8 @@ async function commandBundle(parsed) {
710
721
  const entries = (await (0, worktree_js_1.changedFiles)(folder, rules, null)).map((file) => file.path);
711
722
  const line = progressLine();
712
723
  const result = await (0, cbx_js_1.packBundle)({ root: folder, target, entries, sourceName: node_path_1.default.basename(folder) }, (progress) => {
713
- line(` ${String(progress.percent).padStart(3)}% ${progress.path.slice(-48)}`);
724
+ line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
725
+ dim(progress.path.slice(-40)));
714
726
  });
715
727
  done(line);
716
728
  const saved = Math.max(0, 100 - Math.ceil((result.archiveBytes / Math.max(result.sourceBytes, 1)) * 100));
@@ -728,7 +740,8 @@ async function commandUnbundle(parsed) {
728
740
  const destination = node_path_1.default.resolve(parsed.positional[1] ?? manifest.source_name);
729
741
  const line = progressLine();
730
742
  const result = await (0, cbx_js_1.unpackBundle)(node_path_1.default.resolve(source), destination, (progress) => {
731
- line(` ${String(progress.percent).padStart(3)}% ${progress.path.slice(-48)}`);
743
+ line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
744
+ dim(progress.path.slice(-40)));
732
745
  });
733
746
  done(line);
734
747
  console.log(`${result.files} files · ${bytes(result.bytes)} into ${destination}`);
@@ -1222,8 +1235,10 @@ const SPECS = [
1222
1235
  {
1223
1236
  name: "merges",
1224
1237
  group: "When somebody saved first",
1225
- summary: "uploads of yours waiting on a decision",
1226
- usage: "merges",
1238
+ summary: "this folder's uploads waiting on a decision",
1239
+ usage: "merges [folder]",
1240
+ detail: "Both merge commands read the project this folder is linked to, so run\n" +
1241
+ "them from a working copy — or name one.",
1227
1242
  run: commandMerges,
1228
1243
  },
1229
1244
  {
@@ -0,0 +1,179 @@
1
+ "use strict";
2
+ /**
3
+ * Showing a run as it happens.
4
+ *
5
+ * The runner used to print the name of a job, fall silent for however long the
6
+ * work took, and then print the result. For a five-second echo that is merely
7
+ * odd; for a ten-minute build it means watching a blank terminal with no way
8
+ * to tell compiling from hung from waiting on a prompt that will never be
9
+ * answered. Every byte of output was being captured and shipped to the
10
+ * service, and none of it shown to the person sitting in front of it.
11
+ *
12
+ * Two things fix that, and they are different problems.
13
+ *
14
+ * The fetch has a known total — the version says how many files it holds — so
15
+ * that gets a real bar with a real percentage. The command does not: nothing
16
+ * knows how long `npm run build` takes, and a bar that invents a percentage is
17
+ * worse than no bar, because people plan around it. That phase gets a spinner,
18
+ * an elapsed clock, and the command's own output as it arrives, which is the
19
+ * honest answer to "what is it doing".
20
+ */
21
+ var __importDefault = (this && this.__importDefault) || function (mod) {
22
+ return (mod && mod.__esModule) ? mod : { "default": mod };
23
+ };
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.RunProgress = void 0;
26
+ exports.bar = bar;
27
+ const node_process_1 = __importDefault(require("node:process"));
28
+ /** Braille frames: dense, monospaced, and they animate in one column. */
29
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
30
+ const dim = (value) => `${value}`;
31
+ const accent = (value) => `${value}`;
32
+ /**
33
+ * Whether to animate at all.
34
+ *
35
+ * A spinner written to a file or a CI log is thousands of lines of escape
36
+ * codes nobody can read. When there is no terminal, phases are announced once
37
+ * as plain lines and nothing rewrites itself.
38
+ */
39
+ const live = Boolean(node_process_1.default.stdout.isTTY) && !node_process_1.default.env.NO_COLOR;
40
+ function clock(from) {
41
+ const seconds = Math.floor((Date.now() - from) / 1000);
42
+ const minutes = Math.floor(seconds / 60);
43
+ return minutes
44
+ ? `${minutes}:${String(seconds % 60).padStart(2, "0")}`
45
+ : `${seconds}s`;
46
+ }
47
+ /**
48
+ * A bar drawn from block characters, for a phase that knows its own size.
49
+ *
50
+ * Shared with push, pull and get, which had a percentage and no bar. `fade` is
51
+ * a parameter because the commands honour NO_COLOR through their own helper,
52
+ * and a track drawn with escape codes somebody asked not to receive is worse
53
+ * than an undimmed one.
54
+ */
55
+ function bar(fraction, width = 18, fade = dim) {
56
+ const safe = Number.isFinite(fraction) ? fraction : 0;
57
+ /*
58
+ Floored, so the bar fills only when the work is actually done. Rounding
59
+ drew a solid bar from 97% onwards, which is the same lie the percentage
60
+ used to tell — just harder to argue with.
61
+ */
62
+ const filled = Math.max(0, Math.min(width, Math.floor(safe * width)));
63
+ return `${"█".repeat(filled)}${fade("░".repeat(width - filled))}`;
64
+ }
65
+ class RunProgress {
66
+ frame = 0;
67
+ timer;
68
+ phase = "";
69
+ detail = "";
70
+ fraction = null;
71
+ started = Date.now();
72
+ painted = false;
73
+ /**
74
+ * Move to a new phase.
75
+ *
76
+ * `fraction` is only passed where something real is being counted. Left out,
77
+ * the line shows a spinner and an elapsed time instead of a made-up number.
78
+ */
79
+ step(phase, detail = "", fraction = null) {
80
+ const previous = this.phase;
81
+ this.phase = phase;
82
+ this.detail = detail;
83
+ this.fraction = fraction;
84
+ if (!live) {
85
+ /*
86
+ Without a terminal each phase is announced once, rather than repeating
87
+ the same line every time a file lands.
88
+ */
89
+ if (phase !== previous)
90
+ console.log(` ${phase}${detail ? `: ${detail}` : ""}`);
91
+ return;
92
+ }
93
+ this.paint();
94
+ if (!this.timer) {
95
+ this.timer = setInterval(() => {
96
+ this.frame = (this.frame + 1) % FRAMES.length;
97
+ this.paint();
98
+ }, 90);
99
+ this.timer.unref?.();
100
+ }
101
+ }
102
+ /**
103
+ * Update the counted part of the current phase without changing it.
104
+ *
105
+ * Records the numbers and leaves the drawing to the spinner's own timer. A
106
+ * version with thirty thousand files calls this thirty thousand times, and
107
+ * repainting on each one is thirty thousand writes to render eleven frames
108
+ * a person can actually read — the download ends up waiting on the
109
+ * terminal.
110
+ */
111
+ advance(detail, fraction = null) {
112
+ this.detail = detail;
113
+ this.fraction = fraction;
114
+ }
115
+ /**
116
+ * Output the command itself printed.
117
+ *
118
+ * Cleared out of the way first so the text and the status line do not fight
119
+ * over the same row, then the status line is drawn again underneath — which
120
+ * keeps the newest output directly above where the progress is. A whole
121
+ * chunk at a time, because a build that prints a hundred lines in one breath
122
+ * should cost one repaint rather than a hundred.
123
+ */
124
+ say(chunk) {
125
+ const lines = chunk.split(/\r?\n/).filter((line) => line.trim() !== "");
126
+ if (!lines.length)
127
+ return;
128
+ if (!live) {
129
+ for (const line of lines)
130
+ console.log(` ${line.replace(/\s+$/, "")}`);
131
+ return;
132
+ }
133
+ this.clear();
134
+ for (const line of lines)
135
+ console.log(` ${dim(line.replace(/\s+$/, ""))}`);
136
+ this.paint();
137
+ }
138
+ /**
139
+ * Stop, and take the status line with it.
140
+ *
141
+ * Deliberately silent. The runner already prints the verdict — which is the
142
+ * line somebody scrolls back to — and a progress display that also announced
143
+ * the result would say the same thing twice, differently.
144
+ */
145
+ done() {
146
+ if (this.timer)
147
+ clearInterval(this.timer);
148
+ this.timer = undefined;
149
+ this.clear();
150
+ }
151
+ clear() {
152
+ if (!live || !this.painted)
153
+ return;
154
+ node_process_1.default.stdout.write("\r");
155
+ this.painted = false;
156
+ }
157
+ paint() {
158
+ if (!live)
159
+ return;
160
+ const spinner = accent(FRAMES[this.frame] ?? "⠋");
161
+ const measured = this.fraction === null
162
+ ? ""
163
+ : /*
164
+ Floored to agree with the bar. Rounding printed 100% beside a bar
165
+ with a block still to fill, and two readings of the same number
166
+ disagreeing is worse than either of them being pessimistic.
167
+ */
168
+ `${bar(this.fraction)} ${String(Math.floor(this.fraction * 100)).padStart(3)}% `;
169
+ const line = `${spinner} ${this.phase} ${measured}${dim(this.detail)} ${dim(clock(this.started))}`;
170
+ /*
171
+ Truncated to the terminal so a long file path does not wrap and leave
172
+ half a status line stranded above the next repaint.
173
+ */
174
+ const width = node_process_1.default.stdout.columns ?? 80;
175
+ node_process_1.default.stdout.write(`\r${line.slice(0, width + 40)}`);
176
+ this.painted = true;
177
+ }
178
+ }
179
+ exports.RunProgress = RunProgress;
@@ -37,6 +37,7 @@ const node_path_1 = __importDefault(require("node:path"));
37
37
  const download_js_1 = require("../../desktop-app/src/main/download.js");
38
38
  const identify_js_1 = require("../../desktop-app/src/main/identify.js");
39
39
  const config_js_1 = require("./config.js");
40
+ const progress_js_1 = require("./progress.js");
40
41
  /** How often to tell the service the machine is still working. */
41
42
  const HEARTBEAT_MS = 30_000;
42
43
  /** How often log lines are sent up while a command is running. */
@@ -73,12 +74,19 @@ async function call(route, options = {}) {
73
74
  class LogShipper {
74
75
  repositoryId;
75
76
  runId;
77
+ say;
76
78
  pending = [];
77
79
  sent = 0;
78
80
  failed = false;
79
- constructor(repositoryId, runId) {
81
+ constructor(repositoryId, runId,
82
+ /*
83
+ Said through the progress display rather than straight to the console:
84
+ a line written while the status line is on screen lands on top of it.
85
+ */
86
+ say = console.error) {
80
87
  this.repositoryId = repositoryId;
81
88
  this.runId = runId;
89
+ this.say = say;
82
90
  }
83
91
  add(chunk, stream) {
84
92
  for (const line of chunk.split(/\r?\n/)) {
@@ -110,7 +118,7 @@ class LogShipper {
110
118
  that cannot deliver says so once and stops trying.
111
119
  */
112
120
  this.failed = true;
113
- console.error(` (log upload stopped: ${errorText(error)})`);
121
+ this.say(` (log upload stopped: ${errorText(error)})`);
114
122
  }
115
123
  }
116
124
  }
@@ -317,7 +325,8 @@ function patternMatcher(pattern) {
317
325
  * directory would let one job's leftovers decide the next job's result.
318
326
  */
319
327
  async function performRun(repositoryId, run, runner) {
320
- const logs = new LogShipper(repositoryId, run.runId);
328
+ const progress = new progress_js_1.RunProgress();
329
+ const logs = new LogShipper(repositoryId, run.runId, (line) => progress.say(line));
321
330
  const started = Date.now();
322
331
  const workspace = await (0, promises_1.mkdtemp)(node_path_1.default.join((0, node_os_1.tmpdir)(), "coderook-run-"));
323
332
  let beating;
@@ -338,8 +347,14 @@ async function performRun(repositoryId, run, runner) {
338
347
  }, HEARTBEAT_MS);
339
348
  if (run.versionId) {
340
349
  logs.add(`Fetching v${run.versionSequence ?? "?"}…`, "stdout");
350
+ /*
351
+ The one phase that knows its own size. The version says how many files
352
+ it holds, so this gets a real proportion rather than a spinner
353
+ pretending to measure something.
354
+ */
355
+ progress.step(`Fetching v${run.versionSequence ?? "?"}`, "", 0);
341
356
  const downloader = new download_js_1.Downloader(config_js_1.credentials);
342
- const got = await downloader.run(repositoryId, run.versionId, workspace, () => { }, null, "replace");
357
+ const got = await downloader.run(repositoryId, run.versionId, workspace, (state) => progress.advance(`${state.files}/${state.totalFiles} files`, state.totalFiles ? state.files / state.totalFiles : null), null, "replace");
343
358
  logs.add(`Fetched ${got.files} files.`, "stdout");
344
359
  }
345
360
  else {
@@ -353,6 +368,7 @@ async function performRun(repositoryId, run, runner) {
353
368
  const directory = run.workingDirectory
354
369
  ? insideWorkspace(workspace, run.workingDirectory)
355
370
  : workspace;
371
+ progress.step("Running", command);
356
372
  flushing = setInterval(() => void logs.flush(), FLUSH_MS);
357
373
  const code = await new Promise((settle) => {
358
374
  /*
@@ -373,8 +389,18 @@ async function performRun(repositoryId, run, runner) {
373
389
  : "",
374
390
  },
375
391
  });
376
- child.stdout?.on("data", (chunk) => logs.add(chunk.toString("utf8"), "stdout"));
377
- child.stderr?.on("data", (chunk) => logs.add(chunk.toString("utf8"), "stderr"));
392
+ /*
393
+ Shipped and shown, not one or the other. The service needs the log so
394
+ a failed run can be read later; the person watching needs it so they
395
+ can tell a slow build from a stuck one.
396
+ */
397
+ const tee = (chunk, stream) => {
398
+ const text = chunk.toString("utf8");
399
+ logs.add(text, stream);
400
+ progress.say(text);
401
+ };
402
+ child.stdout?.on("data", (chunk) => tee(chunk, "stdout"));
403
+ child.stderr?.on("data", (chunk) => tee(chunk, "stderr"));
378
404
  child.on("error", (error) => {
379
405
  logs.add(errorText(error), "stderr");
380
406
  settle(1);
@@ -389,7 +415,11 @@ async function performRun(repositoryId, run, runner) {
389
415
  that away is exactly when they would have wanted it.
390
416
  */
391
417
  if (run.artifactPaths?.length) {
392
- const kept = await uploadArtifacts(repositoryId, run.runId, workspace, run.artifactPaths, (line) => logs.add(line, "stdout"));
418
+ progress.step("Keeping what it built");
419
+ const kept = await uploadArtifacts(repositoryId, run.runId, workspace, run.artifactPaths, (line) => {
420
+ logs.add(line, "stdout");
421
+ progress.say(line);
422
+ });
393
423
  if (!kept) {
394
424
  logs.add("Nothing matched the paths this workflow keeps.", "stdout");
395
425
  }
@@ -406,6 +436,11 @@ async function performRun(repositoryId, run, runner) {
406
436
  return { status: "failed", summary: errorText(error).slice(0, 200) };
407
437
  }
408
438
  finally {
439
+ /*
440
+ Before anything else. A spinner still repainting while the last flush
441
+ goes out would overwrite the verdict the caller is about to print.
442
+ */
443
+ progress.done();
409
444
  if (beating)
410
445
  clearInterval(beating);
411
446
  if (flushing)
@@ -31,6 +31,7 @@ const node_fs_1 = require("node:fs");
31
31
  const promises_1 = require("node:fs/promises");
32
32
  const node_path_1 = __importDefault(require("node:path"));
33
33
  const node_zlib_1 = require("node:zlib");
34
+ const download_js_1 = require("./download.js");
34
35
  exports.HEADER_MAGIC = "CBX1";
35
36
  exports.CHUNK_MAGIC = "CHNK";
36
37
  exports.MANIFEST_MAGIC = "MANF";
@@ -175,7 +176,7 @@ async function packBundle(request, report) {
175
176
  bytes: sourceBytes,
176
177
  totalBytes,
177
178
  path: relative,
178
- percent: Math.round((sourceBytes / Math.max(totalBytes, 1)) * 100),
179
+ percent: (0, download_js_1.underway)(sourceBytes, totalBytes),
179
180
  });
180
181
  const whole = (0, node_crypto_1.createHash)("sha256");
181
182
  const references = [];
@@ -261,6 +262,19 @@ async function packBundle(request, report) {
261
262
  await handle.close();
262
263
  await (0, promises_1.rm)(request.target, { force: true });
263
264
  await (0, promises_1.rename)(partial, request.target);
265
+ /*
266
+ The bundle is written; say so. Every other reporter here ends on a full
267
+ bar, and this one used to stop wherever the last file started — which
268
+ left a bundle that finished looking like one that gave up near the end.
269
+ */
270
+ report?.({
271
+ files: request.entries.length,
272
+ totalFiles: request.entries.length,
273
+ bytes: sourceBytes,
274
+ totalBytes,
275
+ path: "Done",
276
+ percent: 100,
277
+ });
264
278
  return {
265
279
  archiveBytes: offset,
266
280
  sourceBytes,
@@ -374,7 +388,7 @@ async function unpackBundle(source, destination, report) {
374
388
  bytes,
375
389
  totalBytes,
376
390
  path: record.path,
377
- percent: Math.round((bytes / Math.max(totalBytes, 1)) * 100),
391
+ percent: (0, download_js_1.underway)(bytes, totalBytes),
378
392
  });
379
393
  const parts = safeParts(record.path);
380
394
  if (!parts.length)
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.Downloader = exports.DownloadCancelled = void 0;
7
+ exports.underway = underway;
7
8
  /**
8
9
  * Bringing a project down: the file list for a version, then its bytes.
9
10
  *
@@ -56,6 +57,20 @@ async function pruneEmpty(root, removed) {
56
57
  directory = node_path_1.default.dirname(directory);
57
58
  }
58
59
  }
60
+ /**
61
+ * A percentage for work that is still going.
62
+ *
63
+ * Rounding hit 100 while files were still arriving. A project where one asset
64
+ * is most of the bytes — which is most projects with an asset — read "100%,
65
+ * 3 of 11" for the rest of the download, and the number people trust most is
66
+ * the one that had stopped meaning anything. Held at 99 until the caller says
67
+ * it is finished, and floored so it never rounds up into a lie either.
68
+ */
69
+ function underway(done, total) {
70
+ if (total <= 0)
71
+ return 0;
72
+ return Math.min(99, Math.floor((done / total) * 100));
73
+ }
59
74
  class DownloadCancelled extends Error {
60
75
  constructor() {
61
76
  super("Download cancelled");
@@ -172,7 +187,7 @@ class Downloader {
172
187
  bytes,
173
188
  totalBytes,
174
189
  path: file.path,
175
- percent: Math.round((bytes / Math.max(totalBytes, 1)) * 100),
190
+ percent: underway(bytes, totalBytes),
176
191
  });
177
192
  // A path that climbs out of the destination is refused outright.
178
193
  const parts = file.path.split("/").filter(Boolean);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
4
4
  "description": "CodeRook from the command line, on any operating system",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "homepage": "https://coderook.com",