hubspot-cms-sync 0.1.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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -0
  3. package/bin/hubspot-cms-sync.mjs +115 -0
  4. package/docs/CONFIGURATION.md +83 -0
  5. package/docs/GITHUB_ACTIONS.md +70 -0
  6. package/docs/MIGRATION_PLAN.md +361 -0
  7. package/docs/PLAN_REVIEW.md +42 -0
  8. package/docs/SKILL_DISTRIBUTION.md +79 -0
  9. package/examples/github-actions/ci.yml +56 -0
  10. package/examples/github-actions/preview.yml +71 -0
  11. package/examples/github-actions/publish.yml +82 -0
  12. package/examples/hubspot-cms-sync.config.mjs +45 -0
  13. package/examples/site.manifest.json +19 -0
  14. package/package.json +41 -0
  15. package/skill/SKILL.md +54 -0
  16. package/skill/references/commands.md +54 -0
  17. package/skill/references/config.md +25 -0
  18. package/skill/references/failures.md +58 -0
  19. package/skill/references/github-actions.md +56 -0
  20. package/skill/references/screenshots-and-fidelity.md +33 -0
  21. package/src/adapters/assets.mjs +576 -0
  22. package/src/adapters/blog.mjs +921 -0
  23. package/src/adapters/content.mjs +213 -0
  24. package/src/adapters/forms.mjs +569 -0
  25. package/src/adapters/pages.mjs +463 -0
  26. package/src/adapters/theme.mjs +503 -0
  27. package/src/config.mjs +113 -0
  28. package/src/corpus-scan.mjs +248 -0
  29. package/src/cta-inventory.mjs +352 -0
  30. package/src/index.mjs +3 -0
  31. package/src/lib/canonical.mjs +234 -0
  32. package/src/lib/hub.mjs +197 -0
  33. package/src/lib/orchestrate.mjs +141 -0
  34. package/src/lib/refs.mjs +398 -0
  35. package/src/lib/sync-state.mjs +86 -0
  36. package/src/manifest.mjs +353 -0
  37. package/src/preflight.mjs +385 -0
  38. package/src/pull.mjs +99 -0
  39. package/src/push.mjs +354 -0
  40. package/src/republish.mjs +102 -0
