@taprootio/site-authoring 0.12.1 → 0.12.3

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/README.md CHANGED
@@ -400,7 +400,11 @@ site.
400
400
  ## Output contract
401
401
 
402
402
  Human progress goes to stderr and `--quiet` silences it (except for `login`,
403
- whose approval code and URL exist only as progress). Stdout carries exactly
403
+ whose approval code and URL exist only as progress). While a deployment is
404
+ awaited, a terminal shows one in-place line (spinner, phase, elapsed time) that
405
+ starts a new line only when the phase changes; a pipe or CI log never receives
406
+ control characters, only one line per phase change plus a heartbeat at most
407
+ every 60 seconds. Stdout carries exactly
404
408
  one JSON object per run, schema version `1`: `ok`, the CLI name and version,
405
409
  the verb, and the verb's own result on success; a stable `error.code` with an
406
410
  optional `field`, `status`, and classified `refusal` on failure. Exit codes are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.12.1",
3
+ "version": "0.12.3",
4
4
  "description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
5
5
  "type": "module",
6
6
  "license": "ISC",
package/src/api.js CHANGED
@@ -1207,7 +1207,7 @@ export async function poll({
1207
1207
  // poll runs every few seconds for up to fifteen minutes, and a line per
1208
1208
  // tick scrolls the one thing the operator still needs — the code — off the
1209
1209
  // top of the terminal.
1210
- if (result.progress !== undefined) onProgress(result.progress);
1210
+ if (result.progress !== undefined) onProgress(result.progress, result.event);
1211
1211
  if (now() >= deadline) break;
1212
1212
  await client.sleep(Math.min(intervalMilliseconds, Math.max(1, deadline - now())), client.signal);
1213
1213
  }
@@ -1220,8 +1220,20 @@ export async function poll({
1220
1220
  }
1221
1221
 
1222
1222
  export async function waitForDeployment(client, { siteId, deploymentId, environment, onProgress, now }) {
1223
+ try {
1224
+ return await awaitDeployment(client, { siteId, deploymentId, environment, onProgress, now });
1225
+ } finally {
1226
+ // The wait is over whether the deployment finished or failed; the verb's later
1227
+ // output (staging checks, the failure text) must not sit beside a spinner.
1228
+ onProgress?.endWait?.();
1229
+ }
1230
+ }
1231
+
1232
+ async function awaitDeployment(client, { siteId, deploymentId, environment, onProgress, now }) {
1223
1233
  let misses = 0;
1224
1234
  let lastEvidence;
1235
+ let truncatedReported = false;
1236
+ const reportedPhases = new Set();
1225
1237
  return await poll({
1226
1238
  client,
1227
1239
  now,
@@ -1248,6 +1260,11 @@ export async function waitForDeployment(client, { siteId, deploymentId, environm
1248
1260
  lastEvidence = evidence;
1249
1261
  if (deployment.phaseTimings.known) {
1250
1262
  for (const phase of deployment.phaseTimings.phases) {
1263
+ // Each phase is reported when it is entered and again when it ends,
1264
+ // never re-listed on every later change.
1265
+ const key = `${phase.status}|${phase.enteredAt}|${phase.durationMilliseconds ?? ""}`;
1266
+ if (reportedPhases.has(key)) continue;
1267
+ reportedPhases.add(key);
1251
1268
  onProgress(
1252
1269
  `Deployment phase ${phase.status} entered ${phase.enteredAt}`
1253
1270
  + (phase.durationMilliseconds === undefined
@@ -1255,7 +1272,8 @@ export async function waitForDeployment(client, { siteId, deploymentId, environm
1255
1272
  : `; ${phase.durationMilliseconds}ms until ${phase.endedAt}.`),
1256
1273
  );
1257
1274
  }
1258
- if (deployment.phaseTimings.truncated) {
1275
+ if (deployment.phaseTimings.truncated && !truncatedReported) {
1276
+ truncatedReported = true;
1259
1277
  onProgress("Earlier deployment phase evidence was truncated by the server.");
1260
1278
  }
1261
1279
  } else onProgress("Deployment phase timings are unknown for this legacy deployment.");
@@ -1270,7 +1288,11 @@ export async function waitForDeployment(client, { siteId, deploymentId, environm
1270
1288
  { status: deployment.status },
1271
1289
  );
1272
1290
  }
1273
- return { done: false, progress: `Waiting for the deployment (${deployment.status}).` };
1291
+ // The phase a reader sees is the latest one the server recorded, or the
1292
+ // deployment's own status when it recorded none.
1293
+ const phases = deployment.phaseTimings.known ? deployment.phaseTimings.phases : [];
1294
+ const phase = phases.length > 0 ? phases[phases.length - 1].status : deployment.status;
1295
+ return { done: false, progress: `Waiting for the deployment (${phase}).`, event: { phase } };
1274
1296
  },
1275
1297
  timeoutCode: "deploy.timeout",
1276
1298
  });
package/src/cli.js CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  SiteAuthoringError,
48
48
  } from "./errors.js";
49
49
  import { failureResult, humanFailure, serializeResult, writeGithubActionsOutput } from "./output.js";
50
+ import { createProgressReporter } from "./progress.js";
50
51
  import {
51
52
  formatReferenceResult,
52
53
  getAppearanceReference,
@@ -1134,6 +1135,7 @@ export async function runCli({
1134
1135
  signal,
1135
1136
  } = {}) {
1136
1137
  let parsed;
1138
+ let progress;
1137
1139
  let completedPreviewRecovery;
1138
1140
  const isReferenceInvocation = Array.isArray(arguments_)
1139
1141
  && (
@@ -1178,6 +1180,11 @@ export async function runCli({
1178
1180
  if (typeof handler !== "function") {
1179
1181
  throw usageError("cli.unsupported_verb", `No handler is registered for '${parsed.verb}'.`);
1180
1182
  }
1183
+ progress = createProgressReporter({
1184
+ stream: stderr,
1185
+ interactive: stderr.isTTY === true && environment.TERM !== "dumb",
1186
+ quiet: parsed.quiet,
1187
+ });
1181
1188
  const result = await handler(Object.freeze({
1182
1189
  verb: parsed.verb,
1183
1190
  cwd,
@@ -1202,13 +1209,17 @@ export async function runCli({
1202
1209
  : parsed.positionals.values,
1203
1210
  }
1204
1211
  : {}),
1205
- onProgress: parsed.quiet ? () => {} : (message) => stderr.write(`${message}\n`),
1212
+ // `endWait` lets a wait say it is over while the verb goes on (staging checks).
1213
+ onProgress: Object.assign((message, event) => progress.report(message, event), {
1214
+ endWait: () => progress.endWait(),
1215
+ }),
1206
1216
  fetch,
1207
1217
  signal,
1208
1218
  }));
1209
1219
  if (parsed.verb === VERB_PREVIEW_PAGE && result?.ok === true) {
1210
1220
  completedPreviewRecovery = normalizePreviewRecovery(result);
1211
1221
  }
1222
+ progress.stop();
1212
1223
  const json = serializeResult(result);
1213
1224
  if (!isValidationInvocation && environment.GITHUB_OUTPUT) {
1214
1225
  await writeGithubActionsOutput(environment.GITHUB_OUTPUT, result);
@@ -1216,6 +1227,7 @@ export async function runCli({
1216
1227
  stdout.write(`${json}\n`);
1217
1228
  return 0;
1218
1229
  } catch (unknownError) {
1230
+ progress?.stop();
1219
1231
  const error = asSiteAuthoringError(unknownError);
1220
1232
  if (completedPreviewRecovery) error.withPreviewRecovery(completedPreviewRecovery);
1221
1233
  const result = failureResult(error);
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Human progress for a long wait, written to stderr (stdout stays the one JSON
3
+ * result). Verbs report plain lines through `report(message)`; a wait reports
4
+ * `report(message, { phase })` once per poll, and this decides how much of that
5
+ * a reader should see.
6
+ *
7
+ * - On a terminal, a wait is one in-place line (spinner, phase, elapsed time)
8
+ * that redraws each second, and a new line starts only when the phase changes.
9
+ * Any other line clears the in-place line first, then the wait line comes back.
10
+ * - Off a terminal (a pipe, CI), no control characters are ever written: one line
11
+ * per phase change, plus a heartbeat at most every `heartbeatMilliseconds` so a
12
+ * long silent phase does not look hung.
13
+ */
14
+ const SPINNER_FRAMES = ["|", "/", "-", "\\"];
15
+ const ERASE_LINE = "\r\u001B[2K";
16
+
17
+ export const PROGRESS_HEARTBEAT_MILLISECONDS = 60_000;
18
+ const REDRAW_MILLISECONDS = 1_000;
19
+
20
+ export function formatElapsed(milliseconds) {
21
+ const totalSeconds = Math.max(0, Math.floor(milliseconds / 1_000));
22
+ const minutes = Math.floor(totalSeconds / 60);
23
+ const seconds = totalSeconds % 60;
24
+ return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
25
+ }
26
+
27
+ /**
28
+ * @param {{
29
+ * stream: { write(text: string): unknown, isTTY?: boolean, columns?: number },
30
+ * interactive?: boolean,
31
+ * quiet?: boolean,
32
+ * now?: () => number,
33
+ * heartbeatMilliseconds?: number,
34
+ * setInterval?: typeof setInterval,
35
+ * clearInterval?: typeof clearInterval,
36
+ * }} options
37
+ */
38
+ export function createProgressReporter({
39
+ stream,
40
+ // A terminal that cannot redraw (TERM=dumb) is handled as a log, like a pipe.
41
+ interactive = stream.isTTY === true,
42
+ quiet = false,
43
+ now = Date.now,
44
+ heartbeatMilliseconds = PROGRESS_HEARTBEAT_MILLISECONDS,
45
+ setInterval: schedule = setInterval,
46
+ clearInterval: cancel = clearInterval,
47
+ }) {
48
+ let phase;
49
+ let phaseStartedAt = 0;
50
+ let waitStartedAt = 0;
51
+ let lastLineAt = 0;
52
+ let frame = 0;
53
+ let inPlace = false;
54
+ let timer;
55
+
56
+ function draw() {
57
+ if (phase === undefined) return;
58
+ frame = (frame + 1) % SPINNER_FRAMES.length;
59
+ const short = phase.replace(/^DEPLOYMENT_STATUS_/u, "");
60
+ const inPhase = formatElapsed(now() - phaseStartedAt);
61
+ const total = formatElapsed(now() - waitStartedAt);
62
+ // One row only: a wrapped line would leave rows the next redraw cannot clear.
63
+ // The phase and both counters are what a reader needs, so the wording around
64
+ // them is what gives way on a narrow terminal.
65
+ const width = Math.max(10, (stream.columns ?? 80) - 1);
66
+ const candidates = [
67
+ `${SPINNER_FRAMES[frame]} Waiting for the deployment (${short}); ${inPhase} in this phase, ${total} total`,
68
+ `${SPINNER_FRAMES[frame]} ${short}: ${inPhase} in phase, ${total} total`,
69
+ `${SPINNER_FRAMES[frame]} ${short} ${inPhase} (${total} total)`,
70
+ ];
71
+ const text = candidates.find((candidate) => candidate.length <= width) ?? candidates.at(-1).slice(0, width);
72
+ stream.write(`${ERASE_LINE}${text}`);
73
+ inPlace = true;
74
+ }
75
+
76
+ function clearInPlace() {
77
+ if (!inPlace) return;
78
+ stream.write(ERASE_LINE);
79
+ inPlace = false;
80
+ }
81
+
82
+ function endWait() {
83
+ if (timer !== undefined) {
84
+ cancel(timer);
85
+ timer = undefined;
86
+ }
87
+ clearInPlace();
88
+ phase = undefined;
89
+ }
90
+
91
+ function line(text) {
92
+ stream.write(`${text}\n`);
93
+ lastLineAt = now();
94
+ }
95
+
96
+ return {
97
+ report(message, event) {
98
+ if (quiet) return;
99
+ if (event?.phase === undefined) {
100
+ // A plain line. On a terminal the wait line is wiped and redrawn by the
101
+ // next tick, so the message is never glued to a half-written spinner.
102
+ clearInPlace();
103
+ line(message);
104
+ if (interactive && phase !== undefined) draw();
105
+ return;
106
+ }
107
+ const at = now();
108
+ if (phase === undefined) waitStartedAt = at;
109
+ const changed = phase !== event.phase;
110
+ if (changed) {
111
+ // The finished phase stays on screen as a line of its own.
112
+ if (interactive && phase !== undefined) {
113
+ clearInPlace();
114
+ line(`Deployment phase ${phase} finished after ${formatElapsed(at - phaseStartedAt)}.`);
115
+ }
116
+ phase = event.phase;
117
+ phaseStartedAt = at;
118
+ }
119
+ if (interactive) {
120
+ draw();
121
+ if (timer === undefined) {
122
+ timer = schedule(draw, REDRAW_MILLISECONDS);
123
+ timer.unref?.();
124
+ }
125
+ return;
126
+ }
127
+ if (changed) {
128
+ line(message);
129
+ } else if (at - lastLineAt >= heartbeatMilliseconds) {
130
+ line(`Still waiting for the deployment (${phase}); ${formatElapsed(at - waitStartedAt)} elapsed.`);
131
+ }
132
+ },
133
+
134
+ /** Ends the wait for a deployment (it finished or failed) while the run carries on. */
135
+ endWait,
136
+
137
+ /** Ends any wait in progress. Safe to call more than once. */
138
+ stop() {
139
+ endWait();
140
+ },
141
+ };
142
+ }
@@ -318,16 +318,22 @@ export async function deploy(invocation) {
318
318
  const stagedPageIds = presentationOnly ? [] : selection.stagedPageIds ?? defaults.stagedPageIds;
319
319
  const selectedSettingsTypes = selection.selectedSettingsTypes ?? defaults.selectedSettingsTypes;
320
320
  const includeNavigation = presentationOnly ? false : selection.includeNavigation ?? defaults.includeNavigation;
321
- if (stagedPageIds.length === 0 && selectedSettingsTypes.length === 0 && !includeNavigation) {
322
- throw new SiteAuthoringError(
321
+ const nothingSelected = stagedPageIds.length === 0 && selectedSettingsTypes.length === 0 && !includeNavigation;
322
+ // A managed Docs site stages settings only, so an empty selection is final.
323
+ // Any other site may still have a publishable change no selection names: an
324
+ // edited redirect map. Readiness below says whether it does.
325
+ const emptySelectionError = () =>
326
+ new SiteAuthoringError(
323
327
  "deploy.empty_selection",
324
328
  presentationOnly
325
329
  ? "A staging deployment on a managed Docs site needs at least one settings change; it stages settings "
326
330
  + "only. Change a theme, brand, header, or footer setting first."
327
- : "A staging deployment needs at least one approved page, settings group, or navigation change. "
328
- + "Run 'taproot-site approve' or change staged settings/navigation first.",
331
+ : "A staging deployment needs at least one approved page, settings group, or navigation change, or an "
332
+ + "edited redirect map. Run 'taproot-site approve' or change staged settings/navigation first.",
329
333
  { field: "Candidate" },
330
334
  );
335
+ if (nothingSelected && presentationOnly) {
336
+ throw emptySelectionError();
331
337
  }
332
338
 
333
339
  const candidate = {
@@ -353,6 +359,9 @@ export async function deploy(invocation) {
353
359
  { field: readiness.blockers[0].imageId || "blockers", status: readiness.blockers[0].state },
354
360
  );
355
361
  }
362
+ if (nothingSelected && !readiness.hasCandidateChanges) {
363
+ throw emptySelectionError();
364
+ }
356
365
  if (!readiness.hasCandidateChanges) {
357
366
  onProgress("Taproot reports this candidate contains no changes; deploying it anyway would be a no-op.");
358
367
  }