@taprootio/site-authoring 0.2.0 → 0.4.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.
Files changed (50) hide show
  1. package/README.md +106 -13
  2. package/examples/riverbend-wellness/manifest.fixture.json +79 -0
  3. package/examples/riverbend-wellness/nav.json +26 -0
  4. package/examples/riverbend-wellness/pages/index.md +93 -0
  5. package/examples/riverbend-wellness/pages/visit.md +33 -0
  6. package/examples/riverbend-wellness/redirects.json +33 -0
  7. package/examples/riverbend-wellness/settings/brand.json +7 -0
  8. package/examples/riverbend-wellness/settings/site-header.json +21 -0
  9. package/examples/riverbend-wellness/settings/site-publishing-preferences.json +107 -0
  10. package/examples/riverbend-wellness/settings/taproot-styles.json +730 -0
  11. package/package.json +2 -1
  12. package/src/api.js +203 -3
  13. package/src/cli-release.js +152 -0
  14. package/src/cli.js +120 -11
  15. package/src/constants.js +59 -2
  16. package/src/contact-url.js +63 -0
  17. package/src/content/components.js +26 -4
  18. package/src/content/free-form-section-registry.json +3 -2
  19. package/src/content/inline-facts.js +5 -1
  20. package/src/content/tiptap-prosemirror.ts +26 -12
  21. package/src/credentials.js +39 -4
  22. package/src/errors.js +25 -0
  23. package/src/fixture-contract.js +64 -0
  24. package/src/footer-contract.js +28 -30
  25. package/src/json-path-diff.js +167 -0
  26. package/src/output.js +9 -0
  27. package/src/presentation-reference.js +5 -3
  28. package/src/redirects-contract.js +444 -0
  29. package/src/redirects-workspace.js +72 -0
  30. package/src/reference-help.js +262 -10
  31. package/src/session.js +161 -7
  32. package/src/transport.js +111 -6
  33. package/src/verbs/approve.js +7 -2
  34. package/src/verbs/deploy.js +29 -2
  35. package/src/verbs/footer-push.js +7 -2
  36. package/src/verbs/index.js +7 -1
  37. package/src/verbs/login.js +11 -1
  38. package/src/verbs/media-upload.js +7 -2
  39. package/src/verbs/nav-push.js +17 -11
  40. package/src/verbs/pages-push.js +93 -8
  41. package/src/verbs/preview-page.js +7 -2
  42. package/src/verbs/preview-revoke.js +7 -2
  43. package/src/verbs/pull.js +357 -22
  44. package/src/verbs/redirects-pull.js +90 -0
  45. package/src/verbs/redirects-push.js +100 -0
  46. package/src/verbs/status.js +90 -3
  47. package/src/verbs/theme-push.js +7 -2
  48. package/src/verbs/validate.js +93 -18
  49. package/src/verbs/whoami.js +79 -1
  50. package/src/workspace.js +153 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
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",
@@ -22,6 +22,7 @@
22
22
  },
