@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.
Files changed (286) hide show
  1. package/CHANGELOG.md +240 -0
  2. package/LICENSE +105 -0
  3. package/README.md +133 -2
  4. package/dist/boot.d.ts +19 -0
  5. package/dist/boot.js +22 -0
  6. package/dist/config.d.ts +18 -0
  7. package/dist/config.js +44 -0
  8. package/dist/create-site/cli.d.ts +2 -0
  9. package/dist/create-site/cli.js +31 -0
  10. package/dist/create-site/generate-site.d.ts +12 -0
  11. package/dist/create-site/generate-site.js +168 -0
  12. package/dist/create-site/mint-token-cli.d.ts +2 -0
  13. package/dist/create-site/mint-token-cli.js +26 -0
  14. package/dist/create-site/mint-token.d.ts +7 -0
  15. package/dist/create-site/mint-token.js +49 -0
  16. package/dist/create-site/template/AGENTS.md +473 -0
  17. package/dist/create-site/template/content/menus/footerCompany.json +9 -0
  18. package/dist/create-site/template/content/menus/footerProduct.json +9 -0
  19. package/dist/create-site/template/content/menus/footerResources.json +9 -0
  20. package/dist/create-site/template/content/menus/main.json +9 -0
  21. package/dist/create-site/template/content/pages/404.json +23 -0
  22. package/dist/create-site/template/content/pages/about/careers.json +22 -0
  23. package/dist/create-site/template/content/pages/about/team.json +22 -0
  24. package/dist/create-site/template/content/pages/about.json +22 -0
  25. package/dist/create-site/template/content/pages/docs/deployment.json +22 -0
  26. package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +22 -0
  27. package/dist/create-site/template/content/pages/docs/getting-started.json +24 -0
  28. package/dist/create-site/template/content/pages/docs.json +22 -0
  29. package/dist/create-site/template/content/pages/index.json +182 -0
  30. package/dist/create-site/template/content/redirects.json +4 -0
  31. package/dist/create-site/template/dockerignore +6 -0
  32. package/dist/create-site/template/gitignore +11 -0
  33. package/dist/create-site/template/theme/assets/site.js +49 -0
  34. package/dist/create-site/template/theme/assets/style.css +1223 -0
  35. package/dist/create-site/template/theme/blocks/button.liquid +21 -0
  36. package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
  37. package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
  38. package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
  39. package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
  40. package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
  41. package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
  42. package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
  43. package/dist/create-site/template/theme/config/site_settings.json +11 -0
  44. package/dist/create-site/template/theme/layouts/theme.liquid +102 -0
  45. package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
  46. package/dist/create-site/template/theme/sections/cta-banner.liquid +36 -0
  47. package/dist/create-site/template/theme/sections/faq.liquid +27 -0
  48. package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
  49. package/dist/create-site/template/theme/sections/hero.liquid +48 -0
  50. package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
  51. package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
  52. package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
  53. package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
  54. package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
  55. package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
  56. package/dist/create-site/template/theme/snippets/responsive-media.liquid +107 -0
  57. package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
  58. package/dist/create-site/template/vhost/Dockerfile +33 -0
  59. package/dist/create-site/template/vhost/docker-entrypoint.sh +58 -0
  60. package/dist/index.d.ts +2 -0
  61. package/dist/index.js +4 -0
  62. package/dist/media/drivers/driver.d.ts +11 -0
  63. package/dist/media/drivers/driver.js +1 -0
  64. package/dist/media/drivers/local-fs-driver.d.ts +3 -0
  65. package/dist/media/drivers/local-fs-driver.js +94 -0
  66. package/dist/media/filename.d.ts +2 -0
  67. package/dist/media/filename.js +54 -0
  68. package/dist/media/manage-media.d.ts +19 -0
  69. package/dist/media/manage-media.js +45 -0
  70. package/dist/media/seed-media-cli.d.ts +2 -0
  71. package/dist/media/seed-media-cli.js +22 -0
  72. package/dist/media/seed-media.d.ts +11 -0
  73. package/dist/media/seed-media.js +76 -0
  74. package/dist/migrations/index.d.ts +3 -0
  75. package/dist/migrations/index.js +83 -0
  76. package/dist/pull-site/cli.d.ts +2 -0
  77. package/dist/pull-site/cli.js +78 -0
  78. package/dist/pull-site/prompt-token.d.ts +16 -0
  79. package/dist/pull-site/prompt-token.js +69 -0
  80. package/dist/pull-site/prompts.d.ts +22 -0
  81. package/dist/pull-site/prompts.js +127 -0
  82. package/dist/pull-site/pull-site.d.ts +23 -0
  83. package/dist/pull-site/pull-site.js +211 -0
  84. package/dist/renderer/engine.d.ts +2 -0
  85. package/dist/renderer/engine.js +48 -0
  86. package/dist/renderer/render-cache.d.ts +11 -0
  87. package/dist/renderer/render-cache.js +11 -0
  88. package/dist/renderer/render-page.d.ts +34 -0
  89. package/dist/renderer/render-page.js +209 -0
  90. package/dist/renderer/theme-templates.d.ts +7 -0
  91. package/dist/renderer/theme-templates.js +82 -0
  92. package/dist/routes/admin-redirect.d.ts +5 -0
  93. package/dist/routes/admin-redirect.js +26 -0
  94. package/dist/routes/assets.d.ts +6 -0
  95. package/dist/routes/assets.js +30 -0
  96. package/dist/routes/batch.d.ts +10 -0
  97. package/dist/routes/batch.js +148 -0
  98. package/dist/routes/capabilities.d.ts +6 -0
  99. package/dist/routes/capabilities.js +27 -0
  100. package/dist/routes/content.d.ts +8 -0
  101. package/dist/routes/content.js +169 -0
  102. package/dist/routes/deleted-pages.d.ts +8 -0
  103. package/dist/routes/deleted-pages.js +9 -0
  104. package/dist/routes/drafts.d.ts +10 -0
  105. package/dist/routes/drafts.js +73 -0
  106. package/dist/routes/git.d.ts +8 -0
  107. package/dist/routes/git.js +158 -0
  108. package/dist/routes/index.d.ts +12 -0
  109. package/dist/routes/index.js +86 -0
  110. package/dist/routes/links.d.ts +8 -0
  111. package/dist/routes/links.js +18 -0
  112. package/dist/routes/media-public.d.ts +11 -0
  113. package/dist/routes/media-public.js +125 -0
  114. package/dist/routes/media.d.ts +9 -0
  115. package/dist/routes/media.js +80 -0
  116. package/dist/routes/menus.d.ts +8 -0
  117. package/dist/routes/menus.js +148 -0
  118. package/dist/routes/preview-revision.d.ts +10 -0
  119. package/dist/routes/preview-revision.js +96 -0
  120. package/dist/routes/preview.d.ts +10 -0
  121. package/dist/routes/preview.js +56 -0
  122. package/dist/routes/public.d.ts +10 -0
  123. package/dist/routes/public.js +123 -0
  124. package/dist/routes/publish.d.ts +10 -0
  125. package/dist/routes/publish.js +139 -0
  126. package/dist/routes/redirects.d.ts +8 -0
  127. package/dist/routes/redirects.js +132 -0
  128. package/dist/routes/search-public.d.ts +6 -0
  129. package/dist/routes/search-public.js +104 -0
  130. package/dist/routes/search.d.ts +8 -0
  131. package/dist/routes/search.js +15 -0
  132. package/dist/routes/settings.d.ts +10 -0
  133. package/dist/routes/settings.js +68 -0
  134. package/dist/routes/sitemap.d.ts +7 -0
  135. package/dist/routes/sitemap.js +79 -0
  136. package/dist/routes/theme.d.ts +10 -0
  137. package/dist/routes/theme.js +116 -0
  138. package/dist/schemas/instance.schema.json +17 -0
  139. package/dist/schemas/menu.schema.json +24 -0
  140. package/dist/schemas/page.schema.json +26 -0
  141. package/dist/schemas/redirects.schema.json +24 -0
  142. package/dist/search/drivers/driver.d.ts +9 -0
  143. package/dist/search/drivers/driver.js +1 -0
  144. package/dist/search/drivers/node-sqlite-driver.d.ts +7 -0
  145. package/dist/search/drivers/node-sqlite-driver.js +19 -0
  146. package/dist/search/query-content.d.ts +32 -0
  147. package/dist/search/query-content.js +207 -0
  148. package/dist/search/query-index.d.ts +5 -0
  149. package/dist/search/query-index.js +21 -0
  150. package/dist/search/rebuild-index.d.ts +3 -0
  151. package/dist/search/rebuild-index.js +320 -0
  152. package/dist/server-config.d.ts +23 -0
  153. package/dist/server-config.js +237 -0
  154. package/dist/server.d.ts +13 -0
  155. package/dist/server.js +235 -0
  156. package/dist/services/batch.d.ts +35 -0
  157. package/dist/services/batch.js +145 -0
  158. package/dist/services/checkpoint.d.ts +4 -0
  159. package/dist/services/checkpoint.js +45 -0
  160. package/dist/services/content-read.d.ts +27 -0
  161. package/dist/services/content-read.js +151 -0
  162. package/dist/services/delete-content.d.ts +12 -0
  163. package/dist/services/delete-content.js +166 -0
  164. package/dist/services/deleted-pages.d.ts +11 -0
  165. package/dist/services/deleted-pages.js +83 -0
  166. package/dist/services/dev-tunnel.d.ts +5 -0
  167. package/dist/services/dev-tunnel.js +5 -0
  168. package/dist/services/drafts.d.ts +16 -0
  169. package/dist/services/drafts.js +164 -0
  170. package/dist/services/etag.d.ts +2 -0
  171. package/dist/services/etag.js +30 -0
  172. package/dist/services/fs-walk.d.ts +1 -0
  173. package/dist/services/fs-walk.js +26 -0
  174. package/dist/services/git-commit.d.ts +3 -0
  175. package/dist/services/git-commit.js +12 -0
  176. package/dist/services/git-history.d.ts +28 -0
  177. package/dist/services/git-history.js +131 -0
  178. package/dist/services/git-revert.d.ts +12 -0
  179. package/dist/services/git-revert.js +140 -0
  180. package/dist/services/git.d.ts +16 -0
  181. package/dist/services/git.js +127 -0
  182. package/dist/services/interval-job.d.ts +4 -0
  183. package/dist/services/interval-job.js +9 -0
  184. package/dist/services/ip-allowlist.d.ts +7 -0
  185. package/dist/services/ip-allowlist.js +31 -0
  186. package/dist/services/links.d.ts +22 -0
  187. package/dist/services/links.js +169 -0
  188. package/dist/services/manage-menus.d.ts +15 -0
  189. package/dist/services/manage-menus.js +138 -0
  190. package/dist/services/manage-redirects.d.ts +25 -0
  191. package/dist/services/manage-redirects.js +156 -0
  192. package/dist/services/menu-references.d.ts +3 -0
  193. package/dist/services/menu-references.js +38 -0
  194. package/dist/services/menus.d.ts +9 -0
  195. package/dist/services/menus.js +43 -0
  196. package/dist/services/migration-runner.d.ts +16 -0
  197. package/dist/services/migration-runner.js +169 -0
  198. package/dist/services/mime-types.d.ts +3 -0
  199. package/dist/services/mime-types.js +43 -0
  200. package/dist/services/move.d.ts +16 -0
  201. package/dist/services/move.js +236 -0
  202. package/dist/services/path-safety.d.ts +6 -0
  203. package/dist/services/path-safety.js +112 -0
  204. package/dist/services/pid-file.d.ts +13 -0
  205. package/dist/services/pid-file.js +54 -0
  206. package/dist/services/post-urls.d.ts +3 -0
  207. package/dist/services/post-urls.js +27 -0
  208. package/dist/services/prepared-operation.d.ts +4 -0
  209. package/dist/services/prepared-operation.js +1 -0
  210. package/dist/services/publish.d.ts +15 -0
  211. package/dist/services/publish.js +313 -0
  212. package/dist/services/rate-limit-config.d.ts +9 -0
  213. package/dist/services/rate-limit-config.js +22 -0
  214. package/dist/services/redirects.d.ts +27 -0
  215. package/dist/services/redirects.js +175 -0
  216. package/dist/services/reindex-on-write.d.ts +3 -0
  217. package/dist/services/reindex-on-write.js +30 -0
  218. package/dist/services/resolve-blog-url.d.ts +11 -0
  219. package/dist/services/resolve-blog-url.js +31 -0
  220. package/dist/services/resolve-url.d.ts +11 -0
  221. package/dist/services/resolve-url.js +20 -0
  222. package/dist/services/site-settings.d.ts +28 -0
  223. package/dist/services/site-settings.js +116 -0
  224. package/dist/services/startup-checks.d.ts +10 -0
  225. package/dist/services/startup-checks.js +81 -0
  226. package/dist/services/static-file.d.ts +7 -0
  227. package/dist/services/static-file.js +37 -0
  228. package/dist/services/theme-component-file.d.ts +6 -0
  229. package/dist/services/theme-component-file.js +39 -0
  230. package/dist/services/theme-files.d.ts +33 -0
  231. package/dist/services/theme-files.js +148 -0
  232. package/dist/services/theme-page-templates.d.ts +7 -0
  233. package/dist/services/theme-page-templates.js +53 -0
  234. package/dist/services/theme-schemas.d.ts +2 -0
  235. package/dist/services/theme-schemas.js +137 -0
  236. package/dist/services/token-auth.d.ts +10 -0
  237. package/dist/services/token-auth.js +64 -0
  238. package/dist/services/urls.d.ts +2 -0
  239. package/dist/services/urls.js +20 -0
  240. package/dist/services/validation.d.ts +26 -0
  241. package/dist/services/validation.js +195 -0
  242. package/dist/services/write-queue.d.ts +1 -0
  243. package/dist/services/write-queue.js +14 -0
  244. package/dist/site-check/cli.d.ts +2 -0
  245. package/dist/site-check/cli.js +48 -0
  246. package/dist/site-check/run-check.d.ts +12 -0
  247. package/dist/site-check/run-check.js +188 -0
  248. package/dist/site-sync/cli-helpers.d.ts +23 -0
  249. package/dist/site-sync/cli-helpers.js +75 -0
  250. package/dist/site-sync/deploy.d.ts +36 -0
  251. package/dist/site-sync/deploy.js +124 -0
  252. package/dist/site-sync/live-content.d.ts +30 -0
  253. package/dist/site-sync/live-content.js +68 -0
  254. package/dist/site-sync/prompts.d.ts +30 -0
  255. package/dist/site-sync/prompts.js +237 -0
  256. package/dist/site-sync/pull-cli.d.ts +2 -0
  257. package/dist/site-sync/pull-cli.js +75 -0
  258. package/dist/site-sync/pull-site.d.ts +28 -0
  259. package/dist/site-sync/pull-site.js +171 -0
  260. package/dist/site-sync/push-cli.d.ts +2 -0
  261. package/dist/site-sync/push-cli.js +268 -0
  262. package/dist/site-sync/push-site.d.ts +96 -0
  263. package/dist/site-sync/push-site.js +376 -0
  264. package/dist/site-sync/remote-site.d.ts +38 -0
  265. package/dist/site-sync/remote-site.js +122 -0
  266. package/dist/site-sync/sync-record.d.ts +26 -0
  267. package/dist/site-sync/sync-record.js +76 -0
  268. package/dist/site-sync/theme-sync.d.ts +34 -0
  269. package/dist/site-sync/theme-sync.js +128 -0
  270. package/dist/stop-site/cli.d.ts +2 -0
  271. package/dist/stop-site/cli.js +28 -0
  272. package/dist/stop-site/stop-site.d.ts +26 -0
  273. package/dist/stop-site/stop-site.js +56 -0
  274. package/dist/theme-state.d.ts +20 -0
  275. package/dist/theme-state.js +33 -0
  276. package/dist/upgrade/changelog.d.ts +8 -0
  277. package/dist/upgrade/changelog.js +46 -0
  278. package/dist/upgrade/finish.d.ts +9 -0
  279. package/dist/upgrade/finish.js +30 -0
  280. package/dist/upgrade/owned-files.d.ts +11 -0
  281. package/dist/upgrade/owned-files.js +42 -0
  282. package/dist/upgrade/package-names.d.ts +4 -0
  283. package/dist/upgrade/package-names.js +26 -0
  284. package/dist/upgrade/upgrade-cli.d.ts +2 -0
  285. package/dist/upgrade/upgrade-cli.js +170 -0
  286. package/package.json +64 -4