@@ -0,0 +1,569 @@
1
+ // sync/adapters/forms.mjs — HubSpot forms + their custom contact properties.
2
+ //
3
+ // Refactor of sync/forms-sync.mjs into the bidirectional-sync adapter interface,
4
+ // ADDING a pull direction so production form definitions become canonical,
5
+ // name-keyed, account-agnostic files.
6
+ //
7
+ // WHAT THIS ADAPTER OWNS (codex review: forms are render-affecting refs that
8
+ // pages/widgets/theme reference by form GUID — finding #2, #12):
9
+ // - HubSpot marketing forms (v2 list / create / update).
10
+ // - The custom contact PROPERTIES those forms collect (crm v3 properties),
11
+ // synced write-scope-only (create-or-patch, converge on 409) exactly as the
12
+ // legacy forms-sync.mjs did.
13
+ //
14
+ // CANONICAL STORE (committed to git, per-account-id-free):
15
+ // content/forms/<form-key>.json one file per form, keyed by a stable logical
16
+ // key derived from the form NAME. Holds
17
+ // { key, name, fields[] } — NO guid, NO portal id.
18
+ // content/forms/properties.json the custom contact properties (name/label/
19
+ // fieldType), sorted by name.
20
+ //
21
+ // REGISTRY (gitignored, per-account, refs.mjs Registry):
22
+ // On PULL we register registry.forms[<key>] = <guid> for the SOURCE account so a
23
+ // same-account pull->push round-trip resolves form refs back to identical
24
+ // GUIDs.
25
+ // On PUSH we POPULATE registry.forms[<key>] = <target guid> after upsert so the
26
+ // theme/pages/widgets/blog adapters (which CONSUME @form:<key> tokens via
27
+ // refs.resolve) can resolve form references to THIS account's GUIDs.
28
+ // We also surface any CTA guids the forms expose into registry.ctas as
29
+ // they become available (none are emitted by the v2 forms API today, but
30
+ // the hook is here so the dependency contract is explicit).
31
+ //
32
+ // IDENTITY: form NAME (human, account-portable) <-> logical key (slug of the name).
33
+ // The legacy forms-sync.mjs shipped a fixed key->name table (contact/demo/...);
34
+ // we preserve those keys for the known site forms and fall back to a deterministic
35
+ // slug for any other form found on pull, so an arbitrary account still round-trips.
36
+ //
37
+ // PRODUCTION (portal 529456) IS READ-ONLY. This adapter never hardcodes a portal;
38
+ // the orchestrator passes `acct`. push() writes only to whatever `acct` it is given.
39
+
40
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync } from 'node:fs';
41
+ import { join } from 'node:path';
42
+
43
+ import { hub as defaultHub } from '../lib/hub.mjs';
44
+ import { stableStringify } from '../lib/canonical.mjs';
45
+
46
+ export const name = 'forms';
47
+
48
+ // Forms have no cross-adapter dependencies at PUSH time: properties and forms are
49
+ // self-contained, and forms POPULATE the registry that OTHER adapters depend on.
50
+ export const dependsOn = [];
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Desired-state seed (carried over from sync/forms-sync.mjs). This is the known
54
+ // set of site forms + the custom contact properties they collect. It supplies:
55
+ // - stable logical keys for the canonical filenames (key <-> name), and
56
+ // - the fields a FRESH account should get when no canonical files exist yet.
57
+ // Once pulled, the canonical files on disk are the source of truth and override
58
+ // these defaults.
59
+ // ---------------------------------------------------------------------------
60
+
61
+ const PROPS = [
62
+ ['topic', 'Inquiry topic', 'text'],
63
+ ['platform', 'Marketing platform', 'text'],
64
+ ['hubspot_hub_size', 'Marketing contacts (range)', 'text'],
65
+ ['app_install_goal', 'App install goal', 'textarea'],
66
+ ['challenge', 'Biggest email challenge', 'textarea'],
67
+ ['agency', 'Agency name', 'text'],
68
+ ['agency_website', 'Agency website', 'text'],
69
+ ['role', 'Role', 'text'],
70
+ ['clients', 'Number of clients', 'text'],
71
+ ['notes', 'Notes', 'textarea'],
72
+ ];
73
+
74
+ const F = (n, label, required = false, fieldType = 'text') => ({ name: n, label, fieldType, required });
75
+ const E = F('email', 'Work email', true, 'text');
76
+
77
+ // key -> { name, fields }. The key is the logical token suffix (@form:<key>).
78
+ const SEED_FORMS = {
79
+ contact: {
80
+ name: 'Website: Contact (general)',
81
+ fields: [F('firstname', 'First name'), F('lastname', 'Last name'), E, F('company', 'Company'), F('topic', 'What’s this about?'), F('message', 'Message', false, 'textarea')],
82
+ },
83
+ demo: {
84
+ name: 'Website: Demo / Trial request',
85
+ fields: [F('firstname', 'First name'), F('lastname', 'Last name'), E, F('company', 'Company'), F('jobtitle', 'Job title'), F('platform', 'Marketing platform'), F('hubspot_hub_size', 'Marketing contacts'), F('challenge', 'Biggest email challenge', false, 'textarea')],
86
+ },
87
+ install: {
88
+ name: 'Website: App install lead',
89
+ fields: [F('firstname', 'First name'), F('lastname', 'Last name'), E, F('company', 'Company'), F('jobtitle', 'Job title'), F('hubspot_hub_size', 'Marketing contacts'), F('app_install_goal', 'Goal', false, 'textarea')],
90
+ },
91
+ partner: {
92
+ name: 'Website: Agency Partner Program',
93
+ fields: [F('firstname', 'First name'), F('lastname', 'Last name'), E, F('agency', 'Agency'), F('agency_website', 'Website'), F('role', 'Role'), F('platform', 'Platform'), F('clients', 'Clients'), F('notes', 'Notes', false, 'textarea')],
94
+ },
95
+ legal: {
96
+ name: 'Website: Legal/Security updates opt-in',
97
+ fields: [E],
98
+ },
99
+ };
100
+
101
+ // name -> key, derived from the seed so pull re-uses the friendly keys for the
102
+ // known site forms.
103
+ const NAME_TO_SEED_KEY = new Map(Object.entries(SEED_FORMS).map(([k, v]) => [v.name, k]));
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Pure helpers (unit-tested): name<->key, field canonicalization, registry pop.
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Stable, account-agnostic logical key for a form NAME. Known site forms keep
111
+ * their friendly seed key; anything else is slugified deterministically.
112
+ * @param {string} formName
113
+ * @returns {string}
114
+ */
115
+ export function formKeyForName(formName) {
116
+ const known = NAME_TO_SEED_KEY.get(formName);
117
+ if (known) return known;
118
+ const slug = String(formName || '')
119
+ .toLowerCase()
120
+ .replace(/[^a-z0-9]+/g, '-')
121
+ .replace(/^-+|-+$/g, '');
122
+ return slug || 'form';
123
+ }
124
+
125
+ /** Flatten a v2 form's formFieldGroups into a flat fields[]. */
126
+ function flattenFormFields(form) {
127
+ return (form.formFieldGroups || []).flatMap((g) => g.fields || []);
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Behavior-loss detection (codex #8): the canonical projection keeps ONLY
132
+ // name/label/fieldType/required. A real prod form can carry far more config
133
+ // that materially changes how the form behaves and what it collects:
134
+ //
135
+ // FIELD-LEVEL (per field, dropped by canonicalForm):
136
+ // options / selectedOptions - dropdown/radio/checkbox CHOICES (no choices
137
+ // => the field collects nothing meaningful)
138
+ // validation - regex / min / max / blockedEmailDomains
139
+ // dependentFieldFilters - progressive / conditional show-hide logic
140
+ // enabled (===false) - a disabled field still POSTed by the API
141
+ // defaultValue / defaultValues, placeholder, description, hidden,
142
+ // useCountryCodeSelect, isSmartField, ...
143
+ //
144
+ // FORM-LEVEL (dropped entirely — only name+fields survive):
145
+ // legalConsentOptions / metaData[].name==='legalConsentOptions'
146
+ // - GDPR / explicit-consent text + checkboxes
147
+ // redirect / configuration.redirectUrl / inlineMessage
148
+ // - what happens AFTER submit (thank-you page vs
149
+ // inline message) — a behavior change, not cosmetic
150
+ // submitText / displayOptions, cssClass, notifyRecipients, followUpId, ...
151
+ //
152
+ // We do NOT try to round-trip all of this (v2 shape drift + server defaults are
153
+ // risky — design §6.3). Instead we FAIL LOUD: capture which keys were present
154
+ // but NOT modeled, attach them to the canonical file under `unsupported`, and
155
+ // emit a per-form note so the partial data is never silently presented as the
156
+ // whole truth.
157
+ // ---------------------------------------------------------------------------
158
+
159
+ // Per-field keys the canonical projection KEEPS (everything else, when present
160
+ // and non-trivial, is behavior we are NOT round-tripping).
161
+ const KEPT_FIELD_KEYS = new Set(['name', 'label', 'fieldType', 'required']);
162
+
163
+ // Field keys that, when present, ALWAYS mean lost behavior (even truthy-empty
164
+ // is meaningful: an empty options[] on a select still differs from a text field).
165
+ const BEHAVIOR_FIELD_KEYS = [
166
+ 'options',
167
+ 'selectedOptions',
168
+ 'validation',
169
+ 'dependentFieldFilters',
170
+ 'defaultValue',
171
+ 'defaultValues',
172
+ 'placeholder',
173
+ 'description',
174
+ 'enabled',
175
+ 'hidden',
176
+ 'isSmartField',
177
+ 'useCountryCodeSelect',
178
+ ];
179
+
180
+ // Form-level keys that carry submit/consent/redirect behavior.
181
+ const BEHAVIOR_FORM_KEYS = [
182
+ 'legalConsentOptions',
183
+ 'redirect',
184
+ 'inlineMessage',
185
+ 'submitText',
186
+ 'configuration',
187
+ 'displayOptions',
188
+ 'cssClass',
189
+ 'notifyRecipients',
190
+ 'followUpId',
191
+ 'metaData',
192
+ ];
193
+
194
+ const isMeaningful = (v) => {
195
+ if (v == null) return false;
196
+ if (Array.isArray(v)) return v.length > 0;
197
+ if (typeof v === 'object') return Object.keys(v).length > 0;
198
+ if (typeof v === 'string') return v.trim() !== '';
199
+ if (typeof v === 'boolean') return v === true || v === false; // presence itself is signal
200
+ return true;
201
+ };
202
+
203
+ /**
204
+ * Inspect a raw v2 form and return the behavior the canonical projection does
205
+ * NOT round-trip, as a stable, serializable object. Empty => fully captured.
206
+ * @param {object} rawForm
207
+ * @returns {{ formLevel: Record<string,unknown>, fields: Record<string,object> }|null}
208
+ */
209
+ export function detectUnsupported(rawForm) {
210
+ const formLevel = {};
211
+ for (const k of BEHAVIOR_FORM_KEYS) {
212
+ // `enabled` on a field is meaningful only when false; on the form, redirect
213
+ // etc. are meaningful when present at all.
214
+ if (isMeaningful(rawForm?.[k])) formLevel[k] = rawForm[k];
215
+ }
216
+ // The form may also carry top-level keys we don't model at all beyond the
217
+ // known volatile ones — but we only LOUDLY flag the behavior-bearing set
218
+ // above to avoid noise from per-account/volatile metadata.
219
+
220
+ const fields = {};
221
+ for (const f of flattenFormFields(rawForm)) {
222
+ const lost = {};
223
+ for (const k of BEHAVIOR_FIELD_KEYS) {
224
+ if (!(k in (f || {}))) continue;
225
+ // `enabled:true` and `hidden:false` are the harmless defaults; only flag
226
+ // the non-default direction.
227
+ if (k === 'enabled' && f[k] !== false) continue;
228
+ if (k === 'hidden' && f[k] !== true) continue;
229
+ if (k === 'isSmartField' && f[k] !== true) continue;
230
+ if (k === 'useCountryCodeSelect' && f[k] !== true) continue;
231
+ if (isMeaningful(f[k])) lost[k] = f[k];
232
+ }
233
+ // Any OTHER unknown field key (not kept, not a known volatile) is also a
234
+ // signal we're dropping something — capture its presence by key.
235
+ for (const k of Object.keys(f || {})) {
236
+ if (KEPT_FIELD_KEYS.has(k)) continue;
237
+ if (BEHAVIOR_FIELD_KEYS.includes(k)) continue;
238
+ if (k === 'fieldType') continue;
239
+ // record only that the key existed; value may be volatile/account-specific
240
+ if (isMeaningful(f[k]) && !(k in lost)) lost[`_${k}`] = true;
241
+ }
242
+ if (Object.keys(lost).length > 0) fields[f.name || '(unnamed)'] = lost;
243
+ }
244
+
245
+ if (Object.keys(formLevel).length === 0 && Object.keys(fields).length === 0) {
246
+ return null;
247
+ }
248
+ return { formLevel, fields };
249
+ }
250
+
251
+ /** One-line human summary of what detectUnsupported found, for the loud note. */
252
+ export function summarizeUnsupported(unsupported) {
253
+ if (!unsupported) return '';
254
+ const parts = [];
255
+ const fl = Object.keys(unsupported.formLevel || {});
256
+ if (fl.length) parts.push(`form-level [${fl.sort().join(', ')}]`);
257
+ const fieldNames = Object.keys(unsupported.fields || {});
258
+ if (fieldNames.length) {
259
+ const detail = fieldNames
260
+ .sort()
261
+ .map((n) => `${n}:{${Object.keys(unsupported.fields[n]).sort().join(',')}}`)
262
+ .join(' ');
263
+ parts.push(`field-level ${detail}`);
264
+ }
265
+ return parts.join('; ');
266
+ }
267
+
268
+ /**
269
+ * Project a raw v2 form into its canonical, account-agnostic definition.
270
+ * Strips guid, portalId, and every per-account/volatile field — keeps only the
271
+ * portable contract: logical key, display name, and the field shape that drives
272
+ * both validation and the styled UX (name/label/fieldType/required).
273
+ *
274
+ * Behavior the projection does NOT model (options/consent/validation/redirect/
275
+ * conditional logic) is detected and attached under `unsupported` so the file
276
+ * is HONEST about being partial — it is never silently presented as the whole
277
+ * form. `unsupported` is omitted entirely when the form is fully captured.
278
+ * @param {object} rawForm a v2 form object (with guid, portalId, formFieldGroups)
279
+ * @returns {{ key: string, name: string, fields: Array, unsupported?: object }}
280
+ */
281
+ export function canonicalForm(rawForm) {
282
+ const fields = flattenFormFields(rawForm).map((f) => ({
283
+ name: f.name,
284
+ label: f.label ?? '',
285
+ fieldType: f.fieldType ?? 'text',
286
+ required: !!f.required,
287
+ }));
288
+ const out = { key: formKeyForName(rawForm.name), name: rawForm.name, fields };
289
+ const unsupported = detectUnsupported(rawForm);
290
+ if (unsupported) out.unsupported = unsupported;
291
+ return out;
292
+ }
293
+
294
+ /** Project raw crm v3 contact properties down to the portable definition. */
295
+ export function canonicalProperties(rawProps) {
296
+ return (rawProps || [])
297
+ .map((p) => ({ name: p.name, label: p.label ?? '', fieldType: p.fieldType ?? 'text' }))
298
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
299
+ }
300
+
301
+ /**
302
+ * POPULATE the per-account registry from upserted (or pulled) forms so other
303
+ * adapters can resolve @form:<key> tokens. Records registry.forms[key] = guid for
304
+ * every form that has a guid, and (when present) any CTA guids the form exposes
305
+ * into registry.ctas. Mutates and returns the registry.
306
+ * @param {object} registry refs.mjs Registry for the current account
307
+ * @param {Array<{key:string, guid?:string, ctas?:Record<string,string>}>} forms
308
+ * @returns {object} the same registry
309
+ */
310
+ export function populateRegistry(registry, forms) {
311
+ for (const f of forms || []) {
312
+ if (f.guid) {
313
+ registry.forms[f.key] = String(f.guid);
314
+ delete registry.__rev_forms; // invalidate refs.mjs memoized reverse index
315
+ }
316
+ if (f.ctas && typeof f.ctas === 'object') {
317
+ for (const [ctaKey, guid] of Object.entries(f.ctas)) {
318
+ if (guid) {
319
+ registry.ctas[ctaKey] = String(guid);
320
+ delete registry.__rev_ctas;
321
+ }
322
+ }
323
+ }
324
+ }
325
+ return registry;
326
+ }
327
+
328
+ // Stable signature for "did the fields drift?" — name + required, in order.
329
+ const sigFields = (fields) => JSON.stringify((fields || []).map((f) => [f.name, !!f.required]));
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // Disk layer for the canonical content/forms tree.
333
+ // ---------------------------------------------------------------------------
334
+
335
+ function formsDir(contentDir) {
336
+ return join(contentDir, 'forms');
337
+ }
338
+
339
+ /** Read every content/forms/<key>.json (excluding properties.json) -> [{key,name,fields}]. */
340
+ export function readCanonicalForms(contentDir) {
341
+ const dir = formsDir(contentDir);
342
+ if (!existsSync(dir)) return [];
343
+ const out = [];
344
+ for (const file of readdirSync(dir)) {
345
+ if (!file.endsWith('.json')) continue;
346
+ if (file === 'properties.json' || file === 'guids.json') continue;
347
+ const def = JSON.parse(readFileSync(join(dir, file), 'utf8'));
348
+ out.push({ key: def.key, name: def.name, fields: def.fields || [] });
349
+ }
350
+ return out;
351
+ }
352
+
353
+ function readCanonicalProperties(contentDir) {
354
+ const file = join(formsDir(contentDir), 'properties.json');
355
+ if (!existsSync(file)) return null;
356
+ return JSON.parse(readFileSync(file, 'utf8'));
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // pull(acct, { contentDir, registry }) -> { pulled, notes }
361
+ // GET v2 forms + GET crm v3 contact properties -> canonical files.
362
+ // Register registry.forms[key] = guid for the SOURCE account (round-trip).
363
+ // ---------------------------------------------------------------------------
364
+ export async function pull(acct, ctx) {
365
+ const { contentDir, registry } = ctx;
366
+ const hub = ctx.hub || defaultHub;
367
+ const notes = [];
368
+
369
+ // --- forms (v2 list) ---
370
+ const res = await hub(acct, 'GET', '/forms/v2/forms?limit=300');
371
+ if (!res.ok) {
372
+ throw new Error(`forms pull: GET /forms/v2/forms -> ${res.status}: ${res.json?.message || ''}`);
373
+ }
374
+ const rawForms = Array.isArray(res.json) ? res.json : res.json.objects || [];
375
+
376
+ const dir = formsDir(contentDir);
377
+ mkdirSync(dir, { recursive: true });
378
+
379
+ let pulled = 0;
380
+ // Guard against the key-collision DATA-LOSS hole: two forms whose names slugify
381
+ // to the same key would silently overwrite each other on disk AND in
382
+ // registry.forms[key]. Disambiguate the second+ with a numeric suffix so NO form
383
+ // is ever lost, and emit a loud note so the collision is visible.
384
+ const usedKeys = new Set();
385
+ for (const raw of rawForms) {
386
+ const canon = canonicalForm(raw);
387
+ if (usedKeys.has(canon.key)) {
388
+ let n = 2;
389
+ while (usedKeys.has(`${canon.key}-${n}`)) n += 1;
390
+ const disambiguated = `${canon.key}-${n}`;
391
+ notes.push(
392
+ `⚠ form name collision: "${raw.name}" slug clashes with an earlier form — stored as "${disambiguated}" (rename one to disambiguate)`,
393
+ );
394
+ canon.key = disambiguated;
395
+ }
396
+ usedKeys.add(canon.key);
397
+ writeFileSync(join(dir, `${canon.key}.json`), stableStringify(canon));
398
+ // FAIL LOUD on behavior loss (codex #8): if the raw prod form carried
399
+ // options/consent/validation/redirect/conditional logic that the canonical
400
+ // projection does NOT round-trip, say so explicitly — naming the form and
401
+ // exactly which config is captured-but-not-round-tripped — so this partial
402
+ // file is never silently mistaken for the live truth. The dropped config is
403
+ // also preserved verbatim under `unsupported` in the file.
404
+ if (canon.unsupported) {
405
+ notes.push(
406
+ `⚠ form "${raw.name}" (${canon.key}) carries config NOT round-tripped: ` +
407
+ `${summarizeUnsupported(canon.unsupported)} — captured under .unsupported (read-only); ` +
408
+ 'push does NOT recreate it. The canonical file is a PARTIAL view of the live form.',
409
+ );
410
+ }
411
+ // Register the SOURCE account's guid under the same logical key so a
412
+ // same-account pull->push converges (the guid never enters the canonical file).
413
+ if (raw.guid) {
414
+ registry.forms[canon.key] = String(raw.guid);
415
+ delete registry.__rev_forms;
416
+ }
417
+ pulled += 1;
418
+ }
419
+ notes.push(`pulled ${pulled} form(s)`);
420
+
421
+ // --- custom contact properties (crm v3) ---
422
+ const propsRes = await hub(acct, 'GET', '/crm/v3/properties/contacts?archived=false');
423
+ if (propsRes.ok) {
424
+ const canonProps = canonicalProperties(propsRes.json.results || []);
425
+ // Keep only the custom properties this site manages (others are HubSpot
426
+ // defaults / unrelated; committing all of them would be noise).
427
+ const managed = new Set(PROPS.map(([n]) => n));
428
+ const filtered = canonProps.filter((p) => managed.has(p.name));
429
+ writeFileSync(join(dir, 'properties.json'), stableStringify(filtered));
430
+ notes.push(`pulled ${filtered.length} managed propert(ies)`);
431
+ } else {
432
+ // No property-read scope: seed the canonical file from the desired state so a
433
+ // subsequent push still has a source of truth.
434
+ //
435
+ // FAIL LOUD (codex #8): this seeded file is the repo's DESIRED state, NOT a
436
+ // read of what the live account actually has. We could not read prod's real
437
+ // property config (labels/types/options may differ, and properties we don't
438
+ // manage are invisible here), so we must warn that properties.json is NOT
439
+ // verified against the live truth — committing it as canonical risks
440
+ // overwriting real prod property config on a later push.
441
+ const seeded = canonicalProperties(PROPS.map(([n, label, fieldType]) => ({ name: n, label, fieldType })));
442
+ writeFileSync(join(dir, 'properties.json'), stableStringify(seeded));
443
+ notes.push(
444
+ `⚠ NO property-read scope (GET /crm/v3/properties/contacts -> ${propsRes.status}) — ` +
445
+ `properties.json was SEEDED from desired state, NOT read from the live account. ` +
446
+ `This file is DESIRED-STATE-ONLY: it may diverge from prod's real property ` +
447
+ `labels/types/options and does NOT reflect properties this site doesn't manage. ` +
448
+ `Do NOT treat it as the live truth; grant crm.schemas.contacts.read and re-pull to verify.`,
449
+ );
450
+ }
451
+
452
+ return { pulled, notes };
453
+ }
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // push(acct, { contentDir, registry }) -> { pushed, notes }
457
+ // Properties first (create-or-patch, write-scope-only convergence on 409),
458
+ // then forms (upsert by NAME), then RECORD registry.forms[key] = target guid so
459
+ // downstream adapters can resolve @form:<key>.
460
+ // ---------------------------------------------------------------------------
461
+ export async function push(acct, ctx) {
462
+ const { contentDir, registry } = ctx;
463
+ const hub = ctx.hub || defaultHub;
464
+ const notes = [];
465
+
466
+ // Source of truth: canonical files if present, else the seed (fresh account).
467
+ let forms = readCanonicalForms(contentDir);
468
+ if (forms.length === 0) {
469
+ forms = Object.entries(SEED_FORMS).map(([key, v]) => ({ key, name: v.name, fields: v.fields }));
470
+ notes.push('no canonical forms on disk — pushing seed defaults');
471
+ }
472
+ let props = readCanonicalProperties(contentDir);
473
+ if (!props) {
474
+ props = canonicalProperties(PROPS.map(([n, label, fieldType]) => ({ name: n, label, fieldType })));
475
+ }
476
+
477
+ await pushProperties(acct, hub, props, notes);
478
+
479
+ // --- upsert forms by name ---
480
+ const listRes = await hub(acct, 'GET', '/forms/v2/forms?limit=300');
481
+ if (!listRes.ok) {
482
+ throw new Error(`forms push: GET /forms/v2/forms -> ${listRes.status}: ${listRes.json?.message || ''}`);
483
+ }
484
+ const existing = new Map(
485
+ (Array.isArray(listRes.json) ? listRes.json : listRes.json.objects || []).map((f) => [f.name, f]),
486
+ );
487
+
488
+ const upserted = [];
489
+ let pushed = 0;
490
+ for (const def of forms) {
491
+ const cur = existing.get(def.name);
492
+ if (!cur) {
493
+ const r = await hub(acct, 'POST', '/forms/v2/forms', {
494
+ name: def.name,
495
+ formFieldGroups: [{ fields: def.fields }],
496
+ });
497
+ if (r.ok && r.json.guid) {
498
+ upserted.push({ key: def.key, guid: r.json.guid });
499
+ notes.push(`form + ${def.key} (${r.json.guid})`);
500
+ pushed += 1;
501
+ } else {
502
+ throw new Error(`forms push: create ${def.key} -> ${r.status}: ${r.json?.message || ''}`);
503
+ }
504
+ continue;
505
+ }
506
+ upserted.push({ key: def.key, guid: cur.guid });
507
+ if (sigFields(flattenFormFields(cur)) === sigFields(def.fields)) {
508
+ notes.push(`form = ${def.key} (${cur.guid})`);
509
+ continue;
510
+ }
511
+ const r = await hub(acct, 'PUT', `/forms/v2/forms/${cur.guid}`, {
512
+ ...cur,
513
+ formFieldGroups: [{ fields: def.fields }],
514
+ });
515
+ if (!r.ok) throw new Error(`forms push: update ${def.key} -> ${r.status}: ${r.json?.message || ''}`);
516
+ notes.push(`form ~ ${def.key} (${cur.guid}, fields synced)`);
517
+ pushed += 1;
518
+ }
519
+
520
+ // RECORD target GUIDs into the registry so theme/pages/widgets/blog push can
521
+ // resolve @form:<key> (and any CTA refs) to THIS account.
522
+ populateRegistry(registry, upserted);
523
+ notes.push(`registry.forms populated with ${upserted.length} key(s)`);
524
+
525
+ return { pushed, notes };
526
+ }
527
+
528
+ // Properties: create-or-patch, write-scope-only. If a read returns the schema we
529
+ // diff; otherwise we create and converge on 409 (matches legacy forms-sync.mjs).
530
+ async function pushProperties(acct, hub, props, notes) {
531
+ const list = await hub(acct, 'GET', '/crm/v3/properties/contacts?archived=false');
532
+ const have = list.ok ? new Map((list.json.results || []).map((p) => [p.name, p])) : null;
533
+ if (!have) notes.push('no property-read scope — using create-or-patch');
534
+
535
+ for (const { name: pname, label, fieldType } of props) {
536
+ const body = { label, fieldType };
537
+ const cur = have && have.get(pname);
538
+
539
+ if (have && !cur) {
540
+ const r = await hub(acct, 'POST', '/crm/v3/properties/contacts', {
541
+ name: pname, type: 'string', groupName: 'contactinformation', ...body,
542
+ });
543
+ notes.push(r.ok ? `prop + ${pname}` : `prop x ${pname}: ${r.json?.message || r.status}`);
544
+ continue;
545
+ }
546
+ if (have && cur && cur.label === label && cur.fieldType === fieldType) {
547
+ notes.push(`prop = ${pname}`);
548
+ continue;
549
+ }
550
+ if (have && cur) {
551
+ const r = await hub(acct, 'PATCH', `/crm/v3/properties/contacts/${pname}`, body);
552
+ notes.push(r.ok ? `prop ~ ${pname}` : `prop x ${pname}: ${r.json?.message || r.status}`);
553
+ continue;
554
+ }
555
+ // No read scope: create, converge via patch on conflict.
556
+ const c = await hub(acct, 'POST', '/crm/v3/properties/contacts', {
557
+ name: pname, type: 'string', groupName: 'contactinformation', ...body,
558
+ });
559
+ if (c.ok) { notes.push(`prop + ${pname}`); continue; }
560
+ if (c.status === 409 || /already exists/i.test(c.json?.message || '')) {
561
+ const u = await hub(acct, 'PATCH', `/crm/v3/properties/contacts/${pname}`, body);
562
+ notes.push(u.ok ? `prop = ${pname} (exists, synced)` : `prop x ${pname}: ${u.json?.message || u.status}`);
563
+ } else {
564
+ notes.push(`prop x ${pname}: ${c.json?.message || c.status}`);
565
+ }
566
+ }
567
+ }
568
+
569
+ export default { name, dependsOn, pull, push };