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.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/bin/hubspot-cms-sync.mjs +115 -0
- package/docs/CONFIGURATION.md +83 -0
- package/docs/GITHUB_ACTIONS.md +70 -0
- package/docs/MIGRATION_PLAN.md +361 -0
- package/docs/PLAN_REVIEW.md +42 -0
- package/docs/SKILL_DISTRIBUTION.md +79 -0
- package/examples/github-actions/ci.yml +56 -0
- package/examples/github-actions/preview.yml +71 -0
- package/examples/github-actions/publish.yml +82 -0
- package/examples/hubspot-cms-sync.config.mjs +45 -0
- package/examples/site.manifest.json +19 -0
- package/package.json +41 -0
- package/skill/SKILL.md +54 -0
- package/skill/references/commands.md +54 -0
- package/skill/references/config.md +25 -0
- package/skill/references/failures.md +58 -0
- package/skill/references/github-actions.md +56 -0
- package/skill/references/screenshots-and-fidelity.md +33 -0
- package/src/adapters/assets.mjs +576 -0
- package/src/adapters/blog.mjs +921 -0
- package/src/adapters/content.mjs +213 -0
- package/src/adapters/forms.mjs +569 -0
- package/src/adapters/pages.mjs +463 -0
- package/src/adapters/theme.mjs +503 -0
- package/src/config.mjs +113 -0
- package/src/corpus-scan.mjs +248 -0
- package/src/cta-inventory.mjs +352 -0
- package/src/index.mjs +3 -0
- package/src/lib/canonical.mjs +234 -0
- package/src/lib/hub.mjs +197 -0
- package/src/lib/orchestrate.mjs +141 -0
- package/src/lib/refs.mjs +398 -0
- package/src/lib/sync-state.mjs +86 -0
- package/src/manifest.mjs +353 -0
- package/src/preflight.mjs +385 -0
- package/src/pull.mjs +99 -0
- package/src/push.mjs +354 -0
- 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 };
|