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,234 @@
1
+ // sync/lib/canonical.mjs
2
+ //
3
+ // Canonicalization for clean, portable git diffs in the bidirectional
4
+ // git <-> HubSpot sync system.
5
+ //
6
+ // PURE: no I/O, no network, no `process`/`fs`. Every export is a pure
7
+ // function so it can be unit-tested without a HubSpot account (per the
8
+ // codex review tier-1 requirement). Higher layers (adapters) do the I/O
9
+ // and call into these helpers.
10
+ //
11
+ // ─────────────────────────────────────────────────────────────────────────
12
+ // WHY canonicalization is RESOURCE/FIELD-SPECIFIC, not a blanket omit
13
+ // (codex finding #8, design §1.3 / §6.3):
14
+ //
15
+ // The naive approach — "recursively drop every null/'' and every empty
16
+ // object" — is WRONG for this corpus, and the codex review flagged it as a
17
+ // must-fix. Page MODULE CONTENT ("widget carriers": widgets /
18
+ // widgetContainers / layoutSections) is pushed back to HubSpot with a
19
+ // REPLACE-not-merge PATCH semantics: the complete carrier object is sent and
20
+ // HubSpot overwrites the whole widget, it does NOT merge field-by-field.
21
+ // That means a widget value of `css: {}`, `child_css: {}`, or `label: ""`
22
+ // is LOAD-BEARING — if canonicalization silently dropped it, the round-trip
23
+ // (pull -> canonical -> push) would send a thinner payload than HubSpot
24
+ // expects and blank-out / reset rendered styling. So inside widget carriers
25
+ // we KEEP empty css/child_css/label/body. The volatile-key stripping
26
+ // (stripVolatile) is therefore a *targeted* removal of per-account/volatile
27
+ // keys (ids, timestamps, urls, hashes, ...), NOT a "remove all empties" pass.
28
+ //
29
+ // Each exported transform documents exactly which fields it touches.
30
+ // ─────────────────────────────────────────────────────────────────────────
31
+
32
+ // Keys that are per-account or volatile and must NEVER land in a committed
33
+ // canonical file. Matched by exact name. *At / *ById are matched by suffix
34
+ // (see SUFFIX_AT / SUFFIX_BY_ID below) so new timestamp variants
35
+ // (publishDate, archivedAt, lastEditedAt, ...) are caught without an
36
+ // ever-growing list.
37
+ const VOLATILE_EXACT = new Set([
38
+ 'id',
39
+ 'currentState',
40
+ 'url',
41
+ 'hash',
42
+ 'folder',
43
+ 'children',
44
+ // publishDate is volatile per design §1.3 (HubSpot recomputes it on
45
+ // publish); it ends in "Date" not "At", so it is listed explicitly here.
46
+ 'publishDate',
47
+ ]);
48
+
49
+ // Suffix matchers for the families of volatile keys.
50
+ // *At -> createdAt, updatedAt, archivedAt, ...
51
+ // *ById -> createdById, updatedById, ...
52
+ function isVolatileKey(key, extra) {
53
+ if (VOLATILE_EXACT.has(key)) return true;
54
+ if (extra.has(key)) return true;
55
+ if (key.endsWith('ById')) return true;
56
+ // `...At` timestamp family (createdAt/updatedAt/archivedAt/...). Guard on
57
+ // length so a literal key named "At" (none in this corpus) doesn't trip,
58
+ // and avoid matching "...Cat"/"...Format" by requiring an uppercase boundary.
59
+ if (/[a-z0-9]At$/.test(key)) return true;
60
+ return false;
61
+ }
62
+
63
+ /**
64
+ * stableStringify(obj) -> string
65
+ *
66
+ * Deterministic JSON serialization for diff-clean commits:
67
+ * - object keys recursively SORTED (lexicographic, default JS string sort)
68
+ * - 2-space indent
69
+ * - LF line endings (JSON.stringify never emits CRLF)
70
+ * - trailing newline
71
+ * - arrays keep their order (order is meaningful: layoutSection rows,
72
+ * stats, logos, ...)
73
+ *
74
+ * Does NOT strip or normalize values — pair it with stripVolatile /
75
+ * canonicalPage first. The only transform is key ordering + formatting, so
76
+ * two semantically-equal but differently-key-ordered inputs serialize byte
77
+ * identically.
78
+ */
79
+ export function stableStringify(obj) {
80
+ return JSON.stringify(sortKeysDeep(obj), null, 2) + '\n';
81
+ }
82
+
83
+ // Recursively return a structurally-equal value with all object keys sorted.
84
+ // Arrays are mapped in place (order preserved). Primitives pass through.
85
+ function sortKeysDeep(value) {
86
+ if (Array.isArray(value)) return value.map(sortKeysDeep);
87
+ if (value && typeof value === 'object') {
88
+ const out = {};
89
+ for (const key of Object.keys(value).sort()) {
90
+ out[key] = sortKeysDeep(value[key]);
91
+ }
92
+ return out;
93
+ }
94
+ return value;
95
+ }
96
+
97
+ /**
98
+ * stripVolatile(obj, extraKeys=[]) -> obj
99
+ *
100
+ * Recursively removes per-account / volatile keys so the canonical tree
101
+ * carries no portal-specific identity. Returns a NEW value (does not mutate
102
+ * the input). Recurses through both objects and arrays.
103
+ *
104
+ * Removed: id, *At (createdAt/updatedAt/archivedAt/publishDate-family via
105
+ * explicit + suffix match), *ById, currentState, url, hash, folder, children,
106
+ * plus any names passed in `extraKeys`.
107
+ *
108
+ * This is a TARGETED key removal, NOT an empty/null omit (see the file
109
+ * header). Empty objects/strings that remain after stripping are preserved.
110
+ */
111
+ export function stripVolatile(obj, extraKeys = []) {
112
+ const extra = extraKeys instanceof Set ? extraKeys : new Set(extraKeys);
113
+ return strip(obj, extra);
114
+ }
115
+
116
+ function strip(value, extra) {
117
+ if (Array.isArray(value)) return value.map((v) => strip(v, extra));
118
+ if (value && typeof value === 'object') {
119
+ const out = {};
120
+ for (const [key, v] of Object.entries(value)) {
121
+ if (isVolatileKey(key, extra)) continue;
122
+ out[key] = strip(v, extra);
123
+ }
124
+ return out;
125
+ }
126
+ return value;
127
+ }
128
+
129
+ /**
130
+ * slugToFile(slug) -> filename
131
+ *
132
+ * Maps a HubSpot page slug to a safe canonical filename stem.
133
+ * - '' (homepage) -> 'home'
134
+ * - '/' inside the slug -> '__' (e.g. 'blog/x' -> 'blog__x')
135
+ *
136
+ * The empty-slug homepage cannot be a filename, so it gets the 'home'
137
+ * sentinel. '/' is not legal in a path segment, so it is escaped to '__'.
138
+ */
139
+ export function slugToFile(slug) {
140
+ if (slug === '' || slug == null) return 'home';
141
+ return String(slug).replace(/\//g, '__');
142
+ }
143
+
144
+ /**
145
+ * fileToSlug(name) -> slug
146
+ *
147
+ * Inverse of slugToFile.
148
+ * - 'home' -> ''
149
+ * - 'blog__x' -> 'blog/x'
150
+ *
151
+ * A trailing '.json' (or any extension) should be stripped by the caller
152
+ * before calling; fileToSlug operates on the bare stem.
153
+ */
154
+ export function fileToSlug(name) {
155
+ if (name === 'home' || name === '' || name == null) return '';
156
+ return String(name).replace(/__/g, '/');
157
+ }
158
+
159
+ // Fields kept on a canonical page widget carrier value. Per codex finding #8
160
+ // these are kept VERBATIM even when empty (css/child_css/label), because the
161
+ // push is replace-not-merge.
162
+ const WIDGET_KEEP = ['body', 'name', 'type', 'label', 'css', 'child_css'];
163
+
164
+ /**
165
+ * canonicalPage(rawPage) -> canonical page definition
166
+ *
167
+ * Produces the portable, slug-keyed page DEFINITION object:
168
+ * { slug, name, htmlTitle, metaDescription, language, templatePath, widgets }
169
+ *
170
+ * - slug: raw slug, defaulting to '' (homepage). NOT the volatile id.
171
+ * - name/htmlTitle/metaDescription: SEO/definition fields, defaulted to ''.
172
+ * - language: defaults to 'en'.
173
+ * - templatePath: kept as-is (the manifest-driven rewrite of non-portable
174
+ * marketplace/generated paths is a separate adapter
175
+ * concern, intentionally NOT done here so this stays pure).
176
+ * - widgets: normalized widget carrier map (see normalizeWidgets).
177
+ *
178
+ * Volatile page-level keys (id, url, *At, currentState, publishDate, ...) are
179
+ * simply not projected here. We project an explicit allow-list of definition
180
+ * fields rather than strip-everything-else, so the schema is stable and
181
+ * documented.
182
+ */
183
+ export function canonicalPage(rawPage) {
184
+ const raw = rawPage || {};
185
+ return {
186
+ slug: raw.slug ?? '',
187
+ name: raw.name ?? '',
188
+ htmlTitle: raw.htmlTitle ?? '',
189
+ metaDescription: raw.metaDescription ?? '',
190
+ language: raw.language ?? 'en',
191
+ templatePath: raw.templatePath ?? '',
192
+ widgets: normalizeWidgets(raw.widgets),
193
+ };
194
+ }
195
+
196
+ /**
197
+ * normalizeWidgets(widgets) -> normalized widget carrier map
198
+ *
199
+ * For each widget instance (keyed by instance name), keep exactly the
200
+ * carrier fields HubSpot needs for a replace-not-merge PATCH:
201
+ * body, name, type, label, css, child_css
202
+ *
203
+ * CRITICAL (codex #8): empty css/child_css/label and body sub-fields are
204
+ * KEPT, not omitted. `body` is passed through unchanged (its per-field values
205
+ * — including empty strings like section_id:'' — are the actual content and
206
+ * must survive). Only keys OUTSIDE the WIDGET_KEEP set (e.g. a stray volatile
207
+ * `id` HubSpot might echo on a widget) are dropped.
208
+ *
209
+ * Default empties are supplied for any missing carrier field so the pushed
210
+ * payload is always complete: label -> '', css/child_css -> {}, type ->
211
+ * 'module', body -> {}.
212
+ */
213
+ export function normalizeWidgets(widgets) {
214
+ if (!widgets || typeof widgets !== 'object') return {};
215
+ const out = {};
216
+ for (const [instanceName, raw] of Object.entries(widgets)) {
217
+ if (!raw || typeof raw !== 'object') continue;
218
+ const w = {};
219
+ for (const field of WIDGET_KEEP) {
220
+ if (field in raw) {
221
+ w[field] = raw[field];
222
+ } else {
223
+ // Supply explicit empties so the carrier is complete for push.
224
+ if (field === 'css' || field === 'child_css') w[field] = {};
225
+ else if (field === 'label') w[field] = '';
226
+ else if (field === 'type') w[field] = 'module';
227
+ else if (field === 'name') w[field] = instanceName;
228
+ else if (field === 'body') w[field] = {};
229
+ }
230
+ }
231
+ out[instanceName] = w;
232
+ }
233
+ return out;
234
+ }
@@ -0,0 +1,197 @@
1
+ // sync/lib/hub.mjs — account + HTTP layer for HubSpot bidirectional sync.
2
+ //
3
+ // The account/HTTP plumbing shared by every adapter and orchestrator. Pure,
4
+ // importable, unit-testable: account resolution and paging accumulation do not
5
+ // touch the network (paging is driven through an injectable hub function), and
6
+ // slug matching is a pure helper over a fetched list.
7
+ //
8
+ // Accounts: sync/accounts.json maps name -> { portalId, label }.
9
+ // Keys: per-portal service keys (Bearer) at $HUBSPOT_KEY_DIR/<portalId>.key
10
+ // (default ~/.hubspot/<portalId>.key). Never committed.
11
+ //
12
+ // Production (portalId 529456) is READ-ONLY. This module provides NO default
13
+ // that writes to prod — callers pass an explicit account object every time.
14
+
15
+ import { readFileSync, existsSync } from 'node:fs';
16
+ import { join, dirname } from 'node:path';
17
+ import { homedir } from 'node:os';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { loadConfigSyncFallback } from '../config.mjs';
20
+
21
+ const API = 'https://api.hubapi.com';
22
+
23
+ const __dirname = dirname(fileURLToPath(import.meta.url));
24
+
25
+ function fallbackConfig() {
26
+ return loadConfigSyncFallback();
27
+ }
28
+
29
+ function keyDir(cfg = fallbackConfig()) {
30
+ return cfg.keyDir || process.env[cfg.keyDirEnv || 'HUBSPOT_KEY_DIR'] || join(homedir(), '.hubspot');
31
+ }
32
+
33
+ /**
34
+ * Load and parse sync/accounts.json.
35
+ * @returns {object} parsed account registry (name -> { portalId, label })
36
+ */
37
+ export function loadAccounts(cfg = fallbackConfig()) {
38
+ const accountsPath = cfg.accountsPath || join(cfg.root || process.cwd(), cfg.accountsFile || 'sync/accounts.json');
39
+ let text;
40
+ try {
41
+ text = readFileSync(accountsPath, 'utf8');
42
+ } catch (e) {
43
+ throw new Error(`Cannot read accounts file at ${accountsPath}: ${e.message}`);
44
+ }
45
+ try {
46
+ return JSON.parse(text);
47
+ } catch (e) {
48
+ throw new Error(`Invalid JSON in ${accountsPath}: ${e.message}`);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Resolve an account by name to { name, portalId, key }.
54
+ * Reads the per-portal service key from $HUBSPOT_KEY_DIR/<portalId>.key.
55
+ * @param {string} name account name as keyed in accounts.json
56
+ * @returns {{ name: string, portalId: string, key: string }}
57
+ */
58
+ export function account(name, cfg = fallbackConfig()) {
59
+ const accounts = loadAccounts(cfg);
60
+ const entry = accounts[name];
61
+ if (!entry || typeof entry !== 'object' || !entry.portalId) {
62
+ const known = Object.keys(accounts)
63
+ .filter((k) => !k.startsWith('_'))
64
+ .join(', ');
65
+ throw new Error(`Unknown account "${name}". Known accounts: ${known || '(none)'}`);
66
+ }
67
+ const portalId = String(entry.portalId);
68
+ const dir = keyDir(cfg);
69
+ const keyFile = join(dir, `${portalId}.key`);
70
+ if (!existsSync(keyFile)) {
71
+ throw new Error(
72
+ `No key for account "${name}" (portal ${portalId}) at ${keyFile}\n` +
73
+ ` Create it: printf '%s' 'pat-naX-...' > ${keyFile} && chmod 600 ${keyFile}\n` +
74
+ ` (override the directory with $HUBSPOT_KEY_DIR)`
75
+ );
76
+ }
77
+ const key = readFileSync(keyFile, 'utf8').trim();
78
+ if (!key) {
79
+ throw new Error(`Key file ${keyFile} for account "${name}" is empty.`);
80
+ }
81
+ return { name, portalId, key };
82
+ }
83
+
84
+ /**
85
+ * Make an authenticated JSON request against the HubSpot API.
86
+ * Never throws on a non-2xx status — returns { ok, status, json } so callers
87
+ * decide how to react (some endpoints, e.g. slug lookups, treat 404 as "none").
88
+ * @param {{ key: string }} acct account object from account()
89
+ * @param {string} method HTTP method
90
+ * @param {string} path API path beginning with '/'
91
+ * @param {*} [body] optional JSON body
92
+ * @returns {Promise<{ ok: boolean, status: number, json: any }>}
93
+ */
94
+ export async function hub(acct, method, path, body) {
95
+ if (!acct || !acct.key) {
96
+ throw new Error('hub() requires an account object with a key (from account()).');
97
+ }
98
+ const res = await fetch(API + path, {
99
+ method,
100
+ headers: {
101
+ Authorization: `Bearer ${acct.key}`,
102
+ 'Content-Type': 'application/json',
103
+ },
104
+ body: body !== undefined ? JSON.stringify(body) : undefined,
105
+ });
106
+ const text = await res.text();
107
+ let json;
108
+ try {
109
+ json = text ? JSON.parse(text) : {};
110
+ } catch {
111
+ json = { raw: text };
112
+ }
113
+ return { ok: res.ok, status: res.status, json };
114
+ }
115
+
116
+ /**
117
+ * Follow v3 paging (paging.next.after), accumulating results[].
118
+ * Throws with a clear message on any non-ok page.
119
+ * @param {{ key: string }} acct account object
120
+ * @param {string} path API path (query string allowed)
121
+ * @returns {Promise<any[]>} concatenated results
122
+ */
123
+ export async function getAll(acct, path) {
124
+ const out = [];
125
+ let after;
126
+ do {
127
+ const sep = path.includes('?') ? '&' : '?';
128
+ const url = `${path}${sep}limit=100${after ? `&after=${after}` : ''}`;
129
+ const { ok, status, json } = await hub(acct, 'GET', url);
130
+ if (!ok) {
131
+ const msg = json?.message || json?.category || JSON.stringify(json).slice(0, 200);
132
+ throw new Error(`GET ${url} -> ${status}: ${msg}`);
133
+ }
134
+ out.push(...(json.results || []));
135
+ after = json.paging?.next?.after;
136
+ } while (after);
137
+ return out;
138
+ }
139
+
140
+ /**
141
+ * Pure: pick the page id whose slug matches `slug` from a v3 page list.
142
+ * '' (empty string) addresses the homepage. Exposed for unit testing.
143
+ * @param {any[]} pages array of page objects ({ id, slug })
144
+ * @param {string} slug normalized slug ('' = homepage)
145
+ * @returns {string|null} matched page id or null
146
+ */
147
+ export function matchPageSlug(pages, slug) {
148
+ const want = slug == null ? '' : String(slug);
149
+ for (const p of pages) {
150
+ if (String(p.slug ?? '') === want) return String(p.id);
151
+ }
152
+ return null;
153
+ }
154
+
155
+ /**
156
+ * Resolve a CMS site page id by slug ('' = homepage). Read call; returns null
157
+ * when no page matches.
158
+ * @param {{ key: string }} acct account object
159
+ * @param {string} slug normalized slug
160
+ * @returns {Promise<string|null>}
161
+ */
162
+ export async function resolvePageBySlug(acct, slug) {
163
+ const pages = await getAll(acct, '/cms/v3/pages/site-pages');
164
+ return matchPageSlug(pages, slug);
165
+ }
166
+
167
+ /**
168
+ * Pure: pick the legacy blog contentGroupId whose slug matches from the v2
169
+ * /content/api/v2/blogs `objects` array. Exposed for unit testing.
170
+ * @param {any[]} blogs array of blog containers ({ id, slug })
171
+ * @param {string} slug normalized blog slug
172
+ * @returns {string|null} matched contentGroupId or null
173
+ */
174
+ export function matchBlogSlug(blogs, slug) {
175
+ const want = slug == null ? '' : String(slug);
176
+ for (const b of blogs) {
177
+ if (String(b.slug ?? '') === want) return String(b.id);
178
+ }
179
+ return null;
180
+ }
181
+
182
+ /**
183
+ * Resolve a blog container (contentGroupId) by slug via the legacy
184
+ * /content/api/v2/blogs endpoint. Returns null when no blog matches.
185
+ * Matching is by slug — never objects[0] — so a stale "Old" blog cannot win.
186
+ * @param {{ key: string }} acct account object
187
+ * @param {string} slug normalized blog slug
188
+ * @returns {Promise<string|null>}
189
+ */
190
+ export async function resolveBlogBySlug(acct, slug) {
191
+ const { ok, status, json } = await hub(acct, 'GET', '/content/api/v2/blogs?limit=100');
192
+ if (!ok) {
193
+ const msg = json?.message || json?.category || JSON.stringify(json).slice(0, 200);
194
+ throw new Error(`GET /content/api/v2/blogs -> ${status}: ${msg}`);
195
+ }
196
+ return matchBlogSlug(json.objects || [], slug);
197
+ }
@@ -0,0 +1,141 @@
1
+ // sync/lib/orchestrate.mjs — shared adapter loading + dependency ordering.
2
+ //
3
+ // The pull/push orchestrators (sync/pull.mjs, sync/push.mjs) both need to:
4
+ // 1. discover every adapter under sync/adapters/*.mjs and key it by `name`, and
5
+ // 2. run those adapters in DEPENDENCY ORDER (an adapter's `dependsOn` names must
6
+ // have run first).
7
+ //
8
+ // On PUSH the order is load-bearing: the `forms` and `assets` adapters POPULATE the
9
+ // per-account registry (logical key -> target id / hosted url) that downstream
10
+ // adapters (theme, pages, content, blog) RESOLVE via refs.resolve. Run a consumer
11
+ // before its producer and resolve() hard-fails on an unmapped ref. topoSort encodes
12
+ // that contract from each adapter's declared `dependsOn`, so neither orchestrator
13
+ // hardcodes a sequence — add an adapter, declare its deps, and the order follows.
14
+ //
15
+ // On PULL order is not strictly required (pull is read-only and auto-registers refs),
16
+ // but we run the SAME topo order for determinism and so a producer's registry entries
17
+ // exist before a consumer pulls (e.g. forms register source GUIDs first).
18
+ //
19
+ // PURE except for loadAdapters' dynamic import: topoSort is a pure function over a
20
+ // {name: {dependsOn}} map, so it unit-tests with fake adapter modules and no fs.
21
+
22
+ import { readdir } from 'node:fs/promises';
23
+ import { join, dirname } from 'node:path';
24
+ import { fileURLToPath, pathToFileURL } from 'node:url';
25
+
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ // sync/lib/orchestrate.mjs -> sync/adapters/
28
+ const ADAPTERS_DIR = join(__dirname, '..', 'adapters');
29
+
30
+ /**
31
+ * loadAdapters(dir?) -> { [name]: module }
32
+ *
33
+ * Dynamically import every `*.mjs` under sync/adapters/ and key each module by its
34
+ * exported `name`. Each adapter module exports: name, dependsOn[], pull(), push().
35
+ * Throws if two adapters declare the same `name` (ambiguous graph) or a module is
36
+ * missing its `name`.
37
+ *
38
+ * @param {string} [dir] override the adapters directory (used by tests)
39
+ * @returns {Promise<Record<string, any>>}
40
+ */
41
+ export async function loadAdapters(dir = ADAPTERS_DIR) {
42
+ const entries = await readdir(dir, { withFileTypes: true });
43
+ const files = entries
44
+ .filter((e) => e.isFile() && e.name.endsWith('.mjs'))
45
+ .map((e) => e.name)
46
+ .sort(); // deterministic import order
47
+
48
+ const adapters = {};
49
+ for (const file of files) {
50
+ const mod = await import(pathToFileURL(join(dir, file)).href);
51
+ const name = mod.name ?? mod.default?.name;
52
+ if (!name) {
53
+ throw new Error(`Adapter ${file} does not export a \`name\``);
54
+ }
55
+ if (adapters[name]) {
56
+ throw new Error(`Duplicate adapter name "${name}" (in ${file})`);
57
+ }
58
+ // Normalize to a single object carrying name/dependsOn/pull/push regardless of
59
+ // whether the adapter exported them individually or via `default`.
60
+ const def = mod.default ?? mod;
61
+ adapters[name] = {
62
+ name,
63
+ dependsOn: def.dependsOn ?? mod.dependsOn ?? [],
64
+ pull: def.pull ?? mod.pull,
65
+ push: def.push ?? mod.push,
66
+ module: mod,
67
+ };
68
+ }
69
+ return adapters;
70
+ }
71
+
72
+ /**
73
+ * topoSort(adapters) -> string[]
74
+ *
75
+ * Kahn/DFS topological sort of adapter NAMES by their `dependsOn`. A name appears
76
+ * AFTER every name it depends on. Deterministic: ties are broken alphabetically so a
77
+ * given graph always yields the same order.
78
+ *
79
+ * Throws on:
80
+ * - an unknown dependency (a `dependsOn` entry with no matching adapter), and
81
+ * - a dependency cycle (naming the offending nodes).
82
+ *
83
+ * `adapters` is any map of `name -> { dependsOn: string[] }` (the real adapter
84
+ * registry, or a fake one in tests).
85
+ *
86
+ * @param {Record<string, { dependsOn?: string[] }>} adapters
87
+ * @returns {string[]} adapter names in dependency order
88
+ */
89
+ export function topoSort(adapters) {
90
+ const names = Object.keys(adapters).sort(); // stable, alphabetical tie-break
91
+
92
+ // Validate dependencies up front so the error names the missing dep, not a
93
+ // confusing "cycle" later.
94
+ for (const name of names) {
95
+ for (const dep of adapters[name].dependsOn ?? []) {
96
+ if (!(dep in adapters)) {
97
+ throw new Error(`Adapter "${name}" dependsOn unknown adapter "${dep}"`);
98
+ }
99
+ }
100
+ }
101
+
102
+ // Kahn's algorithm: repeatedly emit a node whose deps are all already emitted.
103
+ // Among the currently-ready nodes we pick the alphabetically-first, so roots come
104
+ // out first and the order is fully deterministic (e.g. assets, forms before their
105
+ // consumers). This keeps producers (forms/assets) ahead of consumers and reads
106
+ // naturally in the orchestrator's printed order.
107
+ const remaining = new Set(names);
108
+ const order = [];
109
+
110
+ while (remaining.size) {
111
+ const ready = [...remaining]
112
+ .filter((name) => (adapters[name].dependsOn ?? []).every((dep) => !remaining.has(dep)))
113
+ .sort();
114
+ if (ready.length === 0) {
115
+ // Everything left is part of (or blocked by) a cycle. Report the offenders.
116
+ const cycle = describeCycle(adapters, remaining);
117
+ throw new Error(`Dependency cycle detected: ${cycle}`);
118
+ }
119
+ const next = ready[0];
120
+ order.push(next);
121
+ remaining.delete(next);
122
+ }
123
+
124
+ return order;
125
+ }
126
+
127
+ // Walk dependency edges among the still-unresolved nodes to surface a concrete cycle
128
+ // chain (a -> b -> c -> a) for the error message.
129
+ function describeCycle(adapters, remaining) {
130
+ const start = [...remaining].sort()[0];
131
+ const path = [];
132
+ const seen = new Set();
133
+ let node = start;
134
+ while (node != null && !seen.has(node)) {
135
+ seen.add(node);
136
+ path.push(node);
137
+ node = (adapters[node].dependsOn ?? []).find((dep) => remaining.has(dep));
138
+ }
139
+ if (node != null) path.push(node); // close the loop back to a repeated node
140
+ return path.join(' -> ');
141
+ }