@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.
- package/README.md +106 -13
- package/examples/riverbend-wellness/manifest.fixture.json +79 -0
- package/examples/riverbend-wellness/nav.json +26 -0
- package/examples/riverbend-wellness/pages/index.md +93 -0
- package/examples/riverbend-wellness/pages/visit.md +33 -0
- package/examples/riverbend-wellness/redirects.json +33 -0
- package/examples/riverbend-wellness/settings/brand.json +7 -0
- package/examples/riverbend-wellness/settings/site-header.json +21 -0
- package/examples/riverbend-wellness/settings/site-publishing-preferences.json +107 -0
- package/examples/riverbend-wellness/settings/taproot-styles.json +730 -0
- package/package.json +2 -1
- package/src/api.js +203 -3
- package/src/cli-release.js +152 -0
- package/src/cli.js +120 -11
- package/src/constants.js +59 -2
- package/src/contact-url.js +63 -0
- package/src/content/components.js +26 -4
- package/src/content/free-form-section-registry.json +3 -2
- package/src/content/inline-facts.js +5 -1
- package/src/content/tiptap-prosemirror.ts +26 -12
- package/src/credentials.js +39 -4
- package/src/errors.js +25 -0
- package/src/fixture-contract.js +64 -0
- package/src/footer-contract.js +28 -30
- package/src/json-path-diff.js +167 -0
- package/src/output.js +9 -0
- package/src/presentation-reference.js +5 -3
- package/src/redirects-contract.js +444 -0
- package/src/redirects-workspace.js +72 -0
- package/src/reference-help.js +262 -10
- package/src/session.js +161 -7
- package/src/transport.js +111 -6
- package/src/verbs/approve.js +7 -2
- package/src/verbs/deploy.js +29 -2
- package/src/verbs/footer-push.js +7 -2
- package/src/verbs/index.js +7 -1
- package/src/verbs/login.js +11 -1
- package/src/verbs/media-upload.js +7 -2
- package/src/verbs/nav-push.js +17 -11
- package/src/verbs/pages-push.js +93 -8
- package/src/verbs/preview-page.js +7 -2
- package/src/verbs/preview-revoke.js +7 -2
- package/src/verbs/pull.js +357 -22
- package/src/verbs/redirects-pull.js +90 -0
- package/src/verbs/redirects-push.js +100 -0
- package/src/verbs/status.js +90 -3
- package/src/verbs/theme-push.js +7 -2
- package/src/verbs/validate.js +93 -18
- package/src/verbs/whoami.js +79 -1
- 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.
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|