@minamocms/framework 0.0.0-stage → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +234 -0
- package/LICENSE +105 -0
- package/README.md +133 -2
- package/dist/boot.d.ts +19 -0
- package/dist/boot.js +22 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +44 -0
- package/dist/create-site/cli.d.ts +2 -0
- package/dist/create-site/cli.js +31 -0
- package/dist/create-site/generate-site.d.ts +12 -0
- package/dist/create-site/generate-site.js +168 -0
- package/dist/create-site/mint-token-cli.d.ts +2 -0
- package/dist/create-site/mint-token-cli.js +26 -0
- package/dist/create-site/mint-token.d.ts +7 -0
- package/dist/create-site/mint-token.js +49 -0
- package/dist/create-site/template/AGENTS.md +473 -0
- package/dist/create-site/template/content/menus/footerCompany.json +9 -0
- package/dist/create-site/template/content/menus/footerProduct.json +9 -0
- package/dist/create-site/template/content/menus/footerResources.json +9 -0
- package/dist/create-site/template/content/menus/main.json +9 -0
- package/dist/create-site/template/content/pages/404.json +23 -0
- package/dist/create-site/template/content/pages/about/careers.json +22 -0
- package/dist/create-site/template/content/pages/about/team.json +22 -0
- package/dist/create-site/template/content/pages/about.json +22 -0
- package/dist/create-site/template/content/pages/docs/deployment.json +22 -0
- package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +22 -0
- package/dist/create-site/template/content/pages/docs/getting-started.json +24 -0
- package/dist/create-site/template/content/pages/docs.json +22 -0
- package/dist/create-site/template/content/pages/index.json +182 -0
- package/dist/create-site/template/content/redirects.json +4 -0
- package/dist/create-site/template/dockerignore +6 -0
- package/dist/create-site/template/gitignore +11 -0
- package/dist/create-site/template/theme/assets/site.js +49 -0
- package/dist/create-site/template/theme/assets/style.css +1223 -0
- package/dist/create-site/template/theme/blocks/button.liquid +21 -0
- package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
- package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
- package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
- package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
- package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
- package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
- package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
- package/dist/create-site/template/theme/config/site_settings.json +11 -0
- package/dist/create-site/template/theme/layouts/theme.liquid +102 -0
- package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
- package/dist/create-site/template/theme/sections/cta-banner.liquid +36 -0
- package/dist/create-site/template/theme/sections/faq.liquid +27 -0
- package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
- package/dist/create-site/template/theme/sections/hero.liquid +48 -0
- package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
- package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
- package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
- package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
- package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
- package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
- package/dist/create-site/template/theme/snippets/responsive-media.liquid +107 -0
- package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
- package/dist/create-site/template/vhost/Dockerfile +33 -0
- package/dist/create-site/template/vhost/docker-entrypoint.sh +58 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/media/drivers/driver.d.ts +11 -0
- package/dist/media/drivers/driver.js +1 -0
- package/dist/media/drivers/local-fs-driver.d.ts +3 -0
- package/dist/media/drivers/local-fs-driver.js +81 -0
- package/dist/media/filename.d.ts +2 -0
- package/dist/media/filename.js +54 -0
- package/dist/media/manage-media.d.ts +19 -0
- package/dist/media/manage-media.js +45 -0
- package/dist/media/seed-media-cli.d.ts +2 -0
- package/dist/media/seed-media-cli.js +22 -0
- package/dist/media/seed-media.d.ts +11 -0
- package/dist/media/seed-media.js +76 -0
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +83 -0
- package/dist/pull-site/cli.d.ts +2 -0
- package/dist/pull-site/cli.js +78 -0
- package/dist/pull-site/prompt-token.d.ts +16 -0
- package/dist/pull-site/prompt-token.js +69 -0
- package/dist/pull-site/prompts.d.ts +22 -0
- package/dist/pull-site/prompts.js +127 -0
- package/dist/pull-site/pull-site.d.ts +23 -0
- package/dist/pull-site/pull-site.js +211 -0
- package/dist/renderer/engine.d.ts +2 -0
- package/dist/renderer/engine.js +48 -0
- package/dist/renderer/render-cache.d.ts +11 -0
- package/dist/renderer/render-cache.js +11 -0
- package/dist/renderer/render-page.d.ts +34 -0
- package/dist/renderer/render-page.js +209 -0
- package/dist/renderer/theme-templates.d.ts +7 -0
- package/dist/renderer/theme-templates.js +82 -0
- package/dist/routes/admin-redirect.d.ts +5 -0
- package/dist/routes/admin-redirect.js +26 -0
- package/dist/routes/assets.d.ts +6 -0
- package/dist/routes/assets.js +30 -0
- package/dist/routes/batch.d.ts +10 -0
- package/dist/routes/batch.js +148 -0
- package/dist/routes/capabilities.d.ts +6 -0
- package/dist/routes/capabilities.js +27 -0
- package/dist/routes/content.d.ts +8 -0
- package/dist/routes/content.js +169 -0
- package/dist/routes/deleted-pages.d.ts +8 -0
- package/dist/routes/deleted-pages.js +9 -0
- package/dist/routes/drafts.d.ts +10 -0
- package/dist/routes/drafts.js +73 -0
- package/dist/routes/git.d.ts +8 -0
- package/dist/routes/git.js +158 -0
- package/dist/routes/index.d.ts +12 -0
- package/dist/routes/index.js +86 -0
- package/dist/routes/links.d.ts +8 -0
- package/dist/routes/links.js +18 -0
- package/dist/routes/media-public.d.ts +11 -0
- package/dist/routes/media-public.js +125 -0
- package/dist/routes/media.d.ts +9 -0
- package/dist/routes/media.js +80 -0
- package/dist/routes/menus.d.ts +8 -0
- package/dist/routes/menus.js +148 -0
- package/dist/routes/preview-revision.d.ts +10 -0
- package/dist/routes/preview-revision.js +96 -0
- package/dist/routes/preview.d.ts +10 -0
- package/dist/routes/preview.js +56 -0
- package/dist/routes/public.d.ts +10 -0
- package/dist/routes/public.js +123 -0
- package/dist/routes/publish.d.ts +10 -0
- package/dist/routes/publish.js +139 -0
- package/dist/routes/redirects.d.ts +8 -0
- package/dist/routes/redirects.js +132 -0
- package/dist/routes/search-public.d.ts +6 -0
- package/dist/routes/search-public.js +104 -0
- package/dist/routes/search.d.ts +8 -0
- package/dist/routes/search.js +15 -0
- package/dist/routes/settings.d.ts +10 -0
- package/dist/routes/settings.js +68 -0
- package/dist/routes/sitemap.d.ts +7 -0
- package/dist/routes/sitemap.js +79 -0
- package/dist/routes/theme.d.ts +10 -0
- package/dist/routes/theme.js +116 -0
- package/dist/schemas/instance.schema.json +17 -0
- package/dist/schemas/menu.schema.json +24 -0
- package/dist/schemas/page.schema.json +26 -0
- package/dist/schemas/redirects.schema.json +24 -0
- package/dist/search/drivers/driver.d.ts +9 -0
- package/dist/search/drivers/driver.js +1 -0
- package/dist/search/drivers/node-sqlite-driver.d.ts +7 -0
- package/dist/search/drivers/node-sqlite-driver.js +19 -0
- package/dist/search/query-content.d.ts +32 -0
- package/dist/search/query-content.js +207 -0
- package/dist/search/query-index.d.ts +5 -0
- package/dist/search/query-index.js +21 -0
- package/dist/search/rebuild-index.d.ts +3 -0
- package/dist/search/rebuild-index.js +320 -0
- package/dist/server-config.d.ts +23 -0
- package/dist/server-config.js +237 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +235 -0
- package/dist/services/batch.d.ts +35 -0
- package/dist/services/batch.js +145 -0
- package/dist/services/checkpoint.d.ts +4 -0
- package/dist/services/checkpoint.js +45 -0
- package/dist/services/content-read.d.ts +27 -0
- package/dist/services/content-read.js +151 -0
- package/dist/services/delete-content.d.ts +12 -0
- package/dist/services/delete-content.js +166 -0
- package/dist/services/deleted-pages.d.ts +11 -0
- package/dist/services/deleted-pages.js +83 -0
- package/dist/services/dev-tunnel.d.ts +5 -0
- package/dist/services/dev-tunnel.js +5 -0
- package/dist/services/drafts.d.ts +16 -0
- package/dist/services/drafts.js +164 -0
- package/dist/services/etag.d.ts +2 -0
- package/dist/services/etag.js +30 -0
- package/dist/services/fs-walk.d.ts +1 -0
- package/dist/services/fs-walk.js +26 -0
- package/dist/services/git-commit.d.ts +3 -0
- package/dist/services/git-commit.js +12 -0
- package/dist/services/git-history.d.ts +28 -0
- package/dist/services/git-history.js +131 -0
- package/dist/services/git-revert.d.ts +12 -0
- package/dist/services/git-revert.js +140 -0
- package/dist/services/git.d.ts +16 -0
- package/dist/services/git.js +127 -0
- package/dist/services/interval-job.d.ts +4 -0
- package/dist/services/interval-job.js +9 -0
- package/dist/services/ip-allowlist.d.ts +7 -0
- package/dist/services/ip-allowlist.js +31 -0
- package/dist/services/links.d.ts +22 -0
- package/dist/services/links.js +169 -0
- package/dist/services/manage-menus.d.ts +15 -0
- package/dist/services/manage-menus.js +138 -0
- package/dist/services/manage-redirects.d.ts +25 -0
- package/dist/services/manage-redirects.js +156 -0
- package/dist/services/menu-references.d.ts +3 -0
- package/dist/services/menu-references.js +38 -0
- package/dist/services/menus.d.ts +9 -0
- package/dist/services/menus.js +43 -0
- package/dist/services/migration-runner.d.ts +16 -0
- package/dist/services/migration-runner.js +169 -0
- package/dist/services/mime-types.d.ts +3 -0
- package/dist/services/mime-types.js +43 -0
- package/dist/services/move.d.ts +16 -0
- package/dist/services/move.js +236 -0
- package/dist/services/path-safety.d.ts +6 -0
- package/dist/services/path-safety.js +112 -0
- package/dist/services/pid-file.d.ts +13 -0
- package/dist/services/pid-file.js +54 -0
- package/dist/services/post-urls.d.ts +3 -0
- package/dist/services/post-urls.js +27 -0
- package/dist/services/prepared-operation.d.ts +4 -0
- package/dist/services/prepared-operation.js +1 -0
- package/dist/services/publish.d.ts +15 -0
- package/dist/services/publish.js +313 -0
- package/dist/services/rate-limit-config.d.ts +9 -0
- package/dist/services/rate-limit-config.js +22 -0
- package/dist/services/redirects.d.ts +27 -0
- package/dist/services/redirects.js +175 -0
- package/dist/services/reindex-on-write.d.ts +3 -0
- package/dist/services/reindex-on-write.js +30 -0
- package/dist/services/resolve-blog-url.d.ts +11 -0
- package/dist/services/resolve-blog-url.js +31 -0
- package/dist/services/resolve-url.d.ts +11 -0
- package/dist/services/resolve-url.js +20 -0
- package/dist/services/site-settings.d.ts +28 -0
- package/dist/services/site-settings.js +116 -0
- package/dist/services/startup-checks.d.ts +10 -0
- package/dist/services/startup-checks.js +81 -0
- package/dist/services/static-file.d.ts +7 -0
- package/dist/services/static-file.js +37 -0
- package/dist/services/theme-component-file.d.ts +6 -0
- package/dist/services/theme-component-file.js +39 -0
- package/dist/services/theme-files.d.ts +33 -0
- package/dist/services/theme-files.js +148 -0
- package/dist/services/theme-page-templates.d.ts +7 -0
- package/dist/services/theme-page-templates.js +53 -0
- package/dist/services/theme-schemas.d.ts +2 -0
- package/dist/services/theme-schemas.js +137 -0
- package/dist/services/token-auth.d.ts +10 -0
- package/dist/services/token-auth.js +64 -0
- package/dist/services/urls.d.ts +2 -0
- package/dist/services/urls.js +20 -0
- package/dist/services/validation.d.ts +26 -0
- package/dist/services/validation.js +195 -0
- package/dist/services/write-queue.d.ts +1 -0
- package/dist/services/write-queue.js +14 -0
- package/dist/site-check/cli.d.ts +2 -0
- package/dist/site-check/cli.js +48 -0
- package/dist/site-check/run-check.d.ts +12 -0
- package/dist/site-check/run-check.js +188 -0
- package/dist/site-sync/cli-helpers.d.ts +23 -0
- package/dist/site-sync/cli-helpers.js +75 -0
- package/dist/site-sync/deploy.d.ts +36 -0
- package/dist/site-sync/deploy.js +124 -0
- package/dist/site-sync/live-content.d.ts +30 -0
- package/dist/site-sync/live-content.js +68 -0
- package/dist/site-sync/prompts.d.ts +30 -0
- package/dist/site-sync/prompts.js +237 -0
- package/dist/site-sync/pull-cli.d.ts +2 -0
- package/dist/site-sync/pull-cli.js +75 -0
- package/dist/site-sync/pull-site.d.ts +28 -0
- package/dist/site-sync/pull-site.js +171 -0
- package/dist/site-sync/push-cli.d.ts +2 -0
- package/dist/site-sync/push-cli.js +268 -0
- package/dist/site-sync/push-site.d.ts +96 -0
- package/dist/site-sync/push-site.js +376 -0
- package/dist/site-sync/remote-site.d.ts +38 -0
- package/dist/site-sync/remote-site.js +122 -0
- package/dist/site-sync/sync-record.d.ts +26 -0
- package/dist/site-sync/sync-record.js +76 -0
- package/dist/site-sync/theme-sync.d.ts +34 -0
- package/dist/site-sync/theme-sync.js +128 -0
- package/dist/stop-site/cli.d.ts +2 -0
- package/dist/stop-site/cli.js +28 -0
- package/dist/stop-site/stop-site.d.ts +26 -0
- package/dist/stop-site/stop-site.js +56 -0
- package/dist/theme-state.d.ts +20 -0
- package/dist/theme-state.js +33 -0
- package/dist/upgrade/changelog.d.ts +8 -0
- package/dist/upgrade/changelog.js +46 -0
- package/dist/upgrade/finish.d.ts +9 -0
- package/dist/upgrade/finish.js +30 -0
- package/dist/upgrade/owned-files.d.ts +11 -0
- package/dist/upgrade/owned-files.js +42 -0
- package/dist/upgrade/package-names.d.ts +4 -0
- package/dist/upgrade/package-names.js +26 -0
- package/dist/upgrade/upgrade-cli.d.ts +2 -0
- package/dist/upgrade/upgrade-cli.js +170 -0
- package/package.json +64 -4
|
@@ -0,0 +1,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,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[];
|