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,385 @@
|
|
|
1
|
+
// sync/preflight.mjs — BOOTSTRAP PREFLIGHT for `sync push <account>`.
|
|
2
|
+
//
|
|
3
|
+
// Usage: node sync/preflight.mjs <account>
|
|
4
|
+
//
|
|
5
|
+
// codex finding #3: a "fresh account push" is really a push to a *prepared*
|
|
6
|
+
// account. Several prerequisites are UI-gated in HubSpot and cannot be created by
|
|
7
|
+
// the push orchestrator (blog container, homepage designation, custom domain) or
|
|
8
|
+
// depend on the service key carrying the right scopes. This preflight API-checks
|
|
9
|
+
// the TARGET account for those prerequisites and HARD-FAILS with exact remediation
|
|
10
|
+
// instructions before any write happens, so the push orchestrator only ever runs
|
|
11
|
+
// against an account that is actually ready.
|
|
12
|
+
//
|
|
13
|
+
// Checks (hard-fail unless noted):
|
|
14
|
+
// 1. blog container exists with the manifest's blog slug AND points at the
|
|
15
|
+
// seventh-sense-theme blog templates (item + listing template paths).
|
|
16
|
+
// 2. a homepage is designated — a site page resolves at slug '' (root).
|
|
17
|
+
// 3. the service key carries the scopes push needs — probed by exercising one
|
|
18
|
+
// endpoint per scope family (forms list, a content/page GET, a files search)
|
|
19
|
+
// and reporting which are missing.
|
|
20
|
+
// 4. (REPORT-ONLY) custom domain / hs-sites domain availability — never fails
|
|
21
|
+
// the run; surfaced so the operator knows where the site will publish.
|
|
22
|
+
//
|
|
23
|
+
// PRODUCTION (portalId 529456) IS READ-ONLY. Even though preflight performs only
|
|
24
|
+
// reads, it REFUSES to run against prod (hard guard, regardless of CLI args) so it
|
|
25
|
+
// can never be wired into a prod push by mistake — the same guard the push
|
|
26
|
+
// orchestrator enforces.
|
|
27
|
+
//
|
|
28
|
+
// Exit codes: 0 + "ready" when every hard check passes;
|
|
29
|
+
// non-zero + a remediation checklist when any hard check fails.
|
|
30
|
+
//
|
|
31
|
+
// The readiness EVALUATION is a pure function (evaluateReadiness) over gathered
|
|
32
|
+
// probe results, so it unit-tests without the network. gatherProbes() is the thin
|
|
33
|
+
// API layer that feeds it.
|
|
34
|
+
|
|
35
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
36
|
+
import { join, dirname } from 'node:path';
|
|
37
|
+
import { fileURLToPath } from 'node:url';
|
|
38
|
+
|
|
39
|
+
import { account, hub } from './lib/hub.mjs';
|
|
40
|
+
|
|
41
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
42
|
+
const REPO_ROOT = join(__dirname, '..');
|
|
43
|
+
|
|
44
|
+
// Production portal is READ-ONLY; preflight refuses to run against it.
|
|
45
|
+
export const PROD_PORTAL_ID = '529456';
|
|
46
|
+
|
|
47
|
+
// The theme whose blog templates the container must reference.
|
|
48
|
+
export const THEME_NAME = 'seventh-sense-theme';
|
|
49
|
+
|
|
50
|
+
// Default blog slug when the manifest does not pin one.
|
|
51
|
+
const DEFAULT_BLOG_SLUG = 'blog';
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Manifest: the blog slug the target must host. The design's site.manifest.json
|
|
55
|
+
// may carry `{ "blog": { "slug": "blog" } }`; we also accept the committed blog
|
|
56
|
+
// container.json (content/blog/container.json) as a fallback source of the slug.
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the blog slug the target account must host.
|
|
61
|
+
* Precedence: site.manifest.json `blog.slug` -> content/blog/container.json `slug`
|
|
62
|
+
* -> DEFAULT_BLOG_SLUG. Pure-ish (reads repo files only, never the network).
|
|
63
|
+
* @param {string} [repoRoot]
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
export function manifestBlogSlug(repoRoot = REPO_ROOT) {
|
|
67
|
+
const manifestPath = join(repoRoot, 'site.manifest.json');
|
|
68
|
+
if (existsSync(manifestPath)) {
|
|
69
|
+
try {
|
|
70
|
+
const m = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
71
|
+
const slug = m?.blog?.slug;
|
|
72
|
+
if (slug != null && String(slug) !== '') return String(slug);
|
|
73
|
+
} catch {
|
|
74
|
+
/* fall through to the container file / default */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const containerPath = join(repoRoot, 'content', 'blog', 'container.json');
|
|
78
|
+
if (existsSync(containerPath)) {
|
|
79
|
+
try {
|
|
80
|
+
const c = JSON.parse(readFileSync(containerPath, 'utf8'));
|
|
81
|
+
if (c?.slug != null && String(c.slug) !== '') return String(c.slug);
|
|
82
|
+
} catch {
|
|
83
|
+
/* fall through to default */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return DEFAULT_BLOG_SLUG;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// gatherProbes(acct, { blogSlug, hub, getAll, resolveBlogBySlug, resolvePageBySlug })
|
|
91
|
+
// -> probes object consumed by evaluateReadiness.
|
|
92
|
+
//
|
|
93
|
+
// All four collaborators are injectable so the gather step (and therefore the
|
|
94
|
+
// whole CLI) is testable without a live portal. Each probe records a coarse
|
|
95
|
+
// shape that the pure evaluator reasons over; gather NEVER throws on an API
|
|
96
|
+
// error — it captures the failure so evaluateReadiness can turn it into a scope
|
|
97
|
+
// or readiness finding.
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
export async function gatherProbes(acct, opts = {}) {
|
|
100
|
+
const {
|
|
101
|
+
blogSlug = DEFAULT_BLOG_SLUG,
|
|
102
|
+
hub: hubFn = hub,
|
|
103
|
+
} = opts;
|
|
104
|
+
|
|
105
|
+
const probes = { blogSlug };
|
|
106
|
+
|
|
107
|
+
// --- blog container (legacy v2 list; matched by slug, never objects[0]) ------
|
|
108
|
+
// We use the raw v2 endpoint (not resolveBlogBySlug) because we also need the
|
|
109
|
+
// template paths to confirm it points at the theme, and the status to tell a
|
|
110
|
+
// scope failure (403) apart from "no blog yet" (200 + empty).
|
|
111
|
+
{
|
|
112
|
+
const r = await hubFn(acct, 'GET', '/content/api/v2/blogs?limit=100');
|
|
113
|
+
probes.blog = { status: r.status, ok: r.ok };
|
|
114
|
+
if (r.ok) {
|
|
115
|
+
const objects = r.json?.objects || [];
|
|
116
|
+
const match = objects.find((b) => String(b.slug ?? '') === String(blogSlug));
|
|
117
|
+
probes.blog.found = !!match;
|
|
118
|
+
if (match) {
|
|
119
|
+
probes.blog.itemTemplatePath = match.item_template_path || '';
|
|
120
|
+
probes.blog.listingTemplatePath = match.listing_template_path || '';
|
|
121
|
+
}
|
|
122
|
+
probes.blog.slugsSeen = objects.map((b) => String(b.slug ?? ''));
|
|
123
|
+
} else {
|
|
124
|
+
probes.blog.message = r.json?.message || '';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- homepage designation (a site page resolves at root slug '') ------------
|
|
129
|
+
{
|
|
130
|
+
const r = await hubFn(acct, 'GET', '/cms/v3/pages/site-pages?limit=100');
|
|
131
|
+
probes.homepage = { status: r.status, ok: r.ok };
|
|
132
|
+
if (r.ok) {
|
|
133
|
+
const results = r.json?.results || [];
|
|
134
|
+
probes.homepage.found = results.some((p) => String(p.slug ?? '') === '');
|
|
135
|
+
} else {
|
|
136
|
+
probes.homepage.message = r.json?.message || '';
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- scope probes: one cheap GET per scope family push needs ----------------
|
|
141
|
+
// forms (forms scope), content GET (content scope), files search (files scope).
|
|
142
|
+
const scopeProbe = async (id, method, path) => {
|
|
143
|
+
const r = await hubFn(acct, method, path);
|
|
144
|
+
// 401/403 => the key lacks the scope. Any other status (incl. 404/200) means
|
|
145
|
+
// the scope is present; the endpoint answered us.
|
|
146
|
+
return { id, status: r.status, ok: r.ok, denied: r.status === 401 || r.status === 403, message: r.json?.message || '' };
|
|
147
|
+
};
|
|
148
|
+
probes.scopes = {
|
|
149
|
+
forms: await scopeProbe('forms', 'GET', '/forms/v2/forms?limit=1'),
|
|
150
|
+
content: await scopeProbe('content', 'GET', '/cms/v3/pages/site-pages?limit=1'),
|
|
151
|
+
files: await scopeProbe('files', 'GET', '/files/v3/files/search?limit=1'),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// --- (report-only) domain availability --------------------------------------
|
|
155
|
+
{
|
|
156
|
+
const d = await hubFn(acct, 'GET', '/cms/v3/domains');
|
|
157
|
+
probes.domains = { status: d.status, ok: d.ok };
|
|
158
|
+
if (d.ok) {
|
|
159
|
+
const results = d.json?.results || [];
|
|
160
|
+
probes.domains.list = results.map((x) => ({
|
|
161
|
+
domain: x.domain,
|
|
162
|
+
isResolving: !!x.isResolving,
|
|
163
|
+
isHsSitesDomain: !!(x.isHsSitesDomain ?? x.is_hs_sites_domain),
|
|
164
|
+
}));
|
|
165
|
+
} else {
|
|
166
|
+
probes.domains.message = d.json?.message || '';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return probes;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// evaluateReadiness(probes, { blogSlug, themeName }) -> { ready, checks, failures }
|
|
175
|
+
//
|
|
176
|
+
// PURE. Turns gathered probes into a deterministic checklist. Each check is
|
|
177
|
+
// { id, ok, detail, remediation }. `ready` is true only when every HARD check
|
|
178
|
+
// (everything except report-only domain) passes. `failures` is the subset of
|
|
179
|
+
// checks with ok === false AND reportOnly !== true, in stable order.
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
export function evaluateReadiness(probes, opts = {}) {
|
|
182
|
+
const blogSlug = opts.blogSlug ?? probes.blogSlug ?? DEFAULT_BLOG_SLUG;
|
|
183
|
+
const themeName = opts.themeName ?? THEME_NAME;
|
|
184
|
+
const checks = [];
|
|
185
|
+
|
|
186
|
+
const add = (c) => checks.push({ reportOnly: false, ...c });
|
|
187
|
+
|
|
188
|
+
// --- 1. blog container ------------------------------------------------------
|
|
189
|
+
const blog = probes.blog || {};
|
|
190
|
+
if (blog.ok === false) {
|
|
191
|
+
add({
|
|
192
|
+
id: 'blog-container',
|
|
193
|
+
ok: false,
|
|
194
|
+
detail: `cannot list blogs (HTTP ${blog.status}${blog.message ? `: ${blog.message}` : ''})`,
|
|
195
|
+
remediation:
|
|
196
|
+
`Grant the service key the "content" scope, then ensure a blog exists. ` +
|
|
197
|
+
`Listing blogs failed, so blog readiness cannot be confirmed.`,
|
|
198
|
+
});
|
|
199
|
+
} else if (!blog.found) {
|
|
200
|
+
const seen = (blog.slugsSeen || []).filter(Boolean);
|
|
201
|
+
add({
|
|
202
|
+
id: 'blog-container',
|
|
203
|
+
ok: false,
|
|
204
|
+
detail:
|
|
205
|
+
`no blog container with slug "${blogSlug}"` +
|
|
206
|
+
(seen.length ? ` (found: ${seen.join(', ')})` : ' (no blogs exist)'),
|
|
207
|
+
remediation:
|
|
208
|
+
`Create the blog in HubSpot UI: Settings -> Website -> Blog -> "Create another blog", ` +
|
|
209
|
+
`set its URL slug to "${blogSlug}". (Blog creation is UI-gated; push cannot do it.)`,
|
|
210
|
+
});
|
|
211
|
+
} else {
|
|
212
|
+
// Container exists — confirm it points at the theme's blog templates.
|
|
213
|
+
const item = blog.itemTemplatePath || '';
|
|
214
|
+
const listing = blog.listingTemplatePath || '';
|
|
215
|
+
const pointsAtTheme = (p) => p.includes(`${themeName}/`) || p.includes(`${themeName}\\`);
|
|
216
|
+
const itemOk = pointsAtTheme(item);
|
|
217
|
+
const listingOk = pointsAtTheme(listing);
|
|
218
|
+
if (itemOk && listingOk) {
|
|
219
|
+
add({ id: 'blog-container', ok: true, detail: `blog "${blogSlug}" exists and uses ${themeName} templates` });
|
|
220
|
+
} else {
|
|
221
|
+
const wrong = [];
|
|
222
|
+
if (!itemOk) wrong.push(`post/item template "${item || '(unset)'}"`);
|
|
223
|
+
if (!listingOk) wrong.push(`listing template "${listing || '(unset)'}"`);
|
|
224
|
+
add({
|
|
225
|
+
id: 'blog-templates',
|
|
226
|
+
ok: false,
|
|
227
|
+
detail: `blog "${blogSlug}" exists but ${wrong.join(' and ')} not under ${themeName}`,
|
|
228
|
+
remediation:
|
|
229
|
+
`In Settings -> Website -> Blog -> Templates, set the blog post and listing ` +
|
|
230
|
+
`templates to the ${themeName} blog templates, then re-run preflight.`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// --- 2. homepage designation ------------------------------------------------
|
|
236
|
+
const homepage = probes.homepage || {};
|
|
237
|
+
if (homepage.ok === false) {
|
|
238
|
+
add({
|
|
239
|
+
id: 'homepage',
|
|
240
|
+
ok: false,
|
|
241
|
+
detail: `cannot list site pages (HTTP ${homepage.status}${homepage.message ? `: ${homepage.message}` : ''})`,
|
|
242
|
+
remediation: `Grant the service key the "content" scope so the homepage can be confirmed.`,
|
|
243
|
+
});
|
|
244
|
+
} else if (!homepage.found) {
|
|
245
|
+
add({
|
|
246
|
+
id: 'homepage',
|
|
247
|
+
ok: false,
|
|
248
|
+
detail: `no site page resolves at the root slug ''`,
|
|
249
|
+
remediation:
|
|
250
|
+
`Publish a page at the site root and designate it the homepage ` +
|
|
251
|
+
`(Settings -> Website -> Pages -> "System pages"/homepage, or set the page's URL to the domain root). ` +
|
|
252
|
+
`Homepage designation is UI-gated; push cannot do it.`,
|
|
253
|
+
});
|
|
254
|
+
} else {
|
|
255
|
+
add({ id: 'homepage', ok: true, detail: `a page is designated at the root slug ''` });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- 3. service-key scopes --------------------------------------------------
|
|
259
|
+
const scopes = probes.scopes || {};
|
|
260
|
+
const missing = [];
|
|
261
|
+
for (const id of ['forms', 'content', 'files']) {
|
|
262
|
+
const s = scopes[id];
|
|
263
|
+
if (!s || s.denied) missing.push(id);
|
|
264
|
+
}
|
|
265
|
+
if (missing.length === 0) {
|
|
266
|
+
add({ id: 'scopes', ok: true, detail: `service key has forms, content, files scopes` });
|
|
267
|
+
} else {
|
|
268
|
+
add({
|
|
269
|
+
id: 'scopes',
|
|
270
|
+
ok: false,
|
|
271
|
+
detail: `service key is missing scope(s): ${missing.join(', ')}`,
|
|
272
|
+
remediation:
|
|
273
|
+
`In HubSpot UI: Settings -> Integrations -> Private Apps -> (this app) -> Scopes, ` +
|
|
274
|
+
`add the missing scope(s) [${missing.join(', ')}], regenerate the token if needed, ` +
|
|
275
|
+
`and update $HUBSPOT_KEY_DIR/<portalId>.key.`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// --- 4. domain availability (REPORT-ONLY: never fails readiness) ------------
|
|
280
|
+
const domains = probes.domains || {};
|
|
281
|
+
if (domains.ok === false) {
|
|
282
|
+
add({
|
|
283
|
+
id: 'domain',
|
|
284
|
+
ok: false,
|
|
285
|
+
reportOnly: true,
|
|
286
|
+
detail: `could not read domains (HTTP ${domains.status}${domains.message ? `: ${domains.message}` : ''}) — report only`,
|
|
287
|
+
remediation: `(report only) Connect a custom domain or use the hs-sites domain in Settings -> Website -> Domains & URLs.`,
|
|
288
|
+
});
|
|
289
|
+
} else {
|
|
290
|
+
const list = domains.list || [];
|
|
291
|
+
const resolving = list.filter((d) => d.isResolving);
|
|
292
|
+
if (list.length === 0) {
|
|
293
|
+
add({
|
|
294
|
+
id: 'domain',
|
|
295
|
+
ok: false,
|
|
296
|
+
reportOnly: true,
|
|
297
|
+
detail: `no domains connected — content will publish only to the default hs-sites domain (report only)`,
|
|
298
|
+
remediation: `(report only) Connect a custom domain in Settings -> Website -> Domains & URLs.`,
|
|
299
|
+
});
|
|
300
|
+
} else {
|
|
301
|
+
add({
|
|
302
|
+
id: 'domain',
|
|
303
|
+
ok: true,
|
|
304
|
+
reportOnly: true,
|
|
305
|
+
detail:
|
|
306
|
+
`domains: ${list.map((d) => d.domain).join(', ')}` +
|
|
307
|
+
(resolving.length ? ` (resolving: ${resolving.map((d) => d.domain).join(', ')})` : ' (none resolving yet)'),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const failures = checks.filter((c) => !c.ok && !c.reportOnly);
|
|
313
|
+
return { ready: failures.length === 0, checks, failures };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// Rendering (pure): turn an evaluation into a human checklist string.
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
export function renderReport(evald, { account: acctName, portalId, blogSlug } = {}) {
|
|
320
|
+
const lines = [];
|
|
321
|
+
lines.push(`Bootstrap preflight — account "${acctName}" (portal ${portalId}), blog slug "${blogSlug}"`);
|
|
322
|
+
for (const c of evald.checks) {
|
|
323
|
+
const mark = c.ok ? 'PASS' : c.reportOnly ? 'NOTE' : 'FAIL';
|
|
324
|
+
lines.push(` [${mark}] ${c.id}: ${c.detail}`);
|
|
325
|
+
if (!c.ok && c.remediation) lines.push(` -> ${c.remediation}`);
|
|
326
|
+
}
|
|
327
|
+
if (evald.ready) {
|
|
328
|
+
lines.push('ready');
|
|
329
|
+
} else {
|
|
330
|
+
lines.push(`NOT READY — ${evald.failures.length} blocking prerequisite(s):`);
|
|
331
|
+
for (const f of evald.failures) lines.push(` - ${f.id}: ${f.remediation || f.detail}`);
|
|
332
|
+
}
|
|
333
|
+
return lines.join('\n');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// CLI
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
export async function main(argv = process.argv.slice(2), opts = {}) {
|
|
340
|
+
const { config } = opts;
|
|
341
|
+
const acctName = argv[0];
|
|
342
|
+
if (!acctName) {
|
|
343
|
+
process.stderr.write('usage: node sync/preflight.mjs <account>\n');
|
|
344
|
+
return 2;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
let acct;
|
|
348
|
+
try {
|
|
349
|
+
acct = account(acctName, config);
|
|
350
|
+
} catch (e) {
|
|
351
|
+
process.stderr.write(`${e.message}\n`);
|
|
352
|
+
return 2;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// PRODUCTION guard: refuse regardless of CLI args.
|
|
356
|
+
const readOnly = new Set((config?.readOnlyPortalIds?.length ? config.readOnlyPortalIds : [PROD_PORTAL_ID]).map(String));
|
|
357
|
+
if (readOnly.has(String(acct.portalId))) {
|
|
358
|
+
process.stderr.write(
|
|
359
|
+
`Refusing to run: account "${acctName}" maps to read-only portal ${acct.portalId}. ` +
|
|
360
|
+
`Preflight (and push) must never target read-only accounts.\n`,
|
|
361
|
+
);
|
|
362
|
+
return 3;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const blogSlug = config?.blog?.slug || manifestBlogSlug(config?.root || REPO_ROOT);
|
|
366
|
+
let probes;
|
|
367
|
+
try {
|
|
368
|
+
probes = await gatherProbes(acct, { blogSlug });
|
|
369
|
+
} catch (e) {
|
|
370
|
+
process.stderr.write(`preflight: probe error: ${e.message}\n`);
|
|
371
|
+
return 1;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const evald = evaluateReadiness(probes, { blogSlug, themeName: config?.theme?.name || THEME_NAME });
|
|
375
|
+
const report = renderReport(evald, { account: acctName, portalId: acct.portalId, blogSlug });
|
|
376
|
+
process.stdout.write(report + '\n');
|
|
377
|
+
return evald.ready ? 0 : 1;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Run as a script (not when imported by the unit tests).
|
|
381
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
382
|
+
main().then((code) => process.exit(code));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export default { main, gatherProbes, evaluateReadiness, manifestBlogSlug, renderReport };
|
package/src/pull.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sync/pull.mjs — PULL orchestrator: HubSpot account -> canonical git tree.
|
|
3
|
+
//
|
|
4
|
+
// node sync/pull.mjs <account>
|
|
5
|
+
//
|
|
6
|
+
// Loads every sync/adapters/*.mjs, topo-sorts by dependsOn, and runs each adapter's
|
|
7
|
+
// pull() in dependency order against the SOURCE account. Pull is read-only on the
|
|
8
|
+
// HubSpot side; each adapter GETs its resource, canonicalizes it (shape via canonical.mjs,
|
|
9
|
+
// identity via refs.mjs), AUTO-REGISTERS any per-account refs into the registry, and
|
|
10
|
+
// writes the portable result under content/ + the theme paths.
|
|
11
|
+
//
|
|
12
|
+
// The per-account registry (logical key <-> source id/url) is loaded/initialized from
|
|
13
|
+
// .sync-state/<portalId>.registry.json (gitignored), shared across adapters in topo
|
|
14
|
+
// order so a producer (forms/assets) registers source ids before a consumer pulls,
|
|
15
|
+
// and PERSISTED at the end so a same-account pull -> push round-trips to identical ids.
|
|
16
|
+
//
|
|
17
|
+
// PRODUCTION (portal 529456) is the canonical SOURCE — pulling FROM prod is allowed and
|
|
18
|
+
// expected. The read-only guard lives in push.mjs (never write to prod).
|
|
19
|
+
|
|
20
|
+
import { account as realAccount } from './lib/hub.mjs';
|
|
21
|
+
import { loadAdapters as realLoadAdapters, topoSort } from './lib/orchestrate.mjs';
|
|
22
|
+
import {
|
|
23
|
+
contentDir,
|
|
24
|
+
loadAccountRegistry as realLoadAccountRegistry,
|
|
25
|
+
persistAccountRegistry as realPersistAccountRegistry,
|
|
26
|
+
} from './lib/sync-state.mjs';
|
|
27
|
+
|
|
28
|
+
// `deps` is a hidden test seam: production callers pass nothing and get the real
|
|
29
|
+
// hub/orchestrate/sync-state functions. Unit tests inject fakes so pull() can be
|
|
30
|
+
// exercised with no network and no real .sync-state writes. It does NOT change the
|
|
31
|
+
// public signature — pull(name) is unchanged for the CLI and every caller.
|
|
32
|
+
export async function pull(name, deps = {}) {
|
|
33
|
+
const {
|
|
34
|
+
account = realAccount,
|
|
35
|
+
loadAdapters = realLoadAdapters,
|
|
36
|
+
loadAccountRegistry = realLoadAccountRegistry,
|
|
37
|
+
persistAccountRegistry = realPersistAccountRegistry,
|
|
38
|
+
config,
|
|
39
|
+
} = deps;
|
|
40
|
+
|
|
41
|
+
const acct = account(name, config);
|
|
42
|
+
const registry = loadAccountRegistry(acct.portalId, config);
|
|
43
|
+
|
|
44
|
+
const adapters = await loadAdapters();
|
|
45
|
+
// PULL runs in the REVERSE of push (topo) order. On pull the producers
|
|
46
|
+
// (theme/pages/content/blog) must tokenize their `@asset`/ref content BEFORE the
|
|
47
|
+
// asset-COLLECTOR (`assets`) scans the tree to download bytes — otherwise assets
|
|
48
|
+
// finds zero refs and downloads nothing (the pull-ordering bug). On push the
|
|
49
|
+
// forward topo order is correct: `assets`/`forms` run first to register target
|
|
50
|
+
// ids/URLs before consumers resolve() them.
|
|
51
|
+
const order = topoSort(adapters).reverse();
|
|
52
|
+
|
|
53
|
+
const ctx = { contentDir: contentDir(config), registry, config };
|
|
54
|
+
|
|
55
|
+
console.log(`pull <- account "${acct.name}" (portal ${acct.portalId})`);
|
|
56
|
+
console.log(`order: ${order.join(' -> ')}\n`);
|
|
57
|
+
|
|
58
|
+
const summary = [];
|
|
59
|
+
for (const adapterName of order) {
|
|
60
|
+
const adapter = adapters[adapterName];
|
|
61
|
+
if (typeof adapter.pull !== 'function') {
|
|
62
|
+
console.log(`- ${adapterName}: no pull() — skipped`);
|
|
63
|
+
summary.push({ adapter: adapterName, skipped: true });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
process.stdout.write(`- ${adapterName}: pulling… `);
|
|
67
|
+
const result = (await adapter.pull(acct, ctx)) || {};
|
|
68
|
+
// Persist after each adapter so a producer's registered refs survive even if a
|
|
69
|
+
// later adapter throws.
|
|
70
|
+
persistAccountRegistry(acct.portalId, registry, config);
|
|
71
|
+
const count = result.pulled ?? result.written ?? 0;
|
|
72
|
+
console.log(`done (${count})`);
|
|
73
|
+
for (const note of result.notes ?? []) console.log(` ${note}`);
|
|
74
|
+
summary.push({ adapter: adapterName, ...result });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
console.log('\nPull complete. Per-adapter summary:');
|
|
78
|
+
for (const s of summary) {
|
|
79
|
+
if (s.skipped) { console.log(` ${s.adapter}: skipped`); continue; }
|
|
80
|
+
console.log(` ${s.adapter}: ${s.pulled ?? s.written ?? 0}`);
|
|
81
|
+
}
|
|
82
|
+
console.log(`Registry persisted to .sync-state/${acct.portalId}.registry.json`);
|
|
83
|
+
|
|
84
|
+
return { account: acct.name, portalId: acct.portalId, order, summary };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// CLI entry.
|
|
88
|
+
const isMain = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
|
|
89
|
+
if (isMain) {
|
|
90
|
+
const name = process.argv[2];
|
|
91
|
+
if (!name) {
|
|
92
|
+
console.error('usage: node sync/pull.mjs <account>');
|
|
93
|
+
process.exit(2);
|
|
94
|
+
}
|
|
95
|
+
pull(name).catch((e) => {
|
|
96
|
+
console.error(`\npull failed: ${e.message}`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
});
|
|
99
|
+
}
|