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