@taprootio/site-authoring 0.24.0 → 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.
@@ -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
+ }
package/src/verbs/pull.js CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  } from "../settings-catalog.js";
37
37
  import { ApiError } from "../transport.js";
38
38
  import { placesFormIds, readFormIndex, withFormKeys } from "../form-references.js";
39
+ import { MARKETING_MANIFEST_KEY } from "../marketing-workspace.js";
39
40
  import {
40
41
  generatedIdentity,
41
42
  legalIdentity,
@@ -1395,10 +1396,13 @@ export async function pull(invocation) {
1395
1396
 
1396
1397
  // `forms pull` owns the forms baseline, and a plain pull does not read forms.
1397
1398
  // Carrying it forward keeps the next `forms push` fenced instead of refusing
1398
- // it as never pulled. An unreadable old manifest has nothing to carry.
1399
- const formsBaseline = (await readManifest(config.workspaceDir, siteId, { required: false }).catch(() =>
1399
+ // it as never pulled. The marketing baseline is carried for the same reason
1400
+ // (TR01301). An unreadable old manifest has nothing to carry.
1401
+ const previousManifest = await readManifest(config.workspaceDir, siteId, { required: false }).catch(() =>
1400
1402
  undefined
1401
- ))?.forms;
1403
+ );
1404
+ const formsBaseline = previousManifest?.forms;
1405
+ const marketingBaseline = previousManifest?.[MARKETING_MANIFEST_KEY];
1402
1406
  const manifest = {
1403
1407
  manifestVersion: MANIFEST_VERSION,
1404
1408
  siteId,
@@ -1420,6 +1424,7 @@ export async function pull(invocation) {
1420
1424
  ),
1421
1425
  ...(presentationRevision === undefined ? {} : { presentation: presentationManifestEntry(presentationRevision) }),
1422
1426
  ...(formsBaseline === undefined ? {} : { forms: formsBaseline }),
1427
+ ...(marketingBaseline === undefined ? {} : { [MARKETING_MANIFEST_KEY]: marketingBaseline }),
1423
1428
  pages: manifestPages,
1424
1429
  ...(retiredGeneratedSources.length === 0 ? {} : { retiredGeneratedSources }),
1425
1430
  };
@@ -12,7 +12,7 @@ import {
12
12
  } from "../constants.js";
13
13
  import { isBehindLatest } from "../cli-release.js";
14
14
  import { credentialApiOrigin, findCredential } from "../credentials.js";
15
- import { SITE_AUTHORING_CAPABILITIES } from "../capabilities.js";
15
+ import { CAPABILITY_CONTENT, SITE_AUTHORING_CAPABILITIES } from "../capabilities.js";
16
16
  import { openAnonymousSession, successResult } from "../session.js";
17
17
  import { environmentNameFor } from "../settings.js";
18
18
  import { describeSurface } from "../surface.js";
@@ -64,6 +64,33 @@ function cliReleaseLine(lastExchange) {
64
64
  : `Latest CLI release ${when}: ${lastExchange.latestCliVersion}. This CLI is ${CLI_VERSION}.`;
65
65
  }
66
66
 
67
+ /**
68
+ * Whether the credential can manage the site's campaigns and placements (TR01301), from what is known locally.
69
+ *
70
+ * The Content capability carries both permissions those verbs need, so the answer is whether the last exchange
71
+ * held it. Like the rest of this verb it is a recording: `granted` is "what that exchange held", and `exchange`
72
+ * says no exchange has happened yet, when the next command that needs it will mint one.
73
+ */
74
+ function marketingAccess(lastExchange, environmentOverride) {
75
+ if (environmentOverride) return { capability: CAPABILITY_CONTENT, access: "unknown" };
76
+ if (lastExchange === undefined) return { capability: CAPABILITY_CONTENT, access: "on_exchange" };
77
+ return {
78
+ capability: CAPABILITY_CONTENT,
79
+ access: lastExchange.capabilities.includes(CAPABILITY_CONTENT) ? "granted" : "not_in_last_exchange",
80
+ };
81
+ }
82
+
83
+ function marketingLine({ capability, access }) {
84
+ const verbs = "'marketing pull', 'marketing push' and 'link build'";
85
+ return access === "granted"
86
+ ? `Campaigns and placements (${verbs}): allowed; the last exchange held ${capability}.`
87
+ : access === "not_in_last_exchange"
88
+ ? `Campaigns and placements (${verbs}): the last exchange did not hold ${capability}, which the next command that needs it requests; a sign-in's ceiling includes it.`
89
+ : access === "on_exchange"
90
+ ? `Campaigns and placements (${verbs}): carried by ${capability}, which the next command that needs it requests.`
91
+ : `Campaigns and placements (${verbs}): needs ${capability}; ${PUBLISH_KEY_ENVIRONMENT_VARIABLE} supplies the credential, so this command cannot tell.`;
92
+ }
93
+
67
94
  /**
68
95
  * `taproot-site whoami` — which Taproot, which account, which site, until when.
69
96
  *
@@ -155,6 +182,9 @@ export async function whoami(invocation) {
155
182
  onProgress(cliReleaseLine(lastExchange));
156
183
  }
157
184
 
185
+ const marketing = marketingAccess(lastExchange, environmentOverride);
186
+ onProgress(marketingLine(marketing));
187
+
158
188
  return successResult(VERB_WHOAMI, config?.siteId, {
159
189
  apiOrigin,
160
190
  // Named as well as spelled out, so "am I on local right now" is answerable
@@ -176,6 +206,7 @@ export async function whoami(invocation) {
176
206
  // Named for what each is. `capabilities` alone would conflate the ceiling
177
207
  // with the grant, which are different numbers and differently trustworthy.
178
208
  capabilityCeiling: SITE_AUTHORING_CAPABILITIES,
209
+ marketing,
179
210
  ...(environmentOverride || lastExchange === undefined ? {} : {
180
211
  lastExchange: {
181
212
  siteId: lastExchange.siteId,