@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 +5 -1
- package/package.json +1 -1
- package/src/api.js +25 -3
- package/src/cli.js +13 -1
- package/src/progress.js +142 -0
- package/src/verbs/deploy.js +13 -4
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).
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/progress.js
ADDED
|
@@ -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
|
+
}
|
package/src/verbs/deploy.js
CHANGED
|
@@ -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
|
-
|
|
322
|
-
|
|
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
|
}
|