@taprootio/site-authoring 0.23.1 → 0.26.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 +78 -7
- package/package.json +2 -2
- package/src/analytics-campaign.json +31 -0
- package/src/analytics-link-label.json +22 -0
- package/src/analytics-source-vocabulary.json +17 -0
- package/src/api.js +27 -5
- package/src/capabilities.js +2 -1
- package/src/changelog.json +38 -0
- package/src/cli.js +155 -7
- package/src/constants.js +4 -1
- package/src/credentials.js +55 -14
- package/src/marketing-api.js +179 -0
- package/src/marketing-contract.js +289 -0
- package/src/marketing-workspace.js +77 -0
- package/src/redirects-contract.js +0 -3
- package/src/reference-help.js +109 -3
- package/src/session.js +35 -1
- package/src/site-check-findings.js +0 -1
- package/src/site-check-rules.json +0 -1
- package/src/site-checks.js +0 -6
- package/src/typed-page-reference.js +19 -26
- package/src/typed-pages.js +51 -6
- package/src/utm-link.js +214 -0
- package/src/verbs/approve.js +25 -9
- package/src/verbs/index.js +12 -0
- package/src/verbs/link-build.js +195 -0
- package/src/verbs/login.js +33 -17
- package/src/verbs/logout.js +15 -1
- package/src/verbs/marketing-pull.js +80 -0
- package/src/verbs/marketing-push.js +250 -0
- package/src/verbs/pages-delete.js +6 -3
- package/src/verbs/pages-push.js +41 -3
- package/src/verbs/plan.js +6 -1
- package/src/verbs/pull.js +63 -9
- package/src/verbs/whoami.js +42 -2
- package/src/workspace.js +3 -0
package/src/verbs/login.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
VERB_LOGIN,
|
|
23
23
|
VERB_LOGOUT,
|
|
24
24
|
VERB_SITES,
|
|
25
|
+
SURFACE_STANDARD,
|
|
25
26
|
} from "../constants.js";
|
|
26
27
|
import { assertCredentialCapacity, credentialApiOrigin, saveCredential } from "../credentials.js";
|
|
27
28
|
import { asSiteAuthoringError, hasControlCharacter, SiteAuthoringError } from "../errors.js";
|
|
@@ -156,7 +157,7 @@ function describeMinutes(milliseconds) {
|
|
|
156
157
|
* revoked — named by its exact id and display prefix when the server supplied
|
|
157
158
|
* them, and by its display name otherwise.
|
|
158
159
|
*/
|
|
159
|
-
function terminalFailure(answer, keyName) {
|
|
160
|
+
function terminalFailure(answer, keyName, loginCommand) {
|
|
160
161
|
const status = answer.status;
|
|
161
162
|
switch (status) {
|
|
162
163
|
case CLI_AUTHORIZATION_CLAIM_STATUS_DENIED:
|
|
@@ -168,7 +169,7 @@ function terminalFailure(answer, keyName) {
|
|
|
168
169
|
case CLI_AUTHORIZATION_CLAIM_STATUS_EXPIRED:
|
|
169
170
|
return new SiteAuthoringError(
|
|
170
171
|
"login.expired",
|
|
171
|
-
`The authorization expired before it was approved. Run '${
|
|
172
|
+
`The authorization expired before it was approved. Run '${loginCommand}' again.`,
|
|
172
173
|
{ status },
|
|
173
174
|
);
|
|
174
175
|
case CLI_AUTHORIZATION_CLAIM_STATUS_CONSUMED:
|
|
@@ -179,7 +180,7 @@ function terminalFailure(answer, keyName) {
|
|
|
179
180
|
? `Revoke the credential with id ${answer.keyId}`
|
|
180
181
|
+ (answer.keyPrefix === undefined ? "" : ` (display prefix ${answer.keyPrefix})`)
|
|
181
182
|
: `Revoke the credential named '${keyName}'`)
|
|
182
|
-
+ ` under Account -> Settings -> API keys, then run '${
|
|
183
|
+
+ ` under Account -> Settings -> API keys, then run '${loginCommand}' again.`,
|
|
183
184
|
{ status },
|
|
184
185
|
);
|
|
185
186
|
default:
|
|
@@ -191,11 +192,11 @@ function terminalFailure(answer, keyName) {
|
|
|
191
192
|
}
|
|
192
193
|
}
|
|
193
194
|
|
|
194
|
-
function translateClaimApiError(error) {
|
|
195
|
+
function translateClaimApiError(error, loginCommand) {
|
|
195
196
|
if (!(error instanceof ApiError) || error.httpStatus !== 404) return error;
|
|
196
197
|
return new SiteAuthoringError(
|
|
197
198
|
"login.unknown",
|
|
198
|
-
`Taproot does not recognize this authorization. Run '${
|
|
199
|
+
`Taproot does not recognize this authorization. Run '${loginCommand}' to start a new one.`,
|
|
199
200
|
{ status: error.status },
|
|
200
201
|
);
|
|
201
202
|
}
|
|
@@ -222,13 +223,13 @@ function translateStartApiError(error) {
|
|
|
222
223
|
* display prefix, never by value — and direct the owner to revoke it, because
|
|
223
224
|
* silence here is a live credential the owner does not know exists.
|
|
224
225
|
*/
|
|
225
|
-
function unstoredCredentialFailure(error, claimed) {
|
|
226
|
+
function unstoredCredentialFailure(error, claimed, loginCommand) {
|
|
226
227
|
const cause = asSiteAuthoringError(error);
|
|
227
228
|
return new SiteAuthoringError(
|
|
228
229
|
"login.credential_unstored",
|
|
229
230
|
`A credential WAS issued (id ${claimed.keyId}, display prefix ${claimed.keyPrefix}) but could not `
|
|
230
231
|
+ `be stored: ${cause.message} Revoke it under Account -> Settings -> API keys, then run `
|
|
231
|
-
+ `'${
|
|
232
|
+
+ `'${loginCommand}' again.`,
|
|
232
233
|
{ ...(cause.status === undefined ? {} : { status: cause.status }) },
|
|
233
234
|
);
|
|
234
235
|
}
|
|
@@ -311,7 +312,12 @@ async function handOffToBrowser({ verificationUrl, userCode, onProgress, awaitEn
|
|
|
311
312
|
|
|
312
313
|
export async function login(invocation) {
|
|
313
314
|
const keyName = normalizeKeyName(invocation.keyName);
|
|
314
|
-
const { apiBaseUrl, client, now, onProgress } = await openAnonymousSession(invocation);
|
|
315
|
+
const { config, apiBaseUrl, client, now, onProgress } = await openAnonymousSession(invocation);
|
|
316
|
+
const legalDraftSiteId = invocation.legalDrafts ? config?.siteId : undefined;
|
|
317
|
+
if (invocation.legalDrafts && (!legalDraftSiteId || (config.authoringSurface !== undefined && config.authoringSurface !== SURFACE_STANDARD))) {
|
|
318
|
+
throw new SiteAuthoringError("auth.legal_site_required", "Select a standard site with taproot-site use before requesting legal draft access.");
|
|
319
|
+
}
|
|
320
|
+
const loginCommand = `${CLI_BINARY_NAME} ${VERB_LOGIN}${legalDraftSiteId === undefined ? "" : " --legal-drafts"}`;
|
|
315
321
|
const environment = invocation.environment ?? process.env;
|
|
316
322
|
const apiOrigin = credentialApiOrigin(apiBaseUrl);
|
|
317
323
|
|
|
@@ -323,11 +329,11 @@ export async function login(invocation) {
|
|
|
323
329
|
// now rather than at the write, which is the same failure arriving after the
|
|
324
330
|
// damage. A residual write failure can still happen (the disk can fill during
|
|
325
331
|
// the poll), which is what `unstoredCredentialFailure` below is for.
|
|
326
|
-
await assertCredentialCapacity(environment, apiOrigin);
|
|
332
|
+
await assertCredentialCapacity(environment, apiOrigin, { siteId: legalDraftSiteId });
|
|
327
333
|
|
|
328
334
|
let authorization;
|
|
329
335
|
try {
|
|
330
|
-
authorization = await startCliAuthorization(client, { keyName });
|
|
336
|
+
authorization = await startCliAuthorization(client, { keyName, legalDraftSiteId });
|
|
331
337
|
} catch (error) {
|
|
332
338
|
// Announced only when the refusal survives translation: sign-in is the
|
|
333
339
|
// earlier of the two calls that carry this CLI's version, so it is where
|
|
@@ -390,7 +396,7 @@ export async function login(invocation) {
|
|
|
390
396
|
// total wall time.
|
|
391
397
|
read: async () => {
|
|
392
398
|
try {
|
|
393
|
-
return await claimCliAuthorization(client, authorization.deviceCode);
|
|
399
|
+
return await claimCliAuthorization(client, authorization.deviceCode, {}, { legalDraftSiteId, now });
|
|
394
400
|
} catch (error) {
|
|
395
401
|
// A throttle is the endpoint doing its job, not a verdict on this
|
|
396
402
|
// authorization. Back off within the same deadline instead of
|
|
@@ -441,7 +447,7 @@ export async function login(invocation) {
|
|
|
441
447
|
if (error.ambiguousMutation === true) claimResponseLost = true;
|
|
442
448
|
throw error;
|
|
443
449
|
}
|
|
444
|
-
throw translateClaimApiError(error);
|
|
450
|
+
throw translateClaimApiError(error, loginCommand);
|
|
445
451
|
}
|
|
446
452
|
},
|
|
447
453
|
evaluate: (value) => {
|
|
@@ -464,7 +470,7 @@ export async function login(invocation) {
|
|
|
464
470
|
// each line pushes the code the operator is still reading off screen.
|
|
465
471
|
return { done: false };
|
|
466
472
|
}
|
|
467
|
-
throw terminalFailure(value, keyName);
|
|
473
|
+
throw terminalFailure(value, keyName, loginCommand);
|
|
468
474
|
},
|
|
469
475
|
// A deadline reached after a lost answer cannot honestly say nothing was
|
|
470
476
|
// issued: one of those lost checks may have consumed the approval and
|
|
@@ -477,12 +483,12 @@ export async function login(invocation) {
|
|
|
477
483
|
"The polling deadline passed, and at least one check's answer was lost in transit — a credential "
|
|
478
484
|
+ `MAY have been issued without this command receiving it. Nothing was stored locally; check `
|
|
479
485
|
+ `Account -> Settings -> API keys for a credential named '${keyName}' and revoke it before running `
|
|
480
|
-
+ `'${
|
|
486
|
+
+ `'${loginCommand}' again.`,
|
|
481
487
|
)
|
|
482
488
|
: new SiteAuthoringError(
|
|
483
489
|
"login.timeout",
|
|
484
490
|
"The approval was not completed before this command's polling deadline. Nothing was stored; "
|
|
485
|
-
+ `run '${
|
|
491
|
+
+ `run '${loginCommand}' again.`,
|
|
486
492
|
),
|
|
487
493
|
});
|
|
488
494
|
} catch (error) {
|
|
@@ -498,9 +504,12 @@ export async function login(invocation) {
|
|
|
498
504
|
"site.cancelled",
|
|
499
505
|
"The login was cancelled after a claim's answer was lost — a credential MAY have been issued without "
|
|
500
506
|
+ `this command receiving it. Check Account -> Settings -> API keys for a credential named '${keyName}' `
|
|
501
|
-
+ `and revoke it before running '${
|
|
507
|
+
+ `and revoke it before running '${loginCommand}' again.`,
|
|
502
508
|
);
|
|
503
509
|
}
|
|
510
|
+
if (error instanceof SiteAuthoringError && error.code === "site.cancelled" && legalDraftSiteId !== undefined) {
|
|
511
|
+
throw new SiteAuthoringError("site.cancelled", `${error.message} Run '${loginCommand}' again.`);
|
|
512
|
+
}
|
|
504
513
|
throw error;
|
|
505
514
|
}
|
|
506
515
|
|
|
@@ -510,6 +519,7 @@ export async function login(invocation) {
|
|
|
510
519
|
environment,
|
|
511
520
|
{
|
|
512
521
|
apiOrigin,
|
|
522
|
+
...(legalDraftSiteId === undefined ? {} : { purpose: claimed.purpose, siteId: claimed.siteId, capabilities: claimed.capabilities, legalDraftPlatformSite: claimed.legalDraftPlatformSite }),
|
|
513
523
|
accountId: claimed.accountId,
|
|
514
524
|
key: claimed.key,
|
|
515
525
|
keyId: claimed.keyId,
|
|
@@ -519,7 +529,13 @@ export async function login(invocation) {
|
|
|
519
529
|
{ now },
|
|
520
530
|
);
|
|
521
531
|
} catch (error) {
|
|
522
|
-
throw unstoredCredentialFailure(error, claimed);
|
|
532
|
+
throw unstoredCredentialFailure(error, claimed, loginCommand);
|
|
533
|
+
}
|
|
534
|
+
if (legalDraftSiteId !== undefined) {
|
|
535
|
+
onProgress(`Stored legal draft access for site ${legalDraftSiteId} in ${credentialPath}. The credential itself is never displayed.`);
|
|
536
|
+
onProgress(`Use '${CLI_BINARY_NAME} --legal-drafts pages push' to save drafts; approve and publish them in Taproot.`);
|
|
537
|
+
onProgress(`Discard this local grant with '${CLI_BINARY_NAME} --legal-drafts ${VERB_LOGOUT}'; revoke it in Account -> Settings -> API keys.`);
|
|
538
|
+
return successResult(VERB_LOGIN, legalDraftSiteId, { accountId: claimed.accountId, keyId: claimed.keyId, keyPrefix: claimed.keyPrefix, keyExpiresAt: claimed.keyExpiresAt, userCode: authorization.userCode, verificationUrl, credentialPath, legalDrafts: true });
|
|
523
539
|
}
|
|
524
540
|
onProgress(`Stored the sign-in in ${credentialPath}. The credential itself is never displayed.`);
|
|
525
541
|
onProgress(`Run '${CLI_BINARY_NAME} ${VERB_SITES}' to see the sites you can author.`);
|
package/src/verbs/logout.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CLI_BINARY_NAME, VERB_LOGOUT } from "../constants.js";
|
|
2
2
|
import { credentialApiOrigin, removeCredential } from "../credentials.js";
|
|
3
3
|
import { openAnonymousSession, successResult } from "../session.js";
|
|
4
|
+
import { SiteAuthoringError } from "../errors.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* `taproot-site logout` — local discard, and nothing more.
|
|
@@ -19,11 +20,20 @@ import { openAnonymousSession, successResult } from "../session.js";
|
|
|
19
20
|
* discards is addressed by API origin.
|
|
20
21
|
*/
|
|
21
22
|
export async function logout(invocation) {
|
|
22
|
-
const { apiBaseUrl, onProgress } = await openAnonymousSession(invocation);
|
|
23
|
+
const { config, apiBaseUrl, onProgress } = await openAnonymousSession(invocation);
|
|
24
|
+
if (invocation.legalDrafts && !config?.siteId) {
|
|
25
|
+
throw new SiteAuthoringError("config.site_missing", "Select a site before discarding its legal draft grant.");
|
|
26
|
+
}
|
|
23
27
|
const { path: credentialPath, removed } = await removeCredential(
|
|
24
28
|
invocation.environment ?? process.env,
|
|
25
29
|
credentialApiOrigin(apiBaseUrl),
|
|
30
|
+
{ siteId: invocation.legalDrafts ? config.siteId : undefined },
|
|
26
31
|
);
|
|
32
|
+
if (invocation.legalDrafts) {
|
|
33
|
+
onProgress(removed ? `Discarded the legal draft grant for site ${config.siteId} from ${credentialPath}.` : `No legal draft grant for site ${config.siteId} was found in ${credentialPath}.`);
|
|
34
|
+
if (removed) onProgress("This is a local discard; revoke the credential in Account -> Settings -> API keys.");
|
|
35
|
+
return successResult(VERB_LOGOUT, config.siteId, { removed, credentialPath, legalDrafts: true });
|
|
36
|
+
}
|
|
27
37
|
onProgress(
|
|
28
38
|
removed
|
|
29
39
|
? `Discarded the stored Taproot sign-in from ${credentialPath}.`
|
|
@@ -35,5 +45,9 @@ export async function logout(invocation) {
|
|
|
35
45
|
+ `Account -> Settings -> API keys; '${CLI_BINARY_NAME} ${VERB_LOGOUT}' cannot revoke it.`,
|
|
36
46
|
);
|
|
37
47
|
}
|
|
48
|
+
onProgress(
|
|
49
|
+
"Separate site legal draft grants, if any, remain stored. Select each site and run "
|
|
50
|
+
+ `'${CLI_BINARY_NAME} --legal-drafts ${VERB_LOGOUT}' to discard its grant, or revoke the keys in Account -> Settings -> API keys.`,
|
|
51
|
+
);
|
|
38
52
|
return successResult(VERB_LOGOUT, undefined, { removed, credentialPath });
|
|
39
53
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { withRefusalGuidance } from "../api.js";
|
|
2
|
+
import { VERB_MARKETING_PULL } from "../constants.js";
|
|
3
|
+
import { listMarketingRecords } from "../marketing-api.js";
|
|
4
|
+
import {
|
|
5
|
+
MARKETING_KIND_NAMES,
|
|
6
|
+
MARKETING_KINDS,
|
|
7
|
+
projectMarketingDocument,
|
|
8
|
+
} from "../marketing-contract.js";
|
|
9
|
+
import { baselineEntry, withRecordedMarketing } from "../marketing-workspace.js";
|
|
10
|
+
import { boundedList, openSession, successResult } from "../session.js";
|
|
11
|
+
import { readManifest, writeManifest, writeWorkspaceJson } from "../workspace.js";
|
|
12
|
+
|
|
13
|
+
const MAXIMUM_REPORTED = 200;
|
|
14
|
+
|
|
15
|
+
/** The archived keys, bounded like the live ones so a site with thousands of them still fits the result. */
|
|
16
|
+
function archivedSummary(keys) {
|
|
17
|
+
const reported = boundedList(keys, MAXIMUM_REPORTED);
|
|
18
|
+
return {
|
|
19
|
+
archived: reported.items,
|
|
20
|
+
archivedTotal: keys.length,
|
|
21
|
+
...(reported.truncated ? { archivedTruncated: true } : {}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `marketing pull` — write the site's live campaigns and placements and record what was read.
|
|
27
|
+
*
|
|
28
|
+
* It replaces each file with the site's state, so a local edit that was never pushed is replaced; that is what
|
|
29
|
+
* pull means everywhere else in this CLI. Archived records are listed and not written: a record the site has
|
|
30
|
+
* archived keeps its key for ever, and a file that names the key asks for it to be restored. The baseline it
|
|
31
|
+
* records is what `marketing push` fences against.
|
|
32
|
+
*/
|
|
33
|
+
export async function marketingPull(invocation) {
|
|
34
|
+
const { client, config, siteId, onProgress } = await openSession(invocation);
|
|
35
|
+
// The manifest is the pulled workspace this baseline belongs to, the same precondition every push has.
|
|
36
|
+
let manifest = await readManifest(config.workspaceDir, siteId);
|
|
37
|
+
|
|
38
|
+
// Both kinds are read before either file is written, so a refusal on the second never leaves a first file
|
|
39
|
+
// without its baseline.
|
|
40
|
+
const listings = new Map();
|
|
41
|
+
for (const kind of MARKETING_KIND_NAMES) {
|
|
42
|
+
listings.set(
|
|
43
|
+
kind,
|
|
44
|
+
await withRefusalGuidance(
|
|
45
|
+
onProgress,
|
|
46
|
+
"marketing pull",
|
|
47
|
+
async () => await listMarketingRecords(client, siteId, kind),
|
|
48
|
+
),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const summary = {};
|
|
53
|
+
for (const [kind, listing] of listings) {
|
|
54
|
+
const { file, noun } = MARKETING_KINDS[kind];
|
|
55
|
+
const live = listing.records.filter((record) => !record.archived);
|
|
56
|
+
const archived = listing.records.filter((record) => record.archived).map((record) => record.key).sort();
|
|
57
|
+
await writeWorkspaceJson(config.workspaceDir, file, projectMarketingDocument(kind, live));
|
|
58
|
+
manifest = withRecordedMarketing(
|
|
59
|
+
manifest,
|
|
60
|
+
kind,
|
|
61
|
+
listing.records.map((record) => [record.key, baselineEntry(kind, record)]),
|
|
62
|
+
);
|
|
63
|
+
const reported = boundedList(
|
|
64
|
+
live.map((record) => ({ key: record.key, state: record.state })).sort((left, right) => left.key < right.key ? -1 : 1),
|
|
65
|
+
MAXIMUM_REPORTED,
|
|
66
|
+
);
|
|
67
|
+
summary[kind] = {
|
|
68
|
+
file,
|
|
69
|
+
total: live.length,
|
|
70
|
+
items: reported.items,
|
|
71
|
+
...(reported.truncated ? { itemsTruncated: true } : {}),
|
|
72
|
+
...(archived.length > 0 ? archivedSummary(archived) : {}),
|
|
73
|
+
canManage: listing.canManage,
|
|
74
|
+
...(listing.maxActive === undefined ? {} : { maxActive: listing.maxActive }),
|
|
75
|
+
};
|
|
76
|
+
onProgress(`Wrote ${file} with ${live.length} ${noun}${live.length === 1 ? "" : "s"}.`);
|
|
77
|
+
}
|
|
78
|
+
await writeManifest(config.workspaceDir, manifest);
|
|
79
|
+
return successResult(VERB_MARKETING_PULL, siteId, { marketing: summary });
|
|
80
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { withRefusalGuidance } from "../api.js";
|
|
4
|
+
import { VERB_MARKETING_PULL, VERB_MARKETING_PUSH } from "../constants.js";
|
|
5
|
+
import { SiteAuthoringError } from "../errors.js";
|
|
6
|
+
import {
|
|
7
|
+
createMarketingRecord,
|
|
8
|
+
listMarketingRecords,
|
|
9
|
+
setMarketingRecordArchived,
|
|
10
|
+
updateMarketingRecord,
|
|
11
|
+
withSiteWording,
|
|
12
|
+
} from "../marketing-api.js";
|
|
13
|
+
import { MARKETING_KINDS, projectMarketingDocument } from "../marketing-contract.js";
|
|
14
|
+
import {
|
|
15
|
+
baselineEntry,
|
|
16
|
+
readWorkspaceMarketing,
|
|
17
|
+
recordedMarketing,
|
|
18
|
+
sameLabels,
|
|
19
|
+
withRecordedMarketing,
|
|
20
|
+
} from "../marketing-workspace.js";
|
|
21
|
+
import { openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
22
|
+
import { readManifest, writeManifest, writeWorkspaceJson } from "../workspace.js";
|
|
23
|
+
|
|
24
|
+
const MAXIMUM_REPORTED = 50;
|
|
25
|
+
|
|
26
|
+
function pullRequired(kind, key, reason) {
|
|
27
|
+
const { file } = MARKETING_KINDS[kind];
|
|
28
|
+
return new SiteAuthoringError(
|
|
29
|
+
"marketing.pull_required",
|
|
30
|
+
`${file}: ${reason} Run '${VERB_MARKETING_PULL}', reconcile '${key}', and retry. Nothing was written.`,
|
|
31
|
+
{ field: `${file}:${key}` },
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether a recorded baseline already says what the site's record says, including its state: a release that
|
|
37
|
+
* finished since the last write moves a key from publishing to live, and the file is rewritten with that, so the
|
|
38
|
+
* baseline `link build` reads must follow it.
|
|
39
|
+
*/
|
|
40
|
+
function currentBaseline(recorded, remote, kind) {
|
|
41
|
+
return recorded !== undefined && recorded.id === remote.id && recorded.archived === remote.archived
|
|
42
|
+
&& recorded.state === remote.state && sameLabels(kind, recorded, remote);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function changedOnTheSite(kind, key) {
|
|
46
|
+
const { file, noun } = MARKETING_KINDS[kind];
|
|
47
|
+
return new SiteAuthoringError(
|
|
48
|
+
"marketing.concurrent_modification",
|
|
49
|
+
`${file}: the ${noun} '${key}' changed on the site after this workspace read it. Run '${VERB_MARKETING_PULL}', `
|
|
50
|
+
+ "reconcile the file, and retry. Nothing was written.",
|
|
51
|
+
{ field: `${file}:${key}` },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Decides what one kind needs, from the file, the site's records and the baseline the last pull recorded.
|
|
57
|
+
* Throws before anything is written.
|
|
58
|
+
*
|
|
59
|
+
* A key is public URL text and never changes or returns to use, so a changed key in the file is a new record
|
|
60
|
+
* (a create) and the old one is only archived when the push says so. A record the file leaves out is archived
|
|
61
|
+
* only under --archive, and only when this workspace pulled it: a record the workspace never saw is reported,
|
|
62
|
+
* never touched.
|
|
63
|
+
*/
|
|
64
|
+
function planKind(kind, fileRecords, remoteRecords, baseline, archive) {
|
|
65
|
+
const remoteByKey = new Map(remoteRecords.map((record) => [record.key, record]));
|
|
66
|
+
const actions = [];
|
|
67
|
+
const named = new Set();
|
|
68
|
+
for (const desired of fileRecords) {
|
|
69
|
+
named.add(desired.key);
|
|
70
|
+
const remote = remoteByKey.get(desired.key);
|
|
71
|
+
const recorded = baseline.get(desired.key);
|
|
72
|
+
if (remote === undefined) {
|
|
73
|
+
if (recorded !== undefined) {
|
|
74
|
+
throw pullRequired(kind, desired.key, "This workspace recorded it, but the site no longer has it.");
|
|
75
|
+
}
|
|
76
|
+
actions.push({ type: "create", key: desired.key, desired });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
// A write whose answer was lost leaves the site holding what the file asks for while the baseline is stale
|
|
80
|
+
// (or missing, for a create). Nothing needs writing then, so the site's record is adopted as the baseline
|
|
81
|
+
// rather than sending the author to a pull that would discard the file's other edits.
|
|
82
|
+
if (!remote.archived && sameLabels(kind, desired, remote)) {
|
|
83
|
+
actions.push({ type: "unchanged", key: desired.key, desired, remote, adopt: true });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (recorded === undefined) {
|
|
87
|
+
throw pullRequired(kind, desired.key, "The site already has one with this key that this workspace never pulled.");
|
|
88
|
+
}
|
|
89
|
+
// An archived record whose labels already equal the file's had its update land before a restore whose answer
|
|
90
|
+
// was lost; the restore is all that is left, so the drift from the baseline is this push's own.
|
|
91
|
+
const ownUpdate = remote.archived && sameLabels(kind, desired, remote);
|
|
92
|
+
if (recorded.id !== remote.id || recorded.archived !== remote.archived || (!ownUpdate && !sameLabels(kind, recorded, remote))) {
|
|
93
|
+
throw changedOnTheSite(kind, desired.key);
|
|
94
|
+
}
|
|
95
|
+
const labelsChanged = !sameLabels(kind, desired, remote);
|
|
96
|
+
if (remote.archived) {
|
|
97
|
+
actions.push({ type: "restore", key: desired.key, desired, remote, labelsChanged });
|
|
98
|
+
} else {
|
|
99
|
+
actions.push({ type: labelsChanged ? "update" : "unchanged", key: desired.key, desired, remote });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const unlisted = [];
|
|
104
|
+
const unpulled = [];
|
|
105
|
+
for (const remote of remoteRecords) {
|
|
106
|
+
if (named.has(remote.key) || remote.archived) continue;
|
|
107
|
+
const recorded = baseline.get(remote.key);
|
|
108
|
+
if (recorded === undefined) {
|
|
109
|
+
unpulled.push(remote.key);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!archive) {
|
|
113
|
+
unlisted.push(remote.key);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (recorded.id !== remote.id || recorded.archived !== remote.archived || !sameLabels(kind, recorded, remote)) {
|
|
117
|
+
throw changedOnTheSite(kind, remote.key);
|
|
118
|
+
}
|
|
119
|
+
actions.push({ type: "archive", key: remote.key, remote });
|
|
120
|
+
}
|
|
121
|
+
return { actions, unlisted: unlisted.sort(), unpulled: unpulled.sort() };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `marketing push` — make the site's campaigns and placements match the workspace's files.
|
|
126
|
+
*
|
|
127
|
+
* Every file is validated and every record compared with the site before the first write, so a conflict on the
|
|
128
|
+
* third record refuses the whole push rather than landing two. Archives run first, so a push that swaps a key
|
|
129
|
+
* for a new one stays inside the site's quota. Each write advances the baseline straight away, so a failure
|
|
130
|
+
* partway leaves a workspace the next push can resume from. Creating or restoring a key queues the silent
|
|
131
|
+
* release that starts counting it; this verb waits for nothing.
|
|
132
|
+
*
|
|
133
|
+
* There is no plan/apply step. Like the forms push it models, it reads the site, refuses a stale baseline, and
|
|
134
|
+
* writes; there is no remote state to freeze that the baseline does not already fence.
|
|
135
|
+
*/
|
|
136
|
+
export async function marketingPush(invocation) {
|
|
137
|
+
const session = await openSession(invocation);
|
|
138
|
+
const { client, config, siteId, onProgress } = session;
|
|
139
|
+
warnIfExternalWritesPaused(session, VERB_MARKETING_PUSH);
|
|
140
|
+
const archive = invocation.archive === true;
|
|
141
|
+
|
|
142
|
+
const files = await readWorkspaceMarketing(config.workspaceDir);
|
|
143
|
+
if (files.size === 0) {
|
|
144
|
+
throw new SiteAuthoringError(
|
|
145
|
+
"marketing.file_missing",
|
|
146
|
+
"The workspace has no marketing/campaigns.json or marketing/placements.json. "
|
|
147
|
+
+ `Run '${VERB_MARKETING_PULL}' to write them.`,
|
|
148
|
+
{ field: "marketing" },
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
onProgress(`Validated ${[...files.keys()].map((kind) => MARKETING_KINDS[kind].file).join(" and ")}.`);
|
|
152
|
+
let manifest = await readManifest(config.workspaceDir, siteId);
|
|
153
|
+
|
|
154
|
+
const plans = new Map();
|
|
155
|
+
for (const [kind, records] of files) {
|
|
156
|
+
const listing = await withRefusalGuidance(
|
|
157
|
+
onProgress,
|
|
158
|
+
"marketing push",
|
|
159
|
+
async () => await listMarketingRecords(client, siteId, kind),
|
|
160
|
+
);
|
|
161
|
+
plans.set(kind, { ...planKind(kind, records, listing.records, recordedMarketing(manifest, kind), archive), remote: listing.records });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const results = {};
|
|
165
|
+
let changedCount = 0;
|
|
166
|
+
const completedWrites = [];
|
|
167
|
+
for (const [kind, plan] of plans) {
|
|
168
|
+
const { noun } = MARKETING_KINDS[kind];
|
|
169
|
+
const baseline = recordedMarketing(manifest, kind);
|
|
170
|
+
// What the site holds for each key after the writes, so the file and the baseline are written from it.
|
|
171
|
+
const view = new Map(plan.remote.map((record) => [record.key, record]));
|
|
172
|
+
const applied = { created: [], updated: [], restored: [], archived: [], unchanged: [] };
|
|
173
|
+
|
|
174
|
+
const write = async (action, verb, work) => {
|
|
175
|
+
let label;
|
|
176
|
+
try {
|
|
177
|
+
label = await withRefusalGuidance(onProgress, "marketing push", work);
|
|
178
|
+
} catch (caught) {
|
|
179
|
+
const error = withSiteWording(caught, kind, action.key);
|
|
180
|
+
if (error instanceof SiteAuthoringError && completedWrites.length > 0) {
|
|
181
|
+
throw error.withCompletedWrites(completedWrites);
|
|
182
|
+
}
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
completedWrites.push(`${kind}:${action.key}:${verb}`);
|
|
186
|
+
view.set(action.key, label);
|
|
187
|
+
baseline.set(action.key, baselineEntry(kind, label));
|
|
188
|
+
manifest = withRecordedMarketing(manifest, kind, baseline);
|
|
189
|
+
await writeManifest(config.workspaceDir, manifest);
|
|
190
|
+
onProgress(`${action.key}: ${verb} the ${noun}.`);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
for (const action of plan.actions.filter((candidate) => candidate.adopt && !currentBaseline(baseline.get(candidate.key), candidate.remote, kind))) {
|
|
194
|
+
baseline.set(action.key, baselineEntry(kind, action.remote));
|
|
195
|
+
manifest = withRecordedMarketing(manifest, kind, baseline);
|
|
196
|
+
await writeManifest(config.workspaceDir, manifest);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Archives first, so swapping a key for a new one stays inside the quota; creates last.
|
|
200
|
+
for (const action of plan.actions.filter((candidate) => candidate.type === "archive")) {
|
|
201
|
+
await write(action, "archived", () => setMarketingRecordArchived(client, siteId, kind, action.remote.id, true));
|
|
202
|
+
applied.archived.push(action.key);
|
|
203
|
+
}
|
|
204
|
+
for (const action of plan.actions.filter((candidate) => candidate.type === "update" || candidate.labelsChanged)) {
|
|
205
|
+
await write(action, "updated", () => updateMarketingRecord(client, siteId, kind, action.remote.id, action.desired));
|
|
206
|
+
// A restore that also changes labels is reported as a restore.
|
|
207
|
+
if (action.type === "update") applied.updated.push(action.key);
|
|
208
|
+
}
|
|
209
|
+
for (const action of plan.actions.filter((candidate) => candidate.type === "restore")) {
|
|
210
|
+
await write(action, "restored", () => setMarketingRecordArchived(client, siteId, kind, action.remote.id, false));
|
|
211
|
+
applied.restored.push(action.key);
|
|
212
|
+
}
|
|
213
|
+
for (const action of plan.actions.filter((candidate) => candidate.type === "create")) {
|
|
214
|
+
await write(action, "created", () => createMarketingRecord(client, siteId, kind, randomUUID(), action.desired));
|
|
215
|
+
applied.created.push(action.key);
|
|
216
|
+
}
|
|
217
|
+
applied.unchanged = plan.actions.filter((action) => action.type === "unchanged").map((action) => action.key);
|
|
218
|
+
|
|
219
|
+
// The file is rewritten from what the site now holds, in canonical form, so the workspace and the site
|
|
220
|
+
// agree byte for byte and the read-only state and counted_from are current.
|
|
221
|
+
const kept = plan.actions.filter((action) => action.type !== "archive").map((action) => view.get(action.key));
|
|
222
|
+
await writeWorkspaceJson(config.workspaceDir, MARKETING_KINDS[kind].file, projectMarketingDocument(kind, kept));
|
|
223
|
+
// Every list is bounded so a site with thousands of records still fits the result; `counts` says how many.
|
|
224
|
+
const lists = { ...applied, unlisted: plan.unlisted, unpulled: plan.unpulled };
|
|
225
|
+
const capped = Object.entries(lists).filter(([, keys]) => keys.length > MAXIMUM_REPORTED).map(([name]) => name);
|
|
226
|
+
results[kind] = {
|
|
227
|
+
...Object.fromEntries(Object.entries(lists).map(([name, keys]) => [name, keys.slice(0, MAXIMUM_REPORTED)])),
|
|
228
|
+
counts: Object.fromEntries(Object.entries(lists).map(([name, keys]) => [name, keys.length])),
|
|
229
|
+
...(capped.length > 0 ? { truncated: capped } : {}),
|
|
230
|
+
};
|
|
231
|
+
changedCount += applied.created.length + applied.updated.length + applied.restored.length + applied.archived.length;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const changed = Object.entries(results).flatMap(([kind, value]) =>
|
|
235
|
+
["created", "updated", "restored", "archived"].flatMap((name) => value[name].map((key) => `${kind}:${key}`))
|
|
236
|
+
);
|
|
237
|
+
for (const [kind, value] of Object.entries(results)) {
|
|
238
|
+
if (value.counts.unlisted > 0) {
|
|
239
|
+
onProgress(
|
|
240
|
+
`${MARKETING_KINDS[kind].file}: left ${value.unlisted.slice(0, 20).join(", ")}`
|
|
241
|
+
+ `${value.counts.unlisted > 20 ? ` and ${value.counts.unlisted - 20} more` : ""} on the site because the file `
|
|
242
|
+
+ "omits it. Push with --archive to archive a record the file leaves out.",
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return successResult(VERB_MARKETING_PUSH, siteId, {
|
|
247
|
+
marketing: results,
|
|
248
|
+
written: { items: changed, count: changedCount, ...(changed.length < changedCount ? { itemsTruncated: true } : {}) },
|
|
249
|
+
});
|
|
250
|
+
}
|
|
@@ -14,7 +14,7 @@ import { FOOTER_SETTINGS_FILE } from "../footer-workspace.js";
|
|
|
14
14
|
import { afterSubmitPage, FORM_FILE_EXTENSION, FORMS_DIRECTORY, normalizePagePath as normalizeFormPagePath } from "../forms-contract.js";
|
|
15
15
|
import { normalizeRedirectPath } from "../site-checks.js";
|
|
16
16
|
import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
17
|
-
import { isAuthorableTemplateType, isGeneratedTemplateType } from "../typed-pages.js";
|
|
17
|
+
import { isAuthorableTemplateType, isGeneratedTemplateType, isLegalTemplateType } from "../typed-pages.js";
|
|
18
18
|
import {
|
|
19
19
|
deleteWorkspaceFile,
|
|
20
20
|
displayPagePath,
|
|
@@ -194,10 +194,10 @@ function requireDeletable(manifest, entry, pagePath) {
|
|
|
194
194
|
shown,
|
|
195
195
|
);
|
|
196
196
|
}
|
|
197
|
-
if (!isAuthorableTemplateType(entry.templateType)) {
|
|
197
|
+
if (isLegalTemplateType(entry.templateType) || !isAuthorableTemplateType(entry.templateType)) {
|
|
198
198
|
throw refusal(
|
|
199
199
|
"pages.delete_not_authorable",
|
|
200
|
-
`'${shown}' is a ${entry.templateType ?? "system-managed"} page, which this CLI
|
|
200
|
+
`'${shown}' is a ${entry.templateType ?? "system-managed"} page, which this CLI cannot delete; delete it in the app.`,
|
|
201
201
|
shown,
|
|
202
202
|
);
|
|
203
203
|
}
|
|
@@ -241,6 +241,9 @@ export async function pagesDelete(invocation) {
|
|
|
241
241
|
shown,
|
|
242
242
|
);
|
|
243
243
|
}
|
|
244
|
+
if (live !== undefined && isLegalTemplateType(live.templateType)) {
|
|
245
|
+
throw refusal("pages.delete_not_authorable", `'${shown}' is a legal page; delete it in the app.`, shown);
|
|
246
|
+
}
|
|
244
247
|
const alreadyGone = live === undefined || isGonePage(live);
|
|
245
248
|
if (!alreadyGone) {
|
|
246
249
|
// The fence `pages push` uses, against the revision this workspace last reconciled with.
|