hubspot-cms-sync 0.1.0 → 0.2.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 +1 -1
- package/README.md +46 -12
- package/bin/hubspot-cms-sync.mjs +120 -94
- package/docs/CONFIGURATION.md +5 -0
- package/docs/HUBSPOT-SYNC-NOTES.md +123 -0
- package/examples/hubspot-cms-sync.config.mjs +1 -0
- package/package.json +15 -3
- package/src/config.mjs +5 -0
- package/src/index.mjs +1 -0
- package/src/redirects.mjs +283 -0
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
Git-backed bidirectional HubSpot CMS sync for themes, site pages, page module
|
|
4
4
|
content, blogs, forms, and assets.
|
|
5
5
|
|
|
6
|
-
The package provides the `
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
The package provides the `hcms` CLI. The long binary name
|
|
7
|
+
`hubspot-cms-sync` is also installed, but examples use `hcms` for consistency.
|
|
8
|
+
The tool reads account and site settings from `hubspot-cms-sync.config.mjs`,
|
|
9
|
+
stores per-account identity in a gitignored `.sync-state/` directory, and
|
|
10
|
+
refuses to write to configured read-only portal ids.
|
|
10
11
|
|
|
11
12
|
## Install
|
|
12
13
|
|
|
@@ -24,21 +25,54 @@ npm install --save-dev ../hubspot-cms-sync
|
|
|
24
25
|
|
|
25
26
|
```bash
|
|
26
27
|
hcms doctor
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
hubspot-cms-sync push dev --publish
|
|
28
|
+
hcms pull dev
|
|
29
|
+
hcms preflight dev
|
|
30
30
|
hcms push dev --dry-run
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
hcms push dev --publish
|
|
32
|
+
hcms redirects dev --file content/redirects.csv
|
|
33
|
+
hcms redirects dev --file content/redirects.csv --apply
|
|
34
|
+
hcms republish dev --all --blog
|
|
35
|
+
hcms corpus
|
|
33
36
|
hcms manifest validate
|
|
34
37
|
```
|
|
35
38
|
|
|
39
|
+
`hcms redirects` is dry-run by default. Pass `--apply` to create or update
|
|
40
|
+
HubSpot URL redirects from a repo-stored CSV or JSON spec. Managed redirects
|
|
41
|
+
default to `301` and `isOnlyAfterNotFound: false`, so they can intentionally
|
|
42
|
+
take precedence over an existing live HubSpot page during a cutover.
|
|
43
|
+
|
|
36
44
|
## Configuration
|
|
37
45
|
|
|
38
46
|
Copy `examples/hubspot-cms-sync.config.mjs` into the consuming repo, then set
|
|
39
|
-
the theme name, manifest path, read-only portal ids, and
|
|
40
|
-
Account keys live outside git at
|
|
41
|
-
`~/.hubspot/<portalId>.key`.
|
|
47
|
+
the theme name, manifest path, redirect spec path, read-only portal ids, and
|
|
48
|
+
account registry path. Account keys live outside git at
|
|
49
|
+
`$HUBSPOT_KEY_DIR/<portalId>.key` or `~/.hubspot/<portalId>.key`.
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
export default {
|
|
53
|
+
accountsFile: 'sync/accounts.json',
|
|
54
|
+
contentDir: 'content',
|
|
55
|
+
manifestPath: 'site.manifest.json',
|
|
56
|
+
redirectsFile: 'content/redirects.csv',
|
|
57
|
+
readOnlyPortalIds: ['529456'],
|
|
58
|
+
theme: {
|
|
59
|
+
name: 'seventh-sense-theme',
|
|
60
|
+
dirs: ['templates', 'modules', 'css', 'js', 'images'],
|
|
61
|
+
files: ['theme.json', 'fields.json']
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
CSV redirects need at least these columns:
|
|
67
|
+
|
|
68
|
+
```csv
|
|
69
|
+
routePrefix,destination,redirectStyle
|
|
70
|
+
/old-page,/new-page,301
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Optional columns accepted by the HubSpot URL Redirects API include
|
|
74
|
+
`isOnlyAfterNotFound`, `isMatchFullUrl`, `isMatchQueryString`, `isPattern`,
|
|
75
|
+
`isProtocolAgnostic`, `isTrailingSlashOptional`, and `precedence`.
|
|
42
76
|
|
|
43
77
|
## Tests
|
|
44
78
|
|
package/bin/hubspot-cms-sync.mjs
CHANGED
|
@@ -1,39 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
|
|
4
6
|
import { loadConfig } from '../src/config.mjs';
|
|
5
7
|
import { pull } from '../src/pull.mjs';
|
|
6
|
-
import { push } from '../src/push.mjs';
|
|
8
|
+
import { push, preflightRefs } from '../src/push.mjs';
|
|
7
9
|
import { main as preflightMain } from '../src/preflight.mjs';
|
|
8
10
|
import { main as republishMain } from '../src/republish.mjs';
|
|
9
11
|
import { main as manifestMain } from '../src/manifest.mjs';
|
|
10
|
-
|
|
11
|
-
function usage() {
|
|
12
|
-
console.log(`usage: hcms [--root <path>] <command> [args]
|
|
13
|
-
|
|
14
|
-
commands:
|
|
15
|
-
pull <account>
|
|
16
|
-
push <account> [--publish] [--dry-run]
|
|
17
|
-
preflight <account>
|
|
18
|
-
republish <account|portalId> [--all] [--blog] [slugs...]
|
|
19
|
-
corpus [paths...]
|
|
20
|
-
manifest [args...]
|
|
21
|
-
doctor
|
|
22
|
-
`);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function parseGlobal(argv) {
|
|
26
|
-
const out = { root: process.cwd(), args: [] };
|
|
27
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
28
|
-
const a = argv[i];
|
|
29
|
-
if (a === '--root') {
|
|
30
|
-
out.root = argv[++i];
|
|
31
|
-
} else {
|
|
32
|
-
out.args.push(a);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
return out;
|
|
36
|
-
}
|
|
12
|
+
import { renderRedirectReport, syncRedirects } from '../src/redirects.mjs';
|
|
37
13
|
|
|
38
14
|
function runNodeScript(script, args, { cwd }) {
|
|
39
15
|
return new Promise((resolve) => {
|
|
@@ -42,74 +18,124 @@ function runNodeScript(script, args, { cwd }) {
|
|
|
42
18
|
});
|
|
43
19
|
}
|
|
44
20
|
|
|
45
|
-
async function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
21
|
+
async function withConfig(opts) {
|
|
22
|
+
return loadConfig({ root: opts.root });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function main(argv = process.argv) {
|
|
26
|
+
const program = new Command();
|
|
27
|
+
|
|
28
|
+
program
|
|
29
|
+
.name('hcms')
|
|
30
|
+
.description('Git-backed HubSpot CMS sync')
|
|
31
|
+
.option('--root <path>', 'repo root', process.cwd())
|
|
32
|
+
.showHelpAfterError();
|
|
33
|
+
|
|
34
|
+
program
|
|
35
|
+
.command('doctor')
|
|
36
|
+
.description('print resolved configuration')
|
|
37
|
+
.action(async () => {
|
|
38
|
+
const config = await withConfig(program.opts());
|
|
39
|
+
console.log(`root: ${config.root}`);
|
|
40
|
+
console.log(`accounts: ${config.accountsPath}`);
|
|
41
|
+
console.log(`content: ${config.contentDirPath}`);
|
|
42
|
+
console.log(`manifest: ${config.manifestFilePath}`);
|
|
43
|
+
console.log(`redirects: ${config.redirectsFilePath || '(none)'}`);
|
|
44
|
+
console.log(`sync state: ${config.syncStateDirPath}`);
|
|
45
|
+
console.log(`theme: ${config.theme.name}`);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
program
|
|
49
|
+
.command('pull')
|
|
50
|
+
.description('pull HubSpot content into the repo')
|
|
51
|
+
.argument('<account>')
|
|
52
|
+
.action(async (account) => {
|
|
53
|
+
const config = await withConfig(program.opts());
|
|
54
|
+
await pull(account, { config });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
program
|
|
58
|
+
.command('push')
|
|
59
|
+
.description('push repo content to HubSpot')
|
|
60
|
+
.argument('<account>')
|
|
61
|
+
.option('--publish', 'publish/schedule pushed content')
|
|
62
|
+
.option('--dry-run', 'run local push preflight only')
|
|
63
|
+
.action(async (account, options) => {
|
|
64
|
+
const config = await withConfig(program.opts());
|
|
65
|
+
if (options.dryRun) {
|
|
66
|
+
preflightRefs(config.contentDirPath);
|
|
67
|
+
console.log(`dry-run push preflight passed for ${account}`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
await push(account, { publish: !!options.publish, config });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
program
|
|
74
|
+
.command('preflight')
|
|
75
|
+
.description('check account readiness before a push')
|
|
76
|
+
.argument('<account>')
|
|
77
|
+
.action(async (account) => {
|
|
78
|
+
const config = await withConfig(program.opts());
|
|
79
|
+
const code = await preflightMain([account], { config });
|
|
80
|
+
if (code) process.exitCode = code;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
program
|
|
84
|
+
.command('redirects')
|
|
85
|
+
.description('plan or apply managed URL redirects')
|
|
86
|
+
.argument('<account>')
|
|
87
|
+
.option('--file <path>', 'redirect spec CSV or JSON; defaults to config.redirectsFile')
|
|
88
|
+
.option('--apply', 'write creates/updates to HubSpot')
|
|
89
|
+
.action(async (account, options) => {
|
|
90
|
+
const config = await withConfig(program.opts());
|
|
91
|
+
const result = await syncRedirects(account, {
|
|
92
|
+
file: options.file,
|
|
93
|
+
apply: !!options.apply,
|
|
94
|
+
config,
|
|
95
|
+
});
|
|
96
|
+
console.log(renderRedirectReport(result));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
program
|
|
100
|
+
.command('republish')
|
|
101
|
+
.description('republish live pages and/or blog posts')
|
|
102
|
+
.allowUnknownOption()
|
|
103
|
+
.allowExcessArguments()
|
|
104
|
+
.argument('[args...]')
|
|
105
|
+
.action(async (args) => {
|
|
106
|
+
const config = await withConfig(program.opts());
|
|
107
|
+
const code = await republishMain(args, { config });
|
|
108
|
+
if (code) process.exitCode = code;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
program
|
|
112
|
+
.command('corpus')
|
|
113
|
+
.description('scan repo content for unsafe refs and HubSpot artifacts')
|
|
114
|
+
.allowUnknownOption()
|
|
115
|
+
.argument('[paths...]')
|
|
116
|
+
.action(async (paths) => {
|
|
117
|
+
const config = await withConfig(program.opts());
|
|
118
|
+
const script = new URL('../src/corpus-scan.mjs', import.meta.url).pathname;
|
|
119
|
+
const code = await runNodeScript(script, paths, { cwd: config.root });
|
|
120
|
+
if (code) process.exitCode = code;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
program
|
|
124
|
+
.command('manifest')
|
|
125
|
+
.description('manifest utilities')
|
|
126
|
+
.allowUnknownOption()
|
|
127
|
+
.allowExcessArguments()
|
|
128
|
+
.argument('[args...]')
|
|
129
|
+
.action(async (args) => {
|
|
130
|
+
const config = await withConfig(program.opts());
|
|
131
|
+
const code = await manifestMain(args, { config });
|
|
132
|
+
if (code) process.exitCode = code;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await program.parseAsync(argv);
|
|
108
136
|
}
|
|
109
137
|
|
|
110
|
-
main().
|
|
111
|
-
process.exitCode = code;
|
|
112
|
-
}).catch((e) => {
|
|
138
|
+
main().catch((e) => {
|
|
113
139
|
console.error(`hcms failed: ${e.message}`);
|
|
114
140
|
process.exitCode = 1;
|
|
115
141
|
});
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -12,6 +12,7 @@ export default {
|
|
|
12
12
|
contentDir: 'content',
|
|
13
13
|
syncStateDir: '.sync-state',
|
|
14
14
|
manifestPath: 'site.manifest.json',
|
|
15
|
+
redirectsFile: 'content/redirects.csv',
|
|
15
16
|
readOnlyPortalIds: ['529456'],
|
|
16
17
|
knownPortalIds: ['529456', '246389711'],
|
|
17
18
|
assetHosts: {
|
|
@@ -63,8 +64,12 @@ export default {
|
|
|
63
64
|
- The config loader validates shape and prints remediation, not stack traces.
|
|
64
65
|
- `site.manifest.json` remains the deploy surface for content, pages, forms, and
|
|
65
66
|
blog.
|
|
67
|
+
- Repo-stored redirects are a separate deploy surface. `redirectsFile` points to
|
|
68
|
+
a CSV or JSON spec consumed by `hcms redirects <account> [--apply]`.
|
|
66
69
|
- `hubspot-cms-sync.config.mjs` controls environment policy and filesystem
|
|
67
70
|
layout.
|
|
71
|
+
- Managed redirects default to `isOnlyAfterNotFound: false`, so a release can
|
|
72
|
+
intentionally redirect an old live page path without a manual archive step.
|
|
68
73
|
|
|
69
74
|
## Open Config Questions
|
|
70
75
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# HubSpot Sync — Gotchas & Operational Notes
|
|
2
|
+
|
|
3
|
+
_HubSpot CMS API gotchas hit while building the [seventh-sense](https://github.com/telepathdata/7thsense-website) reference site with this engine. Examples reference that site; the behaviors are general._
|
|
4
|
+
|
|
5
|
+
Hard-won notes from migrating the Seventh Sense site/blog onto a CMS sandbox.
|
|
6
|
+
**Read this before pointing `sync/*` at production.** Every item here cost real
|
|
7
|
+
debugging time; they are the difference between a sync script that works and one
|
|
8
|
+
that silently does the wrong thing.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 1. Credentials & scopes — the #1 time sink
|
|
13
|
+
|
|
14
|
+
There are three credential types and they are **not** interchangeable:
|
|
15
|
+
|
|
16
|
+
| Type | Looks like | Use it for | Gotcha |
|
|
17
|
+
|---|---|---|---|
|
|
18
|
+
| **Personal Access Key (PAK)** | `CiRu…` (base64) or surfaced as a long token | the `hs` CLI (theme upload) | **Cannot grant the `content` scope** — so PAKs *cannot* create CMS pages/blog posts. PAK scopes are also **immutable**: "editing" = generating a new key. |
|
|
19
|
+
| **Service Key** (BETA) | `pat-na2-…` | our `sync/*` scripts (direct Bearer) | **Editable** scopes (no regen), but scope changes have **propagation lag** (~30–60s) — a call can still 401 right after you save. Retry before assuming failure. |
|
|
20
|
+
| **Private App token** | `pat-na1-…` | alternative to service key | Also grants `content`; heavier to manage. |
|
|
21
|
+
|
|
22
|
+
- Use service keys as `Authorization: Bearer <key>` directly — **no exchange**. (A `CiRu…` value is a PAK refresh token; it 401s as a Bearer and must be exchanged at `POST /localdevauth/v1/auth/refresh`.)
|
|
23
|
+
- **Read vs write are separate scopes.** `crm.schemas.contacts.write` does **not** include `.read`. The forms adapter must work write-only, so it create-or-patches instead of relying on list-then-decide behavior. Design sync scripts to not *require* the read scope.
|
|
24
|
+
- Per-operation scopes we actually needed:
|
|
25
|
+
- Pages create/publish: **`content`**
|
|
26
|
+
- Blog posts create/publish: **`content`**
|
|
27
|
+
- File Manager (image re-host / recovery): **`files`**
|
|
28
|
+
- Create HubSpot forms: **`forms`** (`forms-write`)
|
|
29
|
+
- Create/patch custom contact properties: **`crm.schemas.contacts.write`**
|
|
30
|
+
- Domains / business-units reads: a domains scope we never got on the sandbox key (blog-create needs it; see §4).
|
|
31
|
+
|
|
32
|
+
## 2. Account types — not all can host content
|
|
33
|
+
|
|
34
|
+
- **`DEVELOPER_TEST`** accounts (created from an app-developer account) include Design Manager (theme dev) but **cannot host CMS pages** — the `content`/`cms.pages.site_pages.write` scope is not grantable. Theme upload works, page creation never will.
|
|
35
|
+
- A **free CMS Developer Sandbox** (`accountType: STANDARD`, signup at `app.hubspot.com/signup-hubspot/cms-developers`) **can** host pages/blog. This is the right target for a staging mirror.
|
|
36
|
+
- Check `accountType` (via the PAK token exchange response `accountType`, or `/account-info/v3/details`) before assuming a portal can take content.
|
|
37
|
+
|
|
38
|
+
## 3. Publishing — the two big quirks
|
|
39
|
+
|
|
40
|
+
- **`POST …/draft/push-live` silently no-ops on first publish.** It returns `204` but the content stays `DRAFT`. **Use the schedule endpoint with a near-future date instead:**
|
|
41
|
+
`POST /cms/v3/pages/site-pages/schedule` (and `/cms/v3/blogs/posts/schedule`) with `{"id", "publishDate": <now + 90s>}`. It fires ~75–90s later and the page goes live.
|
|
42
|
+
- **`publishDate` must be in the future** — "now" is rejected with `publishDate must be in the future`.
|
|
43
|
+
- **Pages/posts require a non-empty title** to publish (`CONTENT_TITLE_MISSING`). Set `htmlTitle`/`name` before scheduling.
|
|
44
|
+
- **Template changes do not re-render already-published content.** After editing a template, you must **re-publish every affected page/post** (schedule them again) to pick up the new template. This is why a dynamic blog-post template still showed old hardcoded content until all 68 posts were re-scheduled.
|
|
45
|
+
- **⚠️ Re-scheduling a post resets its `publishDate` to the scheduled time** — bulk re-scheduling 68 posts clobbered their original 2017–2026 dates to "today," wrecking chronological order. Fix: `PATCH /cms/v3/blogs/posts/{id}` with the original `publishDate` (keeps it `PUBLISHED`, restores the date). The blog adapter always sends `publishDate` from the snapshot so a re-push restores dates.
|
|
46
|
+
- **Invalidate a cached *blog listing*** by re-PUTting the blog (`PUT /content/api/v2/blogs/{id}`) — that flips the edge-cache tag from the old listing page (`CT-…`) to the blog (`B-…`) and serves the current `listing_template_path`.
|
|
47
|
+
- **Edge cache is aggressive:** blog listing/pages serve with `s-maxage=36000` (10h). Cache-busting query params do **not** bypass it. Publishing invalidates by `edge-cache-tag`; otherwise expect lag.
|
|
48
|
+
|
|
49
|
+
## 4. Blog specifics
|
|
50
|
+
|
|
51
|
+
- **Creating a blog is UI-gated.** `POST /content/api/v2/blogs` fails with `BLOG_HS_SITE_DOMAIN_WRITE_SCOPE_MISSING` ("publish blog on domain ''") even with `content` + a domain in the body — binding a blog to the `hs-sites` system domain needs UI-level permission a service key can't get. **Create the blog once in Settings → Website → Blog**, then automate everything else.
|
|
52
|
+
- **Updating a blog DOES work via API:** `PUT /content/api/v2/blogs/{id}` — use it to fix `slug`, `item_template_path`, `listing_template_path` after the UI creates it with wrong defaults (it defaults to `@hubspot/elevate` templates and a `seventh-sense-blog` slug).
|
|
53
|
+
- **`listing_page_id` overrides `listing_template_path`.** A blog auto-creates a listing *page* that renders `/blog` with its own (default) template, ignoring `listing_template_path`. That listing page is **not** reachable via the site-pages or legacy-pages API (404). **Fix that worked:** `PUT /content/api/v2/blogs/{id}` with `{"listing_page_id": 0}` to clear the override, then re-PUT the blog to bust the edge cache → `/blog` then renders `listing_template_path`.
|
|
54
|
+
- **Post slugs are full paths** (e.g. `blog/spf-dkim-…`), not relative to the blog root. Push them as-is.
|
|
55
|
+
- The legacy CMS blogs API can have a **stale "Old" blog** + many `DRAFT`/`SCHEDULED`/`-temporary-slug-` junk posts. Filter to `state == PUBLISHED`, non-`Old` blog, real slug, body length > 500 before migrating (see `blog-sync` pruning).
|
|
56
|
+
|
|
57
|
+
## 5. Images & File Manager
|
|
58
|
+
|
|
59
|
+
- Blog bodies reference images on **legacy hosts** (`cdn2.hubspot.net`, `f.hubspotusercontent00.net`) whose public URLs are often **404 / dead** — these images were deleted years ago and 404 on the live prod blog too. Don't expect 100% recovery (we got 50/172).
|
|
60
|
+
- Recover what's live via the current host; for dead URLs, fall back to **File Manager search** (`GET /files/v3/files/search?name=<stem>`) — needs the **`files`** scope.
|
|
61
|
+
- On push, **re-host** each image to the target File Manager (`POST /files/v3/files`, multipart, `options.access=PUBLIC_INDEXABLE`) and **rewrite** body/featured URLs to the new hosted URL. Never leave prod hotlinks.
|
|
62
|
+
|
|
63
|
+
## 6. Forms
|
|
64
|
+
|
|
65
|
+
- All redesign forms shipped as **static mockups** (`onsubmit="preventDefault(); alert(...)"`). Fix = keep the styled markup, POST to the **Forms Submission API**: `POST https://api.hsforms.com/submissions/v3/integration/submit/{portalId}/{formGuid}` (shared `js/hs-forms.js`). Don't use `{% form %}` embeds — they replace the custom design.
|
|
66
|
+
- **The submission API enforces the form's `required` fields.** A lighter entry point (e.g. the audit-cta email-only form) submitting to a fuller form gets `REQUIRED_FIELD` errors. Pattern: make forms **email-only-required at the API layer**, enforce richer UX with per-page HTML5 `required`. One form can then back multiple entry points.
|
|
67
|
+
- Custom fields must exist as **contact properties** first, and be on the form. The forms adapter upserts both.
|
|
68
|
+
|
|
69
|
+
## 7. Module fields & repeater defaults (refactor blocker)
|
|
70
|
+
|
|
71
|
+
- A field's **`name` cannot be a reserved word**: `name`, `label`, `id`, `type`, `body`, `children`, `default`, `required`, `locked`, etc. Upload fails with `field name cannot be 'X'`. Rename (e.g. `label`→`caption`, `name`→`title`, `body`→`body_html`) and update the `{{ item.X }}` HubL refs.
|
|
72
|
+
- **Theme deploys are per-file (Source Code API PUT), not transactional across files** — a mid-deploy failure (e.g. a bad field name) leaves some module files updated and others not, so a republish renders a *mixed/broken* page. Always re-run `sync:push` to completion (and re-verify fidelity) after fixing.
|
|
73
|
+
- **⚠️ HubSpot does NOT render repeater (group + `occurrence`) field DEFAULTS for `{% module %}` instances in coded templates** — neither for existing pages nor newly-created ones. Simple field defaults (text/richtext) render; **repeater loops come up empty**, so a page that relied on the repeater default loses all its repeated content (stats, cards, FAQ, pricing rows). This broke home-page fidelity in the Phase-2 module refactor.
|
|
74
|
+
- **Fixes:** (a) write the content as **field VALUES on the page instance** via the Pages API `widgets` map (the correct content/theme model — content lives on the page, template is generic); or (b) pass the content as **template-level tag params** `{% module "x" path=… group=[{…}] %}` (HubSpot honors these at render, unlike `fields.json` repeater defaults); or (c) a conditional `{% if module.items %}…{% else %}<orig>{% endif %}` fallback. Do **not** rely on repeater `default` arrays alone.
|
|
75
|
+
- **⚠️ Tag-param serialization (b) only works for SIMPLE content.** It worked for `big-stats` (`stats=[{"num":"+44%","caption":"…"}]`) but **broke for rich content** — repeater items containing inline SVG (`width="24"`) or quoted phrases (`"who's getting what"`) fail HubL param parsing (`Error parsing … encountered '24'`), because HubL string quoting in tag params doesn't cleanly accept JSON-escaped `\"`/apostrophes. **For rich/HTML repeater content, use the `widgets` API (page-instance JSON values), not tag params.** Pattern A is fine for plain-text repeaters; the `widgets` API is the universal path.
|
|
76
|
+
|
|
77
|
+
## 8. Transport / tooling environment
|
|
78
|
+
|
|
79
|
+
- **No CLI.** The entire sync runs on the HubSpot REST API via `fetch` with a service-key Bearer token (`~/.hubspot/<portalId>.key`). The theme uses the **CMS Source Code v3 API** (`GET/PUT /cms/v3/source-code/published/{metadata,content}/<theme>/<path>`); pages/blog/forms/content use their respective v3 APIs.
|
|
80
|
+
- We previously used the `hs` CLI for theme pull/push and **dropped it**: the whole-tree `hs cms upload` silently no-op'd (it mis-named the theme after the build dir), the per-dir form was unreliable, and the CLI dragged in `rollup` (which periodically broke on the npm optional-dependency bug). The per-file Source Code API PUT is deterministic and idempotent (create-or-replace by path).
|
|
81
|
+
- The CLI had **no page/blog content commands** anyway — content was always API-only; `hs cms` only managed theme assets. So every adapter is now uniform: one transport, one auth model.
|
|
82
|
+
|
|
83
|
+
## 9. Operational checklist for the unified sync
|
|
84
|
+
|
|
85
|
+
Production is currently read-only. The supported deployment target is the dev
|
|
86
|
+
sandbox `246389711`; the production portal `529456` is hard-blocked in `hcms push`.
|
|
87
|
+
|
|
88
|
+
1. Create service keys with the needed scopes:
|
|
89
|
+
- Prod pull key at `~/.hubspot/529456.key` (`content` plus read scopes needed for the resources you pull).
|
|
90
|
+
- Dev push key at `~/.hubspot/246389711.key` (`content`, `files`, `forms`, `crm.schemas.contacts.write`).
|
|
91
|
+
2. Confirm the target portal can host CMS content (§2).
|
|
92
|
+
3. Create the UI-gated resources once (§4): blog container, homepage designation,
|
|
93
|
+
domain setup, theme setting values, and native menus.
|
|
94
|
+
4. Pull production into git:
|
|
95
|
+
```sh
|
|
96
|
+
hcms pull -- prod
|
|
97
|
+
```
|
|
98
|
+
5. Review the diff and prove portability:
|
|
99
|
+
```sh
|
|
100
|
+
git diff
|
|
101
|
+
hcms corpus
|
|
102
|
+
```
|
|
103
|
+
6. Preflight the dev sandbox:
|
|
104
|
+
```sh
|
|
105
|
+
hcms preflight -- dev
|
|
106
|
+
```
|
|
107
|
+
7. Push and publish to dev:
|
|
108
|
+
```sh
|
|
109
|
+
hcms push -- dev --publish
|
|
110
|
+
```
|
|
111
|
+
8. Re-publish live pages/posts after template changes (§3):
|
|
112
|
+
```sh
|
|
113
|
+
hcms republish --portal 246389711 --all
|
|
114
|
+
hcms republish --portal 246389711 --all --blog
|
|
115
|
+
```
|
|
116
|
+
9. Verify the rendered site:
|
|
117
|
+
```sh
|
|
118
|
+
npm test
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
For the complete operator flow and GitHub Actions deployment path, use
|
|
122
|
+
[`sync-runbook.md`](./sync-runbook.md). For the architectural rationale, use
|
|
123
|
+
[`deployment-architecture.md`](./deployment-architecture.md).
|
package/package.json
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hubspot-cms-sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Git-backed bidirectional sync for HubSpot CMS themes, content, blogs, forms, and assets.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/7thsense/hubspot-cms-sync.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/7thsense/hubspot-cms-sync/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/7thsense/hubspot-cms-sync#readme",
|
|
7
15
|
"engines": {
|
|
8
16
|
"node": ">=20.10.0",
|
|
9
17
|
"npm": ">=10"
|
|
10
18
|
},
|
|
11
19
|
"bin": {
|
|
12
|
-
"hubspot-cms-sync": "
|
|
13
|
-
"hcms": "
|
|
20
|
+
"hubspot-cms-sync": "bin/hubspot-cms-sync.mjs",
|
|
21
|
+
"hcms": "bin/hubspot-cms-sync.mjs"
|
|
14
22
|
},
|
|
15
23
|
"exports": {
|
|
16
24
|
".": "./src/index.mjs",
|
|
@@ -18,6 +26,7 @@
|
|
|
18
26
|
"./lib/*": "./src/lib/*.mjs",
|
|
19
27
|
"./manifest": "./src/manifest.mjs",
|
|
20
28
|
"./preflight": "./src/preflight.mjs",
|
|
29
|
+
"./redirects": "./src/redirects.mjs",
|
|
21
30
|
"./cta-inventory": "./src/cta-inventory.mjs",
|
|
22
31
|
"./corpus-scan": "./src/corpus-scan.mjs"
|
|
23
32
|
},
|
|
@@ -37,5 +46,8 @@
|
|
|
37
46
|
"lint": "node --check bin/hubspot-cms-sync.mjs && node bin/hubspot-cms-sync.mjs --help >/dev/null",
|
|
38
47
|
"prepare": "npm run lint",
|
|
39
48
|
"prepublishOnly": "npm test && npm run lint && npm pack --dry-run"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"commander": "^14.0.3"
|
|
40
52
|
}
|
|
41
53
|
}
|
package/src/config.mjs
CHANGED
|
@@ -14,6 +14,7 @@ export function defaultConfig(root = process.cwd()) {
|
|
|
14
14
|
contentDir: 'content',
|
|
15
15
|
syncStateDir: '.sync-state',
|
|
16
16
|
manifestPath: 'site.manifest.json',
|
|
17
|
+
redirectsFile: null,
|
|
17
18
|
readOnlyPortalIds: [],
|
|
18
19
|
knownPortalIds: [],
|
|
19
20
|
assetHosts: {
|
|
@@ -67,6 +68,7 @@ export function resolveConfigPaths(cfg) {
|
|
|
67
68
|
contentDirPath: abs(cfg.contentDir),
|
|
68
69
|
syncStateDirPath: abs(cfg.syncStateDir),
|
|
69
70
|
manifestFilePath: abs(cfg.manifestPath),
|
|
71
|
+
redirectsFilePath: cfg.redirectsFile ? abs(cfg.redirectsFile) : null,
|
|
70
72
|
};
|
|
71
73
|
}
|
|
72
74
|
|
|
@@ -94,6 +96,9 @@ export function validateConfig(cfg) {
|
|
|
94
96
|
if (!cfg.contentDir) errors.push('missing contentDir');
|
|
95
97
|
if (!cfg.syncStateDir) errors.push('missing syncStateDir');
|
|
96
98
|
if (!cfg.manifestPath) errors.push('missing manifestPath');
|
|
99
|
+
if (cfg.redirectsFile != null && typeof cfg.redirectsFile !== 'string') {
|
|
100
|
+
errors.push('redirectsFile must be a string when set');
|
|
101
|
+
}
|
|
97
102
|
if (!Array.isArray(cfg.readOnlyPortalIds)) errors.push('readOnlyPortalIds must be an array');
|
|
98
103
|
if (!Array.isArray(cfg.knownPortalIds)) errors.push('knownPortalIds must be an array');
|
|
99
104
|
if (!cfg.theme?.name) errors.push('theme.name is required');
|
package/src/index.mjs
CHANGED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { account as realAccount, getAll as realGetAll, hub as realHub } from './lib/hub.mjs';
|
|
5
|
+
import { READ_ONLY_PORTAL } from './push.mjs';
|
|
6
|
+
|
|
7
|
+
const REQUIRED_FIELDS = ['routePrefix', 'destination'];
|
|
8
|
+
const BOOLEAN_FIELDS = [
|
|
9
|
+
'isMatchFullUrl',
|
|
10
|
+
'isMatchQueryString',
|
|
11
|
+
'isOnlyAfterNotFound',
|
|
12
|
+
'isPattern',
|
|
13
|
+
'isProtocolAgnostic',
|
|
14
|
+
'isTrailingSlashOptional',
|
|
15
|
+
];
|
|
16
|
+
const INTEGER_FIELDS = ['redirectStyle', 'precedence'];
|
|
17
|
+
|
|
18
|
+
function parseBool(value, field, rowNumber) {
|
|
19
|
+
const raw = String(value).trim().toLowerCase();
|
|
20
|
+
if (raw === 'true' || raw === '1' || raw === 'yes') return true;
|
|
21
|
+
if (raw === 'false' || raw === '0' || raw === 'no') return false;
|
|
22
|
+
throw new Error(`redirects row ${rowNumber}: ${field} must be true/false`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseInteger(value, field, rowNumber) {
|
|
26
|
+
const n = Number.parseInt(String(value).trim(), 10);
|
|
27
|
+
if (!Number.isFinite(n)) throw new Error(`redirects row ${rowNumber}: ${field} must be an integer`);
|
|
28
|
+
return n;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function parseCsv(text) {
|
|
32
|
+
const rows = [];
|
|
33
|
+
let row = [];
|
|
34
|
+
let field = '';
|
|
35
|
+
let inQuote = false;
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
38
|
+
const ch = text[i];
|
|
39
|
+
if (inQuote) {
|
|
40
|
+
if (ch === '"' && text[i + 1] === '"') {
|
|
41
|
+
field += '"';
|
|
42
|
+
i += 1;
|
|
43
|
+
} else if (ch === '"') {
|
|
44
|
+
inQuote = false;
|
|
45
|
+
} else {
|
|
46
|
+
field += ch;
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (ch === '"') {
|
|
51
|
+
inQuote = true;
|
|
52
|
+
} else if (ch === ',') {
|
|
53
|
+
row.push(field);
|
|
54
|
+
field = '';
|
|
55
|
+
} else if (ch === '\n') {
|
|
56
|
+
row.push(field);
|
|
57
|
+
rows.push(row);
|
|
58
|
+
row = [];
|
|
59
|
+
field = '';
|
|
60
|
+
} else if (ch !== '\r') {
|
|
61
|
+
field += ch;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (field !== '' || row.length > 0) {
|
|
65
|
+
row.push(field);
|
|
66
|
+
rows.push(row);
|
|
67
|
+
}
|
|
68
|
+
return rows.filter((r) => r.some((v) => String(v).trim() !== ''));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parseRedirectCsv(text) {
|
|
72
|
+
const rows = parseCsv(text);
|
|
73
|
+
if (rows.length === 0) return [];
|
|
74
|
+
const header = rows[0].map((h) => h.trim());
|
|
75
|
+
for (const field of REQUIRED_FIELDS) {
|
|
76
|
+
if (!header.includes(field)) throw new Error(`redirects CSV is missing required column "${field}"`);
|
|
77
|
+
}
|
|
78
|
+
return rows.slice(1).map((row, idx) => {
|
|
79
|
+
const record = {};
|
|
80
|
+
for (let i = 0; i < header.length; i += 1) record[header[i]] = row[i] ?? '';
|
|
81
|
+
return normalizeRedirect(record, idx + 2);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function normalizeRedirect(input, rowNumber = 1) {
|
|
86
|
+
const out = {};
|
|
87
|
+
for (const field of REQUIRED_FIELDS) {
|
|
88
|
+
const value = String(input[field] ?? '').trim();
|
|
89
|
+
if (!value) throw new Error(`redirects row ${rowNumber}: ${field} is required`);
|
|
90
|
+
out[field] = value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
out.redirectStyle = input.redirectStyle === undefined || String(input.redirectStyle).trim() === ''
|
|
94
|
+
? 301
|
|
95
|
+
: parseInteger(input.redirectStyle, 'redirectStyle', rowNumber);
|
|
96
|
+
|
|
97
|
+
// We intentionally set this by default so managed redirects can replace an
|
|
98
|
+
// existing live HubSpot page without a UI archive/move step.
|
|
99
|
+
out.isOnlyAfterNotFound = input.isOnlyAfterNotFound === undefined || String(input.isOnlyAfterNotFound).trim() === ''
|
|
100
|
+
? false
|
|
101
|
+
: parseBool(input.isOnlyAfterNotFound, 'isOnlyAfterNotFound', rowNumber);
|
|
102
|
+
|
|
103
|
+
for (const field of BOOLEAN_FIELDS) {
|
|
104
|
+
if (field === 'isOnlyAfterNotFound') continue;
|
|
105
|
+
if (input[field] !== undefined && String(input[field]).trim() !== '') {
|
|
106
|
+
out[field] = parseBool(input[field], field, rowNumber);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const field of INTEGER_FIELDS) {
|
|
110
|
+
if (field === 'redirectStyle') continue;
|
|
111
|
+
if (input[field] !== undefined && String(input[field]).trim() !== '') {
|
|
112
|
+
out[field] = parseInteger(input[field], field, rowNumber);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function readRedirectSpecs(file) {
|
|
119
|
+
const text = readFileSync(file, 'utf8');
|
|
120
|
+
if (file.endsWith('.json')) {
|
|
121
|
+
const raw = JSON.parse(text);
|
|
122
|
+
if (!Array.isArray(raw)) throw new Error('redirects JSON must be an array');
|
|
123
|
+
return raw.map((r, idx) => normalizeRedirect(r, idx + 1));
|
|
124
|
+
}
|
|
125
|
+
return parseRedirectCsv(text);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function payloadFor(spec) {
|
|
129
|
+
const out = {};
|
|
130
|
+
for (const field of ['routePrefix', 'destination', ...INTEGER_FIELDS, ...BOOLEAN_FIELDS]) {
|
|
131
|
+
if (spec[field] !== undefined) out[field] = spec[field];
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function planRedirects(specs, existing) {
|
|
137
|
+
const seenSpecs = new Set();
|
|
138
|
+
for (const spec of specs) {
|
|
139
|
+
if (seenSpecs.has(spec.routePrefix)) throw new Error(`duplicate managed redirect routePrefix "${spec.routePrefix}"`);
|
|
140
|
+
seenSpecs.add(spec.routePrefix);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const byRoute = new Map();
|
|
144
|
+
for (const redirect of existing) {
|
|
145
|
+
const route = String(redirect.routePrefix ?? '');
|
|
146
|
+
if (!route) continue;
|
|
147
|
+
if (byRoute.has(route)) throw new Error(`multiple existing HubSpot redirects share routePrefix "${route}"`);
|
|
148
|
+
byRoute.set(route, redirect);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return specs.map((spec) => {
|
|
152
|
+
const current = byRoute.get(spec.routePrefix);
|
|
153
|
+
if (!current) return { action: 'create', spec, body: payloadFor(spec) };
|
|
154
|
+
|
|
155
|
+
const body = payloadFor(spec);
|
|
156
|
+
const changes = {};
|
|
157
|
+
for (const [field, value] of Object.entries(body)) {
|
|
158
|
+
if (String(current[field]) !== String(value)) changes[field] = value;
|
|
159
|
+
}
|
|
160
|
+
if (Object.keys(changes).length === 0) {
|
|
161
|
+
return { action: 'unchanged', id: String(current.id), spec, current };
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
action: 'update',
|
|
165
|
+
id: String(current.id),
|
|
166
|
+
spec,
|
|
167
|
+
current,
|
|
168
|
+
body: changes,
|
|
169
|
+
changedFields: Object.keys(changes),
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function readOnlySet(config) {
|
|
175
|
+
return new Set((config?.readOnlyPortalIds?.length ? config.readOnlyPortalIds : [READ_ONLY_PORTAL]).map(String));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function syncRedirects(name, options = {}, deps = {}) {
|
|
179
|
+
const {
|
|
180
|
+
apply = false,
|
|
181
|
+
file,
|
|
182
|
+
config: optionConfig,
|
|
183
|
+
} = options;
|
|
184
|
+
const {
|
|
185
|
+
account = realAccount,
|
|
186
|
+
getAll = realGetAll,
|
|
187
|
+
hub = realHub,
|
|
188
|
+
readSpecs = readRedirectSpecs,
|
|
189
|
+
} = deps;
|
|
190
|
+
const config = deps.config || optionConfig;
|
|
191
|
+
const acct = account(name, config);
|
|
192
|
+
|
|
193
|
+
if (apply && readOnlySet(config).has(String(acct.portalId))) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`portal is read-only: account "${acct.name}" maps to portal ${acct.portalId}; redirects refuses to write`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const sourceFile = file || config?.redirectsFilePath;
|
|
200
|
+
if (!sourceFile) {
|
|
201
|
+
throw new Error('redirects requires --file <path> or config.redirectsFile');
|
|
202
|
+
}
|
|
203
|
+
const specs = readSpecs(resolve(config?.root || process.cwd(), sourceFile));
|
|
204
|
+
const existing = await getAll(acct, '/cms/v3/url-redirects');
|
|
205
|
+
const plan = planRedirects(specs, existing);
|
|
206
|
+
|
|
207
|
+
if (apply) {
|
|
208
|
+
for (const item of plan) {
|
|
209
|
+
if (item.action === 'create') {
|
|
210
|
+
const r = await hub(acct, 'POST', '/cms/v3/url-redirects', item.body);
|
|
211
|
+
if (!r.ok) {
|
|
212
|
+
const msg = r.json?.message || r.json?.category || JSON.stringify(r.json).slice(0, 200);
|
|
213
|
+
throw new Error(`create redirect ${item.spec.routePrefix} -> ${r.status}: ${msg}`);
|
|
214
|
+
}
|
|
215
|
+
item.id = String(r.json?.id ?? '');
|
|
216
|
+
} else if (item.action === 'update') {
|
|
217
|
+
const r = await hub(acct, 'PATCH', `/cms/v3/url-redirects/${item.id}`, item.body);
|
|
218
|
+
if (!r.ok) {
|
|
219
|
+
const msg = r.json?.message || r.json?.category || JSON.stringify(r.json).slice(0, 200);
|
|
220
|
+
throw new Error(`update redirect ${item.spec.routePrefix} (${item.id}) -> ${r.status}: ${msg}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
account: acct.name,
|
|
228
|
+
portalId: acct.portalId,
|
|
229
|
+
file: sourceFile,
|
|
230
|
+
apply,
|
|
231
|
+
plan,
|
|
232
|
+
counts: {
|
|
233
|
+
create: plan.filter((x) => x.action === 'create').length,
|
|
234
|
+
update: plan.filter((x) => x.action === 'update').length,
|
|
235
|
+
unchanged: plan.filter((x) => x.action === 'unchanged').length,
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function renderRedirectReport(result) {
|
|
241
|
+
const mode = result.apply ? 'applied' : 'dry-run';
|
|
242
|
+
const lines = [];
|
|
243
|
+
lines.push(`redirects ${mode} -> account "${result.account}" (portal ${result.portalId})`);
|
|
244
|
+
lines.push(`source: ${result.file}`);
|
|
245
|
+
lines.push(
|
|
246
|
+
`summary: ${result.counts.create} create, ${result.counts.update} update, ${result.counts.unchanged} unchanged`,
|
|
247
|
+
);
|
|
248
|
+
for (const item of result.plan) {
|
|
249
|
+
const arrow = `${item.spec.routePrefix} -> ${item.spec.destination}`;
|
|
250
|
+
if (item.action === 'update') {
|
|
251
|
+
lines.push(` [update] ${arrow} (${item.changedFields.join(', ')})`);
|
|
252
|
+
} else {
|
|
253
|
+
lines.push(` [${item.action}] ${arrow}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return lines.join('\n');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function main(argv = process.argv.slice(2), opts = {}) {
|
|
260
|
+
const accountName = argv.find((a) => !a.startsWith('--'));
|
|
261
|
+
if (!accountName) {
|
|
262
|
+
process.stderr.write('usage: node src/redirects.mjs <account> [--file <path>] [--apply]\n');
|
|
263
|
+
return 2;
|
|
264
|
+
}
|
|
265
|
+
let file;
|
|
266
|
+
let apply = false;
|
|
267
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
268
|
+
if (argv[i] === '--apply') apply = true;
|
|
269
|
+
if (argv[i] === '--file') file = argv[++i];
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
const result = await syncRedirects(accountName, { file, apply, config: opts.config }, opts.deps);
|
|
273
|
+
process.stdout.write(renderRedirectReport(result) + '\n');
|
|
274
|
+
return 0;
|
|
275
|
+
} catch (e) {
|
|
276
|
+
process.stderr.write(`redirects failed: ${e.message}\n`);
|
|
277
|
+
return 1;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
282
|
+
main().then((code) => process.exit(code));
|
|
283
|
+
}
|