@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,19 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
// A plain string constant, not the raw node:sqlite import itself - safe
|
|
3
|
+
// for the capabilities endpoint (Phase 2 Group A) to import without
|
|
4
|
+
// tripping the G5 grep test, and avoids hardcoding this name a second
|
|
5
|
+
// time as a literal that could drift from the actual active driver.
|
|
6
|
+
export const DRIVER_NAME = 'node:sqlite';
|
|
7
|
+
// The only file in the codebase allowed to import node:sqlite
|
|
8
|
+
// (checklist G5, enforced by a grep test in
|
|
9
|
+
// test/static/static-analysis.test.ts). DatabaseSync's own
|
|
10
|
+
// prepare()/exec()/close() already structurally match SearchDriver,
|
|
11
|
+
// so this is a thin adapter, not a reimplementation.
|
|
12
|
+
export function openNodeSqliteDriver(path) {
|
|
13
|
+
const db = new DatabaseSync(path);
|
|
14
|
+
return {
|
|
15
|
+
exec: (sql) => db.exec(sql),
|
|
16
|
+
prepare: (sql) => db.prepare(sql),
|
|
17
|
+
close: () => db.close(),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { openNodeSqliteDriver } from "./drivers/node-sqlite-driver.js";
|
|
2
|
+
// Never queued: an in-flight query holding an open handle during a
|
|
3
|
+
// concurrent rebuild's unlink just keeps reading the pre-rebuild inode
|
|
4
|
+
// (stale but consistent, never torn) - queuing a read against the same
|
|
5
|
+
// queue as writes would only add latency for no correctness benefit.
|
|
6
|
+
export function queryIndex(searchIndexPath, term) {
|
|
7
|
+
const driver = openNodeSqliteDriver(searchIndexPath);
|
|
8
|
+
try {
|
|
9
|
+
const rows = driver.prepare('SELECT url, title FROM pages_fts WHERE pages_fts MATCH ?').all(term);
|
|
10
|
+
// node:sqlite returns rows as [Object: null prototype] instances;
|
|
11
|
+
// rebuilt here as plain objects so callers (and assert.deepEqual)
|
|
12
|
+
// never have to know that's a driver implementation detail.
|
|
13
|
+
return rows.map((row) => {
|
|
14
|
+
const { url, title } = row;
|
|
15
|
+
return { url, title };
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
driver.close();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { listFilesRecursively } from "../services/fs-walk.js";
|
|
4
|
+
import { postPathToUrl } from "../services/post-urls.js";
|
|
5
|
+
import { pagePathToUrl } from "../services/urls.js";
|
|
6
|
+
import { enqueue } from "../services/write-queue.js";
|
|
7
|
+
import { openNodeSqliteDriver } from "./drivers/node-sqlite-driver.js";
|
|
8
|
+
function collectStrings(value, out) {
|
|
9
|
+
if (typeof value === 'string') {
|
|
10
|
+
out.push(value);
|
|
11
|
+
}
|
|
12
|
+
else if (Array.isArray(value)) {
|
|
13
|
+
for (const item of value) {
|
|
14
|
+
collectStrings(item, out);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
else if (value !== null && typeof value === 'object') {
|
|
18
|
+
for (const nested of Object.values(value)) {
|
|
19
|
+
collectStrings(nested, out);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// Recursively collects every string value out of each section's
|
|
24
|
+
// settings and any nested blocks' settings (instance.schema.json's
|
|
25
|
+
// blocks is self-referential, so this covers arbitrarily deep
|
|
26
|
+
// nesting). Only settings values, not id/type, so the indexed body is
|
|
27
|
+
// actual authored content, not structural metadata.
|
|
28
|
+
function extractBody(instances) {
|
|
29
|
+
const strings = [];
|
|
30
|
+
const walk = (list) => {
|
|
31
|
+
if (!list) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
for (const instance of list) {
|
|
35
|
+
collectStrings(instance.settings, strings);
|
|
36
|
+
walk(instance.blocks);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
walk(instances);
|
|
40
|
+
return strings.join(' ');
|
|
41
|
+
}
|
|
42
|
+
async function rebuildIndexJob(config) {
|
|
43
|
+
mkdirSync(config.dataRoot, { recursive: true });
|
|
44
|
+
// Delete-then-recreate, not existsSync-then-unlinkSync (a TOCTOU
|
|
45
|
+
// gap): this makes every rebuild start from a genuinely clean file,
|
|
46
|
+
// which is also what makes "delete the index and rebuild produces
|
|
47
|
+
// equivalent results" (G2) a structural consequence rather than a
|
|
48
|
+
// special case.
|
|
49
|
+
try {
|
|
50
|
+
unlinkSync(config.searchIndexPath);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error.code !== 'ENOENT') {
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const driver = openNodeSqliteDriver(config.searchIndexPath);
|
|
58
|
+
try {
|
|
59
|
+
driver.exec('CREATE VIRTUAL TABLE pages_fts USING fts5(url UNINDEXED, title, body)');
|
|
60
|
+
const insert = driver.prepare('INSERT INTO pages_fts (url, title, body) VALUES (?, ?, ?)');
|
|
61
|
+
// Posts are genuinely public, URL-addressable content search
|
|
62
|
+
// should cover, same as pages - only the root and the URL mapping
|
|
63
|
+
// differ. Menus are deliberately never walked here at all: they
|
|
64
|
+
// have no public URL to point a search result at.
|
|
65
|
+
const collections = [
|
|
66
|
+
{ root: config.pagesRoot, toUrl: pagePathToUrl },
|
|
67
|
+
{ root: config.postsRoot, toUrl: postPathToUrl },
|
|
68
|
+
];
|
|
69
|
+
driver.exec('BEGIN');
|
|
70
|
+
for (const { root, toUrl } of collections) {
|
|
71
|
+
for (const relativePath of listFilesRecursively(root, root, '.json')) {
|
|
72
|
+
let page;
|
|
73
|
+
try {
|
|
74
|
+
page = JSON.parse(readFileSync(join(root, relativePath), 'utf-8'));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// A malformed individual file is skipped, not an all-or-nothing
|
|
78
|
+
// abort: the index is explicitly disposable/best-effort, and
|
|
79
|
+
// aborting the whole rebuild over one bad file would leave no
|
|
80
|
+
// working index at all - strictly worse than skipping one page.
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
// Never walks draftsRoot at all, and skips unpublished content
|
|
84
|
+
// here - both halves of "drafts and unpublished content are
|
|
85
|
+
// absent from the index" (G3) are true by construction, not by
|
|
86
|
+
// a filter that could be gotten wrong.
|
|
87
|
+
if (page.published === false) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const url = toUrl(relativePath);
|
|
91
|
+
const title = typeof page.title === 'string' ? page.title : '';
|
|
92
|
+
const body = extractBody(page.sections);
|
|
93
|
+
insert.run(url, title, body);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
driver.exec('COMMIT');
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
driver.close();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Queued via enqueue(), not because constraint 6 literally demands it
|
|
103
|
+
// for a non-authoritative index, but because of a same-process race
|
|
104
|
+
// specific to this delete-then-recreate design: two concurrent
|
|
105
|
+
// rebuilds can interleave so one's unlink races another's open+CREATE,
|
|
106
|
+
// or one can unlink the file out from under another's in-progress
|
|
107
|
+
// transaction. enqueue() is a generic, domain-agnostic primitive, so
|
|
108
|
+
// reusing it for self-exclusion costs nothing.
|
|
109
|
+
export function rebuildIndex(config) {
|
|
110
|
+
return enqueue(() => rebuildIndexJob(config));
|
|
111
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type Scope = 'content' | 'theme' | 'media';
|
|
2
|
+
export interface TokenEntry {
|
|
3
|
+
hash: string;
|
|
4
|
+
scopes: Scope[];
|
|
5
|
+
}
|
|
6
|
+
export interface RateLimitConfig {
|
|
7
|
+
max: number;
|
|
8
|
+
windowMs: number;
|
|
9
|
+
}
|
|
10
|
+
export interface MediaConfig {
|
|
11
|
+
maxUploadBytes: number;
|
|
12
|
+
}
|
|
13
|
+
export interface ServerConfig {
|
|
14
|
+
port: number;
|
|
15
|
+
tokens: TokenEntry[];
|
|
16
|
+
rateLimit: RateLimitConfig;
|
|
17
|
+
trustProxy: boolean;
|
|
18
|
+
ipAllowlist: string[];
|
|
19
|
+
checkpointIntervalMs: number;
|
|
20
|
+
media: MediaConfig;
|
|
21
|
+
}
|
|
22
|
+
export declare function loadServerConfig(siteRoot: string): ServerConfig;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { VHOST_DIR_NAME } from "./config.js";
|
|
4
|
+
import { StartupCheckError } from "./services/startup-checks.js";
|
|
5
|
+
const DEFAULT_PORT = 3000;
|
|
6
|
+
const DEFAULT_RATE_LIMIT = { max: 60, windowMs: 60000 };
|
|
7
|
+
// 10MB (docs/cms-build-plan.md's own suggested default) - comfortably
|
|
8
|
+
// above a properly web-optimised image, well below an unoptimised raw
|
|
9
|
+
// camera export.
|
|
10
|
+
const DEFAULT_MEDIA_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
11
|
+
// The build plan's own literal example ("every 30 minutes"), adopted
|
|
12
|
+
// as-is rather than re-litigated - its own [REVIEW] flag was about
|
|
13
|
+
// whether checkpoints belong on main vs a dedicated branch (resolved:
|
|
14
|
+
// main), not this number specifically.
|
|
15
|
+
const DEFAULT_CHECKPOINT_INTERVAL_MS = 1_800_000;
|
|
16
|
+
const VALID_SCOPES = new Set(['content', 'theme', 'media']);
|
|
17
|
+
// sha256 digest, hex-encoded: exactly 64 lowercase hex characters.
|
|
18
|
+
const HEX64_PATTERN = /^[0-9a-f]{64}$/;
|
|
19
|
+
// A loose IPv4/IPv6-shaped typo-catcher, not a real IP parser -
|
|
20
|
+
// exact-IP-only is a deliberate scope reduction (see ip-allowlist.ts),
|
|
21
|
+
// so this doesn't need to be exhaustive either.
|
|
22
|
+
const IP_SHAPE_PATTERN = /^[0-9a-fA-F:.]+$/;
|
|
23
|
+
function parseTokens(value) {
|
|
24
|
+
if (value === undefined) {
|
|
25
|
+
// Absent entirely -> no configured tokens -> every scope check
|
|
26
|
+
// fails closed (401 on everything). The safe default, not an
|
|
27
|
+
// incidental side effect - see server-config.test.ts's explicit
|
|
28
|
+
// "no tokens configured" case.
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
if (!Array.isArray(value)) {
|
|
32
|
+
throw new StartupCheckError('invalid-token-config', 'site.config.json\'s "tokens" must be an array');
|
|
33
|
+
}
|
|
34
|
+
const seenHashes = new Set();
|
|
35
|
+
const tokens = [];
|
|
36
|
+
value.forEach((entry, index) => {
|
|
37
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
38
|
+
throw new StartupCheckError('invalid-token-config', `tokens[${index}] must be an object`);
|
|
39
|
+
}
|
|
40
|
+
const record = entry;
|
|
41
|
+
const hash = record.hash;
|
|
42
|
+
if (typeof hash !== 'string' || !HEX64_PATTERN.test(hash)) {
|
|
43
|
+
throw new StartupCheckError('invalid-token-config', `tokens[${index}].hash must be a 64-character hex-encoded sha256 digest`);
|
|
44
|
+
}
|
|
45
|
+
if (seenHashes.has(hash)) {
|
|
46
|
+
throw new StartupCheckError('invalid-token-config', `tokens[${index}].hash duplicates an earlier entry`);
|
|
47
|
+
}
|
|
48
|
+
seenHashes.add(hash);
|
|
49
|
+
const scopesValue = record.scopes;
|
|
50
|
+
if (!Array.isArray(scopesValue) || scopesValue.length === 0) {
|
|
51
|
+
throw new StartupCheckError('invalid-token-config', `tokens[${index}].scopes must be a non-empty array`);
|
|
52
|
+
}
|
|
53
|
+
const scopes = scopesValue.map((scope) => {
|
|
54
|
+
if (typeof scope !== 'string' || !VALID_SCOPES.has(scope)) {
|
|
55
|
+
throw new StartupCheckError('invalid-token-config', `tokens[${index}].scopes contains an unknown scope: ${JSON.stringify(scope)}`);
|
|
56
|
+
}
|
|
57
|
+
return scope;
|
|
58
|
+
});
|
|
59
|
+
tokens.push({ hash, scopes });
|
|
60
|
+
});
|
|
61
|
+
return tokens;
|
|
62
|
+
}
|
|
63
|
+
function parseRateLimit(value) {
|
|
64
|
+
if (value === undefined) {
|
|
65
|
+
// Absent entirely -> the default limit applies - matches tokens'/
|
|
66
|
+
// port's own "absence is not an error" philosophy.
|
|
67
|
+
return DEFAULT_RATE_LIMIT;
|
|
68
|
+
}
|
|
69
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
70
|
+
throw new StartupCheckError('invalid-site-config', 'site.config.json\'s "rateLimit" must be an object');
|
|
71
|
+
}
|
|
72
|
+
const record = value;
|
|
73
|
+
const max = record.max;
|
|
74
|
+
if (typeof max !== 'number' || !Number.isInteger(max) || max < 1) {
|
|
75
|
+
throw new StartupCheckError('invalid-site-config', `site.config.json's "rateLimit.max" must be a positive integer, got ${JSON.stringify(max)}`);
|
|
76
|
+
}
|
|
77
|
+
const windowMs = record.windowMs;
|
|
78
|
+
if (typeof windowMs !== 'number' || !Number.isInteger(windowMs) || windowMs < 1) {
|
|
79
|
+
throw new StartupCheckError('invalid-site-config', `site.config.json's "rateLimit.windowMs" must be a positive integer, got ${JSON.stringify(windowMs)}`);
|
|
80
|
+
}
|
|
81
|
+
return { max, windowMs };
|
|
82
|
+
}
|
|
83
|
+
function parseMedia(value) {
|
|
84
|
+
if (value === undefined) {
|
|
85
|
+
// Absent entirely -> the default limit applies, matching every
|
|
86
|
+
// other optional-object field's own "absence is not an error"
|
|
87
|
+
// philosophy.
|
|
88
|
+
return { maxUploadBytes: DEFAULT_MEDIA_MAX_UPLOAD_BYTES };
|
|
89
|
+
}
|
|
90
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
91
|
+
throw new StartupCheckError('invalid-site-config', 'site.config.json\'s "media" must be an object');
|
|
92
|
+
}
|
|
93
|
+
const record = value;
|
|
94
|
+
const maxUploadBytes = record.maxUploadBytes;
|
|
95
|
+
if (maxUploadBytes === undefined) {
|
|
96
|
+
return { maxUploadBytes: DEFAULT_MEDIA_MAX_UPLOAD_BYTES };
|
|
97
|
+
}
|
|
98
|
+
if (typeof maxUploadBytes !== 'number' || !Number.isInteger(maxUploadBytes) || maxUploadBytes < 1) {
|
|
99
|
+
throw new StartupCheckError('invalid-site-config', `site.config.json's "media.maxUploadBytes" must be a positive integer, got ${JSON.stringify(maxUploadBytes)}`);
|
|
100
|
+
}
|
|
101
|
+
return { maxUploadBytes };
|
|
102
|
+
}
|
|
103
|
+
function parseTrustProxy(value) {
|
|
104
|
+
if (value === undefined) {
|
|
105
|
+
// Absent -> false, matching a bare single-process, no-reverse-
|
|
106
|
+
// proxy deployment assumption unless an operator explicitly opts
|
|
107
|
+
// in.
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if (typeof value !== 'boolean') {
|
|
111
|
+
throw new StartupCheckError('invalid-site-config', `site.config.json's "trustProxy" must be a boolean, got ${JSON.stringify(value)}`);
|
|
112
|
+
}
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
function parseIpAllowlist(value) {
|
|
116
|
+
if (value === undefined) {
|
|
117
|
+
// Absent -> [] -> a no-op (checklist H3), not "nothing is
|
|
118
|
+
// allowed" - see ip-allowlist.ts's isIpAllowed.
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
if (!Array.isArray(value)) {
|
|
122
|
+
throw new StartupCheckError('invalid-site-config', 'site.config.json\'s "ipAllowlist" must be an array');
|
|
123
|
+
}
|
|
124
|
+
value.forEach((entry, index) => {
|
|
125
|
+
if (typeof entry !== 'string' || entry.length === 0 || !IP_SHAPE_PATTERN.test(entry)) {
|
|
126
|
+
throw new StartupCheckError('invalid-site-config', `ipAllowlist[${index}] must be a non-empty IP-address-shaped string, got ${JSON.stringify(entry)}`);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
function parseCheckpointIntervalMs(value) {
|
|
132
|
+
if (value === undefined) {
|
|
133
|
+
return DEFAULT_CHECKPOINT_INTERVAL_MS;
|
|
134
|
+
}
|
|
135
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
|
|
136
|
+
throw new StartupCheckError('invalid-site-config', `site.config.json's "checkpointIntervalMs" must be a positive integer, got ${JSON.stringify(value)}`);
|
|
137
|
+
}
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
// Kept separate from SiteConfig (src/config.ts, pure filesystem paths)
|
|
141
|
+
// and from BootedSite (src/boot.ts): this is site.config.json's
|
|
142
|
+
// non-path settings. Later groups add fields here without needing to
|
|
143
|
+
// restructure this module or its callers - the "media" field below is
|
|
144
|
+
// exactly that: the max-upload-size setting anticipated here since
|
|
145
|
+
// Phase 2 planning, now landed as Phase 4's local-storage foundation.
|
|
146
|
+
//
|
|
147
|
+
// Two different failure classes: the file being entirely absent is
|
|
148
|
+
// not an error (defaults silently) - the whole Phase 1 test estate
|
|
149
|
+
// has no site.config.json and shouldn't need one invented just to
|
|
150
|
+
// boot. The file existing but being malformed IS an error (the
|
|
151
|
+
// operator wrote it and got it wrong), consistent with
|
|
152
|
+
// startup-checks.ts's fail-fast discipline. Token rotation requires a
|
|
153
|
+
// restart, since this loads once at boot - matches the existing
|
|
154
|
+
// "agent upgrade = restart" story already established for schema
|
|
155
|
+
// migrations, a deliberate decision, not an accident.
|
|
156
|
+
export function loadServerConfig(siteRoot) {
|
|
157
|
+
const configPath = join(siteRoot, VHOST_DIR_NAME, 'site.config.json');
|
|
158
|
+
let raw;
|
|
159
|
+
try {
|
|
160
|
+
raw = readFileSync(configPath, 'utf-8');
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return {
|
|
164
|
+
port: DEFAULT_PORT,
|
|
165
|
+
tokens: [],
|
|
166
|
+
rateLimit: DEFAULT_RATE_LIMIT,
|
|
167
|
+
trustProxy: false,
|
|
168
|
+
ipAllowlist: [],
|
|
169
|
+
checkpointIntervalMs: DEFAULT_CHECKPOINT_INTERVAL_MS,
|
|
170
|
+
media: { maxUploadBytes: DEFAULT_MEDIA_MAX_UPLOAD_BYTES },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
let parsed;
|
|
174
|
+
try {
|
|
175
|
+
parsed = JSON.parse(raw);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
179
|
+
throw new StartupCheckError('invalid-site-config', `site.config.json is not valid JSON: ${detail}`);
|
|
180
|
+
}
|
|
181
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
182
|
+
throw new StartupCheckError('invalid-site-config', 'site.config.json must be a JSON object');
|
|
183
|
+
}
|
|
184
|
+
const record = parsed;
|
|
185
|
+
const port = record.port ?? DEFAULT_PORT;
|
|
186
|
+
if (typeof port !== 'number' || !Number.isInteger(port) || port < 0) {
|
|
187
|
+
throw new StartupCheckError('invalid-site-config', `site.config.json's "port" must be a non-negative integer, got ${JSON.stringify(record.port)}`);
|
|
188
|
+
}
|
|
189
|
+
const tokens = parseTokens(record.tokens);
|
|
190
|
+
const rateLimit = parseRateLimit(record.rateLimit);
|
|
191
|
+
const trustProxy = parseTrustProxy(record.trustProxy);
|
|
192
|
+
const ipAllowlist = parseIpAllowlist(record.ipAllowlist);
|
|
193
|
+
const checkpointIntervalMs = parseCheckpointIntervalMs(record.checkpointIntervalMs);
|
|
194
|
+
const media = parseMedia(record.media);
|
|
195
|
+
return { port, tokens, rateLimit, trustProxy, ipAllowlist, checkpointIntervalMs, media };
|
|
196
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type FastifyInstance } from 'fastify';
|
|
2
|
+
import type { BootedSite } from './boot.ts';
|
|
3
|
+
import type { ServerConfig } from './server-config.ts';
|
|
4
|
+
import type { DevTunnel } from './services/dev-tunnel.ts';
|
|
5
|
+
export interface BuildServerOptions {
|
|
6
|
+
logger?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface StartServerOptions extends BuildServerOptions {
|
|
9
|
+
tunnel?: boolean;
|
|
10
|
+
startTunnel?: (port: number) => Promise<DevTunnel>;
|
|
11
|
+
}
|
|
12
|
+
export declare function buildServer(booted: BootedSite, serverConfig: ServerConfig, options?: BuildServerOptions): FastifyInstance;
|
|
13
|
+
export declare function startServer(siteRoot: string, options?: StartServerOptions): Promise<FastifyInstance>;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import rateLimitPlugin from '@fastify/rate-limit';
|
|
2
|
+
import Fastify, {} from 'fastify';
|
|
3
|
+
import { bootSite } from "./boot.js";
|
|
4
|
+
import { loadServerConfig } from "./server-config.js";
|
|
5
|
+
import { assetsRoutes } from "./routes/assets.js";
|
|
6
|
+
import { v1Routes } from "./routes/index.js";
|
|
7
|
+
import { mediaPublicRoutes } from "./routes/media-public.js";
|
|
8
|
+
import { publicRoutes } from "./routes/public.js";
|
|
9
|
+
import { sitemapRoutes } from "./routes/sitemap.js";
|
|
10
|
+
import { CHECKPOINT_AUTHOR, runCheckpoint } from "./services/checkpoint.js";
|
|
11
|
+
import { startDevTunnel } from "./services/dev-tunnel.js";
|
|
12
|
+
import { startIntervalJob } from "./services/interval-job.js";
|
|
13
|
+
// Never wrap v1Routes (or any route-group plugin it registers) with
|
|
14
|
+
// fastify-plugin (fp()): plain app.register() gives each file its own
|
|
15
|
+
// encapsulation scope by default, which Group B's auth preHandler
|
|
16
|
+
// hooks depend on. Wrapping would silently hoist decorators/hooks to
|
|
17
|
+
// the parent scope and break that later.
|
|
18
|
+
export function buildServer(booted, serverConfig, options = {}) {
|
|
19
|
+
const app = Fastify({ logger: options.logger ?? false, trustProxy: serverConfig.trustProxy });
|
|
20
|
+
// global: false - only routes that explicitly opt in via a
|
|
21
|
+
// config: { rateLimit: {...} } marker on their own route options
|
|
22
|
+
// (see src/services/rate-limit-config.ts) are ever limited. The
|
|
23
|
+
// max/timeWindow set here are the configured (or defaulted)
|
|
24
|
+
// site.config.json values - an opting-in route's own empty
|
|
25
|
+
// { rateLimit: {} } marker inherits these, verified empirically.
|
|
26
|
+
// Registered once here, on the root app (an ancestor of v1Routes and
|
|
27
|
+
// everything it nests), so no individual route file needs to import
|
|
28
|
+
// this plugin itself. Its own 429 response body shape
|
|
29
|
+
// ({statusCode, error, message}) already matches this project's own
|
|
30
|
+
// error-JSON convention exactly - verified empirically before relying
|
|
31
|
+
// on it, no errorResponseBuilder override needed.
|
|
32
|
+
app.register(rateLimitPlugin, {
|
|
33
|
+
global: false,
|
|
34
|
+
max: serverConfig.rateLimit.max,
|
|
35
|
+
timeWindow: serverConfig.rateLimit.windowMs,
|
|
36
|
+
});
|
|
37
|
+
// Fastify's own default 500 body leaks the raw thrown Error.message
|
|
38
|
+
// to the client verbatim - confirmed empirically before this was
|
|
39
|
+
// written. Anything below 500 (e.g. a future route's schema-
|
|
40
|
+
// validation failure) already carries a safe, specific message and
|
|
41
|
+
// is passed through unchanged; only 500-and-above gets sanitised.
|
|
42
|
+
app.setErrorHandler((error, request, reply) => {
|
|
43
|
+
const statusCode = error.statusCode ?? 500;
|
|
44
|
+
if (statusCode >= 500) {
|
|
45
|
+
request.log.error(error);
|
|
46
|
+
reply.code(statusCode).send({
|
|
47
|
+
statusCode,
|
|
48
|
+
error: 'Internal Server Error',
|
|
49
|
+
message: 'Internal Server Error',
|
|
50
|
+
});
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
reply.code(statusCode).send({ statusCode, error: error.name || 'Bad Request', message: error.message });
|
|
54
|
+
});
|
|
55
|
+
app.register(v1Routes, {
|
|
56
|
+
prefix: '/v1',
|
|
57
|
+
config: booted.config,
|
|
58
|
+
themeSchemas: booted.themeSchemas,
|
|
59
|
+
pageTemplates: booted.pageTemplates,
|
|
60
|
+
themeTemplates: booted.themeTemplates,
|
|
61
|
+
layouts: booted.layouts,
|
|
62
|
+
engine: booted.engine,
|
|
63
|
+
tokens: serverConfig.tokens,
|
|
64
|
+
ipAllowlist: serverConfig.ipAllowlist,
|
|
65
|
+
maxUploadBytes: serverConfig.media.maxUploadBytes,
|
|
66
|
+
});
|
|
67
|
+
// Registered without a /v1 prefix, alongside v1Routes: this is the
|
|
68
|
+
// site's own public website, not part of the site agent API. Order
|
|
69
|
+
// relative to v1Routes is not load-bearing - Fastify's router
|
|
70
|
+
// prefers exact/prefixed matches over this route's wildcard
|
|
71
|
+
// regardless of registration order (verified empirically). What
|
|
72
|
+
// actually protects unmatched /v1/* paths from being swallowed as
|
|
73
|
+
// page lookups is the explicit guard inside publicRoutes itself.
|
|
74
|
+
app.register(publicRoutes, {
|
|
75
|
+
config: booted.config,
|
|
76
|
+
themeTemplates: booted.themeTemplates,
|
|
77
|
+
layouts: booted.layouts,
|
|
78
|
+
engine: booted.engine,
|
|
79
|
+
});
|
|
80
|
+
// A more specific static-prefixed route than the public catch-all's
|
|
81
|
+
// own /* wildcard - Fastify prefers it regardless of registration
|
|
82
|
+
// order (verified empirically), so no guard is needed here the way
|
|
83
|
+
// publicRoutes needs one against /v1/*.
|
|
84
|
+
app.register(assetsRoutes, { config: booted.config });
|
|
85
|
+
// Same "more specific than the public catch-all" reasoning as
|
|
86
|
+
// assetsRoutes above. Deliberately not registered inside v1Routes
|
|
87
|
+
// (media.ts's own authenticated /v1/media CRUD routes are what live
|
|
88
|
+
// there) - this is the public, unauthenticated read side, matching
|
|
89
|
+
// assetsRoutes/publicRoutes' own placement.
|
|
90
|
+
//
|
|
91
|
+
// @fastify/multipart itself is NOT registered here, only inside
|
|
92
|
+
// media.ts's own plugin scope - see that file's comment for why
|
|
93
|
+
// (confining it to media routes only, leaving every other route
|
|
94
|
+
// file's default JSON body parsing untouched).
|
|
95
|
+
app.register(mediaPublicRoutes, { config: booted.config });
|
|
96
|
+
// Same "more specific than the public catch-all" reasoning again -
|
|
97
|
+
// this exact path always wins over publicRoutes' /* wildcard, and
|
|
98
|
+
// also always wins over a same-named static file under
|
|
99
|
+
// theme/root/sitemap.xml (see routes/public.ts's root-mirror check).
|
|
100
|
+
app.register(sitemapRoutes, { config: booted.config });
|
|
101
|
+
return app;
|
|
102
|
+
}
|
|
103
|
+
// Neither a recurring timer nor process signal handling exists
|
|
104
|
+
// anywhere else in this codebase - this is genuinely new machinery for
|
|
105
|
+
// the low-frequency draft-checkpoint background job (checklist H4).
|
|
106
|
+
// Wired only here, never in buildServer: every existing route test
|
|
107
|
+
// uses buildServer + .inject() directly and must stay completely
|
|
108
|
+
// unaffected by any of this.
|
|
109
|
+
export async function startServer(siteRoot, options = {}) {
|
|
110
|
+
const booted = bootSite(siteRoot);
|
|
111
|
+
const serverConfig = loadServerConfig(siteRoot);
|
|
112
|
+
const app = buildServer(booted, serverConfig, options);
|
|
113
|
+
const doCheckpoint = () => runCheckpoint(booted.config, CHECKPOINT_AUTHOR);
|
|
114
|
+
const scheduler = startIntervalJob(doCheckpoint, serverConfig.checkpointIntervalMs, (error) => {
|
|
115
|
+
app.log.error(error, 'background draft checkpoint failed');
|
|
116
|
+
});
|
|
117
|
+
let tunnel;
|
|
118
|
+
// Node's default action for an unhandled SIGTERM/SIGINT is immediate
|
|
119
|
+
// process termination, bypassing all JS-level cleanup - without
|
|
120
|
+
// this, a real kill/Ctrl-C would never run the final checkpoint
|
|
121
|
+
// (build plan: "and on graceful shutdown"). process.once (not .on)
|
|
122
|
+
// self-removes after firing, and is explicitly removed again inside
|
|
123
|
+
// onClose below, so a test calling app.close() leaves zero global
|
|
124
|
+
// process-level listeners behind across the many startServer/
|
|
125
|
+
// buildServer calls the suite makes.
|
|
126
|
+
const shutdown = () => {
|
|
127
|
+
app.close().catch((error) => app.log.error(error, 'error during shutdown'));
|
|
128
|
+
};
|
|
129
|
+
process.once('SIGTERM', shutdown);
|
|
130
|
+
process.once('SIGINT', shutdown);
|
|
131
|
+
app.addHook('onClose', async () => {
|
|
132
|
+
tunnel?.close();
|
|
133
|
+
scheduler.stop();
|
|
134
|
+
process.removeListener('SIGTERM', shutdown);
|
|
135
|
+
process.removeListener('SIGINT', shutdown);
|
|
136
|
+
try {
|
|
137
|
+
await doCheckpoint();
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
app.log.error(error, 'final draft checkpoint on shutdown failed');
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
// host: '0.0.0.0' - Fastify's own default binds 127.0.0.1 only,
|
|
144
|
+
// which is unreachable from outside any container or remote host.
|
|
145
|
+
await app.listen({ port: serverConfig.port, host: '0.0.0.0' });
|
|
146
|
+
if (options.tunnel) {
|
|
147
|
+
try {
|
|
148
|
+
tunnel = await (options.startTunnel ?? startDevTunnel)(serverConfig.port);
|
|
149
|
+
console.log(`Tunnel open: ${tunnel.url}`);
|
|
150
|
+
console.log('This URL is publicly reachable for as long as this process runs - anyone with it (and a valid API token) can reach this site. Do not leave it running unattended.');
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
// The site itself is already up and correctly serving locally -
|
|
154
|
+
// a failed tunnel is not a reason to fail the whole boot.
|
|
155
|
+
app.log.error(error, 'failed to start dev tunnel');
|
|
156
|
+
console.error('Failed to start the dev tunnel - the site is still running locally.');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
console.log('Tip: run with --tunnel to expose this site publicly for testing against a hosted admin.');
|
|
161
|
+
}
|
|
162
|
+
return app;
|
|
163
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { SiteConfig } from '../config.ts';
|
|
2
|
+
import type { CommitAuthor } from './git.ts';
|
|
3
|
+
import type { ThemeSchemas } from './validation.ts';
|
|
4
|
+
export type BatchOperation = {
|
|
5
|
+
type: 'draft-write';
|
|
6
|
+
path: string;
|
|
7
|
+
content: unknown;
|
|
8
|
+
expectedEtag: string;
|
|
9
|
+
} | {
|
|
10
|
+
type: 'draft-discard';
|
|
11
|
+
path: string;
|
|
12
|
+
} | {
|
|
13
|
+
type: 'content-delete';
|
|
14
|
+
path: string;
|
|
15
|
+
redirectTo?: string;
|
|
16
|
+
} | {
|
|
17
|
+
type: 'move';
|
|
18
|
+
from: string;
|
|
19
|
+
to: string;
|
|
20
|
+
};
|
|
21
|
+
export interface BatchPublish {
|
|
22
|
+
relativePaths: string[];
|
|
23
|
+
}
|
|
24
|
+
export type BatchReason = 'validation-failed' | 'conflict' | 'page-not-found' | 'has-children' | 'redirect-cycle' | 'invalid-redirect-target' | 'source-not-found' | 'destination-exists' | 'draft-not-found' | 'duplicate-path' | 'write-failed' | 'commit-failed' | 'rollback-failed';
|
|
25
|
+
export declare class BatchError extends Error {
|
|
26
|
+
readonly reason: BatchReason;
|
|
27
|
+
readonly stage?: 'operation' | 'publish';
|
|
28
|
+
readonly operationIndex?: number;
|
|
29
|
+
constructor(reason: BatchReason, message: string, options?: {
|
|
30
|
+
cause?: unknown;
|
|
31
|
+
stage?: 'operation' | 'publish';
|
|
32
|
+
operationIndex?: number;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export declare function runBatch(config: SiteConfig, themeSchemas: ThemeSchemas, operations: BatchOperation[], publish: BatchPublish | undefined, message: string, author: CommitAuthor): Promise<void>;
|