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,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
+ }