@o-a/cms-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +132 -0
  3. package/dist/boot.d.ts +15 -0
  4. package/dist/boot.js +33 -0
  5. package/dist/config.d.ts +19 -0
  6. package/dist/config.js +45 -0
  7. package/dist/create-site/cli.d.ts +2 -0
  8. package/dist/create-site/cli.js +31 -0
  9. package/dist/create-site/generate-site.d.ts +9 -0
  10. package/dist/create-site/generate-site.js +109 -0
  11. package/dist/create-site/mint-token-cli.d.ts +2 -0
  12. package/dist/create-site/mint-token-cli.js +26 -0
  13. package/dist/create-site/mint-token.d.ts +7 -0
  14. package/dist/create-site/mint-token.js +49 -0
  15. package/dist/create-site/template/content/menus/footerCompany.json +9 -0
  16. package/dist/create-site/template/content/menus/footerProduct.json +9 -0
  17. package/dist/create-site/template/content/menus/footerResources.json +9 -0
  18. package/dist/create-site/template/content/menus/main.json +9 -0
  19. package/dist/create-site/template/content/pages/404.json +22 -0
  20. package/dist/create-site/template/content/pages/about/careers.json +21 -0
  21. package/dist/create-site/template/content/pages/about/team.json +21 -0
  22. package/dist/create-site/template/content/pages/about.json +21 -0
  23. package/dist/create-site/template/content/pages/docs/deployment.json +21 -0
  24. package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +21 -0
  25. package/dist/create-site/template/content/pages/docs/getting-started.json +23 -0
  26. package/dist/create-site/template/content/pages/docs.json +21 -0
  27. package/dist/create-site/template/content/pages/index.json +181 -0
  28. package/dist/create-site/template/content/redirects.json +4 -0
  29. package/dist/create-site/template/dockerignore +6 -0
  30. package/dist/create-site/template/gitignore +11 -0
  31. package/dist/create-site/template/theme/assets/site.js +49 -0
  32. package/dist/create-site/template/theme/assets/style.css +1153 -0
  33. package/dist/create-site/template/theme/blocks/button.liquid +21 -0
  34. package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
  35. package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
  36. package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
  37. package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
  38. package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
  39. package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
  40. package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
  41. package/dist/create-site/template/theme/layouts/theme.liquid +99 -0
  42. package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
  43. package/dist/create-site/template/theme/sections/cta-banner.liquid +27 -0
  44. package/dist/create-site/template/theme/sections/faq.liquid +27 -0
  45. package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
  46. package/dist/create-site/template/theme/sections/hero.liquid +36 -0
  47. package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
  48. package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
  49. package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
  50. package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
  51. package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
  52. package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
  53. package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
  54. package/dist/create-site/template/vhost/Dockerfile +33 -0
  55. package/dist/create-site/template/vhost/docker-entrypoint.sh +30 -0
  56. package/dist/index.d.ts +2 -0
  57. package/dist/index.js +4 -0
  58. package/dist/media/drivers/driver.d.ts +11 -0
  59. package/dist/media/drivers/driver.js +1 -0
  60. package/dist/media/drivers/local-fs-driver.d.ts +3 -0
  61. package/dist/media/drivers/local-fs-driver.js +81 -0
  62. package/dist/media/filename.d.ts +1 -0
  63. package/dist/media/filename.js +31 -0
  64. package/dist/media/manage-media.d.ts +19 -0
  65. package/dist/media/manage-media.js +45 -0
  66. package/dist/migrations/index.d.ts +3 -0
  67. package/dist/migrations/index.js +49 -0
  68. package/dist/renderer/engine.d.ts +2 -0
  69. package/dist/renderer/engine.js +48 -0
  70. package/dist/renderer/render-page.d.ts +28 -0
  71. package/dist/renderer/render-page.js +143 -0
  72. package/dist/renderer/theme-templates.d.ts +7 -0
  73. package/dist/renderer/theme-templates.js +82 -0
  74. package/dist/routes/assets.d.ts +6 -0
  75. package/dist/routes/assets.js +30 -0
  76. package/dist/routes/batch.d.ts +10 -0
  77. package/dist/routes/batch.js +148 -0
  78. package/dist/routes/capabilities.d.ts +5 -0
  79. package/dist/routes/capabilities.js +27 -0
  80. package/dist/routes/content.d.ts +8 -0
  81. package/dist/routes/content.js +166 -0
  82. package/dist/routes/drafts.d.ts +10 -0
  83. package/dist/routes/drafts.js +73 -0
  84. package/dist/routes/git.d.ts +8 -0
  85. package/dist/routes/git.js +158 -0
  86. package/dist/routes/index.d.ts +19 -0
  87. package/dist/routes/index.js +84 -0
  88. package/dist/routes/media-public.d.ts +5 -0
  89. package/dist/routes/media-public.js +48 -0
  90. package/dist/routes/media.d.ts +9 -0
  91. package/dist/routes/media.js +84 -0
  92. package/dist/routes/menus.d.ts +8 -0
  93. package/dist/routes/menus.js +77 -0
  94. package/dist/routes/preview-revision.d.ts +13 -0
  95. package/dist/routes/preview-revision.js +109 -0
  96. package/dist/routes/preview.d.ts +13 -0
  97. package/dist/routes/preview.js +52 -0
  98. package/dist/routes/public.d.ts +11 -0
  99. package/dist/routes/public.js +117 -0
  100. package/dist/routes/publish.d.ts +10 -0
  101. package/dist/routes/publish.js +105 -0
  102. package/dist/routes/redirects.d.ts +8 -0
  103. package/dist/routes/redirects.js +132 -0
  104. package/dist/routes/search.d.ts +8 -0
  105. package/dist/routes/search.js +11 -0
  106. package/dist/routes/sitemap.d.ts +6 -0
  107. package/dist/routes/sitemap.js +79 -0
  108. package/dist/routes/theme.d.ts +10 -0
  109. package/dist/routes/theme.js +17 -0
  110. package/dist/schemas/instance.schema.json +17 -0
  111. package/dist/schemas/menu.schema.json +23 -0
  112. package/dist/schemas/page.schema.json +20 -0
  113. package/dist/schemas/post.schema.json +25 -0
  114. package/dist/schemas/redirects.schema.json +24 -0
  115. package/dist/search/drivers/driver.d.ts +9 -0
  116. package/dist/search/drivers/driver.js +1 -0
  117. package/dist/search/drivers/node-sqlite-driver.d.ts +3 -0
  118. package/dist/search/drivers/node-sqlite-driver.js +19 -0
  119. package/dist/search/query-index.d.ts +5 -0
  120. package/dist/search/query-index.js +21 -0
  121. package/dist/search/rebuild-index.d.ts +2 -0
  122. package/dist/search/rebuild-index.js +111 -0
  123. package/dist/server-config.d.ts +22 -0
  124. package/dist/server-config.js +196 -0
  125. package/dist/server.d.ts +13 -0
  126. package/dist/server.js +163 -0
  127. package/dist/services/batch.d.ts +35 -0
  128. package/dist/services/batch.js +136 -0
  129. package/dist/services/checkpoint.d.ts +4 -0
  130. package/dist/services/checkpoint.js +45 -0
  131. package/dist/services/content-read.d.ts +27 -0
  132. package/dist/services/content-read.js +156 -0
  133. package/dist/services/delete-content.d.ts +12 -0
  134. package/dist/services/delete-content.js +174 -0
  135. package/dist/services/dev-tunnel.d.ts +5 -0
  136. package/dist/services/dev-tunnel.js +5 -0
  137. package/dist/services/drafts.d.ts +15 -0
  138. package/dist/services/drafts.js +155 -0
  139. package/dist/services/etag.d.ts +2 -0
  140. package/dist/services/etag.js +30 -0
  141. package/dist/services/fs-walk.d.ts +1 -0
  142. package/dist/services/fs-walk.js +26 -0
  143. package/dist/services/git-commit.d.ts +3 -0
  144. package/dist/services/git-commit.js +12 -0
  145. package/dist/services/git-history.d.ts +28 -0
  146. package/dist/services/git-history.js +131 -0
  147. package/dist/services/git-revert.d.ts +12 -0
  148. package/dist/services/git-revert.js +140 -0
  149. package/dist/services/git.d.ts +16 -0
  150. package/dist/services/git.js +127 -0
  151. package/dist/services/interval-job.d.ts +4 -0
  152. package/dist/services/interval-job.js +9 -0
  153. package/dist/services/ip-allowlist.d.ts +7 -0
  154. package/dist/services/ip-allowlist.js +31 -0
  155. package/dist/services/manage-menus.d.ts +10 -0
  156. package/dist/services/manage-menus.js +84 -0
  157. package/dist/services/manage-redirects.d.ts +25 -0
  158. package/dist/services/manage-redirects.js +166 -0
  159. package/dist/services/menus.d.ts +8 -0
  160. package/dist/services/menus.js +42 -0
  161. package/dist/services/migration-runner.d.ts +13 -0
  162. package/dist/services/migration-runner.js +123 -0
  163. package/dist/services/mime-types.d.ts +3 -0
  164. package/dist/services/mime-types.js +36 -0
  165. package/dist/services/move.d.ts +16 -0
  166. package/dist/services/move.js +175 -0
  167. package/dist/services/path-safety.d.ts +6 -0
  168. package/dist/services/path-safety.js +112 -0
  169. package/dist/services/post-urls.d.ts +3 -0
  170. package/dist/services/post-urls.js +27 -0
  171. package/dist/services/prepared-operation.d.ts +4 -0
  172. package/dist/services/prepared-operation.js +1 -0
  173. package/dist/services/publish.d.ts +14 -0
  174. package/dist/services/publish.js +254 -0
  175. package/dist/services/rate-limit-config.d.ts +9 -0
  176. package/dist/services/rate-limit-config.js +20 -0
  177. package/dist/services/redirects.d.ts +27 -0
  178. package/dist/services/redirects.js +175 -0
  179. package/dist/services/resolve-blog-url.d.ts +11 -0
  180. package/dist/services/resolve-blog-url.js +31 -0
  181. package/dist/services/resolve-url.d.ts +11 -0
  182. package/dist/services/resolve-url.js +20 -0
  183. package/dist/services/startup-checks.d.ts +10 -0
  184. package/dist/services/startup-checks.js +81 -0
  185. package/dist/services/static-file.d.ts +7 -0
  186. package/dist/services/static-file.js +37 -0
  187. package/dist/services/theme-component-file.d.ts +5 -0
  188. package/dist/services/theme-component-file.js +32 -0
  189. package/dist/services/theme-page-templates.d.ts +7 -0
  190. package/dist/services/theme-page-templates.js +53 -0
  191. package/dist/services/theme-schemas.d.ts +2 -0
  192. package/dist/services/theme-schemas.js +63 -0
  193. package/dist/services/token-auth.d.ts +10 -0
  194. package/dist/services/token-auth.js +64 -0
  195. package/dist/services/urls.d.ts +2 -0
  196. package/dist/services/urls.js +20 -0
  197. package/dist/services/validation.d.ts +24 -0
  198. package/dist/services/validation.js +158 -0
  199. package/dist/services/write-queue.d.ts +1 -0
  200. package/dist/services/write-queue.js +14 -0
  201. package/package.json +53 -0
