@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,131 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { relative } from 'node:path';
3
+ import { isValidGitRef } from "./git.js";
4
+ import { sanitisePath } from "./path-safety.js";
5
+ const DEFAULT_LOG_LIMIT = 100;
6
+ const MAX_LOG_LIMIT = 500;
7
+ // Settled by the build plan itself (docs/cms-build-plan.md): the
8
+ // future (not-yet-built) Group H checkpoint job commits drafts/ with
9
+ // this literal message. Exact match only, deliberately not a prefix
10
+ // match - migration-runner.ts's own automated commit
11
+ // ("chore: migrate content to schema version N") is a different,
12
+ // meaningful category and must not be flagged as checkpoint noise.
13
+ // The checkpoint commit's *author identity* is a separate, still-open
14
+ // question (docs/phase-2-checklist.md) this module does not attempt
15
+ // to resolve - flagging is by message only.
16
+ export const CHECKPOINT_MESSAGE = 'chore: draft checkpoint';
17
+ const FIELD_SEP = '\x1f';
18
+ const RECORD_SEP = '\x1e';
19
+ export function getCommitLog(config, options) {
20
+ // A brand-new site's repo can exist (startup-checks.ts already
21
+ // verified it's a real work tree at boot) with zero commits until
22
+ // the first publish/checkpoint/manual commit ever happens. Plain
23
+ // `git log` on a HEAD-less repo exits non-zero ("does not have any
24
+ // commits yet"), which must not surface as an uncaught 500 for what
25
+ // is simply an empty history.
26
+ try {
27
+ execFileSync('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], {
28
+ cwd: config.siteRoot,
29
+ stdio: 'ignore',
30
+ });
31
+ }
32
+ catch {
33
+ return { commits: [], hasMore: false };
34
+ }
35
+ const limit = Math.min(Math.max(1, options.limit ?? DEFAULT_LOG_LIMIT), MAX_LOG_LIMIT);
36
+ const args = [
37
+ 'log',
38
+ `--format=%H${FIELD_SEP}%an${FIELD_SEP}%ae${FIELD_SEP}%aI${FIELD_SEP}%B${RECORD_SEP}`,
39
+ '-n',
40
+ String(limit + 1),
41
+ ];
42
+ if (options.path) {
43
+ // A bare fs-pathspec (not the <rev>:<path> colon syntax
44
+ // readFileAtRevision below uses), so sanitisePath's absolute
45
+ // return value is used directly - git resolves an absolute
46
+ // pathspec fine, same as git.ts's own `git add -- <absolutePaths>`
47
+ // already relies on.
48
+ args.push('--', sanitisePath(config.siteRoot, options.path));
49
+ }
50
+ const raw = execFileSync('git', args, { cwd: config.siteRoot }).toString('utf-8');
51
+ const parsed = parseLogOutput(raw);
52
+ return { commits: parsed.slice(0, limit), hasMore: parsed.length > limit };
53
+ }
54
+ function parseLogOutput(raw) {
55
+ return raw
56
+ .split(RECORD_SEP)
57
+ // `--format=` is shorthand for `--pretty=tformat:`, which appends
58
+ // its own newline terminator after EVERY record, including after
59
+ // our own literal RECORD_SEP - that terminator lands as a leading
60
+ // "\n" on the *next* record, not a trailing one on the record it
61
+ // belongs to. Verified empirically against a real multi-commit
62
+ // repo (including a commit with an embedded multi-line message)
63
+ // before trusting this: without stripping it, every commit but the
64
+ // first silently fails to parse, since the leading \n corrupts the
65
+ // hash field.
66
+ .map((record) => record.replace(/^\n/, ''))
67
+ .filter((record) => record.length > 0)
68
+ .map((record) => {
69
+ const [hash, name, email, date, ...rest] = record.split(FIELD_SEP);
70
+ // Defensive rejoin: FIELD_SEP is "unlikely" to appear inside a
71
+ // real commit message, not impossible. If it ever does, this
72
+ // keeps the message intact rather than corrupting every field
73
+ // after it.
74
+ const message = rest.join(FIELD_SEP).trimEnd();
75
+ return {
76
+ hash: hash ?? '',
77
+ author: { name: name ?? '', email: email ?? '' },
78
+ date: date ?? '',
79
+ message,
80
+ isCheckpoint: message === CHECKPOINT_MESSAGE,
81
+ };
82
+ });
83
+ }
84
+ export class GitShowError extends Error {
85
+ reason;
86
+ constructor(reason, message, options) {
87
+ super(message, options);
88
+ this.name = 'GitShowError';
89
+ this.reason = reason;
90
+ }
91
+ }
92
+ export function readFileAtRevision(config, ref, relativePath) {
93
+ if (!isValidGitRef(ref)) {
94
+ throw new GitShowError('invalid-ref', `Invalid ref: "${ref}"`);
95
+ }
96
+ // The <rev>:<path> colon syntax below resolves <path> relative to
97
+ // cwd (config.siteRoot), never as a literal absolute filesystem path
98
+ // - unlike getCommitLog's `--` pathspec above. Re-deriving the
99
+ // repo-relative string from sanitisePath's own already-validated
100
+ // absolute result (not re-decoding relativePath a second time)
101
+ // guarantees the exact same string sanitisePath vetted.
102
+ const repoRelativePath = relative(config.siteRoot, sanitisePath(config.siteRoot, relativePath));
103
+ try {
104
+ execFileSync('git', ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {
105
+ cwd: config.siteRoot,
106
+ stdio: 'ignore',
107
+ });
108
+ }
109
+ catch {
110
+ throw new GitShowError('invalid-ref', `Ref does not resolve to a commit: "${ref}"`);
111
+ }
112
+ let objectType;
113
+ try {
114
+ objectType = execFileSync('git', ['cat-file', '-t', `${ref}:${repoRelativePath}`], {
115
+ cwd: config.siteRoot,
116
+ })
117
+ .toString('utf-8')
118
+ .trim();
119
+ }
120
+ catch {
121
+ throw new GitShowError('not-found-at-ref', `No file at "${relativePath}" at revision "${ref}"`);
122
+ }
123
+ // A directory (tree) at that ref must not fall through to `git show`,
124
+ // which would print a plain-text directory listing as if it were
125
+ // file content, with a wrong Content-Type. This route serves file
126
+ // content only.
127
+ if (objectType !== 'blob') {
128
+ throw new GitShowError('not-found-at-ref', `"${relativePath}" is not a file at revision "${ref}"`);
129
+ }
130
+ return execFileSync('git', ['show', `${ref}:${repoRelativePath}`], { cwd: config.siteRoot });
131
+ }
@@ -0,0 +1,12 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ import type { CommitAuthor } from './git.ts';
3
+ import type { PreparedOperation } from './prepared-operation.ts';
4
+ export type RevertReason = 'invalid-ref' | 'path-not-found-at-ref' | 'write-failed' | 'commit-failed' | 'rollback-failed';
5
+ export declare class RevertError extends Error {
6
+ readonly reason: RevertReason;
7
+ constructor(reason: RevertReason, message: string, options?: {
8
+ cause?: unknown;
9
+ });
10
+ }
11
+ export declare function prepareRevertPaths(config: SiteConfig, ref: string, relativePaths: string[]): PreparedOperation;
12
+ export declare function revertPaths(config: SiteConfig, ref: string, relativePaths: string[], message: string, author: CommitAuthor): Promise<void>;
@@ -0,0 +1,140 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { relative } from 'node:path';
4
+ import { commitPaths, isValidGitRef } from "./git.js";
5
+ import { sanitisePath } from "./path-safety.js";
6
+ import { enqueue } from "./write-queue.js";
7
+ export class RevertError extends Error {
8
+ reason;
9
+ constructor(reason, message, options) {
10
+ super(message, options);
11
+ this.name = 'RevertError';
12
+ this.reason = reason;
13
+ }
14
+ }
15
+ // Validates and writes everything a revert needs, stopping short of
16
+ // the commit itself - mirrors publish.ts/move.ts/delete-content.ts's
17
+ // prepareX/xJob split exactly.
18
+ export function prepareRevertPaths(config, ref, relativePaths) {
19
+ if (!isValidGitRef(ref)) {
20
+ throw new RevertError('invalid-ref', `Invalid ref: "${ref}"`);
21
+ }
22
+ try {
23
+ execFileSync('git', ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {
24
+ cwd: config.siteRoot,
25
+ stdio: 'ignore',
26
+ });
27
+ }
28
+ catch {
29
+ throw new RevertError('invalid-ref', `Ref does not resolve to a commit: "${ref}"`);
30
+ }
31
+ const absolutePaths = relativePaths.map((p) => sanitisePath(config.siteRoot, p));
32
+ const repoRelativePaths = absolutePaths.map((p) => relative(config.siteRoot, p));
33
+ // Validate every path exists at ref BEFORE touching any working-tree
34
+ // file - same "validate everything, write nothing yet" ordering as
35
+ // publish.ts/move.ts, so a bad path in a multi-path revert fails
36
+ // clean with zero side effects on the other paths. Deliberately out
37
+ // of scope: "revert to a state before this file existed" (i.e.
38
+ // simulate a delete) - surfaced as a clean, distinct error instead of
39
+ // silently unlinking a file based on inferred intent nobody asked
40
+ // for.
41
+ for (let i = 0; i < repoRelativePaths.length; i++) {
42
+ try {
43
+ execFileSync('git', ['cat-file', '-e', `${ref}:${repoRelativePaths[i]}`], {
44
+ cwd: config.siteRoot,
45
+ stdio: 'ignore',
46
+ });
47
+ }
48
+ catch {
49
+ throw new RevertError('path-not-found-at-ref', `No file at "${relativePaths[i]}" at revision "${ref}"`);
50
+ }
51
+ }
52
+ const snapshots = [];
53
+ function undo() {
54
+ const failures = [];
55
+ for (const snap of snapshots) {
56
+ try {
57
+ if (snap.existed) {
58
+ writeFileSync(snap.path, snap.original);
59
+ }
60
+ else if (existsSync(snap.path)) {
61
+ unlinkSync(snap.path);
62
+ }
63
+ }
64
+ catch (error) {
65
+ failures.push(error);
66
+ }
67
+ }
68
+ return failures;
69
+ // No explicit unstage here: a failed commitPaths already runs its
70
+ // own `git reset -- <paths>` internally (git.ts), matching
71
+ // delete-content.ts's identical precedent - undo only needs to
72
+ // restore file content.
73
+ }
74
+ try {
75
+ for (const absolutePath of absolutePaths) {
76
+ const existed = existsSync(absolutePath);
77
+ snapshots.push({
78
+ path: absolutePath,
79
+ existed,
80
+ original: existed ? readFileSync(absolutePath) : undefined,
81
+ });
82
+ }
83
+ for (const absolutePath of absolutePaths) {
84
+ execFileSync('git', ['checkout', ref, '--', absolutePath], {
85
+ cwd: config.siteRoot,
86
+ stdio: 'ignore',
87
+ });
88
+ }
89
+ }
90
+ catch (error) {
91
+ const failures = undo();
92
+ if (failures.length > 0) {
93
+ throw new RevertError('rollback-failed', 'Revert failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
94
+ }
95
+ const detail = error instanceof Error ? error.message : String(error);
96
+ throw new RevertError('write-failed', `Revert failed: ${detail}`, { cause: error });
97
+ }
98
+ return { paths: absolutePaths, undo };
99
+ }
100
+ async function revertJob(config, ref, relativePaths, message, author) {
101
+ const { paths, undo } = prepareRevertPaths(config, ref, relativePaths);
102
+ // The batch.ts empty-commit lesson, triggered here by content-
103
+ // identity rather than an empty path list: `git checkout` always
104
+ // stages *something* even when the reverted content is byte-
105
+ // identical to what's already live (e.g. reverting to HEAD, or a
106
+ // double-submitted revert) - `git commit` would then fail with
107
+ // "nothing to commit" for a request that didn't actually do anything
108
+ // wrong. Scoped to just the reverted paths, not the whole repo, so
109
+ // an unrelated pre-existing dirty index (exactly what /git/commit's
110
+ // escape hatch exists for) is never mistaken for "this revert was a
111
+ // no-op".
112
+ let hasChanges = true;
113
+ try {
114
+ execFileSync('git', ['diff', '--cached', '--quiet', '--', ...paths], {
115
+ cwd: config.siteRoot,
116
+ stdio: 'ignore',
117
+ });
118
+ hasChanges = false;
119
+ }
120
+ catch {
121
+ hasChanges = true;
122
+ }
123
+ if (!hasChanges) {
124
+ return;
125
+ }
126
+ try {
127
+ commitPaths(config.siteRoot, paths, message, author);
128
+ }
129
+ catch (error) {
130
+ const failures = undo();
131
+ if (failures.length > 0) {
132
+ throw new RevertError('rollback-failed', 'Revert failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
133
+ }
134
+ const detail = error instanceof Error ? error.message : String(error);
135
+ throw new RevertError('commit-failed', `Revert failed: ${detail}`, { cause: error });
136
+ }
137
+ }
138
+ export function revertPaths(config, ref, relativePaths, message, author) {
139
+ return enqueue(() => revertJob(config, ref, relativePaths, message, author));
140
+ }
@@ -0,0 +1,16 @@
1
+ export type GitOperationReason = 'add-failed' | 'commit-failed' | 'reset-failed';
2
+ export declare class GitOperationError extends Error {
3
+ readonly reason: GitOperationReason;
4
+ constructor(reason: GitOperationReason, message: string, options?: {
5
+ cause?: unknown;
6
+ });
7
+ }
8
+ export interface CommitAuthor {
9
+ name: string;
10
+ email: string;
11
+ }
12
+ export declare function isValidCommitAuthor(value: unknown): value is CommitAuthor;
13
+ export declare function commitPaths(cwd: string, paths: string[], message: string, author: CommitAuthor): void;
14
+ export declare function resetPaths(cwd: string, paths: string[]): void;
15
+ export declare function isValidGitRef(value: string): boolean;
16
+ export declare function commitWorkingTree(cwd: string, message: string, author: CommitAuthor): 'committed' | 'clean';
@@ -0,0 +1,127 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ export class GitOperationError extends Error {
3
+ reason;
4
+ constructor(reason, message, options) {
5
+ super(message, options);
6
+ this.name = 'GitOperationError';
7
+ this.reason = reason;
8
+ }
9
+ }
10
+ const CONTROL_CHAR_PATTERN = /[\r\n]/;
11
+ // The route layer's shape check for a caller-supplied author before it
12
+ // ever reaches `git commit --author` below. execFileSync (not a shell)
13
+ // already rules out command injection, but a name/email containing a
14
+ // literal newline could still smuggle a malformed or multi-line author
15
+ // string into git history - this is the first place in the codebase a
16
+ // caller-supplied identity reaches a real commit (saveDraft/discardDraft
17
+ // never take an author at all), so this check is new, necessary
18
+ // surface, not a repeat of an existing one.
19
+ export function isValidCommitAuthor(value) {
20
+ if (typeof value !== 'object' || value === null) {
21
+ return false;
22
+ }
23
+ const { name, email } = value;
24
+ return (typeof name === 'string' &&
25
+ name.length > 0 &&
26
+ !CONTROL_CHAR_PATTERN.test(name) &&
27
+ typeof email === 'string' &&
28
+ email.length > 0 &&
29
+ !CONTROL_CHAR_PATTERN.test(email));
30
+ }
31
+ // Author identity always comes from the caller, never host config
32
+ // (constraint: commit authorship passthrough, checklist C5). Passing
33
+ // GIT_AUTHOR_*/GIT_COMMITTER_* explicitly means this never depends on
34
+ // ~/.gitconfig or any host identity being configured at all, which also
35
+ // retires the "usable identity config" startup prerequisite entirely
36
+ // rather than merely deferring it.
37
+ function authorEnv(author) {
38
+ return {
39
+ ...process.env,
40
+ GIT_AUTHOR_NAME: author.name,
41
+ GIT_AUTHOR_EMAIL: author.email,
42
+ GIT_COMMITTER_NAME: author.name,
43
+ GIT_COMMITTER_EMAIL: author.email,
44
+ };
45
+ }
46
+ // Guarantees its own all-or-nothing contract: either fully committed,
47
+ // or nothing left staged. Callers (publish.ts) therefore only ever need
48
+ // to reason about filesystem state on failure, never git staging state.
49
+ export function commitPaths(cwd, paths, message, author) {
50
+ try {
51
+ execFileSync('git', ['add', '--', ...paths], { cwd, stdio: 'ignore' });
52
+ }
53
+ catch (error) {
54
+ throw new GitOperationError('add-failed', `git add failed in ${cwd}`, { cause: error });
55
+ }
56
+ try {
57
+ execFileSync('git', ['commit', '-m', message, '--author', `${author.name} <${author.email}>`], {
58
+ cwd,
59
+ env: authorEnv(author),
60
+ stdio: 'ignore',
61
+ });
62
+ }
63
+ catch (error) {
64
+ try {
65
+ execFileSync('git', ['reset', '--', ...paths], { cwd, stdio: 'ignore' });
66
+ }
67
+ catch (resetError) {
68
+ throw new GitOperationError('reset-failed', `git commit failed in ${cwd} and unstaging afterwards also failed; index may be inconsistent`, { cause: resetError });
69
+ }
70
+ throw new GitOperationError('commit-failed', `git commit failed in ${cwd}`, { cause: error });
71
+ }
72
+ }
73
+ export function resetPaths(cwd, paths) {
74
+ try {
75
+ execFileSync('git', ['reset', '--', ...paths], { cwd, stdio: 'ignore' });
76
+ }
77
+ catch (error) {
78
+ throw new GitOperationError('reset-failed', `git reset failed in ${cwd}`, { cause: error });
79
+ }
80
+ }
81
+ // Allowlist, not a denylist of "dangerous" characters - the same
82
+ // reject-on-ambiguity posture sanitisePath itself uses for paths. A
83
+ // denylist (reject leading '-', reject whitespace) is exactly the
84
+ // shape of check that's easy to get subtly wrong one case at a time
85
+ // (reflog @{...} syntax, ^{...} suffixes, unicode whitespace); this
86
+ // closes all of that in one line instead of enumerating each dangerous
87
+ // case as it's discovered.
88
+ const REF_PATTERN = /^[A-Za-z0-9._/-]+$/;
89
+ export function isValidGitRef(value) {
90
+ return value.length > 0 && !value.startsWith('-') && !value.includes('..') && REF_PATTERN.test(value);
91
+ }
92
+ // The escape hatch (checklist G5): stages and commits literally
93
+ // everything currently changed in the working tree, deliberately
94
+ // unscoped - a path-scoped variant would defeat the entire point of
95
+ // "out-of-band changes the API doesn't otherwise know about" (build
96
+ // plan wording). Respects .gitignore like any git invocation, so
97
+ // data/ stays excluded per existing convention.
98
+ export function commitWorkingTree(cwd, message, author) {
99
+ const status = execFileSync('git', ['status', '--porcelain'], { cwd }).toString('utf-8');
100
+ if (status.trim().length === 0) {
101
+ // Nothing to stage - matches batch.ts's empty-commit lesson
102
+ // (git commit with nothing staged exits non-zero), simpler here
103
+ // since there's no path list to check, just the whole tree.
104
+ return 'clean';
105
+ }
106
+ try {
107
+ execFileSync('git', ['add', '-A'], { cwd, stdio: 'ignore' });
108
+ }
109
+ catch (error) {
110
+ throw new GitOperationError('add-failed', `git add -A failed in ${cwd}`, { cause: error });
111
+ }
112
+ try {
113
+ execFileSync('git', ['commit', '-m', message, '--author', `${author.name} <${author.email}>`], { cwd, env: authorEnv(author), stdio: 'ignore' });
114
+ }
115
+ catch (error) {
116
+ try {
117
+ // Bare reset (no path list): unstages everything just added,
118
+ // mirroring commitPaths's own rollback shape.
119
+ execFileSync('git', ['reset'], { cwd, stdio: 'ignore' });
120
+ }
121
+ catch (resetError) {
122
+ throw new GitOperationError('reset-failed', `git commit failed in ${cwd} and unstaging afterwards also failed; index may be inconsistent`, { cause: resetError });
123
+ }
124
+ throw new GitOperationError('commit-failed', `git commit failed in ${cwd}`, { cause: error });
125
+ }
126
+ return 'committed';
127
+ }
@@ -0,0 +1,4 @@
1
+ export interface IntervalJobHandle {
2
+ stop: () => void;
3
+ }
4
+ export declare function startIntervalJob(job: () => Promise<unknown>, intervalMs: number, onError: (error: unknown) => void): IntervalJobHandle;
@@ -0,0 +1,9 @@
1
+ export function startIntervalJob(job, intervalMs, onError) {
2
+ const timer = setInterval(() => {
3
+ job().catch(onError);
4
+ }, intervalMs);
5
+ // Must not itself keep a process (or a test) alive - the listening
6
+ // socket is what should be process-life-sustaining, not this timer.
7
+ timer.unref();
8
+ return { stop: () => clearInterval(timer) };
9
+ }
@@ -0,0 +1,7 @@
1
+ import type { FastifyRequest } from 'fastify';
2
+ export declare class IpAllowlistError extends Error {
3
+ readonly statusCode = 403;
4
+ constructor();
5
+ }
6
+ export declare function isIpAllowed(allowlist: string[], ip: string): boolean;
7
+ export declare function ipAllowlistGuard(allowlist: string[]): (request: FastifyRequest) => Promise<void>;
@@ -0,0 +1,31 @@
1
+ // Mirrors token-auth.ts's AuthError shape: statusCode set explicitly
2
+ // in the constructor so the existing global error handler passes this
3
+ // through with its real message intact, never sanitising it as a
4
+ // generic 500.
5
+ export class IpAllowlistError extends Error {
6
+ statusCode = 403;
7
+ constructor() {
8
+ super('This IP address is not permitted to access this API');
9
+ this.name = 'IpAllowlistError';
10
+ }
11
+ }
12
+ // An empty allowlist is a no-op (checklist H3), not "nothing is
13
+ // allowed" - exact-IP-only for this pass, no CIDR ranges: IP
14
+ // allowlisting is "optional, not the backbone" per the build plan, a
15
+ // deliberate scope reduction rather than an oversight.
16
+ export function isIpAllowed(allowlist, ip) {
17
+ return allowlist.length === 0 || allowlist.includes(ip);
18
+ }
19
+ // Applied as a whole-file addHook('onRequest', ...) inside v1Routes's
20
+ // own plugin body (routes/index.ts), not a per-route preHandler like
21
+ // requireScope - an IP allowlist is a coarse "is this client's network
22
+ // address allowed to talk to the API at all" gate, orthogonal to
23
+ // token-scope exemption, so it applies to the entire /v1 surface
24
+ // uniformly, including capabilities.ts and every GET route.
25
+ export function ipAllowlistGuard(allowlist) {
26
+ return async function guard(request) {
27
+ if (!isIpAllowed(allowlist, request.ip)) {
28
+ throw new IpAllowlistError();
29
+ }
30
+ };
31
+ }
@@ -0,0 +1,22 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export declare function linkTarget(href: string): string | null;
3
+ export declare function collectLinks(value: unknown): string[];
4
+ export declare function rewriteLinks(value: unknown, from: string, to: string): {
5
+ value: unknown;
6
+ changed: boolean;
7
+ };
8
+ export type LinkSourceKind = 'page' | 'draft' | 'menu' | 'settings';
9
+ export interface LinkSource {
10
+ kind: LinkSourceKind;
11
+ path: string;
12
+ file: string;
13
+ }
14
+ export declare function linkSources(config: SiteConfig): LinkSource[];
15
+ export interface LinkReference {
16
+ kind: LinkSourceKind;
17
+ path: string;
18
+ label: string;
19
+ url?: string;
20
+ hrefs: string[];
21
+ }
22
+ export declare function findReferences(config: SiteConfig, to: string): LinkReference[];