@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.
- package/LICENSE +105 -0
- package/README.md +132 -0
- package/dist/boot.d.ts +15 -0
- package/dist/boot.js +33 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +45 -0
- package/dist/create-site/cli.d.ts +2 -0
- package/dist/create-site/cli.js +31 -0
- package/dist/create-site/generate-site.d.ts +9 -0
- package/dist/create-site/generate-site.js +109 -0
- package/dist/create-site/mint-token-cli.d.ts +2 -0
- package/dist/create-site/mint-token-cli.js +26 -0
- package/dist/create-site/mint-token.d.ts +7 -0
- package/dist/create-site/mint-token.js +49 -0
- package/dist/create-site/template/content/menus/footerCompany.json +9 -0
- package/dist/create-site/template/content/menus/footerProduct.json +9 -0
- package/dist/create-site/template/content/menus/footerResources.json +9 -0
- package/dist/create-site/template/content/menus/main.json +9 -0
- package/dist/create-site/template/content/pages/404.json +22 -0
- package/dist/create-site/template/content/pages/about/careers.json +21 -0
- package/dist/create-site/template/content/pages/about/team.json +21 -0
- package/dist/create-site/template/content/pages/about.json +21 -0
- package/dist/create-site/template/content/pages/docs/deployment.json +21 -0
- package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +21 -0
- package/dist/create-site/template/content/pages/docs/getting-started.json +23 -0
- package/dist/create-site/template/content/pages/docs.json +21 -0
- package/dist/create-site/template/content/pages/index.json +181 -0
- package/dist/create-site/template/content/redirects.json +4 -0
- package/dist/create-site/template/dockerignore +6 -0
- package/dist/create-site/template/gitignore +11 -0
- package/dist/create-site/template/theme/assets/site.js +49 -0
- package/dist/create-site/template/theme/assets/style.css +1153 -0
- package/dist/create-site/template/theme/blocks/button.liquid +21 -0
- package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
- package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
- package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
- package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
- package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
- package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
- package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
- package/dist/create-site/template/theme/layouts/theme.liquid +99 -0
- package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
- package/dist/create-site/template/theme/sections/cta-banner.liquid +27 -0
- package/dist/create-site/template/theme/sections/faq.liquid +27 -0
- package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
- package/dist/create-site/template/theme/sections/hero.liquid +36 -0
- package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
- package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
- package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
- package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
- package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
- package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
- package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
- package/dist/create-site/template/vhost/Dockerfile +33 -0
- package/dist/create-site/template/vhost/docker-entrypoint.sh +30 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/media/drivers/driver.d.ts +11 -0
- package/dist/media/drivers/driver.js +1 -0
- package/dist/media/drivers/local-fs-driver.d.ts +3 -0
- package/dist/media/drivers/local-fs-driver.js +81 -0
- package/dist/media/filename.d.ts +1 -0
- package/dist/media/filename.js +31 -0
- package/dist/media/manage-media.d.ts +19 -0
- package/dist/media/manage-media.js +45 -0
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +49 -0
- package/dist/renderer/engine.d.ts +2 -0
- package/dist/renderer/engine.js +48 -0
- package/dist/renderer/render-page.d.ts +28 -0
- package/dist/renderer/render-page.js +143 -0
- package/dist/renderer/theme-templates.d.ts +7 -0
- package/dist/renderer/theme-templates.js +82 -0
- package/dist/routes/assets.d.ts +6 -0
- package/dist/routes/assets.js +30 -0
- package/dist/routes/batch.d.ts +10 -0
- package/dist/routes/batch.js +148 -0
- package/dist/routes/capabilities.d.ts +5 -0
- package/dist/routes/capabilities.js +27 -0
- package/dist/routes/content.d.ts +8 -0
- package/dist/routes/content.js +166 -0
- package/dist/routes/drafts.d.ts +10 -0
- package/dist/routes/drafts.js +73 -0
- package/dist/routes/git.d.ts +8 -0
- package/dist/routes/git.js +158 -0
- package/dist/routes/index.d.ts +19 -0
- package/dist/routes/index.js +84 -0
- package/dist/routes/media-public.d.ts +5 -0
- package/dist/routes/media-public.js +48 -0
- package/dist/routes/media.d.ts +9 -0
- package/dist/routes/media.js +84 -0
- package/dist/routes/menus.d.ts +8 -0
- package/dist/routes/menus.js +77 -0
- package/dist/routes/preview-revision.d.ts +13 -0
- package/dist/routes/preview-revision.js +109 -0
- package/dist/routes/preview.d.ts +13 -0
- package/dist/routes/preview.js +52 -0
- package/dist/routes/public.d.ts +11 -0
- package/dist/routes/public.js +117 -0
- package/dist/routes/publish.d.ts +10 -0
- package/dist/routes/publish.js +105 -0
- package/dist/routes/redirects.d.ts +8 -0
- package/dist/routes/redirects.js +132 -0
- package/dist/routes/search.d.ts +8 -0
- package/dist/routes/search.js +11 -0
- package/dist/routes/sitemap.d.ts +6 -0
- package/dist/routes/sitemap.js +79 -0
- package/dist/routes/theme.d.ts +10 -0
- package/dist/routes/theme.js +17 -0
- package/dist/schemas/instance.schema.json +17 -0
- package/dist/schemas/menu.schema.json +23 -0
- package/dist/schemas/page.schema.json +20 -0
- package/dist/schemas/post.schema.json +25 -0
- package/dist/schemas/redirects.schema.json +24 -0
- package/dist/search/drivers/driver.d.ts +9 -0
- package/dist/search/drivers/driver.js +1 -0
- package/dist/search/drivers/node-sqlite-driver.d.ts +3 -0
- package/dist/search/drivers/node-sqlite-driver.js +19 -0
- package/dist/search/query-index.d.ts +5 -0
- package/dist/search/query-index.js +21 -0
- package/dist/search/rebuild-index.d.ts +2 -0
- package/dist/search/rebuild-index.js +111 -0
- package/dist/server-config.d.ts +22 -0
- package/dist/server-config.js +196 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +163 -0
- package/dist/services/batch.d.ts +35 -0
- package/dist/services/batch.js +136 -0
- package/dist/services/checkpoint.d.ts +4 -0
- package/dist/services/checkpoint.js +45 -0
- package/dist/services/content-read.d.ts +27 -0
- package/dist/services/content-read.js +156 -0
- package/dist/services/delete-content.d.ts +12 -0
- package/dist/services/delete-content.js +174 -0
- package/dist/services/dev-tunnel.d.ts +5 -0
- package/dist/services/dev-tunnel.js +5 -0
- package/dist/services/drafts.d.ts +15 -0
- package/dist/services/drafts.js +155 -0
- package/dist/services/etag.d.ts +2 -0
- package/dist/services/etag.js +30 -0
- package/dist/services/fs-walk.d.ts +1 -0
- package/dist/services/fs-walk.js +26 -0
- package/dist/services/git-commit.d.ts +3 -0
- package/dist/services/git-commit.js +12 -0
- package/dist/services/git-history.d.ts +28 -0
- package/dist/services/git-history.js +131 -0
- package/dist/services/git-revert.d.ts +12 -0
- package/dist/services/git-revert.js +140 -0
- package/dist/services/git.d.ts +16 -0
- package/dist/services/git.js +127 -0
- package/dist/services/interval-job.d.ts +4 -0
- package/dist/services/interval-job.js +9 -0
- package/dist/services/ip-allowlist.d.ts +7 -0
- package/dist/services/ip-allowlist.js +31 -0
- package/dist/services/manage-menus.d.ts +10 -0
- package/dist/services/manage-menus.js +84 -0
- package/dist/services/manage-redirects.d.ts +25 -0
- package/dist/services/manage-redirects.js +166 -0
- package/dist/services/menus.d.ts +8 -0
- package/dist/services/menus.js +42 -0
- package/dist/services/migration-runner.d.ts +13 -0
- package/dist/services/migration-runner.js +123 -0
- package/dist/services/mime-types.d.ts +3 -0
- package/dist/services/mime-types.js +36 -0
- package/dist/services/move.d.ts +16 -0
- package/dist/services/move.js +175 -0
- package/dist/services/path-safety.d.ts +6 -0
- package/dist/services/path-safety.js +112 -0
- package/dist/services/post-urls.d.ts +3 -0
- package/dist/services/post-urls.js +27 -0
- package/dist/services/prepared-operation.d.ts +4 -0
- package/dist/services/prepared-operation.js +1 -0
- package/dist/services/publish.d.ts +14 -0
- package/dist/services/publish.js +254 -0
- package/dist/services/rate-limit-config.d.ts +9 -0
- package/dist/services/rate-limit-config.js +20 -0
- package/dist/services/redirects.d.ts +27 -0
- package/dist/services/redirects.js +175 -0
- package/dist/services/resolve-blog-url.d.ts +11 -0
- package/dist/services/resolve-blog-url.js +31 -0
- package/dist/services/resolve-url.d.ts +11 -0
- package/dist/services/resolve-url.js +20 -0
- package/dist/services/startup-checks.d.ts +10 -0
- package/dist/services/startup-checks.js +81 -0
- package/dist/services/static-file.d.ts +7 -0
- package/dist/services/static-file.js +37 -0
- package/dist/services/theme-component-file.d.ts +5 -0
- package/dist/services/theme-component-file.js +32 -0
- package/dist/services/theme-page-templates.d.ts +7 -0
- package/dist/services/theme-page-templates.js +53 -0
- package/dist/services/theme-schemas.d.ts +2 -0
- package/dist/services/theme-schemas.js +63 -0
- package/dist/services/token-auth.d.ts +10 -0
- package/dist/services/token-auth.js +64 -0
- package/dist/services/urls.d.ts +2 -0
- package/dist/services/urls.js +20 -0
- package/dist/services/validation.d.ts +24 -0
- package/dist/services/validation.js +158 -0
- package/dist/services/write-queue.d.ts +1 -0
- package/dist/services/write-queue.js +14 -0
- 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,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,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';
|