23
23
  "files": [
24
24
  "bin/",
25
+ "examples/",
25
26
  "src/",
26
27
  "LICENSE",
27
28
  "README.md"
package/src/api.js CHANGED
@@ -1,18 +1,48 @@
1
1
  import {
2
2
  CANONICAL_TIMESTAMP,
3
+ CAPABILITY_REFUSAL_FIELD,
4
+ CLI_UPGRADE_COMMAND,
5
+ CLI_UPGRADE_REFUSAL_FIELD,
6
+ CLI_VERSION,
7
+ EXTERNAL_WRITES_SETTING_KEY,
8
+ EXTERNAL_WRITES_SETTING_LOCATION,
3
9
  LIMITS,
10
+ REFUSAL_CAPABILITY_MISSING,
11
+ REFUSAL_CLI_OUTDATED,
4
12
  REFUSAL_CREDENTIAL_REJECTED,
5
13
  REFUSAL_PLAN_LIMIT,
6
14
  REFUSAL_PLATFORM_PAUSED,
7
15
  REFUSAL_THROTTLED,
8
16
  REFUSAL_UNCLASSIFIED,
9
17
  } from "./constants.js";
18
+ import { normalizeLatestCliVersion } from "./cli-release.js";
10
19
  // The display-prefix shape lives with the store because acceptance on claim
11
20
  // must equal acceptance on save — a prefix admitted here but refused there
12
21
  // would turn a successful mint into an orphaned key.
13
22
  import { KEY_PREFIX } from "./credentials.js";
14
23
  import { isCanonicalUuid, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
24
+ import {
25
+ DEFAULT_REDIRECT_STATUS,
26
+ GONE_STATUS,
27
+ REDIRECT_KIND_GONE,
28
+ REDIRECT_KIND_REDIRECT,
29
+ REDIRECT_ORIGIN_AUTHORED,
30
+ REDIRECT_ORIGIN_PATH_HISTORY,
31
+ } from "./redirects-contract.js";
15
32
  import { ApiError, isWellFormedCredential } from "./transport.js";
33
+ // The revision's shape lives with the manifest that records it: a value this
34
+ // module admitted but the registry then dropped would be recorded on a push
35
+ // and unreadable on the next pull, which reads as "never reconciled" forever.
36
+ import { normalizePageBodyRevision } from "./workspace.js";
37
+
38
+ /**
39
+ * The proto enum spellings the redirect map travels as. The CLI's own
40
+ * vocabulary is the lowercase word the workspace file uses; these are the wire
41
+ * identities it is translated from, kept beside the translation.
42
+ */
43
+ const REDIRECT_KIND_REDIRECT_WIRE = "SITE_REDIRECT_KIND_REDIRECT";
44
+ const REDIRECT_KIND_GONE_WIRE = "SITE_REDIRECT_KIND_GONE";
45
+ const REDIRECT_ORIGIN_AUTHORED_WIRE = "SITE_REDIRECT_ORIGIN_AUTHORED";
16
46
 
17
47
  /**
18
48
  * The wire vocabulary and the read helpers every verb shares.
@@ -307,7 +337,8 @@ export function sitePath(siteId, suffix) {
307
337
  const REFUSAL_GUIDANCE = Object.freeze({
308
338
  [REFUSAL_PLATFORM_PAUSED]:
309
339
  "Taproot has paused external site authoring platform-wide. The credential and the request are both fine; "
310
- + "retry later or ask the operator.",
340
+ + "retry later, or ask a Taproot administrator to re-enable the platform setting "
341
+ + `'${EXTERNAL_WRITES_SETTING_KEY}' (${EXTERNAL_WRITES_SETTING_LOCATION}).`,
311
342
  [REFUSAL_CREDENTIAL_REJECTED]:
312
343
  "The site authoring credential was rejected: it is invalid, revoked, or bound to a different site. "
313
344
  + "Stop and re-issue it; retrying cannot succeed.",
@@ -329,6 +360,53 @@ const REFUSAL_GUIDANCE = Object.freeze({
329
360
  export function announceRefusal(error, onProgress, action = "request") {
330
361
  if (!(error instanceof ApiError)) return;
331
362
  const kind = error.refusalKind();
363
+ if (kind === REFUSAL_CLI_OUTDATED) {
364
+ onProgress("");
365
+ onProgress(`CLI UPGRADE REQUIRED (refusal=${REFUSAL_CLI_OUTDATED}, field=${CLI_UPGRADE_REFUSAL_FIELD})`);
366
+ onProgress(
367
+ ` Taproot supports only the latest published release of this package, and refused this ${action} because `
368
+ + `this CLI (${CLI_VERSION}) is behind it. Nothing about the credential, the request, or the site is wrong, `
369
+ + "and no retry of this version can succeed.",
370
+ );
371
+ onProgress(` Upgrade with: ${CLI_UPGRADE_COMMAND}`);
372
+ // The server names both versions and says when waiting is the right move
373
+ // (a deploy that landed ahead of its npm publish). Surface it verbatim
374
+ // rather than restating a policy this side does not own; the bounded
375
+ // ApiError message stays the fallback for a server that sent no
376
+ // description.
377
+ const serverDescription = error.descriptionFor?.(CLI_UPGRADE_REFUSAL_FIELD);
378
+ onProgress(` Taproot's own detail: ${
379
+ sanitizeDiagnostic(serverDescription ?? error.message, "the request was rejected.")
380
+ }`);
381
+ onProgress("");
382
+ return;
383
+ }
384
+ if (kind === REFUSAL_CAPABILITY_MISSING) {
385
+ const { permission, granted, required } = error.capability;
386
+ onProgress("");
387
+ onProgress(`CAPABILITY MISSING (refusal=${REFUSAL_CAPABILITY_MISSING}, field=${CAPABILITY_REFUSAL_FIELD})`);
388
+ onProgress(
389
+ ` Taproot refused this ${action} because the credential it runs on was not exchanged for a capability `
390
+ + `that grants '${permission}'.`,
391
+ );
392
+ onProgress(` Granted: ${granted.length > 0 ? granted.join(", ") : "(none)"}.`);
393
+ onProgress(
394
+ required.length > 0
395
+ ? ` Carried by: ${required.join(", ")}.`
396
+ : " Carried by: no site-authoring capability — this permission is not reachable by any credential.",
397
+ );
398
+ // The credential is exactly what was asked for, so re-issuing it changes
399
+ // nothing. What is wrong is the verb's declared capability set, and saying
400
+ // so is the whole point of the named refusal: the first agent to meet this
401
+ // had to read the package source to find out.
402
+ onProgress(
403
+ ` The credential is not the problem: the exchange minted precisely what the ${action} asked for. `
404
+ + "The verb's declared capability set in the CLI's verb table is short of the requests it makes; "
405
+ + "report it rather than minting a wider key.",
406
+ );
407
+ onProgress("");
408
+ return;
409
+ }
332
410
  if (kind === REFUSAL_PLAN_LIMIT) {
333
411
  onProgress("");
334
412
  onProgress("PLAN LIMIT (refusal=plan_limit, field=UpgradePrompt)");
@@ -379,6 +457,12 @@ export function normalizePageSummary(value) {
379
457
  status: enumValue(summary.status, PAGE_STATUSES, PAGE_STATUS_UNKNOWN, "api.page_status", "page status"),
380
458
  hasDraft: summary.hasDraft === true,
381
459
  isGenerated: summary.isGenerated === true,
460
+ // Absent against a Taproot that predates the revision contract, and absent
461
+ // on the listings that never resolve stored body state. Both mean the same
462
+ // thing to a caller — "this read cannot say what version you are looking
463
+ // at" — and both are carried as `undefined` rather than as a sentinel that
464
+ // would compare unequal to every recorded baseline and refuse every push.
465
+ bodyRevision: normalizePageBodyRevision(summary.bodyRevision),
382
466
  };
383
467
  }
384
468
 
@@ -473,6 +557,98 @@ export async function saveNavigation(client, siteId, navItems) {
473
557
  return Array.isArray(response.navItems) ? response.navItems : [];
474
558
  }
475
559
 
560
+ /**
561
+ * Reads the site's whole redirect map with the revision a replace must carry
562
+ * (TR00702).
563
+ */
564
+ export async function getSiteRedirectMap(client, siteId) {
565
+ return requireRedirectMap(
566
+ await client.request(sitePath(siteId, "redirects"), {
567
+ maximumResponseBytes: LIMITS.redirectMapResponseBytes,
568
+ }),
569
+ siteId,
570
+ );
571
+ }
572
+
573
+ /**
574
+ * Replaces the whole map. `expectedRevision` is the revision the map was read
575
+ * at; the site refuses the write rather than dropping an entry a rename
576
+ * recorded since.
577
+ */
578
+ export async function replaceSiteRedirectMap(client, siteId, expectedRevision, entries) {
579
+ return requireRedirectMap(
580
+ await client.request(sitePath(siteId, "redirects"), {
581
+ method: "PUT",
582
+ body: { siteId, expectedRevision, entries: entries.map(toWireRedirectEntry) },
583
+ // The reply is the whole replaced map, as large as the read.
584
+ maximumResponseBytes: LIMITS.redirectMapResponseBytes,
585
+ }),
586
+ siteId,
587
+ );
588
+ }
589
+
590
+ /**
591
+ * The wire shape of one entry. `origin` is deliberately not sent: it is what
592
+ * the site decided about a stored row, and echoing a pulled value back would
593
+ * be asking to relabel a rename-recorded entry as one this workspace authored.
594
+ */
595
+ function toWireRedirectEntry(entry) {
596
+ return entry.kind === REDIRECT_KIND_GONE
597
+ ? { path: entry.path, kind: REDIRECT_KIND_GONE_WIRE, status: GONE_STATUS }
598
+ : {
599
+ path: entry.path,
600
+ kind: REDIRECT_KIND_REDIRECT_WIRE,
601
+ target: entry.target,
602
+ status: entry.status,
603
+ };
604
+ }
605
+
606
+ function requireRedirectMap(value, siteId) {
607
+ const response = requireObject(value, "api.redirects_contract", "site redirects");
608
+ if (typeof response.revision !== "string" || response.revision === "") {
609
+ throw new SiteAuthoringError(
610
+ "api.redirects_contract",
611
+ "Taproot returned a redirect map with no revision, so a later push could not be fenced against it.",
612
+ { field: "revision" },
613
+ );
614
+ }
615
+ if (typeof response.siteId === "string" && response.siteId !== "" && response.siteId !== siteId) {
616
+ throw new SiteAuthoringError(
617
+ "api.redirects_contract",
618
+ "Taproot returned a redirect map for a different site.",
619
+ { field: "siteId" },
620
+ );
621
+ }
622
+ const entries = Array.isArray(response.entries) ? response.entries : [];
623
+ return {
624
+ revision: response.revision,
625
+ entries: entries.map((entry) => normalizeRedirectMapEntry(entry)),
626
+ };
627
+ }
628
+
629
+ /**
630
+ * Reads one entry off the wire. Transcoding omits proto default values, so an
631
+ * absent `kind`, `status`, or `origin` is the enum's zero — which the contract
632
+ * reads as a redirect, the default status, and path history respectively.
633
+ */
634
+ function normalizeRedirectMapEntry(entry) {
635
+ const source = requireObject(entry, "api.redirects_contract", "site redirect entry");
636
+ const kind = source.kind === REDIRECT_KIND_GONE_WIRE ? REDIRECT_KIND_GONE : REDIRECT_KIND_REDIRECT;
637
+ return {
638
+ path: typeof source.path === "string" ? source.path : "",
639
+ kind,
640
+ target: kind === REDIRECT_KIND_GONE || typeof source.target !== "string" ? "" : source.target,
641
+ status: Number.isSafeInteger(source.status) && source.status > 0
642
+ ? source.status
643
+ : kind === REDIRECT_KIND_GONE
644
+ ? GONE_STATUS
645
+ : DEFAULT_REDIRECT_STATUS,
646
+ origin: source.origin === REDIRECT_ORIGIN_AUTHORED_WIRE
647
+ ? REDIRECT_ORIGIN_AUTHORED
648
+ : REDIRECT_ORIGIN_PATH_HISTORY,
649
+ };
650
+ }
651
+
476
652
  /**
477
653
  * Reads one *draft* settings group for the site. Same rule as navigation:
478
654
  * `GetSettings` calls `RequireEnvironment`, so the zero value is refused rather
@@ -1298,7 +1474,14 @@ export async function startCliAuthorization(client, { keyName }, requestOptions
1298
1474
  const response = requireObject(
1299
1475
  // No site: TR00645 made the account-level sign-in the front door, so the
1300
1476
  // CLI no longer has to know a site id before it can authorize at all.
1301
- await client.request(CLI_AUTHORIZATION_PATH, { ...requestOptions, method: "POST", body: { keyName } }),
1477
+ // `cliVersion` is sent on both calls every online verb passes through
1478
+ // (TR00703). Sign-in is the earlier of the two, so an outdated CLI is told
1479
+ // to upgrade before an owner is asked to approve anything.
1480
+ await client.request(CLI_AUTHORIZATION_PATH, {
1481
+ ...requestOptions,
1482
+ method: "POST",
1483
+ body: { keyName, cliVersion: CLI_VERSION },
1484
+ }),
1302
1485
  "login.start_contract",
1303
1486
  "CLI authorization",
1304
1487
  );
@@ -1376,7 +1559,7 @@ export async function exchangeSiteAuthoringToken(client, { siteId, capabilities
1376
1559
  await client.request(TOKEN_EXCHANGE_PATH, {
1377
1560
  ...requestOptions,
1378
1561
  method: "POST",
1379
- body: { siteId, capabilities },
1562
+ body: { siteId, capabilities, cliVersion: CLI_VERSION },
1380
1563
  }),
1381
1564
  "exchange.contract",
1382
1565
  "token exchange",
@@ -1403,6 +1586,23 @@ export async function exchangeSiteAuthoringToken(client, { siteId, capabilities
1403
1586
  ...(typeof response.signInExpiresAt === "string" && response.signInExpiresAt.length > 0
1404
1587
  ? { signInExpiresAt: requireCanonicalTimestamp(response.signInExpiresAt, "exchange.contract", "signInExpiresAt").value }
1405
1588
  : {}),
1589
+ // Whether the platform is accepting external authoring writes at all
1590
+ // (TR00692). Read strictly: only a real boolean is a state, so a server
1591
+ // that predates the field leaves this undefined rather than reading as
1592
+ // paused and warning before every write against a healthy platform.
1593
+ ...(typeof response.externalWritesEnabled === "boolean"
1594
+ ? { externalWritesEnabled: response.externalWritesEnabled }
1595
+ : {}),
1596
+ // The latest published release Taproot accepts (TR00703). This exchange
1597
+ // already succeeded, so it is never news about *this* request — it is what
1598
+ // lets the offline verbs refuse later. Read strictly for the same reason
1599
+ // the switch above is: only a value this side can compare is kept, so a
1600
+ // server that predates the field, or one that answers something
1601
+ // unparseable, leaves the local gate silent rather than refusing work it
1602
+ // cannot justify.
1603
+ ...(normalizeLatestCliVersion(response.latestCliVersion) === undefined
1604
+ ? {}
1605
+ : { latestCliVersion: response.latestCliVersion }),
1406
1606
  };
1407
1607
  }
1408
1608
 
@@ -0,0 +1,152 @@
1
+ import {
2
+ CLI_UPGRADE_COMMAND,
3
+ CLI_UPGRADE_REFUSAL_FIELD,
4
+ CLI_VERSION,
5
+ REFUSAL_CLI_OUTDATED,
6
+ } from "./constants.js";
7
+ import { SiteAuthoringError } from "./errors.js";
8
+
9
+ /**
10
+ * Whether this CLI is still the release Taproot accepts (TR00703).
11
+ *
12
+ * **The server is the authority, and this is its mirror.** Taproot refuses a
13
+ * CLI that is behind the latest published release at sign-in and at token
14
+ * exchange, so every online verb already gets a definitive answer without any
15
+ * of this. What this file is for is the case where there is no request to be
16
+ * refused: `help`, `validate`, and `whoami` run entirely offline, and an agent
17
+ * that keeps authoring against a contract that has moved will happily spend a
18
+ * whole session validating documents the site can no longer accept.
19
+ *
20
+ * So the exchange returns the latest version, the CLI records it beside the
21
+ * sign-in, and the offline verbs refuse against that recording. A recording is
22
+ * weaker evidence than a refusal, and the rules here follow from that: nothing
23
+ * recorded means proceed (a clean machine can still validate a fixture), an
24
+ * unreadable or malformed recording means proceed, and only a value this side
25
+ * can actually parse and compare is allowed to stop anything.
26
+ */
27
+
28
+ // Exactly the shape `release/trunk/public-repo/scripts/npm-release-guard.mjs`
29
+ // admits to npm, and exactly the shape the API's `SiteAuthoringCliRelease`
30
+ // parses: three numeric components with no leading zeroes, plus optional
31
+ // dot-separated prerelease identifiers. Build metadata is deliberately absent —
32
+ // no version this project publishes carries any.
33
+ const SEMANTIC_VERSION =
34
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/u;
35
+
36
+ // A version string is a handful of characters. Anything longer is not one, and
37
+ // bounding it before the regex keeps a hostile response from spending time here.
38
+ const MAXIMUM_VERSION_LENGTH = 64;
39
+
40
+ const NUMERIC_IDENTIFIER = /^\d+$/u;
41
+
42
+ /**
43
+ * Parses a version into what precedence needs, or returns `undefined`. A
44
+ * leading zero on a numeric prerelease identifier is invalid, exactly as the
45
+ * release guard treats it.
46
+ */
47
+ export function parseCliVersion(value) {
48
+ if (typeof value !== "string" || value.length === 0 || value.length > MAXIMUM_VERSION_LENGTH) {
49
+ return undefined;
50
+ }
51
+ const match = SEMANTIC_VERSION.exec(value);
52
+ if (!match) return undefined;
53
+ const prerelease = match[4] === undefined ? [] : match[4].split(".");
54
+ if (prerelease.some((part) => NUMERIC_IDENTIFIER.test(part) && part.length > 1 && part.startsWith("0"))) {
55
+ return undefined;
56
+ }
57
+ return { core: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease };
58
+ }
59
+
60
+ function comparePrereleaseIdentifier(left, right) {
61
+ if (left === right) return 0;
62
+ const leftNumeric = NUMERIC_IDENTIFIER.test(left);
63
+ const rightNumeric = NUMERIC_IDENTIFIER.test(right);
64
+ if (leftNumeric && rightNumeric) {
65
+ // Digit-only with no leading zero, so length then lexicographic order is
66
+ // numeric order at any magnitude — the same rule the API applies, where a
67
+ // fixed-width parse would lose precision or overflow.
68
+ if (left.length !== right.length) return left.length < right.length ? -1 : 1;
69
+ return left < right ? -1 : 1;
70
+ }
71
+ // A numeric identifier always has lower precedence than an alphanumeric one.
72
+ if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
73
+ return left < right ? -1 : 1;
74
+ }
75
+
76
+ /**
77
+ * Semantic-version precedence, or `undefined` when either side is unparseable.
78
+ * Not string order: `0.10.0` is above `0.9.0`, and a prerelease is below the
79
+ * release it leads to, so `0.4.0-rc.1` is behind `0.4.0`.
80
+ */
81
+ export function compareCliVersions(leftVersion, rightVersion) {
82
+ const left = parseCliVersion(leftVersion);
83
+ const right = parseCliVersion(rightVersion);
84
+ if (left === undefined || right === undefined) return undefined;
85
+ for (let index = 0; index < left.core.length; index += 1) {
86
+ if (left.core[index] !== right.core[index]) return left.core[index] < right.core[index] ? -1 : 1;
87
+ }
88
+ if (left.prerelease.length === 0 || right.prerelease.length === 0) {
89
+ if (left.prerelease.length === right.prerelease.length) return 0;
90
+ return left.prerelease.length === 0 ? 1 : -1;
91
+ }
92
+ const shared = Math.min(left.prerelease.length, right.prerelease.length);
93
+ for (let index = 0; index < shared; index += 1) {
94
+ const comparison = comparePrereleaseIdentifier(left.prerelease[index], right.prerelease[index]);
95
+ if (comparison !== 0) return comparison;
96
+ }
97
+ if (left.prerelease.length === right.prerelease.length) return 0;
98
+ return left.prerelease.length < right.prerelease.length ? -1 : 1;
99
+ }
100
+
101
+ /**
102
+ * A latest-version value from the wire, kept only if this side can compare it.
103
+ *
104
+ * Read strictly and dropped silently on anything else. The value drives an
105
+ * advisory local gate, so a server that predates the field, or one that sends
106
+ * something this parser does not understand, must leave the CLI working rather
107
+ * than refusing offline work it cannot justify.
108
+ */
109
+ export function normalizeLatestCliVersion(value) {
110
+ return parseCliVersion(value) === undefined ? undefined : value;
111
+ }
112
+
113
+ /**
114
+ * Whether {@link CLI_VERSION} is strictly behind a recorded latest. Anything
115
+ * unparseable on either side is not behind: an answer this side cannot compute
116
+ * is not evidence.
117
+ */
118
+ export function isBehindLatest(latestVersion, currentVersion = CLI_VERSION) {
119
+ const comparison = compareCliVersions(currentVersion, latestVersion);
120
+ return comparison !== undefined && comparison < 0;
121
+ }
122
+
123
+ /**
124
+ * The offline refusal.
125
+ *
126
+ * It classifies as `cli_outdated` and carries the server's own field name
127
+ * rather than CLI-local labels, because an agent branching on `refusal` or
128
+ * `field` should not have to know whether the answer came from Taproot or from
129
+ * the recording of Taproot's last answer — the remedy is identical. The `code`
130
+ * is what says it was raised here: `cli.outdated` rather than
131
+ * `api.request_rejected`.
132
+ */
133
+ class CliOutdatedError extends SiteAuthoringError {
134
+ refusalKind() {
135
+ return REFUSAL_CLI_OUTDATED;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * The offline refusal, worded to match what the server says on the wire so an
141
+ * operator meeting it either way reads the same instruction.
142
+ */
143
+ export function cliOutdatedError(latestVersion) {
144
+ return new CliOutdatedError(
145
+ "cli.outdated",
146
+ `This CLI is version ${CLI_VERSION}, and the last Taproot sign-in exchange reported ${latestVersion} as the `
147
+ + "latest published release. Taproot accepts only the latest, so every online verb is refused until this "
148
+ + `package is upgraded. Upgrade with: ${CLI_UPGRADE_COMMAND}. If ${latestVersion} was published in the last `
149
+ + "few minutes, npm may not serve it yet — wait a moment and retry the upgrade.",
150
+ { field: CLI_UPGRADE_REFUSAL_FIELD },
151
+ );
152
+ }