@minamocms/framework 0.0.0-stage → 0.10.2
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/CHANGELOG.md +240 -0
- package/LICENSE +105 -0
- package/README.md +133 -2
- package/dist/boot.d.ts +19 -0
- package/dist/boot.js +22 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +44 -0
- package/dist/create-site/cli.d.ts +2 -0
- package/dist/create-site/cli.js +31 -0
- package/dist/create-site/generate-site.d.ts +12 -0
- package/dist/create-site/generate-site.js +168 -0
- package/dist/create-site/mint-token-cli.d.ts +2 -0
- package/dist/create-site/mint-token-cli.js +26 -0
- package/dist/create-site/mint-token.d.ts +7 -0
- package/dist/create-site/mint-token.js +49 -0
- package/dist/create-site/template/AGENTS.md +473 -0
- package/dist/create-site/template/content/menus/footerCompany.json +9 -0
- package/dist/create-site/template/content/menus/footerProduct.json +9 -0
- package/dist/create-site/template/content/menus/footerResources.json +9 -0
- package/dist/create-site/template/content/menus/main.json +9 -0
- package/dist/create-site/template/content/pages/404.json +23 -0
- package/dist/create-site/template/content/pages/about/careers.json +22 -0
- package/dist/create-site/template/content/pages/about/team.json +22 -0
- package/dist/create-site/template/content/pages/about.json +22 -0
- package/dist/create-site/template/content/pages/docs/deployment.json +22 -0
- package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +22 -0
- package/dist/create-site/template/content/pages/docs/getting-started.json +24 -0
- package/dist/create-site/template/content/pages/docs.json +22 -0
- package/dist/create-site/template/content/pages/index.json +182 -0
- package/dist/create-site/template/content/redirects.json +4 -0
- package/dist/create-site/template/dockerignore +6 -0
- package/dist/create-site/template/gitignore +11 -0
- package/dist/create-site/template/theme/assets/site.js +49 -0
- package/dist/create-site/template/theme/assets/style.css +1223 -0
- package/dist/create-site/template/theme/blocks/button.liquid +21 -0
- package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
- package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
- package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
- package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
- package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
- package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
- package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
- package/dist/create-site/template/theme/config/site_settings.json +11 -0
- package/dist/create-site/template/theme/layouts/theme.liquid +102 -0
- package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
- package/dist/create-site/template/theme/sections/cta-banner.liquid +36 -0
- package/dist/create-site/template/theme/sections/faq.liquid +27 -0
- package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
- package/dist/create-site/template/theme/sections/hero.liquid +48 -0
- package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
- package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
- package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
- package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
- package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
- package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
- package/dist/create-site/template/theme/snippets/responsive-media.liquid +107 -0
- package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
- package/dist/create-site/template/vhost/Dockerfile +33 -0
- package/dist/create-site/template/vhost/docker-entrypoint.sh +58 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/media/drivers/driver.d.ts +11 -0
- package/dist/media/drivers/driver.js +1 -0
- package/dist/media/drivers/local-fs-driver.d.ts +3 -0
- package/dist/media/drivers/local-fs-driver.js +94 -0
- package/dist/media/filename.d.ts +2 -0
- package/dist/media/filename.js +54 -0
- package/dist/media/manage-media.d.ts +19 -0
- package/dist/media/manage-media.js +45 -0
- package/dist/media/seed-media-cli.d.ts +2 -0
- package/dist/media/seed-media-cli.js +22 -0
- package/dist/media/seed-media.d.ts +11 -0
- package/dist/media/seed-media.js +76 -0
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +83 -0
- package/dist/pull-site/cli.d.ts +2 -0
- package/dist/pull-site/cli.js +78 -0
- package/dist/pull-site/prompt-token.d.ts +16 -0
- package/dist/pull-site/prompt-token.js +69 -0
- package/dist/pull-site/prompts.d.ts +22 -0
- package/dist/pull-site/prompts.js +127 -0
- package/dist/pull-site/pull-site.d.ts +23 -0
- package/dist/pull-site/pull-site.js +211 -0
- package/dist/renderer/engine.d.ts +2 -0
- package/dist/renderer/engine.js +48 -0
- package/dist/renderer/render-cache.d.ts +11 -0
- package/dist/renderer/render-cache.js +11 -0
- package/dist/renderer/render-page.d.ts +34 -0
- package/dist/renderer/render-page.js +209 -0
- package/dist/renderer/theme-templates.d.ts +7 -0
- package/dist/renderer/theme-templates.js +82 -0
- package/dist/routes/admin-redirect.d.ts +5 -0
- package/dist/routes/admin-redirect.js +26 -0
- package/dist/routes/assets.d.ts +6 -0
- package/dist/routes/assets.js +30 -0
- package/dist/routes/batch.d.ts +10 -0
- package/dist/routes/batch.js +148 -0
- package/dist/routes/capabilities.d.ts +6 -0
- package/dist/routes/capabilities.js +27 -0
- package/dist/routes/content.d.ts +8 -0
- package/dist/routes/content.js +169 -0
- package/dist/routes/deleted-pages.d.ts +8 -0
- package/dist/routes/deleted-pages.js +9 -0
- package/dist/routes/drafts.d.ts +10 -0
- package/dist/routes/drafts.js +73 -0
- package/dist/routes/git.d.ts +8 -0
- package/dist/routes/git.js +158 -0
- package/dist/routes/index.d.ts +12 -0
- package/dist/routes/index.js +86 -0
- package/dist/routes/links.d.ts +8 -0
- package/dist/routes/links.js +18 -0
- package/dist/routes/media-public.d.ts +11 -0
- package/dist/routes/media-public.js +125 -0
- package/dist/routes/media.d.ts +9 -0
- package/dist/routes/media.js +80 -0
- package/dist/routes/menus.d.ts +8 -0
- package/dist/routes/menus.js +148 -0
- package/dist/routes/preview-revision.d.ts +10 -0
- package/dist/routes/preview-revision.js +96 -0
- package/dist/routes/preview.d.ts +10 -0
- package/dist/routes/preview.js +56 -0
- package/dist/routes/public.d.ts +10 -0
- package/dist/routes/public.js +123 -0
- package/dist/routes/publish.d.ts +10 -0
- package/dist/routes/publish.js +139 -0
- package/dist/routes/redirects.d.ts +8 -0
- package/dist/routes/redirects.js +132 -0
- package/dist/routes/search-public.d.ts +6 -0
- package/dist/routes/search-public.js +104 -0
- package/dist/routes/search.d.ts +8 -0
- package/dist/routes/search.js +15 -0
- package/dist/routes/settings.d.ts +10 -0
- package/dist/routes/settings.js +68 -0
- package/dist/routes/sitemap.d.ts +7 -0
- package/dist/routes/sitemap.js +79 -0
- package/dist/routes/theme.d.ts +10 -0
- package/dist/routes/theme.js +116 -0
- package/dist/schemas/instance.schema.json +17 -0
- package/dist/schemas/menu.schema.json +24 -0
- package/dist/schemas/page.schema.json +26 -0
- package/dist/schemas/redirects.schema.json +24 -0
- package/dist/search/drivers/driver.d.ts +9 -0
- package/dist/search/drivers/driver.js +1 -0
- package/dist/search/drivers/node-sqlite-driver.d.ts +7 -0
- package/dist/search/drivers/node-sqlite-driver.js +19 -0
- package/dist/search/query-content.d.ts +32 -0
- package/dist/search/query-content.js +207 -0
- package/dist/search/query-index.d.ts +5 -0
- package/dist/search/query-index.js +21 -0
- package/dist/search/rebuild-index.d.ts +3 -0
- package/dist/search/rebuild-index.js +320 -0
- package/dist/server-config.d.ts +23 -0
- package/dist/server-config.js +237 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +235 -0
- package/dist/services/batch.d.ts +35 -0
- package/dist/services/batch.js +145 -0
- package/dist/services/checkpoint.d.ts +4 -0
- package/dist/services/checkpoint.js +45 -0
- package/dist/services/content-read.d.ts +27 -0
- package/dist/services/content-read.js +151 -0
- package/dist/services/delete-content.d.ts +12 -0
- package/dist/services/delete-content.js +166 -0
- package/dist/services/deleted-pages.d.ts +11 -0
- package/dist/services/deleted-pages.js +83 -0
- package/dist/services/dev-tunnel.d.ts +5 -0
- package/dist/services/dev-tunnel.js +5 -0
- package/dist/services/drafts.d.ts +16 -0
- package/dist/services/drafts.js +164 -0
- package/dist/services/etag.d.ts +2 -0
- package/dist/services/etag.js +30 -0
- package/dist/services/fs-walk.d.ts +1 -0
- package/dist/services/fs-walk.js +26 -0
- package/dist/services/git-commit.d.ts +3 -0
- package/dist/services/git-commit.js +12 -0
- package/dist/services/git-history.d.ts +28 -0
- package/dist/services/git-history.js +131 -0
- package/dist/services/git-revert.d.ts +12 -0
- package/dist/services/git-revert.js +140 -0
- package/dist/services/git.d.ts +16 -0
- package/dist/services/git.js +127 -0
- package/dist/services/interval-job.d.ts +4 -0
- package/dist/services/interval-job.js +9 -0
- package/dist/services/ip-allowlist.d.ts +7 -0
- package/dist/services/ip-allowlist.js +31 -0
- package/dist/services/links.d.ts +22 -0
- package/dist/services/links.js +169 -0
- package/dist/services/manage-menus.d.ts +15 -0
- package/dist/services/manage-menus.js +138 -0
- package/dist/services/manage-redirects.d.ts +25 -0
- package/dist/services/manage-redirects.js +156 -0
- package/dist/services/menu-references.d.ts +3 -0
- package/dist/services/menu-references.js +38 -0
- package/dist/services/menus.d.ts +9 -0
- package/dist/services/menus.js +43 -0
- package/dist/services/migration-runner.d.ts +16 -0
- package/dist/services/migration-runner.js +169 -0
- package/dist/services/mime-types.d.ts +3 -0
- package/dist/services/mime-types.js +43 -0
- package/dist/services/move.d.ts +16 -0
- package/dist/services/move.js +236 -0
- package/dist/services/path-safety.d.ts +6 -0
- package/dist/services/path-safety.js +112 -0
- package/dist/services/pid-file.d.ts +13 -0
- package/dist/services/pid-file.js +54 -0
- package/dist/services/post-urls.d.ts +3 -0
- package/dist/services/post-urls.js +27 -0
- package/dist/services/prepared-operation.d.ts +4 -0
- package/dist/services/prepared-operation.js +1 -0
- package/dist/services/publish.d.ts +15 -0
- package/dist/services/publish.js +313 -0
- package/dist/services/rate-limit-config.d.ts +9 -0
- package/dist/services/rate-limit-config.js +22 -0
- package/dist/services/redirects.d.ts +27 -0
- package/dist/services/redirects.js +175 -0
- package/dist/services/reindex-on-write.d.ts +3 -0
- package/dist/services/reindex-on-write.js +30 -0
- package/dist/services/resolve-blog-url.d.ts +11 -0
- package/dist/services/resolve-blog-url.js +31 -0
- package/dist/services/resolve-url.d.ts +11 -0
- package/dist/services/resolve-url.js +20 -0
- package/dist/services/site-settings.d.ts +28 -0
- package/dist/services/site-settings.js +116 -0
- package/dist/services/startup-checks.d.ts +10 -0
- package/dist/services/startup-checks.js +81 -0
- package/dist/services/static-file.d.ts +7 -0
- package/dist/services/static-file.js +37 -0
- package/dist/services/theme-component-file.d.ts +6 -0
- package/dist/services/theme-component-file.js +39 -0
- package/dist/services/theme-files.d.ts +33 -0
- package/dist/services/theme-files.js +148 -0
- package/dist/services/theme-page-templates.d.ts +7 -0
- package/dist/services/theme-page-templates.js +53 -0
- package/dist/services/theme-schemas.d.ts +2 -0
- package/dist/services/theme-schemas.js +137 -0
- package/dist/services/token-auth.d.ts +10 -0
- package/dist/services/token-auth.js +64 -0
- package/dist/services/urls.d.ts +2 -0
- package/dist/services/urls.js +20 -0
- package/dist/services/validation.d.ts +26 -0
- package/dist/services/validation.js +195 -0
- package/dist/services/write-queue.d.ts +1 -0
- package/dist/services/write-queue.js +14 -0
- package/dist/site-check/cli.d.ts +2 -0
- package/dist/site-check/cli.js +48 -0
- package/dist/site-check/run-check.d.ts +12 -0
- package/dist/site-check/run-check.js +188 -0
- package/dist/site-sync/cli-helpers.d.ts +23 -0
- package/dist/site-sync/cli-helpers.js +75 -0
- package/dist/site-sync/deploy.d.ts +36 -0
- package/dist/site-sync/deploy.js +124 -0
- package/dist/site-sync/live-content.d.ts +30 -0
- package/dist/site-sync/live-content.js +68 -0
- package/dist/site-sync/prompts.d.ts +30 -0
- package/dist/site-sync/prompts.js +237 -0
- package/dist/site-sync/pull-cli.d.ts +2 -0
- package/dist/site-sync/pull-cli.js +75 -0
- package/dist/site-sync/pull-site.d.ts +28 -0
- package/dist/site-sync/pull-site.js +171 -0
- package/dist/site-sync/push-cli.d.ts +2 -0
- package/dist/site-sync/push-cli.js +268 -0
- package/dist/site-sync/push-site.d.ts +96 -0
- package/dist/site-sync/push-site.js +376 -0
- package/dist/site-sync/remote-site.d.ts +38 -0
- package/dist/site-sync/remote-site.js +122 -0
- package/dist/site-sync/sync-record.d.ts +26 -0
- package/dist/site-sync/sync-record.js +76 -0
- package/dist/site-sync/theme-sync.d.ts +34 -0
- package/dist/site-sync/theme-sync.js +128 -0
- package/dist/stop-site/cli.d.ts +2 -0
- package/dist/stop-site/cli.js +28 -0
- package/dist/stop-site/stop-site.d.ts +26 -0
- package/dist/stop-site/stop-site.js +56 -0
- package/dist/theme-state.d.ts +20 -0
- package/dist/theme-state.js +33 -0
- package/dist/upgrade/changelog.d.ts +8 -0
- package/dist/upgrade/changelog.js +46 -0
- package/dist/upgrade/finish.d.ts +9 -0
- package/dist/upgrade/finish.js +30 -0
- package/dist/upgrade/owned-files.d.ts +11 -0
- package/dist/upgrade/owned-files.js +42 -0
- package/dist/upgrade/package-names.d.ts +4 -0
- package/dist/upgrade/package-names.js +26 -0
- package/dist/upgrade/upgrade-cli.d.ts +2 -0
- package/dist/upgrade/upgrade-cli.js +170 -0
- package/package.json +64 -4
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The current content schema version. Bumping this and adding a new
|
|
2
|
+
// migrations[N] entry is the only way a content shape may change
|
|
3
|
+
// (constraint 4) - never a manual edit convention.
|
|
4
|
+
export const CURRENT_SCHEMA_VERSION = 7;
|
|
5
|
+
// A trivial identity migration, proving the mechanism (per the build
|
|
6
|
+
// plan's Phase 1 scope): no shape change, only the version bump. Safe
|
|
7
|
+
// against page.schema.json's schemaVersion: { minimum: 1 } (not an
|
|
8
|
+
// exact/enum check), so nothing else needs updating for this bump to
|
|
9
|
+
// keep validating.
|
|
10
|
+
function migrateV1ToV2(content) {
|
|
11
|
+
return { ...content, schemaVersion: 2 };
|
|
12
|
+
}
|
|
13
|
+
// page.schema.json now requires "type" (Phase 2 Group D, backing the
|
|
14
|
+
// GET /v1/content list endpoint's type filter). Every page authored
|
|
15
|
+
// before this migration is a generic page, not any newer distinct
|
|
16
|
+
// kind, so "page" is a safe, honest default for existing content -
|
|
17
|
+
// never invented per-file, always this one fixed value.
|
|
18
|
+
function migrateV2ToV3(content) {
|
|
19
|
+
return { ...content, schemaVersion: 3, type: 'page' };
|
|
20
|
+
}
|
|
21
|
+
// page.schema.json now requires "layout" (theme layouts group), naming
|
|
22
|
+
// which theme/layouts/*.liquid file wraps this page's rendered
|
|
23
|
+
// content. Every page authored before this migration used the only
|
|
24
|
+
// layout that existed at the time, so "theme" (theme/layouts/theme.liquid)
|
|
25
|
+
// is the correct default, not an invented placeholder - render-page.ts's
|
|
26
|
+
// own read path applies this exact same default defensively too, since
|
|
27
|
+
// migrations aren't wired to run automatically anywhere yet.
|
|
28
|
+
function migrateV3ToV4(content) {
|
|
29
|
+
return { ...content, schemaVersion: 4, layout: 'theme' };
|
|
30
|
+
}
|
|
31
|
+
// page.schema.json now requires "name" (the admin's own page tree
|
|
32
|
+
// label, distinct from "title" - the same content can now be called
|
|
33
|
+
// "Home Page" in the tree while still rendering a different <title>).
|
|
34
|
+
// Every page authored before this migration has never had a name of
|
|
35
|
+
// its own, so its existing title is the only honest default - not an
|
|
36
|
+
// invented placeholder, and not the file's own path/slug, which is a
|
|
37
|
+
// separate concept again. content-read.ts's listContent applies this
|
|
38
|
+
// exact same title fallback defensively too, since migrations aren't
|
|
39
|
+
// wired to run automatically anywhere yet.
|
|
40
|
+
function migrateV4ToV5(content) {
|
|
41
|
+
const title = typeof content.title === 'string' ? content.title : '';
|
|
42
|
+
return { ...content, schemaVersion: 5, name: title };
|
|
43
|
+
}
|
|
44
|
+
// Folds the distinct "post" content type into "page" - author,
|
|
45
|
+
// publishDate, and tags become optional fields any page may carry
|
|
46
|
+
// (page.schema.json), and the post-only schema (which required them,
|
|
47
|
+
// but had no "name" field at all) is retired. Ordinary pages already
|
|
48
|
+
// at v5 already have "name"; this is only ever a genuine backfill for
|
|
49
|
+
// a legacy content/posts/*.json file, which never had one. Mirrors the
|
|
50
|
+
// admin's own backfillPageName (derivePageLabel.ts) precisely: fills
|
|
51
|
+
// name from title only when name is genuinely absent, never overwrites
|
|
52
|
+
// an existing one (a page's name may deliberately differ from its
|
|
53
|
+
// title - migrateV4ToV5's own unconditional set was safe only because
|
|
54
|
+
// no content reaching it could already have a name).
|
|
55
|
+
// migration-runner.ts's runMigrationsJob is what physically relocates
|
|
56
|
+
// a legacy content/posts/<slug>.json file to content/pages/blog/<slug>.json
|
|
57
|
+
// alongside this shape change - this function only ever transforms
|
|
58
|
+
// content, never a path.
|
|
59
|
+
function migrateV5ToV6(content) {
|
|
60
|
+
if ('name' in content) {
|
|
61
|
+
return { ...content, schemaVersion: 6 };
|
|
62
|
+
}
|
|
63
|
+
const title = typeof content.title === 'string' ? content.title : '';
|
|
64
|
+
return { ...content, schemaVersion: 6, name: title };
|
|
65
|
+
}
|
|
66
|
+
// menu.schema.json gains an optional "name": the admin's own display
|
|
67
|
+
// label for a menu, editable without touching its filename (which is
|
|
68
|
+
// what themes reference it by, as menus.<filename>, so renaming the
|
|
69
|
+
// file would silently empty every nav that uses it). Optional, so no
|
|
70
|
+
// existing menu needs a value invented for it - the admin falls back
|
|
71
|
+
// to deriving one from the filename, exactly as it always has. No
|
|
72
|
+
// shape change for pages at all; only the version bump.
|
|
73
|
+
function migrateV6ToV7(content) {
|
|
74
|
+
return { ...content, schemaVersion: 7 };
|
|
75
|
+
}
|
|
76
|
+
export const migrations = {
|
|
77
|
+
1: migrateV1ToV2,
|
|
78
|
+
2: migrateV2ToV3,
|
|
79
|
+
3: migrateV3ToV4,
|
|
80
|
+
4: migrateV4ToV5,
|
|
81
|
+
5: migrateV5ToV6,
|
|
82
|
+
6: migrateV6ToV7,
|
|
83
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { loadSiteConfig } from "../config.js";
|
|
4
|
+
import { PromptCancelledError, createPrompter, normaliseSiteUrl } from "./prompts.js";
|
|
5
|
+
import { PullSiteError, checkPullableSite, pullSite } from "./pull-site.js";
|
|
6
|
+
const TOKEN_HELP = 'The token is the live site\'s API token (the same one the admin uses), with the "content" and "media" scopes.';
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const force = args.includes('--force');
|
|
9
|
+
const tokenFlag = args.indexOf('--token');
|
|
10
|
+
const flagToken = tokenFlag === -1 ? undefined : args[tokenFlag + 1];
|
|
11
|
+
const positional = args.filter((arg, index) => !arg.startsWith('--') && (tokenFlag === -1 || index !== tokenFlag + 1));
|
|
12
|
+
// Anything not given on the command line is asked for: the address,
|
|
13
|
+
// then the token (hidden). CMS_TOKEN and --token still work for
|
|
14
|
+
// scripts, but either leaves the token in shell history. Output goes
|
|
15
|
+
// to stderr so stdout carries only the result.
|
|
16
|
+
const prompter = createPrompter(process.stdin, process.stderr);
|
|
17
|
+
async function ask(question, hidden = false) {
|
|
18
|
+
try {
|
|
19
|
+
return await prompter.ask(question, { hidden });
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error instanceof PromptCancelledError) {
|
|
23
|
+
process.exit(130);
|
|
24
|
+
}
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const givenUrl = positional[0] ?? (await ask('Live site address: '));
|
|
29
|
+
const siteUrl = normaliseSiteUrl(givenUrl);
|
|
30
|
+
if (!siteUrl) {
|
|
31
|
+
console.error(givenUrl.trim() === '' ? 'No address entered.' : `"${givenUrl}" is not a web address.`);
|
|
32
|
+
console.error('Usage: npm run pull [-- <live-site-address>] [--force]');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
// Checked before asking for the token, so a mistyped address is found
|
|
36
|
+
// out without typing a token for nothing.
|
|
37
|
+
try {
|
|
38
|
+
await checkPullableSite(siteUrl);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error instanceof PullSiteError) {
|
|
42
|
+
console.error(error.message);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
const token = flagToken ?? process.env.CMS_TOKEN ?? (await ask(`API token for ${siteUrl} (hidden): `, true));
|
|
48
|
+
if (!token) {
|
|
49
|
+
console.error('No token entered.');
|
|
50
|
+
console.error(TOKEN_HELP);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
// Run from vhost/ (the scaffold's own "pull" script), so the site root
|
|
54
|
+
// is one level up - the same relationship check-site relies on.
|
|
55
|
+
const config = loadSiteConfig(resolve(process.cwd(), '..'));
|
|
56
|
+
try {
|
|
57
|
+
const result = await pullSite(config, { siteUrl, token, force });
|
|
58
|
+
console.log(`Pulled from ${siteUrl}:`);
|
|
59
|
+
console.log(` ${result.pages} pages, ${result.menus} menus, ${result.drafts} drafts, ${result.redirects} redirects`);
|
|
60
|
+
console.log(` media: ${result.mediaDownloaded} downloaded, ${result.mediaAlreadyPresent} already here`);
|
|
61
|
+
if (result.removed.length > 0) {
|
|
62
|
+
console.log(` removed ${result.removed.length} local files the live site doesn't have:`);
|
|
63
|
+
for (const path of result.removed) {
|
|
64
|
+
console.log(` content/${path}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
console.log('Nothing was committed. Review the changes with "git status" and "git diff" from the site folder.');
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error instanceof PullSiteError) {
|
|
71
|
+
console.error(error.message);
|
|
72
|
+
if (error.reason === 'unauthorised') {
|
|
73
|
+
console.error(TOKEN_HELP);
|
|
74
|
+
}
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface PromptInput {
|
|
2
|
+
isTTY?: boolean;
|
|
3
|
+
setRawMode?: (mode: boolean) => unknown;
|
|
4
|
+
setEncoding: (encoding: BufferEncoding) => unknown;
|
|
5
|
+
on: (event: 'data' | 'end', listener: (chunk?: string) => void) => unknown;
|
|
6
|
+
removeAllListeners: (event: 'data' | 'end') => unknown;
|
|
7
|
+
pause: () => unknown;
|
|
8
|
+
resume: () => unknown;
|
|
9
|
+
}
|
|
10
|
+
export interface PromptOutput {
|
|
11
|
+
write: (text: string) => unknown;
|
|
12
|
+
}
|
|
13
|
+
export declare class PromptCancelledError extends Error {
|
|
14
|
+
constructor();
|
|
15
|
+
}
|
|
16
|
+
export declare function promptHidden(question: string, input: PromptInput, output: PromptOutput): Promise<string>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Asks for a secret without showing it. A token typed on the command
|
|
2
|
+
// line (or in CMS_TOKEN=... before it) ends up in shell history; typed
|
|
3
|
+
// here, it goes nowhere but this process.
|
|
4
|
+
export class PromptCancelledError extends Error {
|
|
5
|
+
constructor() {
|
|
6
|
+
super('Cancelled');
|
|
7
|
+
this.name = 'PromptCancelledError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const CTRL_C = '\u0003';
|
|
11
|
+
const CTRL_D = '\u0004';
|
|
12
|
+
const BACKSPACE = new Set(['\u007f', '\b']);
|
|
13
|
+
// In a terminal: raw mode, so keystrokes aren't echoed; Enter finishes,
|
|
14
|
+
// Backspace deletes, Ctrl+C cancels. A pasted token arrives as one chunk
|
|
15
|
+
// and is handled the same way. Without a terminal (input piped in), the
|
|
16
|
+
// first line is the answer, so `echo $TOKEN | npm run pull -- <url>`
|
|
17
|
+
// still works.
|
|
18
|
+
export function promptHidden(question, input, output) {
|
|
19
|
+
input.setEncoding('utf8');
|
|
20
|
+
const interactive = input.isTTY === true && typeof input.setRawMode === 'function';
|
|
21
|
+
// Echo goes off before the question appears, never after: anything
|
|
22
|
+
// typed or pasted the instant the prompt shows would otherwise be
|
|
23
|
+
// echoed by the terminal in the gap (seen for real in a pty test).
|
|
24
|
+
if (interactive) {
|
|
25
|
+
input.setRawMode?.(true);
|
|
26
|
+
}
|
|
27
|
+
output.write(question);
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
let answer = '';
|
|
30
|
+
function finish(error) {
|
|
31
|
+
input.removeAllListeners('data');
|
|
32
|
+
input.removeAllListeners('end');
|
|
33
|
+
if (interactive) {
|
|
34
|
+
input.setRawMode?.(false);
|
|
35
|
+
}
|
|
36
|
+
input.pause();
|
|
37
|
+
output.write('\n');
|
|
38
|
+
if (error) {
|
|
39
|
+
reject(error);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
resolve(answer.trim());
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
input.resume();
|
|
46
|
+
input.on('data', (chunk) => {
|
|
47
|
+
for (const char of chunk ?? '') {
|
|
48
|
+
if (char === '\r' || char === '\n') {
|
|
49
|
+
finish();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (interactive && char === CTRL_C) {
|
|
53
|
+
finish(new PromptCancelledError());
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (interactive && char === CTRL_D) {
|
|
57
|
+
finish();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (interactive && BACKSPACE.has(char)) {
|
|
61
|
+
answer = answer.slice(0, -1);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
answer += char;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
input.on('end', () => finish());
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface PromptInput {
|
|
2
|
+
isTTY?: boolean;
|
|
3
|
+
setRawMode?: (mode: boolean) => unknown;
|
|
4
|
+
setEncoding: (encoding: BufferEncoding) => unknown;
|
|
5
|
+
on: (event: 'data' | 'end', listener: (chunk?: string) => void) => unknown;
|
|
6
|
+
removeAllListeners: (event: 'data' | 'end') => unknown;
|
|
7
|
+
pause: () => unknown;
|
|
8
|
+
resume: () => unknown;
|
|
9
|
+
}
|
|
10
|
+
export interface PromptOutput {
|
|
11
|
+
write: (text: string) => unknown;
|
|
12
|
+
}
|
|
13
|
+
export declare class PromptCancelledError extends Error {
|
|
14
|
+
constructor();
|
|
15
|
+
}
|
|
16
|
+
export interface Prompter {
|
|
17
|
+
ask: (question: string, options?: {
|
|
18
|
+
hidden?: boolean;
|
|
19
|
+
}) => Promise<string>;
|
|
20
|
+
}
|
|
21
|
+
export declare function createPrompter(input: PromptInput, output: PromptOutput): Prompter;
|
|
22
|
+
export declare function normaliseSiteUrl(value: string): string | null;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Asks questions on the terminal - the live site's address, then its
|
|
2
|
+
// API token without showing it. A token typed on the command line (or
|
|
3
|
+
// in CMS_TOKEN=... before it) ends up in shell history; typed here, it
|
|
4
|
+
// goes nowhere but this process.
|
|
5
|
+
export class PromptCancelledError extends Error {
|
|
6
|
+
constructor() {
|
|
7
|
+
super('Cancelled');
|
|
8
|
+
this.name = 'PromptCancelledError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const CTRL_C = '\u0003';
|
|
12
|
+
const CTRL_D = '\u0004';
|
|
13
|
+
const BACKSPACE = new Set(['\u007f', '\b']);
|
|
14
|
+
// One prompter per input, shared by every question, because input that
|
|
15
|
+
// arrives faster than it is asked for (two piped lines in one chunk, or
|
|
16
|
+
// a line typed ahead) has to be kept for the next question rather than
|
|
17
|
+
// thrown away with the first answer.
|
|
18
|
+
//
|
|
19
|
+
// In a terminal, a hidden question switches echo off (raw mode) before
|
|
20
|
+
// it is shown - never after, or anything typed or pasted the instant it
|
|
21
|
+
// appears is echoed in the gap (seen for real in a pty test) - and
|
|
22
|
+
// handles Enter, Backspace and Ctrl+C itself. A visible question leaves
|
|
23
|
+
// the terminal's own line editing alone. Without a terminal (input
|
|
24
|
+
// piped in), each line answers the next question.
|
|
25
|
+
export function createPrompter(input, output) {
|
|
26
|
+
const interactive = input.isTTY === true && typeof input.setRawMode === 'function';
|
|
27
|
+
let buffered = '';
|
|
28
|
+
let ended = false;
|
|
29
|
+
input.setEncoding('utf8');
|
|
30
|
+
function ask(question, { hidden = false } = {}) {
|
|
31
|
+
const raw = interactive && hidden;
|
|
32
|
+
if (raw) {
|
|
33
|
+
input.setRawMode?.(true);
|
|
34
|
+
}
|
|
35
|
+
output.write(question);
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
let answer = '';
|
|
38
|
+
function finish(error) {
|
|
39
|
+
input.removeAllListeners('data');
|
|
40
|
+
input.removeAllListeners('end');
|
|
41
|
+
if (raw) {
|
|
42
|
+
input.setRawMode?.(false);
|
|
43
|
+
// Raw mode swallowed the Enter the terminal would have echoed.
|
|
44
|
+
output.write('\n');
|
|
45
|
+
}
|
|
46
|
+
input.pause();
|
|
47
|
+
if (error) {
|
|
48
|
+
reject(error);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
resolve(answer.trim());
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Consumes characters up to the end of this answer; returns true
|
|
55
|
+
// once the answer is complete, leaving the rest buffered.
|
|
56
|
+
function consume(text) {
|
|
57
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
58
|
+
const char = text[index];
|
|
59
|
+
if (char === '\r' || char === '\n') {
|
|
60
|
+
// A \r\n pair ends one answer, not two.
|
|
61
|
+
const next = index + 1;
|
|
62
|
+
buffered = text.slice(char === '\r' && text[next] === '\n' ? next + 1 : next);
|
|
63
|
+
finish();
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (raw && char === CTRL_C) {
|
|
67
|
+
buffered = '';
|
|
68
|
+
finish(new PromptCancelledError());
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
if (raw && char === CTRL_D) {
|
|
72
|
+
buffered = text.slice(index + 1);
|
|
73
|
+
finish();
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
if (raw && BACKSPACE.has(char)) {
|
|
77
|
+
answer = answer.slice(0, -1);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
answer += char;
|
|
81
|
+
}
|
|
82
|
+
buffered = '';
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
if (consume(buffered)) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (ended) {
|
|
89
|
+
finish();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
input.on('data', (chunk) => {
|
|
93
|
+
consume(chunk ?? '');
|
|
94
|
+
});
|
|
95
|
+
input.on('end', () => {
|
|
96
|
+
ended = true;
|
|
97
|
+
finish();
|
|
98
|
+
});
|
|
99
|
+
input.resume();
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return { ask };
|
|
103
|
+
}
|
|
104
|
+
// Accepts an address as someone would type it: "my-site.example" is
|
|
105
|
+
// taken to mean https://my-site.example, but a site on this machine
|
|
106
|
+
// ("localhost:3600", "127.0.0.1:3600") http://, since a local site has
|
|
107
|
+
// no certificate. Returns null for anything that still isn't an http(s)
|
|
108
|
+
// address.
|
|
109
|
+
const LOCAL_HOST = /^(localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?(?:\/|$)/i;
|
|
110
|
+
export function normaliseSiteUrl(value) {
|
|
111
|
+
const trimmed = value.trim();
|
|
112
|
+
if (trimmed === '') {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed);
|
|
116
|
+
const withScheme = hasScheme ? trimmed : `${LOCAL_HOST.test(trimmed) ? 'http' : 'https'}://${trimmed}`;
|
|
117
|
+
try {
|
|
118
|
+
const url = new URL(withScheme);
|
|
119
|
+
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.hostname === '') {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return url.origin;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SiteConfig } from '../config.ts';
|
|
2
|
+
export type PullSiteReason = 'not-a-site' | 'unauthorised' | 'newer-schema' | 'uncommitted-changes' | 'fetch-failed' | 'unsafe-path';
|
|
3
|
+
export declare class PullSiteError extends Error {
|
|
4
|
+
readonly reason: PullSiteReason;
|
|
5
|
+
constructor(reason: PullSiteReason, message: string);
|
|
6
|
+
}
|
|
7
|
+
export interface PullSiteOptions {
|
|
8
|
+
siteUrl: string;
|
|
9
|
+
token: string;
|
|
10
|
+
force?: boolean;
|
|
11
|
+
fetchImpl?: typeof fetch;
|
|
12
|
+
}
|
|
13
|
+
export interface PullSiteResult {
|
|
14
|
+
pages: number;
|
|
15
|
+
menus: number;
|
|
16
|
+
drafts: number;
|
|
17
|
+
redirects: number;
|
|
18
|
+
removed: string[];
|
|
19
|
+
mediaDownloaded: number;
|
|
20
|
+
mediaAlreadyPresent: number;
|
|
21
|
+
}
|
|
22
|
+
export declare function checkPullableSite(siteUrl: string, fetchImpl?: typeof fetch): Promise<void>;
|
|
23
|
+
export declare function pullSite(config: SiteConfig, options: PullSiteOptions): Promise<PullSiteResult>;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { CURRENT_SCHEMA_VERSION } from "../migrations/index.js";
|
|
5
|
+
import { listFilesRecursively } from "../services/fs-walk.js";
|
|
6
|
+
import { sanitisePath } from "../services/path-safety.js";
|
|
7
|
+
import { serialiseRedirects } from "../services/redirects.js";
|
|
8
|
+
import { rebuildIndex } from "../search/rebuild-index.js";
|
|
9
|
+
export class PullSiteError extends Error {
|
|
10
|
+
reason;
|
|
11
|
+
constructor(reason, message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'PullSiteError';
|
|
14
|
+
this.reason = reason;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// Every path from the server is untrusted input that becomes a local
|
|
18
|
+
// file path. Checked here for shape (a pages/ or menus/ JSON file, no
|
|
19
|
+
// dot segments), then again by sanitisePath before any write.
|
|
20
|
+
const CONTENT_PATH = /^(pages|menus)\/(?:[A-Za-z0-9_-][A-Za-z0-9._-]*\/)*[A-Za-z0-9_-][A-Za-z0-9._-]*\.json$/;
|
|
21
|
+
const MEDIA_NAME = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
|
|
22
|
+
function encodePath(path) {
|
|
23
|
+
return path.split('/').map(encodeURIComponent).join('/');
|
|
24
|
+
}
|
|
25
|
+
class RemoteSite {
|
|
26
|
+
siteUrl;
|
|
27
|
+
token;
|
|
28
|
+
fetchImpl;
|
|
29
|
+
constructor(siteUrl, token, fetchImpl) {
|
|
30
|
+
this.siteUrl = siteUrl;
|
|
31
|
+
this.token = token;
|
|
32
|
+
this.fetchImpl = fetchImpl ?? fetch;
|
|
33
|
+
}
|
|
34
|
+
url(path) {
|
|
35
|
+
return new URL(path, this.siteUrl);
|
|
36
|
+
}
|
|
37
|
+
async get(path, { auth = true, allow404 = false } = {}) {
|
|
38
|
+
let response;
|
|
39
|
+
try {
|
|
40
|
+
response = await this.fetchImpl(this.url(path), {
|
|
41
|
+
headers: auth ? { authorization: `Bearer ${this.token}` } : {},
|
|
42
|
+
signal: AbortSignal.timeout(30_000),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
47
|
+
throw new PullSiteError('fetch-failed', `Could not reach ${this.url(path).toString()}: ${detail}`);
|
|
48
|
+
}
|
|
49
|
+
if (response.status === 401 || response.status === 403) {
|
|
50
|
+
throw new PullSiteError('unauthorised', `The live site rejected the token (${response.status}). It needs the "content" and "media" scopes.`);
|
|
51
|
+
}
|
|
52
|
+
if (allow404 && response.status === 404) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
throw new PullSiteError('fetch-failed', `GET ${path} returned ${response.status}`);
|
|
57
|
+
}
|
|
58
|
+
return response;
|
|
59
|
+
}
|
|
60
|
+
async getJson(path, options = {}) {
|
|
61
|
+
const response = await this.get(path, options);
|
|
62
|
+
try {
|
|
63
|
+
return await response.json();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new PullSiteError('fetch-failed', `GET ${path} did not return JSON`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async getBytes(path, options = {}) {
|
|
70
|
+
const response = await this.get(path, options);
|
|
71
|
+
return response === null ? null : Buffer.from(await response.arrayBuffer());
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function hasUncommittedContentChanges(config) {
|
|
75
|
+
try {
|
|
76
|
+
const status = execFileSync('git', ['status', '--porcelain', '--', 'content'], { cwd: config.siteRoot });
|
|
77
|
+
return status.toString('utf-8').trim() !== '';
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Not a git repository (or no git): nothing to protect via git, and
|
|
81
|
+
// no way to recover what a pull overwrites - treat as changed.
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function writeUnder(root, relativePath, bytes) {
|
|
86
|
+
mkdirSync(root, { recursive: true });
|
|
87
|
+
const target = sanitisePath(root, relativePath);
|
|
88
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
89
|
+
writeFileSync(target, bytes);
|
|
90
|
+
}
|
|
91
|
+
// Makes this local site's content a copy of a running site's: pages,
|
|
92
|
+
// menus, drafts and redirects are mirrored (local files the live site
|
|
93
|
+
// doesn't have are removed), and any media file missing locally is
|
|
94
|
+
// downloaded. The theme is never touched - the API doesn't serve it,
|
|
95
|
+
// and it's the developer's code rather than the site's content. Nothing
|
|
96
|
+
// is committed: the result is left in the working tree to review.
|
|
97
|
+
//
|
|
98
|
+
// Everything is fetched before anything is written, so a failure part
|
|
99
|
+
// way through the content leaves the local site exactly as it was.
|
|
100
|
+
// Media is downloaded last and only ever adds files, so a failure there
|
|
101
|
+
// leaves the content pulled and some images still to fetch on a re-run.
|
|
102
|
+
// Whether an address is a site this agent can pull from, checked
|
|
103
|
+
// without a token (GET /v1/capabilities is public) - so the CLI can say
|
|
104
|
+
// the address is wrong before asking for a token at all.
|
|
105
|
+
export async function checkPullableSite(siteUrl, fetchImpl) {
|
|
106
|
+
const capabilities = (await new RemoteSite(siteUrl, '', fetchImpl).getJson('/v1/capabilities', { auth: false }));
|
|
107
|
+
if (typeof capabilities.agentVersion !== 'string' || typeof capabilities.contentSchemaVersion !== 'number') {
|
|
108
|
+
throw new PullSiteError('not-a-site', `${siteUrl} is not a Granite site`);
|
|
109
|
+
}
|
|
110
|
+
if (capabilities.contentSchemaVersion > CURRENT_SCHEMA_VERSION) {
|
|
111
|
+
throw new PullSiteError('newer-schema', `The live site uses content schema ${capabilities.contentSchemaVersion} (agent ${capabilities.agentVersion}), newer than this site's ${CURRENT_SCHEMA_VERSION}. Upgrade @o-a/cms-agent here first.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export async function pullSite(config, options) {
|
|
115
|
+
const remote = new RemoteSite(options.siteUrl, options.token, options.fetchImpl);
|
|
116
|
+
await checkPullableSite(options.siteUrl, options.fetchImpl);
|
|
117
|
+
if (!options.force && hasUncommittedContentChanges(config)) {
|
|
118
|
+
throw new PullSiteError('uncommitted-changes', 'content/ has uncommitted changes, which pulling would overwrite. Commit them first, or pass --force.');
|
|
119
|
+
}
|
|
120
|
+
// --- Fetch everything ---
|
|
121
|
+
const listed = await remote.getJson('/v1/content');
|
|
122
|
+
if (!Array.isArray(listed)) {
|
|
123
|
+
throw new PullSiteError('fetch-failed', 'GET /v1/content did not return a list');
|
|
124
|
+
}
|
|
125
|
+
const live = [];
|
|
126
|
+
const drafts = [];
|
|
127
|
+
for (const entry of listed) {
|
|
128
|
+
const path = entry.path;
|
|
129
|
+
if (typeof path !== 'string' || !CONTENT_PATH.test(path)) {
|
|
130
|
+
throw new PullSiteError('unsafe-path', `The live site listed an unexpected content path: ${JSON.stringify(path)}`);
|
|
131
|
+
}
|
|
132
|
+
const liveBytes = await remote.getBytes(`/v1/content/${encodePath(path)}`, { allow404: true });
|
|
133
|
+
if (liveBytes !== null) {
|
|
134
|
+
live.push({ path, bytes: liveBytes });
|
|
135
|
+
}
|
|
136
|
+
if (entry.hasDraft === true) {
|
|
137
|
+
const draftBytes = await remote.getBytes(`/v1/drafts/${encodePath(path)}`, { allow404: true });
|
|
138
|
+
if (draftBytes !== null) {
|
|
139
|
+
drafts.push({ path, bytes: draftBytes });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const redirectsBody = (await remote.getJson('/v1/redirects'));
|
|
144
|
+
const redirectEntries = Array.isArray(redirectsBody.entries) ? redirectsBody.entries : [];
|
|
145
|
+
// --- Write content (mirror) ---
|
|
146
|
+
const remoteLive = new Set(live.map((file) => file.path));
|
|
147
|
+
const remoteDrafts = new Set(drafts.map((file) => file.path));
|
|
148
|
+
const removed = [];
|
|
149
|
+
for (const local of [
|
|
150
|
+
...listFilesRecursively(config.pagesRoot, config.contentRoot, '.json'),
|
|
151
|
+
...listFilesRecursively(config.menusRoot, config.contentRoot, '.json'),
|
|
152
|
+
]) {
|
|
153
|
+
if (!remoteLive.has(local)) {
|
|
154
|
+
unlinkSync(sanitisePath(config.contentRoot, local));
|
|
155
|
+
removed.push(local);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (const local of listFilesRecursively(config.draftsRoot, config.draftsRoot, '.json')) {
|
|
159
|
+
if (!remoteDrafts.has(local)) {
|
|
160
|
+
unlinkSync(sanitisePath(config.draftsRoot, local));
|
|
161
|
+
removed.push(`drafts/${local}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (const file of live) {
|
|
165
|
+
writeUnder(config.contentRoot, file.path, file.bytes);
|
|
166
|
+
}
|
|
167
|
+
for (const file of drafts) {
|
|
168
|
+
writeUnder(config.draftsRoot, file.path, file.bytes);
|
|
169
|
+
}
|
|
170
|
+
if (redirectEntries.length > 0 || existsSync(config.redirectsPath)) {
|
|
171
|
+
writeFileSync(config.redirectsPath, serialiseRedirects(redirectEntries));
|
|
172
|
+
}
|
|
173
|
+
// --- Media: download what's missing ---
|
|
174
|
+
// Names are content-addressed (a hash of the bytes), so a file that
|
|
175
|
+
// already exists locally under the same name and size is the same
|
|
176
|
+
// file. Local extras are kept: unreferenced media is harmless, and a
|
|
177
|
+
// local upload not yet on the live site shouldn't vanish.
|
|
178
|
+
const mediaList = await remote.getJson('/v1/media');
|
|
179
|
+
if (!Array.isArray(mediaList)) {
|
|
180
|
+
throw new PullSiteError('fetch-failed', 'GET /v1/media did not return a list');
|
|
181
|
+
}
|
|
182
|
+
let mediaDownloaded = 0;
|
|
183
|
+
let mediaAlreadyPresent = 0;
|
|
184
|
+
mkdirSync(config.mediaRoot, { recursive: true });
|
|
185
|
+
for (const item of mediaList) {
|
|
186
|
+
const { name, size } = item;
|
|
187
|
+
if (typeof name !== 'string' || !MEDIA_NAME.test(name)) {
|
|
188
|
+
throw new PullSiteError('unsafe-path', `The live site listed an unexpected media name: ${JSON.stringify(name)}`);
|
|
189
|
+
}
|
|
190
|
+
const target = sanitisePath(config.mediaRoot, name);
|
|
191
|
+
if (existsSync(target) && statSync(target).size === size) {
|
|
192
|
+
mediaAlreadyPresent += 1;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const bytes = await remote.getBytes(`/media/${encodeURIComponent(name)}`, { auth: false });
|
|
196
|
+
writeFileSync(target, bytes);
|
|
197
|
+
mediaDownloaded += 1;
|
|
198
|
+
}
|
|
199
|
+
// The local search index describes the content that was just
|
|
200
|
+
// replaced; rebuild it from what's on disk now.
|
|
201
|
+
await rebuildIndex(config);
|
|
202
|
+
return {
|
|
203
|
+
pages: live.filter((file) => file.path.startsWith('pages/')).length,
|
|
204
|
+
menus: live.filter((file) => file.path.startsWith('menus/')).length,
|
|
205
|
+
drafts: drafts.length,
|
|
206
|
+
redirects: redirectEntries.length,
|
|
207
|
+
removed: removed.sort(),
|
|
208
|
+
mediaDownloaded,
|
|
209
|
+
mediaAlreadyPresent,
|
|
210
|
+
};
|
|
211
|
+
}
|