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
package/src/push.mjs ADDED
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env node
2
+ // sync/push.mjs — PUSH orchestrator: canonical git tree -> HubSpot account.
3
+ //
4
+ // node sync/push.mjs <account> [--publish]
5
+ //
6
+ // Loads every sync/adapters/*.mjs, topo-sorts by dependsOn, and runs each adapter's
7
+ // push() in dependency order against the TARGET account. The order is load-bearing:
8
+ // the ROOT adapters `forms` and `assets` POPULATE the per-account registry (logical
9
+ // key -> target GUID / hosted url); downstream adapters (theme, pages, content, blog)
10
+ // RESOLVE those tokens via refs.resolve and HARD-FAIL on any unmapped ref. Running a
11
+ // consumer before its producer therefore aborts the push — exactly the contract topo
12
+ // order enforces.
13
+ //
14
+ // We PERSIST the registry after every adapter so the freshly-populated target mappings
15
+ // are durable for later adapters (and a re-run), and so a resolve() hard-fail aborts
16
+ // the whole push BEFORE more writes land.
17
+ //
18
+ // ⚠️ PRODUCTION (portal 529456) IS READ-ONLY. The first thing push() does — before
19
+ // loading a single adapter or touching the network — is HARD-GUARD: if the resolved
20
+ // account maps to portal 529456 it throws, regardless of CLI flags. There is no
21
+ // override.
22
+
23
+ import * as realFs from 'node:fs';
24
+ import { join, dirname } from 'node:path';
25
+
26
+ import { account as realAccount } from './lib/hub.mjs';
27
+ import { loadAdapters as realLoadAdapters, topoSort } from './lib/orchestrate.mjs';
28
+ import { listLogicalTokens } from './lib/refs.mjs';
29
+ import { resolveAssetBytesPath } from './adapters/assets.mjs';
30
+ import {
31
+ contentDir,
32
+ loadAccountRegistry as realLoadAccountRegistry,
33
+ persistAccountRegistry as realPersistAccountRegistry,
34
+ } from './lib/sync-state.mjs';
35
+
36
+ // The one portal we must never write to. Hard-coded by policy, not configurable.
37
+ export const READ_ONLY_PORTAL = '529456';
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // PUSH PREFLIGHT — account-independent producer-source check (fail-closed).
41
+ //
42
+ // THE HAZARD this exists to close: push() runs adapters in topo order and writes
43
+ // to the network as it goes. A consumer adapter (theme/pages/content/blog) calls
44
+ // refs.resolve() and HARD-FAILS on any @logical token with no TARGET mapping —
45
+ // but that throw lands MID-LOOP, after earlier producers (forms/assets) have
46
+ // already written to the account. The account is left half-updated.
47
+ //
48
+ // The preflight runs BEFORE the adapter loop (before ANY network write) and is
49
+ // ACCOUNT-INDEPENDENT: it does not look at any target registry. It only asks
50
+ // "does every @logical ref in the to-be-pushed canonical content have a backing
51
+ // PRODUCER SOURCE ON DISK?" — i.e. is the ref even SATISFIABLE in principle. If a
52
+ // ref can never be satisfied (e.g. @cta — no producer adapter exists yet), or its
53
+ // producer source is missing (e.g. a referenced @asset with no committed bytes),
54
+ // the push fails CLOSED here, before the account is touched.
55
+ //
56
+ // Satisfiability rules (the producer contracts, per refs.mjs + the adapters):
57
+ // @portal -> ALWAYS satisfiable (every account has a portal id).
58
+ // @form:<k> -> content/forms/<k>.json exists, OR <k> is a key in
59
+ // content/forms/guids.json (the on-disk @form producer source).
60
+ // @asset:<p> -> committed bytes exist for the asset key, under EITHER the
61
+ // unified assets tree (content/assets/<p>) OR the blog adapter's
62
+ // own manifest tree (content/blog/assets/<p>). Both are legitimate
63
+ // @asset producer sources: the assets adapter uploads
64
+ // content/assets/<p>, and blog.rehostAssets uploads its manifest
65
+ // files committed under content/blog/assets/<p>. The preflight
66
+ // accepts either so a blog-manifest @asset is satisfiable. (codex
67
+ // #6 asset-scheme unification.)
68
+ // @cta:<k> -> UNSATISFIABLE: no adapter produces @cta yet (known gap).
69
+ // @menu:<k> -> UNSATISFIABLE: no adapter produces @menu yet (known gap).
70
+ //
71
+ // Sources scanned — EVERY canonical content file that can carry a @logical token,
72
+ // found by RECURSIVELY walking each ref-bearing tree (so a new file dropped into a
73
+ // scanned tree is covered automatically — no hand-maintained file list to drift):
74
+ // content/pages/**.json (incl. *.widgets.json)
75
+ // content/blog/**.json EXCEPT the byte tree content/blog/assets/** — that
76
+ // carrier-EXEMPT tree holds the blog adapter's committed
77
+ // IMAGE BYTES (a @asset PRODUCER source), not tokens. This
78
+ // is the bug the broadened scan closes: the first full push
79
+ // never scanned content/blog/authors.json (an avatar @asset
80
+ // with no committed bytes) and only the assets adapter — a
81
+ // mid-loop, post-network-write throw — caught it. Scanning
82
+ // authors.json / tags.json / blogs.json / container.json /
83
+ // posts/** here fails that case CLOSED at preflight.
84
+ // content/forms/**.json EXCEPT properties.json + guids.json (producer sources,
85
+ // not token carriers; guids.json is the @form producer).
86
+ // theme ref-bearers at the repo root: js/hs-forms.js,
87
+ // modules/*/fields.json, modules/*/module.html
88
+ //
89
+ // EXEMPT (a @logical PRODUCER source / raw bytes, never a token carrier — scanning it
90
+ // is wrong, not merely redundant): content/assets/** and content/blog/assets/** hold
91
+ // binary image bytes; content/forms/{guids,properties}.json are form producer state.
92
+ //
93
+ // Pure + fs-injectable so it unit-tests with a fake fs and no network.
94
+ // ---------------------------------------------------------------------------
95
+
96
+ // Recursively list the *.json files under `dir`. Returns absolute paths. A missing
97
+ // dir yields []. `exclude(absPath)` is consulted for EVERY entry (file or dir): a
98
+ // dir for which it returns true is not descended (its whole subtree is skipped — e.g.
99
+ // the content/blog/assets byte tree), and a file for which it returns true is omitted
100
+ // (e.g. content/forms/guids.json, a producer source not a token carrier). `fs` is
101
+ // injected for testing. readdirSync uses withFileTypes so the fake fs in tests must
102
+ // supply Dirent-like entries — but to keep the fake fs minimal we detect directories
103
+ // via existsSync on the child rather than relying on Dirent. (See walk below.)
104
+ function listJsonFilesRecursive(fs, dir, exclude = () => false) {
105
+ if (!fs.existsSync(dir)) return [];
106
+ const out = [];
107
+ const walk = (d) => {
108
+ let names;
109
+ try {
110
+ names = fs.readdirSync(d);
111
+ } catch {
112
+ return; // not a directory / unreadable
113
+ }
114
+ for (const name of names) {
115
+ const full = join(d, name);
116
+ if (exclude(full)) continue;
117
+ // A child that itself lists children is a directory; recurse. Otherwise, if it
118
+ // ends in .json, it's a ref-carrier file we must scan. (Probing via readdirSync
119
+ // keeps the fake test fs free of Dirent types.)
120
+ if (isDir(fs, full)) walk(full);
121
+ else if (name.endsWith('.json')) out.push(full);
122
+ }
123
+ };
124
+ walk(dir);
125
+ return out;
126
+ }
127
+
128
+ // True if `p` is a directory under the injected fs. The fake test fs models dirs as
129
+ // "readdirSync succeeds"; the real fs has statSync, so prefer it when present.
130
+ function isDir(fs, p) {
131
+ if (typeof fs.statSync === 'function') {
132
+ try {
133
+ return fs.statSync(p).isDirectory();
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+ try {
139
+ fs.readdirSync(p);
140
+ return true;
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+
146
+ // The repo-root theme files that carry @logical tokens. `contentDir` is
147
+ // `<root>/content`, so the theme tree is its sibling at `<root>`.
148
+ function themeRefFiles(fs, root) {
149
+ const files = [join(root, 'js', 'hs-forms.js')];
150
+ const modulesDir = join(root, 'modules');
151
+ if (fs.existsSync(modulesDir)) {
152
+ for (const ent of fs.readdirSync(modulesDir)) {
153
+ files.push(join(modulesDir, ent, 'fields.json'));
154
+ files.push(join(modulesDir, ent, 'module.html'));
155
+ }
156
+ }
157
+ return files.filter((f) => fs.existsSync(f));
158
+ }
159
+
160
+ // Build the set of @form keys that HAVE a producer source on disk, from both the
161
+ // per-form files (content/forms/<k>.json) and the keyed content/forms/guids.json.
162
+ function knownFormKeys(fs, formsDir) {
163
+ const keys = new Set();
164
+ if (fs.existsSync(formsDir)) {
165
+ for (const n of fs.readdirSync(formsDir)) {
166
+ if (!n.endsWith('.json')) continue;
167
+ if (n === 'properties.json' || n === 'guids.json') continue;
168
+ keys.add(n.slice(0, -'.json'.length));
169
+ }
170
+ }
171
+ const guidsFile = join(formsDir, 'guids.json');
172
+ if (fs.existsSync(guidsFile)) {
173
+ try {
174
+ const obj = JSON.parse(fs.readFileSync(guidsFile, 'utf8'));
175
+ if (obj && typeof obj === 'object') for (const k of Object.keys(obj)) keys.add(k);
176
+ } catch {
177
+ /* a malformed guids.json contributes no keys (the offending @form refs then fail) */
178
+ }
179
+ }
180
+ return keys;
181
+ }
182
+
183
+ /**
184
+ * preflightRefs(contentDir, deps) — account-independent satisfiability check.
185
+ * Scans the canonical content + theme ref-bearing files for @logical tokens and
186
+ * verifies each has a backing producer SOURCE on disk. THROWS an aggregated error
187
+ * naming EVERY unsatisfiable ref (with the file it appears in) if any is found;
188
+ * returns the list of scanned files on success. Pure aside from the injected fs.
189
+ *
190
+ * @param {string} contentDirPath absolute path to the canonical content/ tree
191
+ * @param {{ fs?: typeof import('node:fs') }} [deps] fs seam for tests
192
+ * @returns {{ scanned: string[] }}
193
+ */
194
+ export function preflightRefs(contentDirPath, deps = {}) {
195
+ const fs = deps.fs || realFs;
196
+ const root = dirname(contentDirPath); // <root>/content -> <root>
197
+ const formsDir = join(contentDirPath, 'forms');
198
+ const formKeys = knownFormKeys(fs, formsDir);
199
+
200
+ // Carrier-EXEMPT paths: PRODUCER sources / raw bytes that are NOT token carriers, so
201
+ // they must be skipped by the recursive walk (the assets trees hold binary bytes;
202
+ // the forms producer files hold @form-source state, not refs).
203
+ const blogAssetsDir = join(contentDirPath, 'blog', 'assets'); // blog byte tree
204
+ const formsGuids = join(formsDir, 'guids.json'); // @form producer source
205
+ const formsProps = join(formsDir, 'properties.json'); // form field producer source
206
+ const excludeBlog = (p) => p === blogAssetsDir; // skip the whole blog byte subtree
207
+ const excludeForms = (p) => p === formsGuids || p === formsProps;
208
+
209
+ // Every file we must scan for tokens: RECURSIVELY across each ref-bearing canonical
210
+ // tree (pages, blog-minus-bytes, forms-minus-producers) plus the theme ref-bearers.
211
+ // The recursion means newly-added files (e.g. content/blog/authors.json, which the
212
+ // first full push never scanned) are covered automatically.
213
+ const files = [
214
+ ...listJsonFilesRecursive(fs, join(contentDirPath, 'pages')),
215
+ ...listJsonFilesRecursive(fs, join(contentDirPath, 'blog'), excludeBlog),
216
+ ...listJsonFilesRecursive(fs, formsDir, excludeForms),
217
+ ...themeRefFiles(fs, root),
218
+ ];
219
+
220
+ // Collect EVERY unsatisfiable ref before throwing (operator fixes them in one pass).
221
+ const offenders = []; // { file, token, reason }
222
+ for (const file of files) {
223
+ let text;
224
+ try {
225
+ text = fs.readFileSync(file, 'utf8');
226
+ } catch {
227
+ continue; // unreadable file contributes no tokens
228
+ }
229
+ for (const { kind, key, token } of listLogicalTokens(text)) {
230
+ if (kind === 'portal') continue; // always satisfiable
231
+ if (kind === 'form') {
232
+ if (!formKeys.has(key)) {
233
+ offenders.push({ file, token, reason: `no content/forms/${key}.json and not in guids.json` });
234
+ }
235
+ continue;
236
+ }
237
+ if (kind === 'asset') {
238
+ // An @asset's committed bytes may live in EITHER scheme's tree
239
+ // (content/assets/<key> for the assets adapter, content/blog/assets/<key>
240
+ // for the blog manifest). resolveAssetBytesPath unifies both — a
241
+ // blog-manifest @asset is satisfiable here. (codex #6.)
242
+ if (!resolveAssetBytesPath(contentDirPath, key, fs.existsSync)) {
243
+ offenders.push({
244
+ file,
245
+ token,
246
+ reason: `no committed bytes at content/assets/${key} or content/blog/assets/${key}`,
247
+ });
248
+ }
249
+ continue;
250
+ }
251
+ // @cta / @menu — no producer adapter exists yet (known gap): UNSATISFIABLE.
252
+ offenders.push({ file, token, reason: `no producer for @${kind} (unsatisfiable)` });
253
+ }
254
+ }
255
+
256
+ if (offenders.length > 0) {
257
+ const lines = offenders
258
+ .map((o) => ` ${o.token} in ${o.file} — ${o.reason}`)
259
+ .sort();
260
+ throw new Error(
261
+ `push preflight: ${offenders.length} unsatisfiable @logical ref(s) have no producer source on disk; ` +
262
+ `push refuses to run (fail-closed before any network write):\n${lines.join('\n')}`,
263
+ );
264
+ }
265
+
266
+ return { scanned: files };
267
+ }
268
+
269
+ // `deps` is a hidden test seam: production callers pass nothing and get the real
270
+ // hub/orchestrate/sync-state functions. Unit tests inject fakes so push() can be
271
+ // exercised with no network and no real .sync-state writes.
272
+ export async function push(name, options = {}, deps = {}) {
273
+ const { publish = false, config: optionConfig } = options;
274
+ const {
275
+ account = realAccount,
276
+ loadAdapters = realLoadAdapters,
277
+ loadAccountRegistry = realLoadAccountRegistry,
278
+ persistAccountRegistry = realPersistAccountRegistry,
279
+ fs = realFs,
280
+ } = deps;
281
+ const config = deps.config || optionConfig;
282
+
283
+ const acct = account(name, config);
284
+
285
+ // HARD GUARD #1 (FIRST, before the preflight) — refuse to write to production no
286
+ // matter what was asked.
287
+ const readOnly = new Set((config?.readOnlyPortalIds?.length ? config.readOnlyPortalIds : [READ_ONLY_PORTAL]).map(String));
288
+ if (readOnly.has(String(acct.portalId))) {
289
+ throw new Error(
290
+ `portal is read-only: account "${acct.name}" maps to portal ${acct.portalId}; push refuses to run`,
291
+ );
292
+ }
293
+
294
+ // PREFLIGHT (account-independent) — verify every @logical ref in the to-be-pushed
295
+ // canonical content has a backing producer source on disk. Runs BEFORE the adapter
296
+ // loop (before ANY network write / registry load) so an unsatisfiable ref fails the
297
+ // push CLOSED instead of half-updating the account on a mid-loop resolve() throw.
298
+ preflightRefs(contentDir(config), { fs });
299
+
300
+ const registry = loadAccountRegistry(acct.portalId, config);
301
+
302
+ const adapters = await loadAdapters();
303
+ const order = topoSort(adapters);
304
+
305
+ const ctx = { contentDir: contentDir(config), registry, publish, config };
306
+
307
+ console.log(`push -> account "${acct.name}" (portal ${acct.portalId})${publish ? ' [--publish]' : ''}`);
308
+ console.log(`order: ${order.join(' -> ')}\n`);
309
+
310
+ const summary = [];
311
+ for (const adapterName of order) {
312
+ const adapter = adapters[adapterName];
313
+ if (typeof adapter.push !== 'function') {
314
+ console.log(`- ${adapterName}: no push() — skipped`);
315
+ summary.push({ adapter: adapterName, skipped: true });
316
+ continue;
317
+ }
318
+ process.stdout.write(`- ${adapterName}: pushing… `);
319
+ // A resolve() hard-fail inside an adapter throws here and aborts the whole push
320
+ // (the surrounding try in the CLI handler / caller); nothing further is written.
321
+ const result = (await adapter.push(acct, ctx)) || {};
322
+ // Persist immediately so forms/assets target mappings are durable before the next
323
+ // (consuming) adapter resolves against them.
324
+ persistAccountRegistry(acct.portalId, registry, config);
325
+ const count = result.pushed ?? 0;
326
+ console.log(`done (${count})`);
327
+ for (const note of result.notes ?? []) console.log(` ${note}`);
328
+ summary.push({ adapter: adapterName, ...result });
329
+ }
330
+
331
+ console.log('\nPush complete. Per-adapter summary:');
332
+ for (const s of summary) {
333
+ if (s.skipped) { console.log(` ${s.adapter}: skipped`); continue; }
334
+ console.log(` ${s.adapter}: ${s.pushed ?? 0}`);
335
+ }
336
+
337
+ return { account: acct.name, portalId: acct.portalId, order, summary };
338
+ }
339
+
340
+ // CLI entry.
341
+ const isMain = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
342
+ if (isMain) {
343
+ const args = process.argv.slice(2);
344
+ const publish = args.includes('--publish');
345
+ const name = args.find((a) => !a.startsWith('--'));
346
+ if (!name) {
347
+ console.error('usage: node sync/push.mjs <account> [--publish]');
348
+ process.exit(2);
349
+ }
350
+ push(name, { publish }).catch((e) => {
351
+ console.error(`\npush failed: ${e.message}`);
352
+ process.exit(1);
353
+ });
354
+ }
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ // Republish CMS pages/posts so template/CSS/asset changes take effect.
3
+
4
+ import { readFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { homedir } from 'node:os';
7
+ import { account as resolveAccount } from './lib/hub.mjs';
8
+
9
+ const API = 'https://api.hubapi.com';
10
+
11
+ function future() {
12
+ return new Date(Date.now() + 90_000).toISOString().replace(/\.\d+Z$/, '.000Z');
13
+ }
14
+
15
+ function parse(argv) {
16
+ let portal;
17
+ let account;
18
+ let all = false;
19
+ let blog = false;
20
+ const slugs = [];
21
+ for (let i = 0; i < argv.length; i += 1) {
22
+ if (argv[i] === '--portal') portal = argv[++i];
23
+ else if (argv[i] === '--account') account = argv[++i];
24
+ else if (argv[i] === '--all') all = true;
25
+ else if (argv[i] === '--blog') blog = true;
26
+ else if (!account && !portal && !argv[i].startsWith('--')) account = argv[i];
27
+ else slugs.push(argv[i]);
28
+ }
29
+ return { portal, account, all, blog, slugs };
30
+ }
31
+
32
+ function keyForPortal(portal, config) {
33
+ const dir = config?.keyDir || process.env[config?.keyDirEnv || 'HUBSPOT_KEY_DIR'] || join(homedir(), '.hubspot');
34
+ return readFileSync(join(dir, `${portal}.key`), 'utf8').trim();
35
+ }
36
+
37
+ async function republish(argv = process.argv.slice(2), opts = {}) {
38
+ const { config } = opts;
39
+ const parsed = parse(argv);
40
+ let portal = parsed.portal;
41
+ let key;
42
+ if (parsed.account && !portal) {
43
+ const acct = resolveAccount(parsed.account, config);
44
+ portal = acct.portalId;
45
+ key = acct.key;
46
+ }
47
+ if (!portal) {
48
+ process.stderr.write('usage: hcms republish <account>|--portal <id> [slug...] [--all] [--blog]\n');
49
+ return 1;
50
+ }
51
+ key ||= keyForPortal(portal, config);
52
+
53
+ async function hub(method, path, body) {
54
+ const r = await fetch(API + path, {
55
+ method,
56
+ headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
57
+ body: body && JSON.stringify(body),
58
+ });
59
+ return { ok: r.ok, status: r.status, j: await r.json().catch(() => ({})) };
60
+ }
61
+ async function getAll(path) {
62
+ const out = [];
63
+ let after;
64
+ do {
65
+ const sep = path.includes('?') ? '&' : '?';
66
+ const { j } = await hub('GET', `${path}${sep}limit=100${after ? `&after=${after}` : ''}`);
67
+ out.push(...(j.results || []));
68
+ after = j.paging?.next?.after;
69
+ } while (after);
70
+ return out;
71
+ }
72
+
73
+ const fut = future();
74
+ let ok = 0;
75
+ let fail = 0;
76
+ async function schedule(kind, id, publishDate) {
77
+ const body = { id: String(id), publishDate: publishDate || fut };
78
+ const { status } = await hub('POST', `/cms/v3/${kind}/schedule`, body);
79
+ status === 204 ? ok++ : (fail++, console.error(` ${kind} ${id} -> ${status}`));
80
+ }
81
+
82
+ const pages = await getAll('/cms/v3/pages/site-pages?property=id,slug,state');
83
+ const live = pages.filter((p) => p.state === 'PUBLISHED' || p.state === 'PUBLISHED_OR_SCHEDULED' || !p.state);
84
+ const targets = parsed.all ? live : live.filter((p) => parsed.slugs.includes(p.slug) || (p.slug === '' && parsed.slugs.includes('/')));
85
+ console.log(`republishing ${targets.length} page(s) on ${portal} @ ${fut}`);
86
+ for (const p of targets) await schedule('pages/site-pages', p.id);
87
+
88
+ if (parsed.blog) {
89
+ const posts = (await getAll('/cms/v3/blogs/posts?property=id,slug,state,publishDate'))
90
+ .filter((p) => p.state === 'PUBLISHED' && !/temporary-slug/.test(p.slug || ''));
91
+ console.log(`republishing ${posts.length} blog post(s) (preserving publishDate)`);
92
+ for (const p of posts) await schedule('blogs/posts', p.id, p.publishDate);
93
+ }
94
+ console.log(`scheduled ${ok} | failed ${fail} (live in ~90s)`);
95
+ return fail ? 1 : 0;
96
+ }
97
+
98
+ export { republish, republish as main };
99
+
100
+ if (import.meta.url === `file://${process.argv[1]}`) {
101
+ republish().then((code) => process.exit(code));
102
+ }