@taprootio/site-authoring 0.12.0 → 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 +6 -2
- package/package.json +1 -1
- package/src/api.js +25 -3
- package/src/cli.js +17 -3
- package/src/progress.js +142 -0
- package/src/redirects-contract.js +101 -9
- package/src/reference-help.js +11 -6
- package/src/verbs/deploy.js +26 -23
- package/src/verbs/redirects-check.js +1 -1
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
|
|
@@ -501,7 +505,7 @@ fingerprint supply no gating evidence. Typed preview failure diagnostics contain
|
|
|
501
505
|
only error class and known page/node/attribute labels, never renderer messages.
|
|
502
506
|
|
|
503
507
|
Staging deploys check the current redirect map through the real staging edge.
|
|
504
|
-
Run `taproot-site redirects check` to repeat
|
|
508
|
+
Run `taproot-site redirects check` to repeat once the deployment has completed; a redirect is a file inside the release, so it is live exactly when its release is served.
|
|
505
509
|
The Node CLI consumes a separate handoff, retains its short staging cookie in
|
|
506
510
|
memory, and requests each map path without following redirects. Results report
|
|
507
511
|
path, HTTP status and Location; `verified` requires all entries to match and the
|
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,
|
|
@@ -251,7 +252,8 @@ const VERBS = Object.freeze([
|
|
|
251
252
|
capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
|
|
252
253
|
tokens: ["redirects", "check"],
|
|
253
254
|
summary: "Check the current redirect map through the authenticated staging edge.",
|
|
254
|
-
note: "Reports path, HTTP status and Location without following redirects.
|
|
255
|
+
note: "Reports path, HTTP status and Location without following redirects. A redirect is live only with the release "
|
|
256
|
+
+ "that carries it, so this reads the staged release. "
|
|
255
257
|
+ "The result includes a fresh single-use staging handoff; keep it private.",
|
|
256
258
|
},
|
|
257
259
|
{
|
|
@@ -275,7 +277,8 @@ const VERBS = Object.freeze([
|
|
|
275
277
|
summary: "Validate redirects.json and replace the whole redirect map.",
|
|
276
278
|
note: "Run 'redirects pull' first. The whole file is validated locally by entry index — path normalization, "
|
|
277
279
|
+ "targets, statuses, duplicates, chains, and loops — before anything is sent, and the site refuses a "
|
|
278
|
-
+ "source a live page occupies.
|
|
280
|
+
+ "source a live page occupies or a file the site generates. An entry is a file inside the next release, so it "
|
|
281
|
+
+ "goes live with that deploy: staging first, production when promoted. "
|
|
279
282
|
+ "See 'taproot-site help redirects'.",
|
|
280
283
|
},
|
|
281
284
|
{
|
|
@@ -1132,6 +1135,7 @@ export async function runCli({
|
|
|
1132
1135
|
signal,
|
|
1133
1136
|
} = {}) {
|
|
1134
1137
|
let parsed;
|
|
1138
|
+
let progress;
|
|
1135
1139
|
let completedPreviewRecovery;
|
|
1136
1140
|
const isReferenceInvocation = Array.isArray(arguments_)
|
|
1137
1141
|
&& (
|
|
@@ -1176,6 +1180,11 @@ export async function runCli({
|
|
|
1176
1180
|
if (typeof handler !== "function") {
|
|
1177
1181
|
throw usageError("cli.unsupported_verb", `No handler is registered for '${parsed.verb}'.`);
|
|
1178
1182
|
}
|
|
1183
|
+
progress = createProgressReporter({
|
|
1184
|
+
stream: stderr,
|
|
1185
|
+
interactive: stderr.isTTY === true && environment.TERM !== "dumb",
|
|
1186
|
+
quiet: parsed.quiet,
|
|
1187
|
+
});
|
|
1179
1188
|
const result = await handler(Object.freeze({
|
|
1180
1189
|
verb: parsed.verb,
|
|
1181
1190
|
cwd,
|
|
@@ -1200,13 +1209,17 @@ export async function runCli({
|
|
|
1200
1209
|
: parsed.positionals.values,
|
|
1201
1210
|
}
|
|
1202
1211
|
: {}),
|
|
1203
|
-
|
|
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
|
+
}),
|
|
1204
1216
|
fetch,
|
|
1205
1217
|
signal,
|
|
1206
1218
|
}));
|
|
1207
1219
|
if (parsed.verb === VERB_PREVIEW_PAGE && result?.ok === true) {
|
|
1208
1220
|
completedPreviewRecovery = normalizePreviewRecovery(result);
|
|
1209
1221
|
}
|
|
1222
|
+
progress.stop();
|
|
1210
1223
|
const json = serializeResult(result);
|
|
1211
1224
|
if (!isValidationInvocation && environment.GITHUB_OUTPUT) {
|
|
1212
1225
|
await writeGithubActionsOutput(environment.GITHUB_OUTPUT, result);
|
|
@@ -1214,6 +1227,7 @@ export async function runCli({
|
|
|
1214
1227
|
stdout.write(`${json}\n`);
|
|
1215
1228
|
return 0;
|
|
1216
1229
|
} catch (unknownError) {
|
|
1230
|
+
progress?.stop();
|
|
1217
1231
|
const error = asSiteAuthoringError(unknownError);
|
|
1218
1232
|
if (completedPreviewRecovery) error.withPreviewRecovery(completedPreviewRecovery);
|
|
1219
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
|
+
}
|
|
@@ -42,13 +42,16 @@ export const REDIRECT_STATUSES = Object.freeze([301, 302, 307, 308]);
|
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* Bounds, mirrored from `SiteRedirectMapContract` on the server. The path bound
|
|
45
|
-
* is
|
|
46
|
-
*
|
|
45
|
+
* is the map's own; the target bound is the edge's own, and a target must also
|
|
46
|
+
* fit as metadata on its redirect file once encoded.
|
|
47
47
|
*/
|
|
48
48
|
export const REDIRECT_LIMITS = Object.freeze({
|
|
49
49
|
entries: 2_000,
|
|
50
50
|
pathBytes: 400,
|
|
51
51
|
targetBytes: 2_048,
|
|
52
|
+
// Once percent-encoded, a target rides on its redirect file as object
|
|
53
|
+
// metadata; the server pins the same number (MaxMarkerTargetEncodedLength).
|
|
54
|
+
targetEncodedCharacters: 1_810,
|
|
52
55
|
});
|
|
53
56
|
|
|
54
57
|
// C0, DEL, and C1 — every code point the server's own `char.IsControl`
|
|
@@ -88,8 +91,7 @@ const UNSENDABLE_SPELLING = /[^!-~]|["<>`{}]|%(?![0-9a-fA-F]{2})/u;
|
|
|
88
91
|
* between the API's .NET and the edge's WHATWG parser, so a spelling this pass
|
|
89
92
|
* encoded would still not be the one the Worker computes and the layers would
|
|
90
93
|
* go on comparing different strings. Write what a browser sends and every layer
|
|
91
|
-
* compares it unchanged — and the byte bounds then measure
|
|
92
|
-
* key actually costs.
|
|
94
|
+
* compares it unchanged — and the byte bounds then measure that same spelling.
|
|
93
95
|
*/
|
|
94
96
|
function isRequestSpelling(value) {
|
|
95
97
|
return !UNSENDABLE_SPELLING.test(value);
|
|
@@ -154,6 +156,70 @@ export function normalizeRedirectPath(value) {
|
|
|
154
156
|
return trimmed === "" ? "/" : trimmed;
|
|
155
157
|
}
|
|
156
158
|
|
|
159
|
+
// The root segment the published output reserves for integration data; the release
|
|
160
|
+
// inventory leaves everything under it out.
|
|
161
|
+
const RESERVED_INTEGRATION_DATA_SEGMENT = "integrations";
|
|
162
|
+
|
|
163
|
+
// Root-level files the site generates. A redirect is a marker file inside the
|
|
164
|
+
// published site (TR00968), so a source that names one has nowhere to live.
|
|
165
|
+
const GENERATED_ROOT_FILES = Object.freeze(["404.html", "sitemap.xml", "robots.txt", "favicon.ico"]);
|
|
166
|
+
// The longest decoded path segment a marker file name may have; the API rule and the
|
|
167
|
+
// generator use the same 200 bytes.
|
|
168
|
+
const MAX_MARKER_SEGMENT_BYTES = 200;
|
|
169
|
+
const WINDOWS_DEVICE_STEMS = new Set(["con", "prn", "aux", "nul"]);
|
|
170
|
+
|
|
171
|
+
function isWindowsDeviceSegment(segment) {
|
|
172
|
+
const stem = segment.split(".", 1)[0].toLowerCase();
|
|
173
|
+
return WINDOWS_DEVICE_STEMS.has(stem) || /^(?:com|lpt)[1-9]$/u.test(stem);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Why a normalized source path cannot be a redirect, or undefined when it can.
|
|
178
|
+
* A mirror of `SiteRedirectMapContract.SourceRefusalReason` on the server (the
|
|
179
|
+
* API is the authority; both run the same test vectors): `…/index.html` is
|
|
180
|
+
* answered by the platform for every page, the generator writes the files
|
|
181
|
+
* named above, and the marker is stored under the decoded spelling, so a path
|
|
182
|
+
* that cannot be stored once decoded (an escaped percent sign, a dot-prefixed
|
|
183
|
+
* segment, a reserved device name) is refused. The site root is left to the
|
|
184
|
+
* occupancy rule.
|
|
185
|
+
*/
|
|
186
|
+
export function redirectSourceRefusalReason(path) {
|
|
187
|
+
if (path === "/") return undefined;
|
|
188
|
+
// Every rule reads the decoded spelling: it is the name the marker is stored
|
|
189
|
+
// under, and the name the generator's own files use (`/%69ndex.html` is
|
|
190
|
+
// `/index.html`).
|
|
191
|
+
let decoded;
|
|
192
|
+
try {
|
|
193
|
+
decoded = path.replace(/^\/+|\/+$/gu, "").split("/").map(decodeURIComponent).join("/");
|
|
194
|
+
} catch {
|
|
195
|
+
return "it holds a percent escape that does not decode.";
|
|
196
|
+
}
|
|
197
|
+
const segments = decoded.split("/");
|
|
198
|
+
const first = segments[0].toLowerCase();
|
|
199
|
+
if (segments.some((segment) => segment.toLowerCase() === "index.html")) {
|
|
200
|
+
return "the platform already redirects every '…/index.html' address to its directory, so it cannot also be "
|
|
201
|
+
+ "a redirect source, and the page or home file there is generated.";
|
|
202
|
+
}
|
|
203
|
+
if (first === RESERVED_INTEGRATION_DATA_SEGMENT) {
|
|
204
|
+
return `'/${segments[0]}' is reserved for integration data, so nothing under it can be a redirect source.`;
|
|
205
|
+
}
|
|
206
|
+
if (GENERATED_ROOT_FILES.includes(first)) {
|
|
207
|
+
return `'/${segments[0]}' is a file the site generates, so neither it nor anything under it can be a redirect source.`;
|
|
208
|
+
}
|
|
209
|
+
if (
|
|
210
|
+
decoded.includes("..")
|
|
211
|
+
|| decoded !== decoded.normalize("NFC")
|
|
212
|
+
|| segments.some((segment) =>
|
|
213
|
+
segment === "" || segment.startsWith(".") || /[%\\?#\u0000-\u001F\u007F-\u009F]/u.test(segment)
|
|
214
|
+
|| isWindowsDeviceSegment(segment) || new TextEncoder().encode(segment).length > MAX_MARKER_SEGMENT_BYTES
|
|
215
|
+
)
|
|
216
|
+
) {
|
|
217
|
+
return "a redirect is stored as a file in the published site, and this path cannot be one (no dot-prefixed "
|
|
218
|
+
+ "segments, reserved device names, '..', escaped percent signs, or segments over 200 bytes).";
|
|
219
|
+
}
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
157
223
|
function looksAbsolute(value) {
|
|
158
224
|
return !value.startsWith("/") && value.includes("://");
|
|
159
225
|
}
|
|
@@ -235,14 +301,21 @@ function validateEntry(value, index, seen) {
|
|
|
235
301
|
`${field}.path`,
|
|
236
302
|
);
|
|
237
303
|
}
|
|
238
|
-
|
|
304
|
+
const sourceRefusal = redirectSourceRefusalReason(path);
|
|
305
|
+
if (sourceRefusal !== undefined) {
|
|
306
|
+
throw entryError("redirects.path_unpublishable", `'${path}': ${sourceRefusal}`, `${field}.path`);
|
|
307
|
+
}
|
|
308
|
+
// The marker is stored under the decoded spelling, so `/a%41` and `/aA` are one
|
|
309
|
+
// file, and the second would silently replace the first.
|
|
310
|
+
const markerKey = decodeURIComponent(path).toLowerCase();
|
|
311
|
+
if (seen.has(markerKey)) {
|
|
239
312
|
throw entryError(
|
|
240
313
|
"redirects.path_duplicate",
|
|
241
314
|
`'${path}' appears more than once; one path has one entry.`,
|
|
242
315
|
`${field}.path`,
|
|
243
316
|
);
|
|
244
317
|
}
|
|
245
|
-
seen.add(
|
|
318
|
+
seen.add(markerKey);
|
|
246
319
|
|
|
247
320
|
const kind = value.kind === undefined ? REDIRECT_KIND_REDIRECT : value.kind;
|
|
248
321
|
if (!REDIRECT_KINDS.includes(kind)) {
|
|
@@ -296,6 +369,15 @@ function validateEntry(value, index, seen) {
|
|
|
296
369
|
);
|
|
297
370
|
}
|
|
298
371
|
|
|
372
|
+
if (encodeURIComponent(rawTarget).length > REDIRECT_LIMITS.targetEncodedCharacters) {
|
|
373
|
+
throw entryError(
|
|
374
|
+
"redirects.target_too_long_to_store",
|
|
375
|
+
"'target' is stored as metadata on the published site's redirect file and is too long to fit "
|
|
376
|
+
+ `(${REDIRECT_LIMITS.targetEncodedCharacters} characters once percent-encoded).`,
|
|
377
|
+
`${field}.target`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
299
381
|
let target;
|
|
300
382
|
if (looksAbsolute(rawTarget)) {
|
|
301
383
|
if (!isAbsoluteRedirectTarget(rawTarget)) {
|
|
@@ -341,13 +423,23 @@ function validateEntry(value, index, seen) {
|
|
|
341
423
|
return { path, kind: REDIRECT_KIND_REDIRECT, target, status };
|
|
342
424
|
}
|
|
343
425
|
|
|
426
|
+
// The marker is stored under the decoded spelling, so `/%61` and `/a` are one route
|
|
427
|
+
// and `/%61 -> /a` is a loop. A target's escape may not decode; it then compares raw.
|
|
428
|
+
function routeIdentity(path) {
|
|
429
|
+
try {
|
|
430
|
+
return decodeURIComponent(path).toLowerCase();
|
|
431
|
+
} catch {
|
|
432
|
+
return path.toLowerCase();
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
344
436
|
function assertNoChains(entries) {
|
|
345
|
-
const sources = new Set(entries.map((entry) => entry.path
|
|
437
|
+
const sources = new Set(entries.map((entry) => routeIdentity(entry.path)));
|
|
346
438
|
entries.forEach((entry, index) => {
|
|
347
439
|
const targetPath = internalRedirectTargetPath(entry);
|
|
348
|
-
if (targetPath === undefined || !sources.has(targetPath
|
|
440
|
+
if (targetPath === undefined || !sources.has(routeIdentity(targetPath))) return;
|
|
349
441
|
const field = `entries[${index}].target`;
|
|
350
|
-
if (targetPath
|
|
442
|
+
if (routeIdentity(targetPath) === routeIdentity(entry.path)) {
|
|
351
443
|
throw entryError("redirects.loop", `'${entry.path}' redirects to itself.`, field);
|
|
352
444
|
}
|
|
353
445
|
throw entryError(
|
package/src/reference-help.js
CHANGED
|
@@ -602,8 +602,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
602
602
|
+ "a separate staging handoff and checks the current map at the real edge, using an in-memory cookie and "
|
|
603
603
|
+ "manual redirects. It reports each path, HTTP status and Location without following targets.",
|
|
604
604
|
"verified is true only if every entry matched and the map revision stayed unchanged during the check. "
|
|
605
|
-
+ "The check proves the current map at staging, not a historical deployment. A mismatch
|
|
606
|
-
+ "
|
|
605
|
+
+ "The check proves the current map at staging, not a historical deployment. A mismatch means the staged "
|
|
606
|
+
+ "release does not carry the entry (yet); re-run redirects check after the deployment completes, before promoting. Requests are bounded to 90 seconds, eight at a time; "
|
|
607
607
|
+ "large JSON lists are truncated explicitly with failed entries first. Human output reports every entry.",
|
|
608
608
|
"stagingPreview.url is a fresh, single-use two-minute handoff, minted after checks. It is emitted only "
|
|
609
609
|
+ "in final JSON, excluded from progress and GITHUB_OUTPUT; keep it private. The staging cookie lasts "
|
|
@@ -654,10 +654,15 @@ const WORKFLOW_REFERENCES = Object.freeze({
|
|
|
654
654
|
"Converting an engagement's CSV into this file is the agent's job. The CLI's contract is JSON, on "
|
|
655
655
|
+ "purpose: a real migration list needs judgement about which old URLs still deserve a destination and "
|
|
656
656
|
+ "which are simply gone.",
|
|
657
|
-
"Entries take effect on the next deploy, staging first.
|
|
658
|
-
+ "when the
|
|
659
|
-
+ "
|
|
660
|
-
+ "
|
|
657
|
+
"Entries take effect on the next deploy, staging first. Each is a file inside that release, so it goes live "
|
|
658
|
+
+ "exactly when the release does: staging with the staged release, production when it is promoted. There is "
|
|
659
|
+
+ "no separate propagation delay; an entry a check does not see belongs to a release that is not being "
|
|
660
|
+
+ "served yet.",
|
|
661
|
+
"A source cannot name a file the site generates: '/index.html' or any page's own 'index.html', '/404.html', "
|
|
662
|
+
+ "'/sitemap.xml', '/robots.txt', '/favicon.ico', or a path under the sitemap or robots names. The platform "
|
|
663
|
+
+ "already redirects every '…/index.html' address to its directory, so no entry is needed for one. A source "
|
|
664
|
+
+ "that could not be stored as a file (a dot-prefixed segment, a reserved device name, an escaped percent "
|
|
665
|
+
+ "sign) is refused too. 'validate' names the entry and the reason.",
|
|
661
666
|
]),
|
|
662
667
|
example: Object.freeze({
|
|
663
668
|
siteId: "<site-uuid>",
|
package/src/verbs/deploy.js
CHANGED
|
@@ -27,24 +27,18 @@ import { ApiError } from "../transport.js";
|
|
|
27
27
|
import { readManifest, writeManifest } from "../workspace.js";
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
*
|
|
30
|
+
* When a redirect goes live (TR00702, TR00968).
|
|
31
31
|
*
|
|
32
|
-
* A
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* DNS over.
|
|
38
|
-
*
|
|
39
|
-
* No number is quoted, because there is none to quote: the coordinator holds
|
|
40
|
-
* nothing back for a standard site's redirects. Its propagation grace governs
|
|
41
|
-
* only the *deletion* of a superseded Docs pointer namespace; a standard site's
|
|
42
|
-
* rows are written and removed immediately.
|
|
32
|
+
* A redirect or gone entry is a file inside the release this deploy publishes.
|
|
33
|
+
* It goes live with that release and no other way: staging shows it as soon as
|
|
34
|
+
* the staged release is served, production shows it when the release is
|
|
35
|
+
* promoted. There is no separate store to wait on, so a check that does not see
|
|
36
|
+
* an entry is looking at a release that does not carry it yet.
|
|
43
37
|
*/
|
|
44
|
-
const
|
|
45
|
-
"Redirect and gone entries
|
|
46
|
-
+ "
|
|
47
|
-
+ "
|
|
38
|
+
const REDIRECT_ACTIVATION_NOTE =
|
|
39
|
+
"Redirect and gone entries live inside the release this deploy publishes: staging serves them with the staged "
|
|
40
|
+
+ "release and production serves them once that release is promoted. There is no separate propagation delay; "
|
|
41
|
+
+ "an entry a check does not see belongs to a release that is not being served yet.";
|
|
48
42
|
|
|
49
43
|
/**
|
|
50
44
|
* `deploy --staging` / `deploy --production` — readiness, deploy, poll to
|
|
@@ -158,7 +152,7 @@ async function inspectStagingPreview(client, siteId, onProgress, now) {
|
|
|
158
152
|
checks = await checkStagingRedirects(client, siteId, onProgress, now);
|
|
159
153
|
if (!checks.redirects.verified) {
|
|
160
154
|
onProgress(
|
|
161
|
-
"Warning: staging redirects are not all verified.
|
|
155
|
+
"Warning: staging redirects are not all verified. Check the reported mismatches against the staged release; run 'taproot-site redirects check' again once the deployment has completed.",
|
|
162
156
|
);
|
|
163
157
|
}
|
|
164
158
|
} catch {
|
|
@@ -288,7 +282,7 @@ export async function deploy(invocation) {
|
|
|
288
282
|
now,
|
|
289
283
|
});
|
|
290
284
|
await recordDeployment(config, manifest, "production", completed);
|
|
291
|
-
if (!presentationOnly) onProgress(
|
|
285
|
+
if (!presentationOnly) onProgress(REDIRECT_ACTIVATION_NOTE);
|
|
292
286
|
return successResult(VERB_DEPLOY, siteId, {
|
|
293
287
|
target,
|
|
294
288
|
environment: DEPLOYMENT_ENVIRONMENT_PRODUCTION,
|
|
@@ -324,16 +318,22 @@ export async function deploy(invocation) {
|
|
|
324
318
|
const stagedPageIds = presentationOnly ? [] : selection.stagedPageIds ?? defaults.stagedPageIds;
|
|
325
319
|
const selectedSettingsTypes = selection.selectedSettingsTypes ?? defaults.selectedSettingsTypes;
|
|
326
320
|
const includeNavigation = presentationOnly ? false : selection.includeNavigation ?? defaults.includeNavigation;
|
|
327
|
-
|
|
328
|
-
|
|
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(
|
|
329
327
|
"deploy.empty_selection",
|
|
330
328
|
presentationOnly
|
|
331
329
|
? "A staging deployment on a managed Docs site needs at least one settings change; it stages settings "
|
|
332
330
|
+ "only. Change a theme, brand, header, or footer setting first."
|
|
333
|
-
: "A staging deployment needs at least one approved page, settings group, or navigation change
|
|
334
|
-
+ "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.",
|
|
335
333
|
{ field: "Candidate" },
|
|
336
334
|
);
|
|
335
|
+
if (nothingSelected && presentationOnly) {
|
|
336
|
+
throw emptySelectionError();
|
|
337
337
|
}
|
|
338
338
|
|
|
339
339
|
const candidate = {
|
|
@@ -359,6 +359,9 @@ export async function deploy(invocation) {
|
|
|
359
359
|
{ field: readiness.blockers[0].imageId || "blockers", status: readiness.blockers[0].state },
|
|
360
360
|
);
|
|
361
361
|
}
|
|
362
|
+
if (nothingSelected && !readiness.hasCandidateChanges) {
|
|
363
|
+
throw emptySelectionError();
|
|
364
|
+
}
|
|
362
365
|
if (!readiness.hasCandidateChanges) {
|
|
363
366
|
onProgress("Taproot reports this candidate contains no changes; deploying it anyway would be a no-op.");
|
|
364
367
|
}
|
|
@@ -398,7 +401,7 @@ export async function deploy(invocation) {
|
|
|
398
401
|
});
|
|
399
402
|
}
|
|
400
403
|
const stagingPreview = await inspectStagingPreview(client, siteId, onProgress, now);
|
|
401
|
-
onProgress(
|
|
404
|
+
onProgress(REDIRECT_ACTIVATION_NOTE);
|
|
402
405
|
return successResult(VERB_DEPLOY, siteId, {
|
|
403
406
|
target,
|
|
404
407
|
environment: DEPLOYMENT_ENVIRONMENT_STAGING,
|
|
@@ -9,7 +9,7 @@ export async function redirectsCheck(invocation) {
|
|
|
9
9
|
const checks = await checkStagingRedirects(client, siteId, onProgress, now);
|
|
10
10
|
if (!checks.redirects.verified) {
|
|
11
11
|
onProgress(
|
|
12
|
-
"Warning: staging redirects are not verified. Check the reported mismatches and retry
|
|
12
|
+
"Warning: staging redirects are not verified. Check the reported mismatches against the staged release and retry once its deployment has completed.",
|
|
13
13
|
);
|
|
14
14
|
}
|
|
15
15
|
const handoff = await mintStagingPreviewHandoff(client, siteId, { now });
|