@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.
@@ -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 '${CLI_BINARY_NAME} ${VERB_LOGIN}' again.`,
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 '${CLI_BINARY_NAME} ${VERB_LOGIN}' again.`,
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 '${CLI_BINARY_NAME} ${VERB_LOGIN}' to start a new one.`,
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
- + `'${CLI_BINARY_NAME} ${VERB_LOGIN}' again.`,
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
- + `'${CLI_BINARY_NAME} ${VERB_LOGIN}' again.`,
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 '${CLI_BINARY_NAME} ${VERB_LOGIN}' again.`,
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 '${CLI_BINARY_NAME} ${VERB_LOGIN}' again.`,
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.`);
@@ -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 does not author; delete it in the app.`,
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.