@taprootio/site-authoring 0.24.0 → 0.27.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,195 @@
1
+ import { listAuthorableSites, withRefusalGuidance } from "../api.js";
2
+ import { CLI_BINARY_NAME, VERB_LINK_BUILD, VERB_LOGIN } from "../constants.js";
3
+ import { sanitizeDiagnostic, SiteAuthoringError } from "../errors.js";
4
+ import { listMarketingRecords } from "../marketing-api.js";
5
+ import {
6
+ isMarketingKey,
7
+ LINK_SOURCES,
8
+ MARKETING_KINDS,
9
+ MARKETING_STATES,
10
+ MARKETING_STATE_ARCHIVED,
11
+ MARKETING_KEY_RULE,
12
+ notLiveNote,
13
+ } from "../marketing-contract.js";
14
+ import { readWorkspaceMarketing, recordedMarketing } from "../marketing-workspace.js";
15
+ import {
16
+ openAccountSession,
17
+ openAnonymousSession,
18
+ openSession,
19
+ requireRecordedSurface,
20
+ successResult,
21
+ } from "../session.js";
22
+ import { readManifest } from "../workspace.js";
23
+ import { ENGAGEMENT_HOST_PATTERN, normalizeSitePath, trackedAddress } from "../utm-link.js";
24
+
25
+ function invalidOption(code, message, field) {
26
+ return new SiteAuthoringError(code, message, { field, exitCode: 2 });
27
+ }
28
+
29
+ /** The site's public host: the one given, or the signed-in account's record of the configured site. */
30
+ async function resolveHost(invocation, config) {
31
+ if (invocation.linkDomain !== undefined) {
32
+ const host = invocation.linkDomain.trim().toLowerCase();
33
+ if (!ENGAGEMENT_HOST_PATTERN.test(host)) {
34
+ throw invalidOption("link.domain_invalid", "--domain is the site's public host name, such as example.com, without a scheme or path.", "domain");
35
+ }
36
+ return host;
37
+ }
38
+ let sites;
39
+ try {
40
+ const account = await openAccountSession(invocation);
41
+ sites = await withRefusalGuidance(account.onProgress, "site list", async () => await listAuthorableSites(account.client));
42
+ } catch (error) {
43
+ if (error instanceof SiteAuthoringError && error.code === "auth.key_missing") {
44
+ throw new SiteAuthoringError(
45
+ "link.domain_unknown",
46
+ `A link needs the site's public host, which comes from the signed-in site. Run '${CLI_BINARY_NAME} ${VERB_LOGIN}', `
47
+ + "or pass --domain <host>: a site credential cannot read hosting configuration.",
48
+ { field: "domain" },
49
+ );
50
+ }
51
+ throw error;
52
+ }
53
+ const host = sites.find((site) => site.siteId === config.siteId)?.primaryDomain ?? "";
54
+ if (host === "") {
55
+ throw new SiteAuthoringError(
56
+ "link.no_public_address",
57
+ "This site does not have a public address yet, so there is nothing to build a link to. "
58
+ + "Set it in Settings, Licence & domain, or pass --domain <host>.",
59
+ { field: "domain" },
60
+ );
61
+ }
62
+ return host.toLowerCase();
63
+ }
64
+
65
+ /**
66
+ * The campaign or placement a link names. The state comes from the baseline the last pull or push recorded in the
67
+ * manifest when the workspace holds this kind's file and a known, unarchived state was recorded for the key; in every
68
+ * other case the live site is asked. (A file's own `state` is only what the author left there, so it is never used.)
69
+ * A key the site does not hold is not counted, so it is refused rather than printed; an archived key is no longer
70
+ * admitted, so it is refused the same way.
71
+ */
72
+ async function findRecord(kind, key, files, invocation, config) {
73
+ const { noun, file } = MARKETING_KINDS[kind];
74
+ const fromFile = files.get(kind);
75
+ if (fromFile !== undefined) {
76
+ const record = fromFile.find((candidate) => candidate.key === key);
77
+ if (record === undefined) {
78
+ throw new SiteAuthoringError(
79
+ `link.${noun}_unknown`,
80
+ `${file} has no ${noun} '${key}'. Run 'marketing pull' if it was made since, or add it to the file and `
81
+ + "run 'marketing push': a key the site does not hold is not counted.",
82
+ { field: noun },
83
+ );
84
+ }
85
+ // The state a pull or push recorded for this key is the one to report. A key with no baseline entry (made or
86
+ // restored in the app and then added to the file), an archived entry, or an entry with no known state is not
87
+ // known to be live, so the site is asked, and it refuses a key it does not hold.
88
+ const manifest = await readManifest(config.workspaceDir, config.siteId, { required: false });
89
+ const recorded = manifest === undefined ? undefined : recordedMarketing(manifest, kind).get(key);
90
+ if (
91
+ recorded !== undefined && !recorded.archived && MARKETING_STATES.includes(recorded.state)
92
+ && recorded.state !== MARKETING_STATE_ARCHIVED
93
+ ) {
94
+ return { name: record.name, state: recorded.state, fromFile: file };
95
+ }
96
+ }
97
+ const session = await openSession(invocation);
98
+ const listing = await withRefusalGuidance(
99
+ session.onProgress,
100
+ "link build",
101
+ async () => await listMarketingRecords(session.client, session.siteId, kind),
102
+ );
103
+ const record = listing.records.find((candidate) => candidate.key === key);
104
+ if (record === undefined || record.state === MARKETING_STATE_ARCHIVED) {
105
+ throw new SiteAuthoringError(
106
+ `link.${noun}_unknown`,
107
+ `The site has no ${record === undefined ? "" : "unarchived "}${noun} '${key}'. A key the site does not hold is `
108
+ + "not counted. Create it with 'marketing push' (or restore it by naming it in the file).",
109
+ { field: noun },
110
+ );
111
+ }
112
+ return { name: record.name, state: record.state };
113
+ }
114
+
115
+ /**
116
+ * `link build` — print one tracked address and save nothing.
117
+ *
118
+ * It runs the Marketing link builder's own rules (compiled into `utm-link.js`) and its source vocabulary, so an
119
+ * address built here counts exactly as one built in the app. A campaign or placement that is not live yet is
120
+ * allowed, with the builder's note that page views count under the source only until it is.
121
+ */
122
+ export async function linkBuild(invocation) {
123
+ const { linkPath, linkSource, linkCampaign, linkPlacement } = invocation;
124
+ if (linkPath === undefined) {
125
+ throw invalidOption("link.path_missing", "link build needs --path, the page on this site; use / for the home page.", "path");
126
+ }
127
+ if (linkSource === undefined) {
128
+ throw invalidOption("link.source_missing", `link build needs --source, one of: ${LINK_SOURCES.map((source) => source.key).join(", ")}.`, "source");
129
+ }
130
+ const source = LINK_SOURCES.find((candidate) => candidate.key === linkSource);
131
+ if (source === undefined) {
132
+ throw new SiteAuthoringError(
133
+ "link.source_unknown",
134
+ linkSource === "reader"
135
+ ? "The reader source is set by a published page's own share action and cannot be built here."
136
+ : `'${linkSource}' is not a source. Use one of: ${LINK_SOURCES.map((candidate) => candidate.key).join(", ")}.`,
137
+ { field: "source", exitCode: 2, alternatives: LINK_SOURCES.map((candidate) => candidate.key) },
138
+ );
139
+ }
140
+ for (const [field, value] of [["campaign", linkCampaign], ["placement", linkPlacement]]) {
141
+ if (value !== undefined && !isMarketingKey(value)) {
142
+ throw invalidOption("link.key_invalid", `--${field} is a key of ${MARKETING_KEY_RULE}.`, field);
143
+ }
144
+ }
145
+
146
+ const { config, onProgress } = await openAnonymousSession({ ...invocation, client: null });
147
+ if (config?.siteId === undefined) {
148
+ throw new SiteAuthoringError(
149
+ "config.site_missing",
150
+ `No site is selected. Run '${CLI_BINARY_NAME} use <site>' to choose one, or set siteId in taproot-site.json.`,
151
+ );
152
+ }
153
+ // Offline, like every other verb: a site that cannot take it is told so before any request.
154
+ requireRecordedSurface(invocation, config);
155
+ const host = await resolveHost(invocation, config);
156
+ const normalized = normalizeSitePath(linkPath, host);
157
+ if (!normalized.ok) throw invalidOption("link.path_invalid", normalized.error, "path");
158
+
159
+ // Only the files of the kinds named are read, so a half-edited file never stops a link that does not use it.
160
+ const files = await readWorkspaceMarketing(
161
+ config.workspaceDir,
162
+ [["campaigns", linkCampaign], ["placements", linkPlacement]].filter(([, key]) => key !== undefined).map(([kind]) => kind),
163
+ );
164
+ const notes = [];
165
+ const named = {};
166
+ for (const [kind, key] of [["campaigns", linkCampaign], ["placements", linkPlacement]]) {
167
+ if (key === undefined) continue;
168
+ const record = await findRecord(kind, key, files, invocation, config);
169
+ named[kind] = key;
170
+ const note = notLiveNote(sanitizeDiagnostic(record.name, key), record.state);
171
+ // A file's state is the one written at the last pull or push, so say so rather than present it as current.
172
+ if (note !== "") notes.push(record.fromFile === undefined ? note : `${note} (As of the last marketing pull or push; ${record.fromFile}.)`);
173
+ }
174
+
175
+ const address = trackedAddress(host, normalized.path, {
176
+ source: source.key,
177
+ medium: source.medium,
178
+ campaign: named.campaigns,
179
+ placement: named.placements,
180
+ });
181
+ onProgress(address);
182
+ for (const note of notes) onProgress(note);
183
+ return successResult(VERB_LINK_BUILD, config.siteId, {
184
+ link: {
185
+ address,
186
+ host,
187
+ path: normalized.path,
188
+ source: source.key,
189
+ medium: source.medium,
190
+ ...(named.campaigns === undefined ? {} : { campaign: named.campaigns }),
191
+ ...(named.placements === undefined ? {} : { placement: named.placements }),
192
+ },
193
+ ...(notes.length === 0 ? {} : { notes }),
194
+ });
195
+ }
@@ -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
+ }
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,