@@ -0,0 +1,155 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { ContentReadError, readContentFile } from "./content-read.js";
4
+ import { computeEtag, etagsMatch } from "./etag.js";
5
+ import { sanitisePath } from "./path-safety.js";
6
+ import { validateContent } from "./validation.js";
7
+ import { enqueue } from "./write-queue.js";
8
+ export class DraftError extends Error {
9
+ reason;
10
+ // Group I: the real ajv errors (already field-pointing, e.g.
11
+ // /sections/0/settings/columns) - previously only available
12
+ // stringified inside .message. Always [] for the 'conflict' reason.
13
+ errors;
14
+ constructor(reason, message, errors = []) {
15
+ super(message);
16
+ this.name = 'DraftError';
17
+ this.reason = reason;
18
+ this.errors = errors;
19
+ }
20
+ }
21
+ // Draft takes precedence over live: the ETag a client's If-Match must
22
+ // match is whatever they would have read from a prior GET, and D1/D2
23
+ // already prefer the draft over the live file at the same path. Reuses
24
+ // readContentFile/computeEtag (etag.ts's own doc comment anticipates
25
+ // this exact reuse) rather than reimplementing hashing here. Returns
26
+ // null when neither a draft nor a live file exists yet (a brand-new
27
+ // page) - see saveDraftJob's own comment for what that means.
28
+ function readCurrentEtag(config, relativePath) {
29
+ try {
30
+ return readContentFile(config.draftsRoot, relativePath).etag;
31
+ }
32
+ catch (error) {
33
+ if (!(error instanceof ContentReadError) || error.reason !== 'not-found') {
34
+ throw error;
35
+ }
36
+ }
37
+ try {
38
+ return readContentFile(config.contentRoot, relativePath).etag;
39
+ }
40
+ catch (error) {
41
+ if (!(error instanceof ContentReadError) || error.reason !== 'not-found') {
42
+ throw error;
43
+ }
44
+ return null;
45
+ }
46
+ }
47
+ async function saveDraftJob(config, themeSchemas, relativePath, content, expectedEtag) {
48
+ const draftPath = sanitisePath(config.draftsRoot, relativePath);
49
+ // The If-Match check happens here, inside the queued job, never at
50
+ // the route layer before enqueuing: the queue only serialises the
51
+ // writes themselves, not a check that happened earlier, so a
52
+ // route-layer check-then-enqueue has a real TOCTOU gap (verified
53
+ // empirically: run 2000 times, a check-before-enqueue design lets
54
+ // both of two racing requests succeed every single time; the same
55
+ // check run inside the job, like this, produced exactly one winner
56
+ // every time, no exceptions).
57
+ const currentEtag = readCurrentEtag(config, relativePath);
58
+ if (currentEtag !== null && !etagsMatch(currentEtag, expectedEtag)) {
59
+ throw new DraftError('conflict', `If-Match "${expectedEtag}" does not match the current ETag for "${relativePath}"`);
60
+ }
61
+ // currentEtag === null means neither a draft nor a live file exists
62
+ // yet (a brand-new page) - deliberately not checked further here.
63
+ // This isn't a race loophole: the *next* write to this same path
64
+ // lands after this job's write has created a real draft, so that
65
+ // job's own currentEtag is no longer null and gets checked for
66
+ // real. Only the very first write to a wholly new path skips the
67
+ // comparison, and nothing (E1-E6) tests or specifies what that first
68
+ // write's If-Match value should mean.
69
+ // Validate before touching disk at all, so a validation failure
70
+ // writes nothing (checklist C3) structurally, not via cleanup.
71
+ const result = validateContent(relativePath, content, themeSchemas);
72
+ if (!result.valid) {
73
+ throw new DraftError('validation-failed', `Draft at "${relativePath}" failed validation: ${JSON.stringify(result.errors)}`, result.errors);
74
+ }
75
+ mkdirSync(dirname(draftPath), { recursive: true });
76
+ const bytes = Buffer.from(JSON.stringify(content, null, 2));
77
+ writeFileSync(draftPath, bytes);
78
+ // No git call: saves are cheap and frequent, and never create history
79
+ // (checklist C2, constraint 5).
80
+ // Computed from the exact bytes just written, never a second disk
81
+ // read (inside or outside the queue) - a second read could itself
82
+ // race a subsequent queued job and hand the client a stale value.
83
+ return computeEtag(bytes);
84
+ }
85
+ async function discardDraftJob(config, relativePath) {
86
+ const draftPath = sanitisePath(config.draftsRoot, relativePath);
87
+ // Idempotent, matching DELETE semantics: silently succeed if the
88
+ // draft is already absent rather than inventing a not-found error
89
+ // the checklist doesn't ask for.
90
+ if (existsSync(draftPath)) {
91
+ unlinkSync(draftPath);
92
+ }
93
+ // No git call: discard never touches /content/ or history.
94
+ }
95
+ export function saveDraft(config, themeSchemas, relativePath, content, expectedEtag) {
96
+ return enqueue(() => saveDraftJob(config, themeSchemas, relativePath, content, expectedEtag));
97
+ }
98
+ export function discardDraft(config, relativePath) {
99
+ return enqueue(() => discardDraftJob(config, relativePath));
100
+ }
101
+ // The batch.ts-composable shape, mirroring publish.ts/move.ts/
102
+ // delete-content.ts's own prepare* exports for a single uniform
103
+ // vocabulary - even though saveDraftJob/discardDraftJob never call
104
+ // commitPaths at all (drafts are never git-tracked, so paths is
105
+ // always []), batch.ts still needs an undo for them: F6 requires "no
106
+ // partial writes" for a whole failed batch, which includes rolling
107
+ // back an earlier draft write if a later operation in the same batch
108
+ // fails. Snapshots the draft's prior bytes/existence *before*
109
+ // delegating to the existing job function, rather than reimplementing
110
+ // its logic here.
111
+ export async function prepareSaveDraft(config, themeSchemas, relativePath, content, expectedEtag) {
112
+ const draftPath = sanitisePath(config.draftsRoot, relativePath);
113
+ const existedBefore = existsSync(draftPath);
114
+ const originalBytes = existedBefore ? readFileSync(draftPath) : null;
115
+ const etag = await saveDraftJob(config, themeSchemas, relativePath, content, expectedEtag);
116
+ return {
117
+ paths: [],
118
+ etag,
119
+ undo: () => {
120
+ try {
121
+ if (existedBefore) {
122
+ writeFileSync(draftPath, originalBytes);
123
+ }
124
+ else {
125
+ unlinkSync(draftPath);
126
+ }
127
+ return [];
128
+ }
129
+ catch (error) {
130
+ return [error];
131
+ }
132
+ },
133
+ };
134
+ }
135
+ export async function prepareDiscardDraft(config, relativePath) {
136
+ const draftPath = sanitisePath(config.draftsRoot, relativePath);
137
+ const existedBefore = existsSync(draftPath);
138
+ const originalBytes = existedBefore ? readFileSync(draftPath) : null;
139
+ await discardDraftJob(config, relativePath);
140
+ return {
141
+ paths: [],
142
+ undo: () => {
143
+ try {
144
+ if (existedBefore) {
145
+ mkdirSync(dirname(draftPath), { recursive: true });
146
+ writeFileSync(draftPath, originalBytes);
147
+ }
148
+ return [];
149
+ }
150
+ catch (error) {
151
+ return [error];
152
+ }
153
+ },
154
+ };
155
+ }
@@ -0,0 +1,2 @@
1
+ export declare function computeEtag(bytes: Buffer): string;
2
+ export declare function etagsMatch(a: string, b: string): boolean;
@@ -0,0 +1,30 @@
1
+ import { createHash } from 'node:crypto';
2
+ // A plain content hash, not a git blob hash: git.ts only shells out to
3
+ // git for actual commits, never for a per-request read, and nothing
4
+ // downstream needs the ETag to coincide with a real git blob hash
5
+ // (the git log/show endpoints address content by ref+path, not by
6
+ // hash lookup). A quoted strong ETag, matching HTTP's own syntax -
7
+ // this is a byte-for-byte digest, so strong semantics are correct.
8
+ //
9
+ // The one hash function every ETag-producing read (D1/D2) and every
10
+ // future If-Match comparison (Group E) must share - never inlined
11
+ // separately, or the two could silently drift.
12
+ export function computeEtag(bytes) {
13
+ return `"${createHash('sha256').update(bytes).digest('hex')}"`;
14
+ }
15
+ // RFC 7232 weak comparison: a compressing proxy sitting in front of a
16
+ // real deployment (confirmed live against a Railway-hosted admin - its
17
+ // edge downgrades this server's own strong ETag to weak in transit,
18
+ // since the compressed bytes on the wire differ from what computeEtag
19
+ // hashed) is standard, spec-compliant proxy behaviour, not a bug on
20
+ // the proxy's part. A client that captured and echoes back that
21
+ // now-weak value represents the exact same content a strict `===`
22
+ // would wrongly reject as changed - every If-Match check must compare
23
+ // this way, never with a bare `!==`, or any real host behind a
24
+ // compressing proxy/CDN produces spurious conflicts on every save.
25
+ function stripWeakPrefix(etag) {
26
+ return etag.startsWith('W/') ? etag.slice(2) : etag;
27
+ }
28
+ export function etagsMatch(a, b) {
29
+ return stripWeakPrefix(a) === stripWeakPrefix(b);
30
+ }
@@ -0,0 +1 @@
1
+ export declare function listFilesRecursively(dir: string, base: string, extension: string): string[];
@@ -0,0 +1,26 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ // Recursively lists files matching extension under dir, returned as
4
+ // paths relative to base. Agent-configured roots only (content/drafts
5
+ // roots, theme directories), never a request-supplied :path - callers
6
+ // that walk request-shaped paths must go through sanitisePath instead.
7
+ export function listFilesRecursively(dir, base, extension) {
8
+ let out = [];
9
+ let entries;
10
+ try {
11
+ entries = readdirSync(dir, { withFileTypes: true });
12
+ }
13
+ catch {
14
+ return out;
15
+ }
16
+ for (const entry of entries) {
17
+ const full = join(dir, entry.name);
18
+ if (entry.isDirectory()) {
19
+ out = out.concat(listFilesRecursively(full, base, extension));
20
+ }
21
+ else if (entry.isFile() && entry.name.endsWith(extension)) {
22
+ out.push(relative(base, full));
23
+ }
24
+ }
25
+ return out;
26
+ }
@@ -0,0 +1,3 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ import type { CommitAuthor } from './git.ts';
3
+ export declare function commitWorkingTreeChanges(config: SiteConfig, message: string, author: CommitAuthor): Promise<'committed' | 'clean'>;
@@ -0,0 +1,12 @@
1
+ import { commitWorkingTree } from "./git.js";
2
+ import { enqueue } from "./write-queue.js";
3
+ // The escape hatch (checklist G5). No PreparedOperation/undo-stack
4
+ // here, unlike publish/move/delete/revert: those services write new
5
+ // content themselves on request, so a write can fail independently of
6
+ // the git step, needing an undo. Here the "write" already exists in
7
+ // the working tree before this is ever called - the only failure
8
+ // modes are git add/git commit themselves, which commitWorkingTree's
9
+ // own bare `git reset` already unwinds (git.ts).
10
+ export function commitWorkingTreeChanges(config, message, author) {
11
+ return enqueue(() => Promise.resolve(commitWorkingTree(config.siteRoot, message, author)));
12
+ }
@@ -0,0 +1,28 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export interface LogEntry {
3
+ hash: string;
4
+ author: {
5
+ name: string;
6
+ email: string;
7
+ };
8
+ date: string;
9
+ message: string;
10
+ isCheckpoint: boolean;
11
+ }
12
+ export interface LogResult {
13
+ commits: LogEntry[];
14
+ hasMore: boolean;
15
+ }
16
+ export declare const CHECKPOINT_MESSAGE = "chore: draft checkpoint";
17
+ export declare function getCommitLog(config: SiteConfig, options: {
18
+ path?: string;
19
+ limit?: number;
20
+ }): LogResult;
21
+ export type GitShowReason = 'invalid-ref' | 'not-found-at-ref';
22
+ export declare class GitShowError extends Error {
23
+ readonly reason: GitShowReason;
24
+ constructor(reason: GitShowReason, message: string, options?: {
25
+ cause?: unknown;
26
+ });
27
+ }
28
+ export declare function readFileAtRevision(config: SiteConfig, ref: string, relativePath: string): Buffer;
@@ -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';