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,503 @@
1
+ // sync/adapters/theme.mjs — theme code adapter (HubSpot CMS "seventh-sense-theme").
2
+ //
3
+ // The theme is the repo-root tree: templates/ modules/*.module/ css/ js/ images/
4
+ // plus theme.json and the root fields.json. Those files ALREADY live in git and ARE
5
+ // the canonical store — this adapter reconciles + canonicalizes them on pull and
6
+ // builds a target-specific copy on push. Identity is theme-name + path (never a
7
+ // per-account id), so nothing committed keys off a portal.
8
+ //
9
+ // Transport is the CMS Source Code REST API only — NO `hs` CLI. pull() walks the theme
10
+ // via the metadata endpoint and downloads each file's content; push() PUTs each built
11
+ // file. (The old `hs cms fetch`/`hs cms upload` calls were unreliable: the whole-tree
12
+ // upload silently no-op'd and mis-named the theme after the build dir.)
13
+ //
14
+ // PULL (codex canonicalization):
15
+ // GET /cms/v3/source-code/published/metadata|content/seventh-sense-theme/<path>
16
+ // (recursive walk, downloaded straight into the git paths — no staging dir), then
17
+ // for every fetched file:
18
+ // - meta.json: STRIP `module_id` (codex #1 diff-noise: a per-portal id),
19
+ // migrate `host_template_types` -> `content_types`, re-serialize with
20
+ // canon.stableStringify (sorted keys).
21
+ // - fields.json / theme.json: re-serialize with stableStringify.
22
+ // - any text file: normalize to LF, strip BOM.
23
+ // - portability: run refs.canonicalize over files that embed per-account refs
24
+ // (js/hs-forms.js hardcodes the portal id; module fields/html carry form_id
25
+ // GUIDs) so the committed bytes hold @portal / @form:<key> tokens, never the
26
+ // source portal/GUID. Newly-seen ids are registered into the registry.
27
+ // Reconciled bytes are written back into the git theme paths.
28
+ //
29
+ // PUSH (codex #2 — build target tree, THEN upload):
30
+ // Copy the theme into a TEMP BUILD tree, then refs.resolve every logical-ized file
31
+ // against the TARGET account's registry — injecting the target portal id into
32
+ // js/hs-forms.js and the target form GUIDs into module form_id fields — BEFORE
33
+ // uploading. `refs.resolve` HARD-FAILS if any @form/@portal token has no target
34
+ // mapping, so we never upload JS that still carries the source portal. Then PUT each
35
+ // built file to PUT /cms/v3/source-code/published/content/seventh-sense-theme/<path>.
36
+ // The orchestrator passes `acct`; we never hardcode a portal, so prod (529456) is
37
+ // never a target
38
+ // unless the orchestrator explicitly selects it (and it is read-only by policy).
39
+ //
40
+ // I/O lives in pull()/push(); the canonicalization and build-tree GUID injection are
41
+ // factored into the pure helpers canonicalizeMeta / canonicalizeThemeText /
42
+ // injectRefsIntoTree, which are exported for unit testing without a HubSpot account.
43
+
44
+ import { promises as fs } from 'node:fs';
45
+ import { existsSync } from 'node:fs';
46
+ import { join, dirname, relative, sep, basename } from 'node:path';
47
+ import { fileURLToPath } from 'node:url';
48
+ import { mkdtemp, rm } from 'node:fs/promises';
49
+
50
+ import { stableStringify } from '../lib/canonical.mjs';
51
+ import { canonicalize as canonicalizeRefs, resolve as resolveRefs } from '../lib/refs.mjs';
52
+
53
+ export const name = 'theme';
54
+
55
+ // Theme push consumes form GUIDs (and the portal id) that the forms adapter
56
+ // populates into the registry on its push. No other adapter must run first.
57
+ export const dependsOn = ['forms'];
58
+
59
+ export const THEME_NAME = 'seventh-sense-theme';
60
+
61
+ // repo root = sync/adapters/theme.mjs -> ../../
62
+ const __dirname = dirname(fileURLToPath(import.meta.url));
63
+ const REPO_ROOT = join(__dirname, '..', '..');
64
+
65
+ // The theme tree, relative to whatever root we operate on (repo root on pull-write,
66
+ // a temp build dir on push). Directories are walked recursively; loose files copied
67
+ // as-is. images/ is binary and copied verbatim — never text-normalized.
68
+ const THEME_DIRS = ['templates', 'modules', 'css', 'js', 'images'];
69
+ const THEME_FILES = ['theme.json', 'fields.json'];
70
+
71
+ /**
72
+ * isThemePath(relPath) -> boolean (codex #12 — scoped upload guard).
73
+ *
74
+ * Single source of truth for "does this repo-relative path belong in the theme upload?".
75
+ * A path qualifies iff its FIRST segment is one of THEME_DIRS, or the whole path is one
76
+ * of THEME_FILES. Everything else at the repo root — docs/ sync/ content/ node_modules/
77
+ * test/ .sync-state/ .git/ package.json README.md … — is explicitly EXCLUDED so the
78
+ * Design Manager theme never receives non-theme bytes. `listThemeFiles` only walks the
79
+ * theme roots, so this is a belt-and-suspenders guard, but exporting it makes the scope
80
+ * assertable and keeps the inclusion rule in exactly one place.
81
+ */
82
+ export function isThemePath(relPath) {
83
+ const p = String(relPath).split(sep).join('/').replace(/^\.\//, '');
84
+ if (p === '' || p.startsWith('../')) return false; // never escape the theme root
85
+ if (THEME_FILES.includes(p)) return true;
86
+ const first = p.split('/')[0];
87
+ return THEME_DIRS.includes(first);
88
+ }
89
+
90
+ // Files that may embed per-account references (portal id / form GUIDs) and therefore
91
+ // must round-trip through refs.canonicalize (pull) / refs.resolve (push). We match by
92
+ // path suffix so it is independent of the operating root.
93
+ const REF_BEARING = (relPath) => {
94
+ const p = relPath.split(sep).join('/');
95
+ return (
96
+ p === 'js/hs-forms.js' ||
97
+ p.endsWith('.module/fields.json') ||
98
+ p.endsWith('.module/module.html')
99
+ );
100
+ };
101
+
102
+ // HubSpot's `hs cms fetch` ALWAYS emits a `module.css` and `module.js` for every
103
+ // `*.module/`, even when the module defines neither — they come down as empty (or
104
+ // whitespace-only) files. Committing those would add a churning empty file to git for
105
+ // every module on the first pull (and re-upload them on push). We therefore IGNORE an
106
+ // auto-created empty module.css/module.js on pull *unless that file already exists in
107
+ // the tree* (i.e. someone authored real CSS/JS for the module — then we round-trip it).
108
+ // Matched by path suffix so it is independent of the operating root.
109
+ const isModuleAsset = (relPath) => {
110
+ const p = relPath.split(sep).join('/');
111
+ return p.endsWith('.module/module.css') || p.endsWith('.module/module.js');
112
+ };
113
+ // Empty == nothing but whitespace once BOM/CRLF are normalized away.
114
+ const isBlank = (s) => normalizeText(s).trim() === '';
115
+
116
+ // Text vs binary: only these extensions are text-normalized / ref-processed. Anything
117
+ // under images/ (or any other extension) is treated as opaque bytes.
118
+ const TEXT_EXT = new Set(['.html', '.css', '.js', '.json', '.txt', '.csv', '.svg']);
119
+ const extOf = (p) => {
120
+ const i = p.lastIndexOf('.');
121
+ return i < 0 ? '' : p.slice(i).toLowerCase();
122
+ };
123
+ const isText = (relPath) => TEXT_EXT.has(extOf(relPath));
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // PURE canonicalization helpers (exported for unit tests)
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /** Strip BOM, normalize CRLF/CR -> LF. Pure string transform. */
130
+ export function normalizeText(s) {
131
+ let t = String(s);
132
+ if (t.charCodeAt(0) === 0xfeff) t = t.slice(1); // strip leading BOM
133
+ return t.replace(/\r\n?/g, '\n');
134
+ }
135
+
136
+ /**
137
+ * canonicalizeMeta(rawMetaText | obj) -> stable meta.json TEXT.
138
+ *
139
+ * The two codex-mandated migrations for module meta.json:
140
+ * - DELETE `module_id` (a per-portal id — the #1 source of diff noise; every pull
141
+ * from a different account would otherwise rewrite it).
142
+ * - MIGRATE `host_template_types` -> `content_types` (never keep both). If the file
143
+ * already uses `content_types`, leave it; if both are present, the legacy key is
144
+ * dropped in favour of the already-migrated value.
145
+ * Then re-serialize with stableStringify (sorted keys, 2-space, LF, trailing NL).
146
+ */
147
+ export function canonicalizeMeta(input) {
148
+ const meta = typeof input === 'string' ? JSON.parse(normalizeText(input)) : { ...input };
149
+
150
+ // Strip the per-portal id.
151
+ delete meta.module_id;
152
+
153
+ // Migrate host_template_types -> content_types (prefer an already-migrated value).
154
+ if ('host_template_types' in meta) {
155
+ if (!('content_types' in meta)) {
156
+ meta.content_types = meta.host_template_types;
157
+ }
158
+ delete meta.host_template_types;
159
+ }
160
+
161
+ return stableStringify(meta);
162
+ }
163
+
164
+ /**
165
+ * canonicalizeJsonText(rawText) -> stable JSON text.
166
+ * For fields.json / theme.json: parse, re-serialize with stableStringify. Field UUIDs
167
+ * and every other value are preserved verbatim; only key order + formatting change.
168
+ */
169
+ export function canonicalizeJsonText(rawText) {
170
+ return stableStringify(JSON.parse(normalizeText(rawText)));
171
+ }
172
+
173
+ /**
174
+ * canonicalizeThemeText(relPath, rawText, registry) -> canonical TEXT.
175
+ *
176
+ * The single pull-time text pipeline:
177
+ * 1. normalize to LF / strip BOM
178
+ * 2. shape-canonicalize JSON (meta.json migrations; fields/theme re-serialize)
179
+ * 3. portability: for ref-bearing files, logical-ize per-account refs
180
+ * (portal id -> @portal, form GUIDs -> @form:<key>) via refs.canonicalize,
181
+ * registering any newly-seen ids into `registry`.
182
+ * Non-JSON, non-ref-bearing text (template HubL, css, main.js) is returned LF-clean.
183
+ */
184
+ export function canonicalizeThemeText(relPath, rawText, registry) {
185
+ const p = relPath.split(sep).join('/');
186
+ let text = normalizeText(rawText);
187
+
188
+ if (p.endsWith('.module/meta.json')) {
189
+ // meta.json: migrate, but it carries no portal/form refs, so no ref pass.
190
+ return canonicalizeMeta(text);
191
+ }
192
+
193
+ if (REF_BEARING(p)) {
194
+ // module fields.json is still JSON: shape-canonicalize first, then logical-ize the
195
+ // embedded form_id GUID. js/hs-forms.js and module.html are plain text: just
196
+ // logical-ize the portal id / GUIDs in place.
197
+ if (p.endsWith('.json')) text = canonicalizeJsonText(text);
198
+ if (registry) text = canonicalizeRefs(text, registry);
199
+ return text;
200
+ }
201
+
202
+ if (p === 'theme.json' || p.endsWith('/fields.json') || p === 'fields.json') {
203
+ return canonicalizeJsonText(text);
204
+ }
205
+
206
+ return text;
207
+ }
208
+
209
+ /**
210
+ * injectRefsIntoTree(files, registry) -> [{ relPath, text }]
211
+ *
212
+ * PUSH build step (pure): given the theme's ref-bearing text files as
213
+ * { relPath, text } (text already LF-clean and holding @portal / @form tokens),
214
+ * resolve each against the TARGET `registry`, returning the injected text. THROWS via
215
+ * refs.resolve if any logical token has no target mapping — so the caller never writes
216
+ * a build tree that still carries the source portal/GUID. Files without logical tokens
217
+ * pass through unchanged.
218
+ */
219
+ export function injectRefsIntoTree(files, registry) {
220
+ return files.map(({ relPath, text }) => ({
221
+ relPath,
222
+ text: REF_BEARING(relPath) ? resolveRefs(text, registry) : text,
223
+ }));
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // I/O helpers
228
+ // ---------------------------------------------------------------------------
229
+
230
+ // Recursively list files under `root` for the theme tree. Returns relPaths (POSIX-ish
231
+ // via the OS sep) relative to `root`.
232
+ //
233
+ // Scope (codex #12): we ONLY descend THEME_DIRS and ONLY add THEME_FILES, so non-theme
234
+ // roots (docs/ sync/ content/ node_modules/ test/ .sync-state/ …) are never visited.
235
+ // Every emitted path is additionally run through isThemePath() as a defensive guard, so
236
+ // the build tree provably contains only theme files even if the walk ever regressed.
237
+ async function listThemeFiles(root) {
238
+ const out = [];
239
+ async function walk(absDir) {
240
+ let entries;
241
+ try {
242
+ entries = await fs.readdir(absDir, { withFileTypes: true });
243
+ } catch {
244
+ return; // missing optional dir (e.g. images/) — skip
245
+ }
246
+ for (const e of entries) {
247
+ const abs = join(absDir, e.name);
248
+ if (e.isDirectory()) await walk(abs);
249
+ else if (e.isFile()) {
250
+ const rel = relative(root, abs);
251
+ if (isThemePath(rel)) out.push(rel); // belt-and-suspenders: never emit a non-theme path
252
+ }
253
+ }
254
+ }
255
+ for (const d of THEME_DIRS) await walk(join(root, d));
256
+ for (const f of THEME_FILES) if (existsSync(join(root, f))) out.push(f);
257
+ return out;
258
+ }
259
+
260
+ const HUB_API = 'https://api.hubapi.com';
261
+
262
+ /**
263
+ * PUT one built file to the target account via the CMS Source Code API
264
+ * (create-or-replace by path). Used instead of `hs cms upload`, whose whole-tree
265
+ * form silently no-ops / mis-names the theme after the build dir, and whose per-dir
266
+ * form is flaky. The API PUT is deterministic and idempotent. `relPath` is an
267
+ * OS-path relative to the build root; the remote path always uses forward slashes.
268
+ */
269
+ async function uploadSourceFile(acct, themeName, relPath, absPath, { tries = 4 } = {}) {
270
+ const remote = relPath.split(sep).join('/');
271
+ const buf = await fs.readFile(absPath);
272
+ let lastErr;
273
+ for (let attempt = 1; attempt <= tries; attempt++) {
274
+ try {
275
+ const fd = new FormData();
276
+ fd.append('file', new Blob([buf]), basename(remote));
277
+ const res = await fetch(
278
+ `${HUB_API}/cms/v3/source-code/published/content/${themeName}/${remote}`,
279
+ { method: 'PUT', headers: { Authorization: `Bearer ${acct.key}` }, body: fd },
280
+ );
281
+ if (res.ok) return;
282
+ // 429/5xx are transient — back off and retry; 4xx (except 429) are fatal.
283
+ const body = await res.text().catch(() => '');
284
+ if (res.status !== 429 && res.status < 500) {
285
+ throw new Error(`source-code PUT ${remote} -> ${res.status} ${body.slice(0, 200)}`);
286
+ }
287
+ lastErr = new Error(`source-code PUT ${remote} -> ${res.status}`);
288
+ } catch (e) {
289
+ lastErr = e;
290
+ }
291
+ if (attempt < tries) await new Promise((r) => setTimeout(r, 400 * attempt));
292
+ }
293
+ throw lastErr;
294
+ }
295
+
296
+ /**
297
+ * GET one Source Code API path with retry on 429/5xx. Returns the Response (callers
298
+ * handle 404 — a theme dir we ask for may simply not exist). Mirrors the upload helper.
299
+ */
300
+ async function sourceGet(acct, kind, path, { tries = 4 } = {}) {
301
+ const url = `${HUB_API}/cms/v3/source-code/published/${kind}/${path}`;
302
+ let lastErr;
303
+ for (let attempt = 1; attempt <= tries; attempt++) {
304
+ try {
305
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${acct.key}` } });
306
+ if (res.ok || res.status === 404) return res;
307
+ if (res.status !== 429 && res.status < 500) {
308
+ const b = await res.text().catch(() => '');
309
+ throw new Error(`source-code GET ${kind}/${path} -> ${res.status} ${b.slice(0, 200)}`);
310
+ }
311
+ lastErr = new Error(`source-code GET ${kind}/${path} -> ${res.status}`);
312
+ } catch (e) {
313
+ lastErr = e;
314
+ }
315
+ if (attempt < tries) await new Promise((r) => setTimeout(r, 400 * attempt));
316
+ }
317
+ throw lastErr;
318
+ }
319
+
320
+ // Bounded-concurrency runner so the recursive tree walk doesn't fan out into hundreds
321
+ // of simultaneous requests (which would trip rate limits).
322
+ function makeLimiter(max) {
323
+ let active = 0;
324
+ const queue = [];
325
+ const pump = () => {
326
+ while (active < max && queue.length) {
327
+ active++;
328
+ const { fn, resolve, reject } = queue.shift();
329
+ fn().then(resolve, reject).finally(() => {
330
+ active--;
331
+ pump();
332
+ });
333
+ }
334
+ };
335
+ return (fn) => new Promise((resolve, reject) => {
336
+ queue.push({ fn, resolve, reject });
337
+ pump();
338
+ });
339
+ }
340
+
341
+ /**
342
+ * Recursively download a theme subtree (the given top-level entries) from the CMS
343
+ * Source Code API, invoking `onFile(relPath, Buffer)` for each file. Folders are
344
+ * discovered via the metadata endpoint (`folder` + `children` names); missing entries
345
+ * (404) are skipped. No staging dir — callers reconcile each file straight to git.
346
+ */
347
+ async function downloadThemeTree(acct, themeName, entries, onFile) {
348
+ const limit = makeLimiter(8);
349
+ async function walk(relPath) {
350
+ const metaRes = await limit(() => sourceGet(acct, 'metadata', `${themeName}/${relPath}`));
351
+ if (metaRes.status === 404) return;
352
+ const meta = await metaRes.json();
353
+ if (meta.folder) {
354
+ await Promise.all((meta.children || []).map((child) => walk(`${relPath}/${child}`)));
355
+ return;
356
+ }
357
+ const res = await limit(() => sourceGet(acct, 'content', `${themeName}/${relPath}`));
358
+ if (res.status === 404) return;
359
+ await onFile(relPath, Buffer.from(await res.arrayBuffer()));
360
+ }
361
+ await Promise.all(entries.map((entry) => walk(entry)));
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // pull
366
+ // ---------------------------------------------------------------------------
367
+
368
+ /**
369
+ * pull(acct, { contentDir, registry }) -> { pulled, notes }
370
+ *
371
+ * `contentDir` is the operating root for theme files. The theme tree lives at the REPO
372
+ * root (templates/ modules/ css/ js/ ...), so by default we write there; tests pass an
373
+ * explicit contentDir to redirect writes. Source portal is acct.portalId; nothing is
374
+ * written back (read-only). Files are downloaded directly via the CMS Source Code API
375
+ * and reconciled straight into the git paths — NO intermediate staging dir (the old
376
+ * `hs cms fetch` needed one because it wrote a whole tree at once).
377
+ */
378
+ export async function pull(acct, { contentDir, registry, config } = {}) {
379
+ const root = config?.root || contentDir || REPO_ROOT;
380
+ const themeName = config?.theme?.name || THEME_NAME;
381
+ const notes = [];
382
+ let pulled = 0;
383
+ let skippedEmptyModuleAssets = 0;
384
+
385
+ // Download + canonicalize each theme file in place. Only the theme roots are walked
386
+ // (templates/ modules/ css/ js/ images/ + theme.json/fields.json), matching what the
387
+ // git tree tracks.
388
+ await downloadThemeTree(acct, themeName, [...THEME_DIRS, ...THEME_FILES], async (relPath, buf) => {
389
+ const dst = join(root, relPath.split('/').join(sep));
390
+
391
+ // HubSpot auto-creates empty module.css/module.js. If the module never had one in
392
+ // git, don't create churn by committing an empty file; only round-trip it when it
393
+ // already exists (real authored CSS/JS) — see isModuleAsset note above.
394
+ if (isModuleAsset(relPath) && !existsSync(dst) && isBlank(buf.toString('utf8'))) {
395
+ skippedEmptyModuleAssets++;
396
+ return;
397
+ }
398
+
399
+ await fs.mkdir(dirname(dst), { recursive: true });
400
+ if (isText(relPath)) {
401
+ await fs.writeFile(dst, canonicalizeThemeText(relPath, buf.toString('utf8'), registry), 'utf8');
402
+ } else {
403
+ // binary (images/...) — write verbatim, git is source of truth for bytes.
404
+ await fs.writeFile(dst, buf);
405
+ }
406
+ pulled++;
407
+ });
408
+
409
+ notes.push(
410
+ `fetched ${themeName} from portal ${acct.portalId} via source-code API; canonicalized ${pulled} file(s)`,
411
+ );
412
+ if (skippedEmptyModuleAssets) {
413
+ notes.push(`ignored ${skippedEmptyModuleAssets} auto-created empty module.css/module.js file(s)`);
414
+ }
415
+
416
+ return { pulled, notes };
417
+ }
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // push
421
+ // ---------------------------------------------------------------------------
422
+
423
+ /**
424
+ * push(acct, { contentDir, registry }) -> { pushed, notes }
425
+ *
426
+ * Build a temp tree, inject the TARGET account's portal id + form GUIDs into the
427
+ * ref-bearing files (refs.resolve, which hard-fails on any unmapped token), then
428
+ * upload that tree. Idempotent: `hs cms upload` to the same theme name creates-or-
429
+ * updates by path. Never targets a hardcoded portal — `acct` is supplied by the
430
+ * orchestrator.
431
+ */
432
+ export async function push(acct, { contentDir, registry, config } = {}) {
433
+ const root = config?.root || contentDir || REPO_ROOT;
434
+ const themeName = config?.theme?.name || THEME_NAME;
435
+ const notes = [];
436
+
437
+ if (!registry || registry.portalId == null) {
438
+ throw new Error(
439
+ `theme.push: target registry has no portalId — cannot inject @portal for account ${acct.name}`,
440
+ );
441
+ }
442
+
443
+ // Build tree under the repo (same mount as cwd) so `hs cms upload` reads it
444
+ // reliably — os.tmpdir() may be a different mount (see pull()).
445
+ const buildBase = config?.syncStateDirPath || join(root, '.sync-state');
446
+ await fs.mkdir(buildBase, { recursive: true });
447
+ const build = await mkdtemp(join(buildBase, 'theme-build-'));
448
+ let pushed = 0;
449
+ try {
450
+ const files = await listThemeFiles(root);
451
+
452
+ for (const relPath of files) {
453
+ const src = join(root, relPath);
454
+ const dst = join(build, relPath);
455
+ await fs.mkdir(dirname(dst), { recursive: true });
456
+
457
+ if (REF_BEARING(relPath)) {
458
+ // Inject target refs BEFORE writing into the build tree.
459
+ const raw = normalizeText(await fs.readFile(src, 'utf8'));
460
+ const [injected] = injectRefsIntoTree([{ relPath, text: raw }], registry);
461
+ await fs.writeFile(dst, injected.text, 'utf8');
462
+ } else if (isText(relPath)) {
463
+ await fs.writeFile(dst, normalizeText(await fs.readFile(src, 'utf8')), 'utf8');
464
+ } else {
465
+ await fs.copyFile(src, dst);
466
+ }
467
+ pushed++;
468
+ }
469
+
470
+ notes.push(
471
+ `built target theme for portal ${registry.portalId} (${pushed} files); injected refs into ref-bearing files`,
472
+ );
473
+
474
+ // Upload the BUILT tree to the target account via the CMS Source Code API,
475
+ // PUTting each file directly. We do NOT use `hs cms upload`: its whole-tree form
476
+ // silently no-ops and mis-names the theme after the build dir, and the per-dir
477
+ // form is unreliable in this environment. The API PUT is deterministic and
478
+ // idempotent (create-or-replace by path). `files` is the list copied into the
479
+ // build tree, so ref-bearing files already carry the injected target tokens.
480
+ const CONCURRENCY = 8;
481
+ let next = 0;
482
+ let uploaded = 0;
483
+ async function worker() {
484
+ while (next < files.length) {
485
+ const relPath = files[next++];
486
+ await uploadSourceFile(acct, themeName, relPath, join(build, relPath));
487
+ uploaded++;
488
+ }
489
+ }
490
+ await Promise.all(
491
+ Array.from({ length: Math.min(CONCURRENCY, files.length) }, () => worker()),
492
+ );
493
+ notes.push(
494
+ `uploaded ${uploaded} file(s) to ${themeName} via source-code API (account ${acct.name}, portal ${acct.portalId})`,
495
+ );
496
+ } finally {
497
+ await rm(build, { recursive: true, force: true });
498
+ }
499
+
500
+ return { pushed, notes };
501
+ }
502
+
503
+ export default { name, dependsOn, pull, push };
package/src/config.mjs ADDED
@@ -0,0 +1,113 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { resolve, join } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { homedir } from 'node:os';
6
+
7
+ const DEFAULT_CONFIG_FILE = 'hubspot-cms-sync.config.mjs';
8
+
9
+ export function defaultConfig(root = process.cwd()) {
10
+ return {
11
+ root: resolve(root),
12
+ accountsFile: 'sync/accounts.json',
13
+ keyDirEnv: 'HUBSPOT_KEY_DIR',
14
+ contentDir: 'content',
15
+ syncStateDir: '.sync-state',
16
+ manifestPath: 'site.manifest.json',
17
+ readOnlyPortalIds: [],
18
+ knownPortalIds: [],
19
+ assetHosts: {
20
+ canonicalizeHostPatterns: ['hubfs', 'hubspotusercontent', 'cdn\\d*\\.hubspot\\.net'],
21
+ legacySiteHosts: [],
22
+ },
23
+ adapters: {
24
+ externalDirs: [],
25
+ },
26
+ theme: {
27
+ name: 'theme',
28
+ dirs: ['templates', 'modules', 'css', 'js', 'images'],
29
+ files: ['theme.json', 'fields.json'],
30
+ },
31
+ blog: {
32
+ slug: 'blog',
33
+ itemTemplate: '',
34
+ listingTemplate: '',
35
+ },
36
+ uiGated: [],
37
+ verification: {
38
+ baseUrlEnv: 'SITE_BASE_URL',
39
+ commands: {},
40
+ },
41
+ };
42
+ }
43
+
44
+ function mergeConfig(base, override) {
45
+ const out = { ...base, ...override };
46
+ out.theme = { ...base.theme, ...(override.theme || {}) };
47
+ out.blog = { ...base.blog, ...(override.blog || {}) };
48
+ out.assetHosts = { ...base.assetHosts, ...(override.assetHosts || {}) };
49
+ out.adapters = { ...base.adapters, ...(override.adapters || {}) };
50
+ out.verification = { ...base.verification, ...(override.verification || {}) };
51
+ out.verification.commands = {
52
+ ...(base.verification?.commands || {}),
53
+ ...(override.verification?.commands || {}),
54
+ };
55
+ return out;
56
+ }
57
+
58
+ export function resolveConfigPaths(cfg) {
59
+ const root = resolve(cfg.root || process.cwd());
60
+ const abs = (p) => resolve(root, p);
61
+ const keyDir = process.env[cfg.keyDirEnv || 'HUBSPOT_KEY_DIR'] || join(homedir(), '.hubspot');
62
+ return {
63
+ ...cfg,
64
+ root,
65
+ keyDir,
66
+ accountsPath: abs(cfg.accountsFile),
67
+ contentDirPath: abs(cfg.contentDir),
68
+ syncStateDirPath: abs(cfg.syncStateDir),
69
+ manifestFilePath: abs(cfg.manifestPath),
70
+ };
71
+ }
72
+
73
+ export async function loadConfig({ root = process.cwd(), configPath } = {}) {
74
+ const base = defaultConfig(root);
75
+ const file = resolve(root, configPath || DEFAULT_CONFIG_FILE);
76
+ let user = {};
77
+ if (existsSync(file)) {
78
+ const mod = await import(pathToFileURL(file).href);
79
+ user = mod.default || mod.config || {};
80
+ }
81
+ const cfg = resolveConfigPaths(mergeConfig(base, user));
82
+ validateConfig(cfg);
83
+ return cfg;
84
+ }
85
+
86
+ export function loadConfigSyncFallback({ root = process.cwd() } = {}) {
87
+ return resolveConfigPaths(defaultConfig(root));
88
+ }
89
+
90
+ export function validateConfig(cfg) {
91
+ const errors = [];
92
+ if (!cfg.root) errors.push('missing root');
93
+ if (!cfg.accountsFile) errors.push('missing accountsFile');
94
+ if (!cfg.contentDir) errors.push('missing contentDir');
95
+ if (!cfg.syncStateDir) errors.push('missing syncStateDir');
96
+ if (!cfg.manifestPath) errors.push('missing manifestPath');
97
+ if (!Array.isArray(cfg.readOnlyPortalIds)) errors.push('readOnlyPortalIds must be an array');
98
+ if (!Array.isArray(cfg.knownPortalIds)) errors.push('knownPortalIds must be an array');
99
+ if (!cfg.theme?.name) errors.push('theme.name is required');
100
+ if (!Array.isArray(cfg.theme?.dirs)) errors.push('theme.dirs must be an array');
101
+ if (!Array.isArray(cfg.theme?.files)) errors.push('theme.files must be an array');
102
+ if (errors.length) {
103
+ throw new Error(`Invalid hubspot-cms-sync config:\n${errors.map((e) => `- ${e}`).join('\n')}`);
104
+ }
105
+ }
106
+
107
+ export async function readJsonFile(file, label = file) {
108
+ try {
109
+ return JSON.parse(await readFile(file, 'utf8'));
110
+ } catch (e) {
111
+ throw new Error(`Cannot read ${label} at ${file}: ${e.message}`);
112
+ }
113
+ }