@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,237 @@
|
|
|
1
|
+
// Asks questions on the terminal - the live site's address, then its
|
|
2
|
+
// access 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
|
+
// The end of input is listened for for the prompter's whole life, not
|
|
30
|
+
// only while a question waits: piped input can end between questions,
|
|
31
|
+
// and a later question must still find out rather than wait forever.
|
|
32
|
+
let onEnd = null;
|
|
33
|
+
input.setEncoding('utf8');
|
|
34
|
+
input.on('end', () => {
|
|
35
|
+
ended = true;
|
|
36
|
+
onEnd?.();
|
|
37
|
+
});
|
|
38
|
+
function ask(question, { hidden = false } = {}) {
|
|
39
|
+
const raw = interactive && hidden;
|
|
40
|
+
if (raw) {
|
|
41
|
+
input.setRawMode?.(true);
|
|
42
|
+
}
|
|
43
|
+
output.write(question);
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
let answer = '';
|
|
46
|
+
function finish(error) {
|
|
47
|
+
input.removeAllListeners('data');
|
|
48
|
+
onEnd = null;
|
|
49
|
+
if (raw) {
|
|
50
|
+
input.setRawMode?.(false);
|
|
51
|
+
// Raw mode swallowed the Enter the terminal would have echoed.
|
|
52
|
+
output.write('\n');
|
|
53
|
+
}
|
|
54
|
+
input.pause();
|
|
55
|
+
if (error) {
|
|
56
|
+
reject(error);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
resolve(answer.trim());
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Consumes characters up to the end of this answer; returns true
|
|
63
|
+
// once the answer is complete, leaving the rest buffered.
|
|
64
|
+
function consume(text) {
|
|
65
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
66
|
+
const char = text[index];
|
|
67
|
+
if (char === '\r' || char === '\n') {
|
|
68
|
+
// A \r\n pair ends one answer, not two.
|
|
69
|
+
const next = index + 1;
|
|
70
|
+
buffered = text.slice(char === '\r' && text[next] === '\n' ? next + 1 : next);
|
|
71
|
+
finish();
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
if (raw && char === CTRL_C) {
|
|
75
|
+
buffered = '';
|
|
76
|
+
finish(new PromptCancelledError());
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
if (raw && char === CTRL_D) {
|
|
80
|
+
buffered = text.slice(index + 1);
|
|
81
|
+
finish();
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
if (raw && BACKSPACE.has(char)) {
|
|
85
|
+
answer = answer.slice(0, -1);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
answer += char;
|
|
89
|
+
}
|
|
90
|
+
buffered = '';
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
if (consume(buffered)) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (ended) {
|
|
97
|
+
finish();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
input.on('data', (chunk) => {
|
|
101
|
+
consume(chunk ?? '');
|
|
102
|
+
});
|
|
103
|
+
onEnd = () => finish();
|
|
104
|
+
input.resume();
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
// A checkbox list. In a terminal: up/down (or k/j) to move, space to
|
|
108
|
+
// tick, Enter to confirm, Ctrl+C to cancel. Without one (piped), one
|
|
109
|
+
// line names the choices wanted ("content,theme", or "none"); an
|
|
110
|
+
// empty line keeps the defaults.
|
|
111
|
+
async function choose(question, choices) {
|
|
112
|
+
const state = choices.map((choice) => ({ ...choice, checked: choice.checked && !choice.disabled }));
|
|
113
|
+
const ticked = () => state.filter((choice) => choice.checked).map((choice) => choice.name);
|
|
114
|
+
if (!interactive) {
|
|
115
|
+
const names = state.map((choice) => choice.name).join(', ');
|
|
116
|
+
const line = (await ask(`${question} (${names}; Enter for the defaults) `)).toLowerCase();
|
|
117
|
+
if (line === '') {
|
|
118
|
+
return ticked();
|
|
119
|
+
}
|
|
120
|
+
if (line === 'none') {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
const wanted = new Set(line.split(/[\s,]+/).filter(Boolean));
|
|
124
|
+
return state.filter((choice) => wanted.has(choice.name) && !choice.disabled).map((choice) => choice.name);
|
|
125
|
+
}
|
|
126
|
+
const width = Math.max(...state.map((choice) => choice.label.length));
|
|
127
|
+
let cursor = Math.max(0, state.findIndex((choice) => !choice.disabled));
|
|
128
|
+
let drawn = 0;
|
|
129
|
+
function draw() {
|
|
130
|
+
if (drawn > 0) {
|
|
131
|
+
output.write(`\u001b[${drawn}A`);
|
|
132
|
+
}
|
|
133
|
+
const lines = state.map((choice, index) => {
|
|
134
|
+
const pointer = index === cursor ? '>' : ' ';
|
|
135
|
+
const box = choice.disabled ? '[-]' : choice.checked ? '[x]' : '[ ]';
|
|
136
|
+
const detail = choice.detail ? ` (${choice.detail})` : '';
|
|
137
|
+
return `\u001b[2K${pointer} ${box} ${choice.label.padEnd(width)}${detail}\n`;
|
|
138
|
+
});
|
|
139
|
+
output.write(lines.join(''));
|
|
140
|
+
drawn = lines.length;
|
|
141
|
+
}
|
|
142
|
+
output.write(`${question}\n (up/down to move, space to tick, Enter to go)\n`);
|
|
143
|
+
input.setRawMode?.(true);
|
|
144
|
+
output.write('\u001b[?25l');
|
|
145
|
+
draw();
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
function finish(error) {
|
|
148
|
+
input.removeAllListeners('data');
|
|
149
|
+
input.setRawMode?.(false);
|
|
150
|
+
output.write('\u001b[?25h');
|
|
151
|
+
input.pause();
|
|
152
|
+
if (error) {
|
|
153
|
+
reject(error);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
resolve(ticked());
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function move(step) {
|
|
160
|
+
for (let tries = 0; tries < state.length; tries += 1) {
|
|
161
|
+
cursor = (cursor + step + state.length) % state.length;
|
|
162
|
+
if (!state[cursor]?.disabled) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
input.on('data', (chunk) => {
|
|
168
|
+
const keys = splitKeys(chunk ?? '');
|
|
169
|
+
for (const key of keys) {
|
|
170
|
+
if (key === CTRL_C) {
|
|
171
|
+
finish(new PromptCancelledError());
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (key === '\r' || key === '\n') {
|
|
175
|
+
finish();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (key === '\u001b[A' || key === 'k') {
|
|
179
|
+
move(-1);
|
|
180
|
+
}
|
|
181
|
+
else if (key === '\u001b[B' || key === 'j') {
|
|
182
|
+
move(1);
|
|
183
|
+
}
|
|
184
|
+
else if (key === ' ') {
|
|
185
|
+
const choice = state[cursor];
|
|
186
|
+
if (choice && !choice.disabled) {
|
|
187
|
+
choice.checked = !choice.checked;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
draw();
|
|
192
|
+
});
|
|
193
|
+
input.resume();
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return { ask, choose };
|
|
197
|
+
}
|
|
198
|
+
const ESC = '\u001b';
|
|
199
|
+
// One chunk of terminal input can hold several key presses; an arrow
|
|
200
|
+
// key arrives as a three-character escape sequence (ESC [ A).
|
|
201
|
+
function splitKeys(text) {
|
|
202
|
+
const keys = [];
|
|
203
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
204
|
+
if (text[index] === ESC && text[index + 1] === '[' && index + 2 < text.length) {
|
|
205
|
+
keys.push(text.slice(index, index + 3));
|
|
206
|
+
index += 2;
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
keys.push(text[index]);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return keys;
|
|
213
|
+
}
|
|
214
|
+
// Accepts an address as someone would type it: "my-site.example" is
|
|
215
|
+
// taken to mean https://my-site.example, but a site on this machine
|
|
216
|
+
// ("localhost:3600", "127.0.0.1:3600") http://, since a local site has
|
|
217
|
+
// no certificate. Returns null for anything that still isn't an http(s)
|
|
218
|
+
// address.
|
|
219
|
+
const LOCAL_HOST = /^(localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?(?:\/|$)/i;
|
|
220
|
+
export function normaliseSiteUrl(value) {
|
|
221
|
+
const trimmed = value.trim();
|
|
222
|
+
if (trimmed === '') {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed);
|
|
226
|
+
const withScheme = hasScheme ? trimmed : `${LOCAL_HOST.test(trimmed) ? 'http' : 'https'}://${trimmed}`;
|
|
227
|
+
try {
|
|
228
|
+
const url = new URL(withScheme);
|
|
229
|
+
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.hostname === '') {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
return url.origin;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { loadSiteConfig } from "../config.js";
|
|
4
|
+
import { ask, chooseParts, parseSyncArgs, resolveToken, TOKEN_HELP } from "./cli-helpers.js";
|
|
5
|
+
import { normaliseSiteUrl } from "./prompts.js";
|
|
6
|
+
import { checkPullableSite, pullSite } from "./pull-site.js";
|
|
7
|
+
import { RemoteSite, SiteSyncError } from "./remote-site.js";
|
|
8
|
+
import { fetchLiveTheme } from "./theme-sync.js";
|
|
9
|
+
const args = parseSyncArgs(process.argv.slice(2));
|
|
10
|
+
const force = args.flags.has('--force');
|
|
11
|
+
const givenUrl = args.positional[0] ?? (await ask('Live site address: '));
|
|
12
|
+
const siteUrl = normaliseSiteUrl(givenUrl);
|
|
13
|
+
if (!siteUrl) {
|
|
14
|
+
console.error(givenUrl.trim() === '' ? 'No address entered.' : `"${givenUrl}" is not a web address.`);
|
|
15
|
+
console.error('Usage: npm run pull [-- <live-site-address>] [--content] [--theme] [--force]');
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
// Checked before asking for the token, so a mistyped address is found
|
|
19
|
+
// out without typing a token for nothing.
|
|
20
|
+
try {
|
|
21
|
+
await checkPullableSite(siteUrl);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error instanceof SiteSyncError) {
|
|
25
|
+
console.error(error.message);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
const token = await resolveToken(args, siteUrl);
|
|
31
|
+
// Run from vhost/ (the scaffold's own "pull" script), so the site root
|
|
32
|
+
// is one level up - the same relationship check-site relies on.
|
|
33
|
+
const config = loadSiteConfig(resolve(process.cwd(), '..'));
|
|
34
|
+
// Asked before anything is changed: the theme needs a token with the
|
|
35
|
+
// "theme" scope, found out here with one read rather than part way in.
|
|
36
|
+
const liveTheme = await fetchLiveTheme(new RemoteSite(siteUrl, token)).catch((error) => ({ unavailable: error instanceof Error ? error.message : String(error) }));
|
|
37
|
+
const parts = await chooseParts(args, 'pull', {
|
|
38
|
+
content: { available: true, detail: 'pages, menus, redirects, images', checked: true },
|
|
39
|
+
theme: 'theme' in liveTheme
|
|
40
|
+
? { available: true, detail: 'templates, styles, scripts', checked: true }
|
|
41
|
+
: { available: false, detail: liveTheme.unavailable, checked: false },
|
|
42
|
+
});
|
|
43
|
+
if (!parts.content && !parts.theme) {
|
|
44
|
+
console.error('Nothing chosen. Nothing was pulled.');
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const result = await pullSite(config, { siteUrl, token, force, parts });
|
|
49
|
+
console.log(`Pulled from ${siteUrl}:`);
|
|
50
|
+
if (result.theme) {
|
|
51
|
+
console.log(` theme: ${result.theme.files} files, ${result.theme.downloaded} downloaded`);
|
|
52
|
+
for (const path of result.theme.removed) {
|
|
53
|
+
console.log(` removed theme/${path} (not on the live site)`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (result.content) {
|
|
57
|
+
const content = result.content;
|
|
58
|
+
console.log(` content: ${content.pages} pages, ${content.menus} menus, ${content.drafts} drafts, ${content.redirects} redirects`);
|
|
59
|
+
console.log(` images: ${content.mediaDownloaded} downloaded, ${content.mediaAlreadyPresent} already here`);
|
|
60
|
+
for (const path of content.removed) {
|
|
61
|
+
console.log(` removed content/${path} (not on the live site)`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
console.log('Nothing was committed. Review the changes with "git status" and "git diff" from the site folder.');
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error instanceof SiteSyncError) {
|
|
68
|
+
console.error(error.message);
|
|
69
|
+
if (error.reason === 'unauthorised') {
|
|
70
|
+
console.error(TOKEN_HELP);
|
|
71
|
+
}
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SiteConfig } from '../config.ts';
|
|
2
|
+
import { type ThemePullResult } from './theme-sync.ts';
|
|
3
|
+
export interface SyncParts {
|
|
4
|
+
content: boolean;
|
|
5
|
+
theme: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface PullSiteOptions {
|
|
8
|
+
siteUrl: string;
|
|
9
|
+
token: string;
|
|
10
|
+
parts: SyncParts;
|
|
11
|
+
force?: boolean;
|
|
12
|
+
fetchImpl?: typeof fetch;
|
|
13
|
+
}
|
|
14
|
+
export interface PullContentResult {
|
|
15
|
+
pages: number;
|
|
16
|
+
menus: number;
|
|
17
|
+
drafts: number;
|
|
18
|
+
redirects: number;
|
|
19
|
+
removed: string[];
|
|
20
|
+
mediaDownloaded: number;
|
|
21
|
+
mediaAlreadyPresent: number;
|
|
22
|
+
}
|
|
23
|
+
export interface PullSiteResult {
|
|
24
|
+
content?: PullContentResult;
|
|
25
|
+
theme?: ThemePullResult;
|
|
26
|
+
}
|
|
27
|
+
export declare function checkPullableSite(siteUrl: string, fetchImpl?: typeof fetch): Promise<void>;
|
|
28
|
+
export declare function pullSite(config: SiteConfig, options: PullSiteOptions): Promise<PullSiteResult>;
|
|
@@ -0,0 +1,171 @@
|
|
|
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 { loadRedirects, serialiseRedirects } from "../services/redirects.js";
|
|
8
|
+
import { rebuildIndex } from "../search/rebuild-index.js";
|
|
9
|
+
import { PACKAGE_NAME } from "../upgrade/package-names.js";
|
|
10
|
+
import { fetchLiveContent, fetchLiveMedia, readCapabilities } from "./live-content.js";
|
|
11
|
+
import { RemoteSite, SiteSyncError } from "./remote-site.js";
|
|
12
|
+
import { hashOf, sameRedirects, settingsHash, updateSyncRecord } from "./sync-record.js";
|
|
13
|
+
import { readSiteSettings, siteSettingsPath } from "../services/site-settings.js";
|
|
14
|
+
import { fetchLiveTheme, pullTheme } from "./theme-sync.js";
|
|
15
|
+
function hasUncommittedChanges(config, folder) {
|
|
16
|
+
try {
|
|
17
|
+
const status = execFileSync('git', ['status', '--porcelain', '--', folder], { cwd: config.siteRoot });
|
|
18
|
+
return status.toString('utf-8').trim() !== '';
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Not a git repository (or no git): nothing to protect via git, and
|
|
22
|
+
// no way to recover what a pull overwrites - treat as changed.
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function writeUnder(root, relativePath, bytes) {
|
|
27
|
+
mkdirSync(root, { recursive: true });
|
|
28
|
+
const target = sanitisePath(root, relativePath);
|
|
29
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
30
|
+
writeFileSync(target, bytes);
|
|
31
|
+
}
|
|
32
|
+
// Whether an address is a site this agent can pull from, checked
|
|
33
|
+
// without a token - so the CLI can say the address is wrong before
|
|
34
|
+
// asking for a token at all.
|
|
35
|
+
export async function checkPullableSite(siteUrl, fetchImpl) {
|
|
36
|
+
const capabilities = await readCapabilities(siteUrl, fetchImpl);
|
|
37
|
+
if (capabilities.contentSchemaVersion > CURRENT_SCHEMA_VERSION) {
|
|
38
|
+
throw new SiteSyncError('newer-schema', `The live site uses content schema ${capabilities.contentSchemaVersion} (agent ${capabilities.agentVersion}), newer than this site's ${CURRENT_SCHEMA_VERSION}. Upgrade ${PACKAGE_NAME} here first.`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Makes this local site a copy of a running site's, for the parts
|
|
42
|
+
// chosen. Content: pages, menus, drafts and redirects are mirrored
|
|
43
|
+
// (local files the live site doesn't have are removed), and any media
|
|
44
|
+
// file missing locally is downloaded. Theme: theme/ is mirrored the same
|
|
45
|
+
// way (theme-sync.ts), which needs a token with the "theme" scope.
|
|
46
|
+
// Nothing is committed: the result is left in the working tree to
|
|
47
|
+
// review.
|
|
48
|
+
//
|
|
49
|
+
// Everything is fetched before anything is written, so a failure part
|
|
50
|
+
// way through the content leaves the local site exactly as it was.
|
|
51
|
+
// Media is downloaded last and only ever adds files, so a failure there
|
|
52
|
+
// leaves the content pulled and some images still to fetch on a re-run.
|
|
53
|
+
//
|
|
54
|
+
// Records what was pulled (sync-record.ts), which is what lets a later
|
|
55
|
+
// push tell local changes apart from ones made on the live site since.
|
|
56
|
+
export async function pullSite(config, options) {
|
|
57
|
+
const remote = new RemoteSite(options.siteUrl, options.token, { fetchImpl: options.fetchImpl });
|
|
58
|
+
await checkPullableSite(options.siteUrl, options.fetchImpl);
|
|
59
|
+
const folders = ['content', 'theme'].filter((part) => options.parts[part]);
|
|
60
|
+
const dirty = options.force ? [] : folders.filter((folder) => hasUncommittedChanges(config, folder));
|
|
61
|
+
if (dirty.length > 0) {
|
|
62
|
+
throw new SiteSyncError('uncommitted-changes', `${dirty.map((folder) => `${folder}/`).join(' and ')} ${dirty.length === 1 ? 'has' : 'have'} uncommitted changes, which pulling would overwrite. Commit them first, or pass --force.`);
|
|
63
|
+
}
|
|
64
|
+
const result = {};
|
|
65
|
+
// Theme first: fetched and checked before any content is touched, so
|
|
66
|
+
// a token without the "theme" scope fails before anything changes.
|
|
67
|
+
if (options.parts.theme) {
|
|
68
|
+
const fetched = await fetchLiveTheme(remote);
|
|
69
|
+
if ('unavailable' in fetched) {
|
|
70
|
+
throw new SiteSyncError('unauthorised', `Can't pull the theme: ${fetched.unavailable}.`);
|
|
71
|
+
}
|
|
72
|
+
const live = fetched.theme;
|
|
73
|
+
result.theme = await pullTheme(config, remote, live);
|
|
74
|
+
updateSyncRecord(config, options.siteUrl, { theme: { syncedAt: new Date().toISOString(), files: Object.fromEntries(live) } });
|
|
75
|
+
}
|
|
76
|
+
if (options.parts.content) {
|
|
77
|
+
result.content = await pullContent(config, remote, options.siteUrl);
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
async function pullContent(config, remote, siteUrl) {
|
|
82
|
+
// --- Fetch everything ---
|
|
83
|
+
const { live, drafts, redirects, settings } = await fetchLiveContent(remote);
|
|
84
|
+
// --- Write content (mirror) ---
|
|
85
|
+
const removed = [];
|
|
86
|
+
for (const local of [
|
|
87
|
+
...listFilesRecursively(config.pagesRoot, config.contentRoot, '.json'),
|
|
88
|
+
...listFilesRecursively(config.menusRoot, config.contentRoot, '.json'),
|
|
89
|
+
]) {
|
|
90
|
+
if (!live.has(local)) {
|
|
91
|
+
unlinkSync(sanitisePath(config.contentRoot, local));
|
|
92
|
+
removed.push(local);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
for (const local of listFilesRecursively(config.draftsRoot, config.draftsRoot, '.json')) {
|
|
96
|
+
if (!drafts.has(local)) {
|
|
97
|
+
unlinkSync(sanitisePath(config.draftsRoot, local));
|
|
98
|
+
removed.push(`drafts/${local}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const [path, file] of live) {
|
|
102
|
+
writeUnder(config.contentRoot, path, file.bytes);
|
|
103
|
+
}
|
|
104
|
+
for (const [path, bytes] of drafts) {
|
|
105
|
+
writeUnder(config.draftsRoot, path, bytes);
|
|
106
|
+
}
|
|
107
|
+
// Only when they differ: rewriting identical redirects would still
|
|
108
|
+
// show as a change (the file's formatting), seen on a real pull.
|
|
109
|
+
const localRedirects = existsSync(config.redirectsPath) ? loadRedirects(config).entries : [];
|
|
110
|
+
if (!sameRedirects(localRedirects, redirects)) {
|
|
111
|
+
writeFileSync(config.redirectsPath, serialiseRedirects(redirects));
|
|
112
|
+
}
|
|
113
|
+
// Site settings, mirrored like everything else: the live site's saved
|
|
114
|
+
// values, or no settings.json when it has none saved. Left alone
|
|
115
|
+
// entirely for a live CMS older than site settings, and only rewritten
|
|
116
|
+
// when the values actually differ.
|
|
117
|
+
const settingsPath = siteSettingsPath(config);
|
|
118
|
+
if (settings !== null) {
|
|
119
|
+
if (settings.etag === null) {
|
|
120
|
+
if (existsSync(settingsPath)) {
|
|
121
|
+
unlinkSync(settingsPath);
|
|
122
|
+
removed.push('settings.json');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
const local = existsSync(settingsPath) ? readSiteSettings(config).settings : null;
|
|
127
|
+
if (local === null || settingsHash(local) !== settingsHash(settings.values)) {
|
|
128
|
+
writeFileSync(settingsPath, `${JSON.stringify({ schemaVersion: CURRENT_SCHEMA_VERSION, settings: settings.values }, null, 2)}\n`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
updateSyncRecord(config, siteUrl, {
|
|
133
|
+
content: {
|
|
134
|
+
syncedAt: new Date().toISOString(),
|
|
135
|
+
files: Object.fromEntries([...live].map(([path, file]) => [path, hashOf(file.bytes)])),
|
|
136
|
+
redirects,
|
|
137
|
+
...(settings?.etag ? { settings: settingsHash(settings.values) } : {}),
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
// --- Media: download what's missing ---
|
|
141
|
+
// Names are content-addressed (a hash of the bytes), so a file that
|
|
142
|
+
// already exists locally under the same name and size is the same
|
|
143
|
+
// file. Local extras are kept: unreferenced media is harmless, and a
|
|
144
|
+
// local upload not yet on the live site shouldn't vanish.
|
|
145
|
+
let mediaDownloaded = 0;
|
|
146
|
+
let mediaAlreadyPresent = 0;
|
|
147
|
+
mkdirSync(config.mediaRoot, { recursive: true });
|
|
148
|
+
for (const { name, size } of await fetchLiveMedia(remote)) {
|
|
149
|
+
const target = sanitisePath(config.mediaRoot, name);
|
|
150
|
+
if (existsSync(target) && statSync(target).size === size) {
|
|
151
|
+
mediaAlreadyPresent += 1;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const bytes = await remote.getBytes(`/media/${encodeURIComponent(name)}`, { auth: false });
|
|
155
|
+
writeFileSync(target, bytes);
|
|
156
|
+
mediaDownloaded += 1;
|
|
157
|
+
}
|
|
158
|
+
// The local search index describes the content that was just
|
|
159
|
+
// replaced; rebuild it from what's on disk now.
|
|
160
|
+
await rebuildIndex(config);
|
|
161
|
+
const paths = [...live.keys()];
|
|
162
|
+
return {
|
|
163
|
+
pages: paths.filter((path) => path.startsWith('pages/')).length,
|
|
164
|
+
menus: paths.filter((path) => path.startsWith('menus/')).length,
|
|
165
|
+
drafts: drafts.size,
|
|
166
|
+
redirects: redirects.length,
|
|
167
|
+
removed: removed.sort(),
|
|
168
|
+
mediaDownloaded,
|
|
169
|
+
mediaAlreadyPresent,
|
|
170
|
+
};
|
|
171
|
+
}
|