@@ -0,0 +1,376 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { CURRENT_SCHEMA_VERSION } from "../migrations/index.js";
4
+ import { listFilesRecursively } from "../services/fs-walk.js";
5
+ import { sanitisePath } from "../services/path-safety.js";
6
+ import { loadRedirects } from "../services/redirects.js";
7
+ import { encodePath, fetchLiveContent, fetchLiveMedia, readCapabilities } from "./live-content.js";
8
+ import { RemoteSite, SiteSyncError } from "./remote-site.js";
9
+ import { hashOf, readSyncRecord, sameRedirects, settingsHash, updateSyncRecord } from "./sync-record.js";
10
+ import { readSiteSettings, siteSettingsPath } from "../services/site-settings.js";
11
+ import { executeThemePush, fetchLiveTheme, planThemePush, readLocalTheme } from "./theme-sync.js";
12
+ import { hashThemeFile } from "../services/theme-files.js";
13
+ // Three versions of every page and menu: as last pulled (the record),
14
+ // here, and on the live site now. Only a local change is pushed:
15
+ //
16
+ // local same as pulled -> nothing to push (any live edit is kept)
17
+ // local same as live -> nothing to push (already there)
18
+ // live changed since the pull too -> conflict: never overwritten
19
+ // otherwise -> create, update or delete
20
+ //
21
+ // So a page an editor changed on the live site is never overwritten, a
22
+ // page an editor created there is never deleted, and a page is only
23
+ // deleted if it existed at the pull and was deleted here since. A page
24
+ // with an unpublished draft on the live site is a conflict too: writing
25
+ // it would replace that draft. Redirects are compared as one list the
26
+ // same way, then turned into per-redirect operations.
27
+ export function planPush(record, local, live, liveMedia) {
28
+ const changes = [];
29
+ const conflicts = [];
30
+ const paths = new Set([...Object.keys(record.files), ...local.files.keys(), ...live.live.keys()]);
31
+ for (const path of [...paths].sort()) {
32
+ const pulled = record.files[path];
33
+ const here = local.files.has(path) ? hashOf(local.files.get(path)) : undefined;
34
+ const liveFile = live.live.get(path);
35
+ const there = liveFile ? hashOf(liveFile.bytes) : undefined;
36
+ if (here === pulled || here === there) {
37
+ continue;
38
+ }
39
+ if (there !== pulled) {
40
+ conflicts.push({
41
+ path,
42
+ reason: there === undefined
43
+ ? 'deleted on the live site since your last pull'
44
+ : pulled === undefined
45
+ ? 'created on the live site since your last pull'
46
+ : 'changed on the live site since your last pull',
47
+ });
48
+ continue;
49
+ }
50
+ if (live.drafts.has(path)) {
51
+ conflicts.push({ path, reason: 'has unpublished draft changes on the live site' });
52
+ continue;
53
+ }
54
+ changes.push({ path, action: here === undefined ? 'delete' : there === undefined ? 'create' : 'update' });
55
+ }
56
+ const redirectOperations = [];
57
+ if (!sameRedirects(local.redirects, record.redirects) && !sameRedirects(local.redirects, live.redirects)) {
58
+ if (!sameRedirects(live.redirects, record.redirects)) {
59
+ conflicts.push({ path: 'redirects.json', reason: 'redirects changed on the live site since your last pull' });
60
+ }
61
+ else {
62
+ const liveByFrom = new Map(live.redirects.map((entry) => [entry.from, entry]));
63
+ const localByFrom = new Map(local.redirects.map((entry) => [entry.from, entry]));
64
+ for (const entry of live.redirects) {
65
+ if (!localByFrom.has(entry.from)) {
66
+ redirectOperations.push({ method: 'DELETE', entry });
67
+ }
68
+ }
69
+ for (const entry of local.redirects) {
70
+ const existing = liveByFrom.get(entry.from);
71
+ if (!existing) {
72
+ redirectOperations.push({ method: 'POST', entry });
73
+ }
74
+ else if (existing.to !== entry.to || (existing.note ?? '') !== (entry.note ?? '')) {
75
+ redirectOperations.push({ method: 'PUT', entry });
76
+ }
77
+ }
78
+ }
79
+ }
80
+ // Only media the pushed pages actually use: local media/ can hold
81
+ // uploads nothing refers to, which have no business on the live site.
82
+ const referenced = new Set();
83
+ for (const change of changes) {
84
+ if (change.action === 'delete') {
85
+ continue;
86
+ }
87
+ const text = local.files.get(change.path).toString('utf-8');
88
+ for (const match of text.matchAll(/\/media\/([A-Za-z0-9_-][A-Za-z0-9._-]*)/g)) {
89
+ referenced.add(match[1]);
90
+ }
91
+ }
92
+ const mediaToUpload = [];
93
+ const mediaMissing = [];
94
+ for (const name of [...referenced].sort()) {
95
+ if (liveMedia.has(name)) {
96
+ continue;
97
+ }
98
+ (local.mediaNames.has(name) ? mediaToUpload : mediaMissing).push(name);
99
+ }
100
+ // Site settings: the same three-way rule, as one unit. Only ever
101
+ // pushed from a copy that has its own settings.json - with none, the
102
+ // live site's settings are left alone, never cleared - and not at all
103
+ // to a CMS without site settings.
104
+ let settings = false;
105
+ if (local.settings !== null && live.settings !== null) {
106
+ const pulledSettings = record.settings;
107
+ const here = settingsHash(local.settings);
108
+ const there = live.settings.etag === null ? undefined : settingsHash(live.settings.values);
109
+ if (here !== pulledSettings && here !== there) {
110
+ if (there !== pulledSettings) {
111
+ conflicts.push({ path: 'settings.json', reason: 'site settings changed on the live site since your last pull' });
112
+ }
113
+ else {
114
+ settings = true;
115
+ }
116
+ }
117
+ }
118
+ return { changes, conflicts, redirectOperations, mediaToUpload, mediaMissing, settings };
119
+ }
120
+ // --- Reading this site, and the live one ---
121
+ function readLocalContent(config) {
122
+ const files = new Map();
123
+ for (const path of [
124
+ ...listFilesRecursively(config.pagesRoot, config.contentRoot, '.json'),
125
+ ...listFilesRecursively(config.menusRoot, config.contentRoot, '.json'),
126
+ ]) {
127
+ files.set(path, readFileSync(sanitisePath(config.contentRoot, path)));
128
+ }
129
+ const mediaNames = new Set(existsSync(config.mediaRoot) ? listFilesRecursively(config.mediaRoot, config.mediaRoot, '') : []);
130
+ const settings = existsSync(siteSettingsPath(config)) ? readSiteSettings(config).settings : null;
131
+ return { files, redirects: loadRedirects(config).entries, mediaNames, settings };
132
+ }
133
+ // The live site's history should say who pushed, so the author is this
134
+ // copy's own git identity rather than anything invented.
135
+ function readGitAuthor(config) {
136
+ const read = (key) => {
137
+ try {
138
+ return execFileSync('git', ['config', key], { cwd: config.siteRoot }).toString('utf-8').trim();
139
+ }
140
+ catch {
141
+ return '';
142
+ }
143
+ };
144
+ const name = read('user.name');
145
+ const email = read('user.email');
146
+ if (!name || !email) {
147
+ throw new SiteSyncError('no-author', 'Push records who made the change in the live site\'s history, from git: set user.name and user.email (git config --global user.name "Your Name").');
148
+ }
149
+ return { name, email };
150
+ }
151
+ // A first push - no pull record for a part - can't tell a change made
152
+ // here from one made on the live site, so the live site as it is now
153
+ // stands in for the pull: everything that differs locally is listed as
154
+ // a create or update for the developer to check. Deletes are left out
155
+ // entirely, since a live page (or redirect, or theme file) missing here
156
+ // may be one an editor created. Once pushed, the record describes the
157
+ // live site, and every later push is protected as usual.
158
+ function withoutDeletes(plan) {
159
+ return {
160
+ ...plan,
161
+ changes: plan.changes.filter((change) => change.action !== 'delete'),
162
+ redirectOperations: plan.redirectOperations.filter((operation) => operation.method !== 'DELETE'),
163
+ };
164
+ }
165
+ export async function preparePush(config, options) {
166
+ const record = readSyncRecord(config);
167
+ if (record && record.siteUrl !== options.siteUrl) {
168
+ throw new SiteSyncError('different-site', `This copy was last pulled from ${record.siteUrl}, not ${options.siteUrl}. Push only goes back to the site it came from.`);
169
+ }
170
+ const capabilities = await readCapabilities(options.siteUrl, options.fetchImpl);
171
+ const author = readGitAuthor(config);
172
+ const remote = new RemoteSite(options.siteUrl, options.token, {
173
+ fetchImpl: options.fetchImpl,
174
+ sleep: options.sleep,
175
+ onWait: options.onWait,
176
+ });
177
+ const live = await fetchLiveContent(remote);
178
+ const liveMedia = new Set((await fetchLiveMedia(remote)).map((item) => item.name));
179
+ const local = readLocalContent(config);
180
+ const contentRecord = record?.content ?? {
181
+ syncedAt: '',
182
+ files: Object.fromEntries([...live.live].map(([path, file]) => [path, hashOf(file.bytes)])),
183
+ redirects: live.redirects,
184
+ ...(live.settings?.etag ? { settings: settingsHash(live.settings.values) } : {}),
185
+ };
186
+ const contentPlan = planPush(contentRecord, local, live, liveMedia);
187
+ const content = {
188
+ ready: true,
189
+ value: {
190
+ plan: record?.content ? contentPlan : withoutDeletes(contentPlan),
191
+ record: contentRecord,
192
+ firstPush: !record?.content,
193
+ local,
194
+ live,
195
+ },
196
+ };
197
+ let theme;
198
+ const fetchedTheme = await fetchLiveTheme(remote);
199
+ if ('unavailable' in fetchedTheme) {
200
+ theme = { ready: false, reason: fetchedTheme.unavailable };
201
+ }
202
+ else {
203
+ const localTheme = readLocalTheme(config);
204
+ const liveTheme = fetchedTheme.theme;
205
+ const pulled = record?.theme?.files ?? Object.fromEntries(liveTheme);
206
+ const plan = planThemePush(pulled, localTheme, liveTheme);
207
+ theme = {
208
+ ready: true,
209
+ value: {
210
+ plan: record?.theme ? plan : { ...plan, changes: plan.changes.filter((change) => change.action !== 'delete') },
211
+ pulled,
212
+ firstPush: !record?.theme,
213
+ local: localTheme,
214
+ live: liveTheme,
215
+ },
216
+ };
217
+ }
218
+ return {
219
+ config,
220
+ remote,
221
+ siteUrl: options.siteUrl,
222
+ author,
223
+ liveVersion: capabilities.agentVersion,
224
+ liveSchemaOlder: capabilities.contentSchemaVersion < CURRENT_SCHEMA_VERSION,
225
+ content,
226
+ theme,
227
+ };
228
+ }
229
+ // --- Executing a confirmed plan ---
230
+ // An uploaded file is named "<slug>-<hash of its bytes>.<ext>" by the
231
+ // site. Sent under its local name, the site would slug that whole name
232
+ // and add a second hash. Sent as "<slug>.<ext>", it gets back exactly
233
+ // the local name - same slug, same bytes, same hash - which is checked.
234
+ function uploadName(name) {
235
+ return name.replace(/-[0-9a-f]{12}(\.[A-Za-z0-9]+)$/, '$1');
236
+ }
237
+ // What the record says about site settings after a push: what was just
238
+ // sent; otherwise, as pulled - except on a first push, when only
239
+ // settings this copy and the live site already agree on are recorded.
240
+ function recordedSettings(plan, record, local, live, firstPush) {
241
+ if (plan.settings && local.settings !== null) {
242
+ return { settings: settingsHash(local.settings) };
243
+ }
244
+ if (firstPush) {
245
+ const agreed = local.settings !== null && live.settings?.etag && settingsHash(local.settings) === settingsHash(live.settings.values);
246
+ return agreed ? { settings: settingsHash(local.settings) } : {};
247
+ }
248
+ return record.settings === undefined ? {} : { settings: record.settings };
249
+ }
250
+ // The theme goes first: a page using a new section type can only be
251
+ // accepted once the theme that defines it is live. Nothing at all is
252
+ // sent while any chosen part has a conflict or missing media.
253
+ export async function executePush(prepared, parts) {
254
+ const content = parts.content && prepared.content.ready ? prepared.content.value : null;
255
+ const theme = parts.theme && prepared.theme.ready ? prepared.theme.value : null;
256
+ if ((content || theme) && prepared.liveSchemaOlder) {
257
+ throw new SiteSyncError('older-live-schema', `The live site's CMS (${prepared.liveVersion}) uses an older content format than this copy's, and could reject what it's sent. Push the CMS upgrade first.`);
258
+ }
259
+ if ((content && (content.plan.conflicts.length > 0 || content.plan.mediaMissing.length > 0)) || (theme && theme.plan.conflicts.length > 0)) {
260
+ throw new SiteSyncError('conflicts', 'This push has conflicts or missing media and cannot go ahead.');
261
+ }
262
+ const result = {};
263
+ if (theme && theme.plan.changes.length > 0) {
264
+ const { warnings } = await executeThemePush(prepared.remote, theme.plan, theme.local, theme.live, prepared.author);
265
+ // The site stores theme files exactly as sent, so a pushed file's
266
+ // hash is its local one. Only pushed files move on; anything else
267
+ // keeps its pulled state, so a live-only edit is still recognised as
268
+ // one next time.
269
+ // As for content: on a first push, only files this copy has and the
270
+ // live site matches, never live-only ones.
271
+ const merged = theme.firstPush
272
+ ? Object.fromEntries(Object.entries(theme.pulled).filter(([path, hash]) => theme.local.has(path) && hashThemeFile(theme.local.get(path)) === hash))
273
+ : { ...theme.pulled };
274
+ for (const change of theme.plan.changes) {
275
+ if (change.action === 'delete') {
276
+ delete merged[change.path];
277
+ }
278
+ else {
279
+ merged[change.path] = hashThemeFile(theme.local.get(change.path));
280
+ }
281
+ }
282
+ updateSyncRecord(prepared.config, prepared.siteUrl, { theme: { syncedAt: new Date().toISOString(), files: merged } });
283
+ result.theme = { files: theme.plan.changes.length, warnings };
284
+ }
285
+ if (content) {
286
+ result.content = await executeContentPush(prepared, content);
287
+ }
288
+ return result;
289
+ }
290
+ // Order: media first (additive, and pages need it to exist), then every
291
+ // page and menu change as ONE batch - draft writes, deletes and a
292
+ // publish, which the site applies as a single commit or rolls back
293
+ // entirely - then redirects, one commit each. Each draft write carries
294
+ // the ETag fetched while planning, so a page an editor saves in the
295
+ // meantime fails the whole batch rather than being overwritten.
296
+ async function executeContentPush(prepared, prepContent) {
297
+ const { config, remote, author } = prepared;
298
+ const { plan, record, local, live, firstPush } = prepContent;
299
+ for (const name of plan.mediaToUpload) {
300
+ const bytes = readFileSync(sanitisePath(config.mediaRoot, name));
301
+ const result = (await remote.upload('/v1/media', uploadName(name), bytes));
302
+ if (result?.name !== name) {
303
+ throw new SiteSyncError('rejected', `The live site stored ${name} as ${String(result?.name)}, so pages referring to it would break. Nothing else was pushed.`);
304
+ }
305
+ }
306
+ const written = plan.changes.filter((change) => change.action !== 'delete');
307
+ if (plan.changes.length > 0) {
308
+ const operations = plan.changes.map((change) => change.action === 'delete'
309
+ ? { type: 'content-delete', path: change.path }
310
+ : {
311
+ type: 'draft-write',
312
+ path: change.path,
313
+ content: JSON.parse(local.files.get(change.path).toString('utf-8')),
314
+ expectedEtag: live.live.get(change.path)?.etag ?? 'new',
315
+ });
316
+ await remote.sendJson('POST', '/v1/batch', {
317
+ operations,
318
+ ...(written.length > 0 ? { publish: { paths: written.map((change) => change.path) } } : {}),
319
+ message: `Push ${plan.changes.length} change${plan.changes.length === 1 ? '' : 's'} from a local copy`,
320
+ author,
321
+ });
322
+ }
323
+ for (const operation of plan.redirectOperations) {
324
+ const { from, to, note } = operation.entry;
325
+ const verb = operation.method === 'DELETE' ? 'Remove' : operation.method === 'POST' ? 'Add' : 'Update';
326
+ await remote.sendJson(operation.method, '/v1/redirects', {
327
+ from,
328
+ ...(operation.method === 'DELETE' ? {} : { to, ...(note === undefined ? {} : { note }) }),
329
+ message: `${verb} redirect ${from} (pushed from a local copy)`,
330
+ author,
331
+ });
332
+ }
333
+ // Site settings last, one commit, against the ETag seen while
334
+ // planning ("*" while the live site has none saved yet).
335
+ if (plan.settings && local.settings !== null) {
336
+ await remote.sendJson('PUT', '/v1/settings', { settings: local.settings, message: 'Update site settings (pushed from a local copy)', author }, { 'if-match': live.settings?.etag ?? '*' });
337
+ }
338
+ // The record now reflects the live site for everything pushed: the
339
+ // bytes the site actually stored (it re-serialises a draft write),
340
+ // not what was sent. Anything not pushed keeps its pulled state, so a
341
+ // live-only edit is still recognised as one next time.
342
+ // On a first push, only what this copy and the live site now agree on
343
+ // is recorded: a live page this copy never had must stay unrecorded,
344
+ // or the next push would read it as deleted here and delete it there.
345
+ const files = firstPush
346
+ ? Object.fromEntries(Object.entries(record.files).filter(([path, hash]) => local.files.has(path) && hashOf(local.files.get(path)) === hash))
347
+ : { ...record.files };
348
+ for (const change of plan.changes) {
349
+ if (change.action === 'delete') {
350
+ delete files[change.path];
351
+ continue;
352
+ }
353
+ const stored = await remote.getBytes(`/v1/content/${encodePath(change.path)}`, { allow404: true });
354
+ if (stored !== null) {
355
+ files[change.path] = hashOf(stored);
356
+ }
357
+ }
358
+ updateSyncRecord(config, prepared.siteUrl, {
359
+ content: {
360
+ syncedAt: new Date().toISOString(),
361
+ files,
362
+ // On a first push, this copy's own list: live-only redirects then
363
+ // never read as removed here.
364
+ redirects: firstPush || plan.redirectOperations.length > 0 ? local.redirects : record.redirects,
365
+ ...recordedSettings(plan, record, local, live, firstPush),
366
+ },
367
+ });
368
+ return {
369
+ created: plan.changes.filter((change) => change.action === 'create').length,
370
+ updated: plan.changes.filter((change) => change.action === 'update').length,
371
+ deleted: plan.changes.filter((change) => change.action === 'delete').length,
372
+ redirects: plan.redirectOperations.length,
373
+ mediaUploaded: plan.mediaToUpload.length,
374
+ settings: plan.settings,
375
+ };
376
+ }
@@ -0,0 +1,38 @@
1
+ export type SiteSyncReason = 'not-a-site' | 'unauthorised' | 'newer-schema' | 'older-live-schema' | 'uncommitted-changes' | 'fetch-failed' | 'unsafe-path' | 'no-sync-record' | 'different-site' | 'conflicts' | 'missing-media' | 'rejected' | 'no-author';
2
+ export declare class SiteSyncError extends Error {
3
+ readonly reason: SiteSyncReason;
4
+ constructor(reason: SiteSyncReason, message: string);
5
+ }
6
+ export interface RemoteSiteOptions {
7
+ fetchImpl?: typeof fetch;
8
+ sleep?: (ms: number) => Promise<void>;
9
+ onWait?: (seconds: number) => void;
10
+ }
11
+ export declare class RemoteSite {
12
+ readonly siteUrl: string;
13
+ private readonly token;
14
+ private readonly fetchImpl;
15
+ private readonly sleep;
16
+ private readonly onWait;
17
+ constructor(siteUrl: string, token: string, options?: RemoteSiteOptions);
18
+ private url;
19
+ private request;
20
+ get(path: string, { auth, allow404 }?: {
21
+ auth?: boolean | undefined;
22
+ allow404?: boolean | undefined;
23
+ }): Promise<Response | null>;
24
+ getJson(path: string, options?: {
25
+ auth?: boolean;
26
+ }): Promise<unknown>;
27
+ getBytes(path: string, options?: {
28
+ auth?: boolean;
29
+ allow404?: boolean;
30
+ }): Promise<Buffer | null>;
31
+ getFile(path: string): Promise<{
32
+ bytes: Buffer;
33
+ etag: string;
34
+ } | null>;
35
+ sendJson(method: 'POST' | 'PUT' | 'DELETE', path: string, body: unknown, headers?: Record<string, string>): Promise<unknown>;
36
+ upload(path: string, filename: string, bytes: Buffer): Promise<unknown>;
37
+ private readWriteResult;
38
+ }
@@ -0,0 +1,122 @@
1
+ // A running site's /v1/ API, as pull-site and push-site talk to it.
2
+ export class SiteSyncError extends Error {
3
+ reason;
4
+ constructor(reason, message) {
5
+ super(message);
6
+ this.name = 'SiteSyncError';
7
+ this.reason = reason;
8
+ }
9
+ }
10
+ // Write routes on a site are rate limited (60 a minute by default), and
11
+ // a push can make more requests than that - one per image. A 429 is
12
+ // waited out, not treated as a failure.
13
+ const MAX_RATE_LIMIT_WAITS = 20;
14
+ export class RemoteSite {
15
+ siteUrl;
16
+ token;
17
+ fetchImpl;
18
+ sleep;
19
+ onWait;
20
+ constructor(siteUrl, token, options = {}) {
21
+ this.siteUrl = siteUrl;
22
+ this.token = token;
23
+ this.fetchImpl = options.fetchImpl ?? fetch;
24
+ this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
25
+ this.onWait = options.onWait ?? (() => { });
26
+ }
27
+ url(path) {
28
+ return new URL(path, this.siteUrl);
29
+ }
30
+ async request(method, path, { auth = true, body, headers = {} } = {}) {
31
+ for (let attempt = 0;; attempt += 1) {
32
+ let response;
33
+ try {
34
+ response = await this.fetchImpl(this.url(path), {
35
+ method,
36
+ headers: { ...(auth ? { authorization: `Bearer ${this.token}` } : {}), ...headers },
37
+ body,
38
+ signal: AbortSignal.timeout(60_000),
39
+ });
40
+ }
41
+ catch (error) {
42
+ const detail = error instanceof Error ? error.message : String(error);
43
+ throw new SiteSyncError('fetch-failed', `Could not reach ${this.url(path).toString()}: ${detail}`);
44
+ }
45
+ if (response.status === 429 && attempt < MAX_RATE_LIMIT_WAITS) {
46
+ const seconds = Math.max(1, Number(response.headers.get('retry-after')) || 5);
47
+ this.onWait(seconds);
48
+ await this.sleep(seconds * 1000);
49
+ continue;
50
+ }
51
+ if (response.status === 401 || response.status === 403) {
52
+ throw new SiteSyncError('unauthorised', `The live site rejected the token (${response.status}). It needs the "content" and "media" scopes.`);
53
+ }
54
+ return response;
55
+ }
56
+ }
57
+ async get(path, { auth = true, allow404 = false } = {}) {
58
+ const response = await this.request('GET', path, { auth });
59
+ if (allow404 && response.status === 404) {
60
+ return null;
61
+ }
62
+ if (!response.ok) {
63
+ throw new SiteSyncError('fetch-failed', `GET ${path} returned ${response.status}`);
64
+ }
65
+ return response;
66
+ }
67
+ async getJson(path, options = {}) {
68
+ const response = await this.get(path, options);
69
+ try {
70
+ return await response.json();
71
+ }
72
+ catch {
73
+ throw new SiteSyncError('fetch-failed', `GET ${path} did not return JSON`);
74
+ }
75
+ }
76
+ async getBytes(path, options = {}) {
77
+ const response = await this.get(path, options);
78
+ return response === null ? null : Buffer.from(await response.arrayBuffer());
79
+ }
80
+ // Like getBytes, plus the ETag a later write must present as If-Match
81
+ // (or as a batch draft-write's expectedEtag).
82
+ async getFile(path) {
83
+ const response = await this.get(path, { allow404: true });
84
+ if (response === null) {
85
+ return null;
86
+ }
87
+ return { bytes: Buffer.from(await response.arrayBuffer()), etag: response.headers.get('etag') ?? '' };
88
+ }
89
+ // A JSON write. A refusal from the site (4xx) is reported with the
90
+ // site's own message: a validation failure or conflict is something
91
+ // the person pushing needs to read, not a status code.
92
+ async sendJson(method, path, body, headers = {}) {
93
+ const response = await this.request(method, path, {
94
+ body: JSON.stringify(body),
95
+ headers: { 'content-type': 'application/json', ...headers },
96
+ });
97
+ return this.readWriteResult(method, path, response);
98
+ }
99
+ async upload(path, filename, bytes) {
100
+ const form = new FormData();
101
+ form.append('file', new Blob([new Uint8Array(bytes)]), filename);
102
+ const response = await this.request('POST', path, { body: form });
103
+ return this.readWriteResult('POST', path, response);
104
+ }
105
+ async readWriteResult(method, path, response) {
106
+ const text = await response.text();
107
+ let parsed = null;
108
+ try {
109
+ parsed = text === '' ? null : JSON.parse(text);
110
+ }
111
+ catch {
112
+ parsed = null;
113
+ }
114
+ if (!response.ok) {
115
+ const message = parsed !== null && typeof parsed === 'object' && typeof parsed.message === 'string'
116
+ ? parsed.message
117
+ : `${method} ${path} returned ${response.status}`;
118
+ throw new SiteSyncError('rejected', message);
119
+ }
120
+ return parsed;
121
+ }
122
+ }
@@ -0,0 +1,26 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ import type { RedirectEntry } from '../services/redirects.ts';
3
+ export interface ContentRecord {
4
+ syncedAt: string;
5
+ files: Record<string, string>;
6
+ redirects: RedirectEntry[];
7
+ settings?: string;
8
+ }
9
+ export interface ThemeRecord {
10
+ syncedAt: string;
11
+ files: Record<string, string>;
12
+ }
13
+ export interface SyncRecord {
14
+ siteUrl: string;
15
+ content?: ContentRecord;
16
+ theme?: ThemeRecord;
17
+ }
18
+ export declare function hashOf(bytes: Buffer): string;
19
+ export declare function settingsHash(values: Record<string, unknown>): string;
20
+ export declare function readSyncRecord(config: SiteConfig): SyncRecord | null;
21
+ export declare function updateSyncRecord(config: SiteConfig, siteUrl: string, part: {
22
+ content: ContentRecord;
23
+ } | {
24
+ theme: ThemeRecord;
25
+ }): void;
26
+ export declare function sameRedirects(a: RedirectEntry[], b: RedirectEntry[]): boolean;
@@ -0,0 +1,76 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ // Compares content, not formatting: the live site re-serialises a page
5
+ // it is sent, so the same page reads back with different whitespace.
6
+ // Hashing the raw bytes would make every page pushed once look changed
7
+ // locally forever after. Anything that isn't JSON is hashed as-is.
8
+ export function hashOf(bytes) {
9
+ let canonical;
10
+ try {
11
+ canonical = JSON.stringify(JSON.parse(bytes.toString('utf-8')));
12
+ }
13
+ catch {
14
+ return createHash('sha256').update(bytes).digest('hex');
15
+ }
16
+ return createHash('sha256').update(canonical).digest('hex');
17
+ }
18
+ function recordPath(config) {
19
+ return join(config.dataRoot, 'sync-record.json');
20
+ }
21
+ function isFileMap(value) {
22
+ return typeof value === 'object' && value !== null && Object.values(value).every((hash) => typeof hash === 'string');
23
+ }
24
+ // Site settings compared by content, keys in any order.
25
+ export function settingsHash(values) {
26
+ const sorted = (value) => Array.isArray(value)
27
+ ? value.map(sorted)
28
+ : value !== null && typeof value === 'object'
29
+ ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, sorted(value[key])]))
30
+ : value;
31
+ return createHash('sha256').update(JSON.stringify(sorted(values))).digest('hex');
32
+ }
33
+ export function readSyncRecord(config) {
34
+ let parsed;
35
+ try {
36
+ parsed = JSON.parse(readFileSync(recordPath(config), 'utf-8'));
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ if (typeof parsed.siteUrl !== 'string') {
42
+ return null;
43
+ }
44
+ const record = { siteUrl: parsed.siteUrl };
45
+ // 0.5.5 wrote content only, at the top level.
46
+ const content = (parsed.content ?? (parsed.files ? parsed : undefined));
47
+ if (content && isFileMap(content.files) && Array.isArray(content.redirects)) {
48
+ record.content = {
49
+ syncedAt: typeof content.syncedAt === 'string' ? content.syncedAt : '',
50
+ files: content.files,
51
+ redirects: content.redirects,
52
+ ...(typeof content.settings === 'string' ? { settings: content.settings } : {}),
53
+ };
54
+ }
55
+ const theme = parsed.theme;
56
+ if (theme && isFileMap(theme.files)) {
57
+ record.theme = { syncedAt: typeof theme.syncedAt === 'string' ? theme.syncedAt : '', files: theme.files };
58
+ }
59
+ return record;
60
+ }
61
+ // Replaces one part of the record and keeps the other - unless the
62
+ // record is for a different site, which starts it afresh.
63
+ export function updateSyncRecord(config, siteUrl, part) {
64
+ const existing = readSyncRecord(config);
65
+ const base = existing && existing.siteUrl === siteUrl ? existing : { siteUrl };
66
+ const record = { ...base, ...part };
67
+ mkdirSync(config.dataRoot, { recursive: true });
68
+ writeFileSync(recordPath(config), `${JSON.stringify(record, null, 2)}\n`);
69
+ }
70
+ // A redirects list compared by content, not order.
71
+ export function sameRedirects(a, b) {
72
+ const key = (entries) => JSON.stringify([...entries]
73
+ .map((entry) => ({ from: entry.from, to: entry.to, note: entry.note ?? null }))
74
+ .sort((x, y) => (x.from < y.from ? -1 : x.from > y.from ? 1 : 0)));
75
+ return key(a) === key(b);
76
+ }
@@ -0,0 +1,34 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ import { type RemoteSite } from './remote-site.ts';
3
+ export type LiveTheme = Map<string, string>;
4
+ export type LiveThemeResult = {
5
+ theme: LiveTheme;
6
+ } | {
7
+ unavailable: string;
8
+ };
9
+ export declare function fetchLiveTheme(remote: RemoteSite): Promise<LiveThemeResult>;
10
+ export declare function readLocalTheme(config: SiteConfig): Map<string, Buffer>;
11
+ export interface ThemePullResult {
12
+ files: number;
13
+ downloaded: number;
14
+ removed: string[];
15
+ }
16
+ export declare function pullTheme(config: SiteConfig, remote: RemoteSite, live: LiveTheme): Promise<ThemePullResult>;
17
+ export interface ThemeChange {
18
+ path: string;
19
+ action: 'create' | 'update' | 'delete';
20
+ }
21
+ export interface ThemePlan {
22
+ changes: ThemeChange[];
23
+ conflicts: Array<{
24
+ path: string;
25
+ reason: string;
26
+ }>;
27
+ }
28
+ export declare function planThemePush(pulled: Record<string, string>, local: Map<string, Buffer>, live: LiveTheme): ThemePlan;
29
+ export declare function executeThemePush(remote: RemoteSite, plan: ThemePlan, local: Map<string, Buffer>, live: LiveTheme, author: {
30
+ name: string;
31
+ email: string;
32
+ }): Promise<{
33
+ warnings: string[];
34
+ }>;