@stelstone/server 0.26.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/README.md +275 -0
- package/bin/stelstone.mjs +181 -0
- package/package.json +53 -0
- package/src/adapters/_shared.mjs +401 -0
- package/src/adapters/basic-auth.mjs +102 -0
- package/src/adapters/build-netlify.mjs +60 -0
- package/src/adapters/cdn-proxy-media.mjs +79 -0
- package/src/adapters/cloudflare-access.mjs +144 -0
- package/src/adapters/fs-json-content.mjs +302 -0
- package/src/adapters/fs-templates.mjs +57 -0
- package/src/adapters/github-api.mjs +100 -0
- package/src/adapters/github-content.mjs +577 -0
- package/src/adapters/github-oauth.mjs +153 -0
- package/src/adapters/github-templates.mjs +100 -0
- package/src/adapters/index.mjs +12 -0
- package/src/adapters/local-assets-media.mjs +68 -0
- package/src/adapters/media-url.mjs +133 -0
- package/src/adapters/resend-mail.mjs +41 -0
- package/src/adapters/types.mjs +104 -0
- package/src/admin-ui-path.mjs +77 -0
- package/src/core/adapter-options.mjs +167 -0
- package/src/core/config-schema.mjs +408 -0
- package/src/core/forms.mjs +99 -0
- package/src/core/handler.mjs +209 -0
- package/src/core/node-adapter.mjs +99 -0
- package/src/core/static-files.mjs +115 -0
- package/src/default-public-config.mjs +39 -0
- package/src/index.mjs +22 -0
- package/src/routes.mjs +737 -0
- package/src/server.mjs +325 -0
- package/src/version.mjs +8 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Access auth adapter.
|
|
3
|
+
*
|
|
4
|
+
* Verifies the `Cf-Access-Jwt-Assertion` header (or `CF_Authorization` cookie)
|
|
5
|
+
* signed by your Cloudflare Access team using RS256. No third-party libraries —
|
|
6
|
+
* uses Node's built-in `crypto` module.
|
|
7
|
+
*
|
|
8
|
+
* cms.config.mjs:
|
|
9
|
+
* auth: {
|
|
10
|
+
* provider: "cloudflare-access",
|
|
11
|
+
* teamDomain: "https://yourteam.cloudflareaccess.com",
|
|
12
|
+
* audience: "your-application-audience-tag", // from Access app settings
|
|
13
|
+
* roles: { // optional: map email → role
|
|
14
|
+
* "fatih@example.com": "admin",
|
|
15
|
+
* },
|
|
16
|
+
* defaultRole: "editor", // role when email not in roles map
|
|
17
|
+
* }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import crypto from "crypto";
|
|
21
|
+
|
|
22
|
+
const JWKS_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {Object} opts
|
|
26
|
+
* @param {string} opts.teamDomain e.g. "https://yourteam.cloudflareaccess.com"
|
|
27
|
+
* @param {string} opts.audience Application Audience tag from Cloudflare Access
|
|
28
|
+
* @param {Object} [opts.roles] { "email": "admin" | "editor" }
|
|
29
|
+
* @param {string} [opts.defaultRole]
|
|
30
|
+
*/
|
|
31
|
+
export function createCloudflareAccess({ teamDomain, audience, roles = {}, defaultRole = "editor" }) {
|
|
32
|
+
const domain = teamDomain.replace(/\/$/, "");
|
|
33
|
+
const certsUrl = `${domain}/cdn-cgi/access/certs`;
|
|
34
|
+
|
|
35
|
+
// JWKS cache
|
|
36
|
+
let jwksCache = null;
|
|
37
|
+
let jwksCachedAt = 0;
|
|
38
|
+
|
|
39
|
+
async function getJwks() {
|
|
40
|
+
if (jwksCache && Date.now() - jwksCachedAt < JWKS_TTL_MS) return jwksCache;
|
|
41
|
+
const res = await fetch(certsUrl);
|
|
42
|
+
if (!res.ok) throw new Error(`Failed to fetch Cloudflare Access JWKS: ${res.status}`);
|
|
43
|
+
jwksCache = await res.json();
|
|
44
|
+
jwksCachedAt = Date.now();
|
|
45
|
+
return jwksCache;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function base64urlDecode(str) {
|
|
49
|
+
return Buffer.from(str.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function importPublicKey(jwk) {
|
|
53
|
+
// Cloudflare Access JWKS uses x5c (certificate chain) or n/e (RSA components).
|
|
54
|
+
if (jwk.x5c?.length) {
|
|
55
|
+
const pem = `-----BEGIN CERTIFICATE-----\n${jwk.x5c[0].match(/.{1,64}/g).join("\n")}\n-----END CERTIFICATE-----`;
|
|
56
|
+
return crypto.createPublicKey(pem);
|
|
57
|
+
}
|
|
58
|
+
if (jwk.n && jwk.e) {
|
|
59
|
+
return crypto.createPublicKey({ key: jwk, format: "jwk" });
|
|
60
|
+
}
|
|
61
|
+
throw new Error("Unsupported JWK format — no x5c or n/e fields");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function verifyToken(token) {
|
|
65
|
+
const parts = token.split(".");
|
|
66
|
+
if (parts.length !== 3) throw new Error("Invalid JWT format");
|
|
67
|
+
|
|
68
|
+
const [headerB64, payloadB64, sigB64] = parts;
|
|
69
|
+
const header = JSON.parse(base64urlDecode(headerB64).toString());
|
|
70
|
+
const payload = JSON.parse(base64urlDecode(payloadB64).toString());
|
|
71
|
+
|
|
72
|
+
// Claim validation
|
|
73
|
+
const now = Math.floor(Date.now() / 1000);
|
|
74
|
+
if (payload.exp && payload.exp < now) throw new Error("JWT expired");
|
|
75
|
+
if (payload.nbf && payload.nbf > now) throw new Error("JWT not yet valid");
|
|
76
|
+
if (payload.iss !== domain) throw new Error(`JWT issuer mismatch: ${payload.iss}`);
|
|
77
|
+
if (audience && payload.aud !== audience && !payload.aud?.includes?.(audience)) {
|
|
78
|
+
throw new Error("JWT audience mismatch");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Signature verification
|
|
82
|
+
const jwks = await getJwks();
|
|
83
|
+
const jwk = jwks.keys?.find((k) => k.kid === header.kid) ?? jwks.keys?.[0];
|
|
84
|
+
if (!jwk) throw new Error("No matching JWK found");
|
|
85
|
+
|
|
86
|
+
const pubKey = importPublicKey(jwk);
|
|
87
|
+
const verify = crypto.createVerify("RSA-SHA256");
|
|
88
|
+
verify.update(`${headerB64}.${payloadB64}`);
|
|
89
|
+
const valid = verify.verify(pubKey, base64urlDecode(sigB64));
|
|
90
|
+
if (!valid) throw new Error("JWT signature invalid");
|
|
91
|
+
|
|
92
|
+
return payload;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function extractToken(request) {
|
|
96
|
+
const header = request.headers.get("cf-access-jwt-assertion");
|
|
97
|
+
if (header) return header;
|
|
98
|
+
// Fall back to cookie
|
|
99
|
+
const cookie = request.headers.get("cookie") || "";
|
|
100
|
+
const match = cookie.match(/(?:^|;\s*)CF_Authorization=([^;]+)/);
|
|
101
|
+
return match ? match[1] : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function resolveUser(payload) {
|
|
105
|
+
const email = payload.email || payload.sub;
|
|
106
|
+
const role = roles[email] ?? defaultRole;
|
|
107
|
+
return { login: email, name: email, role };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Cloudflare Access authenticates with its own header (or cookie) rather
|
|
112
|
+
* than Authorization — which is exactly why the handler passes the whole
|
|
113
|
+
* Request to `verify` instead of just the Authorization header. Before that,
|
|
114
|
+
* this adapter had no way to see its own token and returned null
|
|
115
|
+
* unconditionally.
|
|
116
|
+
*
|
|
117
|
+
* @param {Request} request
|
|
118
|
+
* @returns {Promise<object|null>}
|
|
119
|
+
*/
|
|
120
|
+
async function verify(request) {
|
|
121
|
+
const token = extractToken(request);
|
|
122
|
+
if (!token) return null;
|
|
123
|
+
try {
|
|
124
|
+
return resolveUser(await verifyToken(token));
|
|
125
|
+
} catch (err) {
|
|
126
|
+
console.warn(`[cloudflare-access] Access denied: ${err.message}`);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
configured: Boolean(teamDomain && audience),
|
|
133
|
+
// Cloudflare Access issues no tokens of its own — media is expected to be
|
|
134
|
+
// protected by Cloudflare's own CDN auth. Callers ask before calling
|
|
135
|
+
// rather than discovering it as a 500.
|
|
136
|
+
supports: () => false,
|
|
137
|
+
issueMediaToken: () => {
|
|
138
|
+
throw new Error(
|
|
139
|
+
"cloudflare-access cannot issue media tokens — check auth.supports('mediaToken') first",
|
|
140
|
+
);
|
|
141
|
+
},
|
|
142
|
+
verify,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import crypto from "crypto";
|
|
4
|
+
import { execSync } from "child_process";
|
|
5
|
+
import { sanitize, safeFileName, sortPages, buildDuplicateData, listScheduledDue, buildListEntry, relationFields, isEntryFile } from "./_shared.mjs";
|
|
6
|
+
|
|
7
|
+
const HISTORY_KEEP = 50; // max revisions kept per file
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Filesystem-backed content adapter. Pages live as JSON files under
|
|
11
|
+
* `<rootDir>/<pagesDir>/<collection>/<file>.json`. Publishing means
|
|
12
|
+
* `git add && commit && push` to a configured branch.
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} opts
|
|
15
|
+
* @param {string} opts.rootDir
|
|
16
|
+
* @param {string} opts.pagesDir
|
|
17
|
+
* @param {string} opts.publishBranch
|
|
18
|
+
* @param {string[]} opts.publishPaths
|
|
19
|
+
* @param {(timestamp: string) => string} opts.commitMessage
|
|
20
|
+
* @returns {import('./types.mjs').ContentAdapter}
|
|
21
|
+
*/
|
|
22
|
+
export function createFsJsonContent({
|
|
23
|
+
rootDir,
|
|
24
|
+
pagesDir,
|
|
25
|
+
publishBranch,
|
|
26
|
+
publishPaths,
|
|
27
|
+
commitMessage,
|
|
28
|
+
collections = {},
|
|
29
|
+
}) {
|
|
30
|
+
const PAGES_DIR = path.join(rootDir, pagesDir);
|
|
31
|
+
const HISTORY_DIR = path.join(rootDir, ".cms-history");
|
|
32
|
+
const PATHS_ARG = publishPaths.join(" ");
|
|
33
|
+
|
|
34
|
+
function historyDir(collection, file) {
|
|
35
|
+
return path.join(HISTORY_DIR, sanitize(collection), sanitize(file));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function saveHistory(collection, file) {
|
|
39
|
+
const src = pagePath(collection, file);
|
|
40
|
+
if (!fs.existsSync(src)) return;
|
|
41
|
+
const dir = historyDir(collection, file);
|
|
42
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
43
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
44
|
+
fs.copyFileSync(src, path.join(dir, `${ts}.json`));
|
|
45
|
+
// Prune old revisions beyond HISTORY_KEEP
|
|
46
|
+
const entries = fs.readdirSync(dir).filter((f) => f.endsWith(".json")).sort();
|
|
47
|
+
while (entries.length > HISTORY_KEEP) {
|
|
48
|
+
fs.unlinkSync(path.join(dir, entries.shift()));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function git(cmd) {
|
|
53
|
+
return execSync(`git ${cmd}`, { cwd: rootDir, encoding: "utf-8" }).trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function pagePath(collection, file) {
|
|
57
|
+
return path.join(PAGES_DIR, sanitize(collection), sanitize(file));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
async listCollections() {
|
|
62
|
+
if (!fs.existsSync(PAGES_DIR)) return [];
|
|
63
|
+
const dirs = fs
|
|
64
|
+
.readdirSync(PAGES_DIR)
|
|
65
|
+
.filter((d) => fs.statSync(path.join(PAGES_DIR, d)).isDirectory());
|
|
66
|
+
return dirs.map((dir) => {
|
|
67
|
+
const files = fs
|
|
68
|
+
.readdirSync(path.join(PAGES_DIR, dir))
|
|
69
|
+
.filter(isEntryFile);
|
|
70
|
+
return { name: dir, count: files.length };
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async listPages(collection, sortConfig = null) {
|
|
75
|
+
const dir = path.join(PAGES_DIR, sanitize(collection));
|
|
76
|
+
if (!fs.existsSync(dir)) return null;
|
|
77
|
+
|
|
78
|
+
// Build slug→displayName lookups for collections referenced by this
|
|
79
|
+
// collection's relation fields, so list columns/filters show names
|
|
80
|
+
// instead of raw slugs.
|
|
81
|
+
const lookups = {};
|
|
82
|
+
const targets = Object.values(relationFields(collections[collection]))
|
|
83
|
+
.flatMap((rel) => rel.collections);
|
|
84
|
+
for (const target of targets) {
|
|
85
|
+
if (lookups[target]) continue;
|
|
86
|
+
const targetDir = path.join(PAGES_DIR, sanitize(target));
|
|
87
|
+
if (!fs.existsSync(targetDir)) continue;
|
|
88
|
+
const table = {};
|
|
89
|
+
for (const f of fs.readdirSync(targetDir)) {
|
|
90
|
+
if (!isEntryFile(f)) continue;
|
|
91
|
+
try {
|
|
92
|
+
const d = JSON.parse(fs.readFileSync(path.join(targetDir, f), "utf-8"));
|
|
93
|
+
if (d.slug) table[d.slug] = d.meta?.title || d.meta?.name || d.slug;
|
|
94
|
+
} catch {
|
|
95
|
+
// Skip unreadable entries
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
lookups[target] = table;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const files = fs.readdirSync(dir).filter(isEntryFile);
|
|
102
|
+
const pages = [];
|
|
103
|
+
for (const file of files) {
|
|
104
|
+
try {
|
|
105
|
+
const data = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
|
|
106
|
+
pages.push(buildListEntry(collections[collection], collection, file, data, lookups));
|
|
107
|
+
} catch (err) {
|
|
108
|
+
// One corrupt entry must not take down the whole collection listing.
|
|
109
|
+
console.warn(`[listPages] Skipping unreadable ${collection}/${file}: ${err.message}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return sortPages(pages, sortConfig);
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
async readPage(collection, file) {
|
|
116
|
+
if (!safeFileName(file)) return null;
|
|
117
|
+
const filePath = pagePath(collection, file);
|
|
118
|
+
if (!fs.existsSync(filePath)) return null;
|
|
119
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Version token for optimistic concurrency — a hash of the file's bytes.
|
|
124
|
+
* Content-derived rather than time-derived so it is stable across checkouts
|
|
125
|
+
* and unaffected by clock skew.
|
|
126
|
+
*/
|
|
127
|
+
async versionOf(collection, file) {
|
|
128
|
+
if (!safeFileName(file)) return null;
|
|
129
|
+
const filePath = pagePath(collection, file);
|
|
130
|
+
if (!fs.existsSync(filePath)) return null;
|
|
131
|
+
return crypto.createHash("sha1").update(fs.readFileSync(filePath)).digest("hex");
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {Object} [opts]
|
|
136
|
+
* @param {string} [opts.expectedVersion] Refuse the write when the stored
|
|
137
|
+
* version no longer matches — the caller edited a stale copy.
|
|
138
|
+
*/
|
|
139
|
+
async writePage(collection, file, data, { expectedVersion } = {}) {
|
|
140
|
+
if (expectedVersion !== undefined && expectedVersion !== null) {
|
|
141
|
+
const current = await this.versionOf(collection, file);
|
|
142
|
+
if (current && current !== expectedVersion) {
|
|
143
|
+
const err = new Error("This entry changed since you loaded it.");
|
|
144
|
+
err.status = 412;
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
saveHistory(collection, file);
|
|
149
|
+
const filePath = pagePath(collection, file);
|
|
150
|
+
const dir = path.dirname(filePath);
|
|
151
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
152
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
async createPage(collection, data) {
|
|
156
|
+
const slug = data.slug || data.id || `new-${Date.now()}`;
|
|
157
|
+
if (!sanitize(slug) || sanitize(slug) !== String(slug)) {
|
|
158
|
+
// Reject rather than silently stripping characters — otherwise the
|
|
159
|
+
// file name and data.slug would diverge (e.g. "ürünler" → "rnler").
|
|
160
|
+
const err = new Error(
|
|
161
|
+
`Slug "${slug}" contains unsupported characters — use only a-z, 0-9, dots, dashes, underscores.`,
|
|
162
|
+
);
|
|
163
|
+
err.status = 400;
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
166
|
+
const fileName = `${sanitize(data.lang || "en")}-${sanitize(slug)}.json`;
|
|
167
|
+
const filePath = pagePath(collection, fileName);
|
|
168
|
+
if (fs.existsSync(filePath)) {
|
|
169
|
+
const err = new Error(
|
|
170
|
+
`An entry already exists for "${data.lang || "en"}/${slug}". Change the slug or language.`,
|
|
171
|
+
);
|
|
172
|
+
err.status = 409;
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
const dir = path.dirname(filePath);
|
|
176
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
177
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
|
|
178
|
+
return { file: fileName };
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
async deletePage(collection, file) {
|
|
182
|
+
if (!safeFileName(file)) return null;
|
|
183
|
+
const filePath = pagePath(collection, file);
|
|
184
|
+
if (!fs.existsSync(filePath)) return false;
|
|
185
|
+
fs.unlinkSync(filePath);
|
|
186
|
+
return true;
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
async duplicatePage(collection, file) {
|
|
190
|
+
if (!safeFileName(file)) return null;
|
|
191
|
+
const filePath = pagePath(collection, file);
|
|
192
|
+
if (!fs.existsSync(filePath)) return null;
|
|
193
|
+
const src = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
194
|
+
|
|
195
|
+
const { data, fileName } = buildDuplicateData(src);
|
|
196
|
+
const dest = pagePath(collection, fileName);
|
|
197
|
+
const dir = path.dirname(dest);
|
|
198
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
199
|
+
fs.writeFileSync(dest, JSON.stringify(data, null, 2), "utf-8");
|
|
200
|
+
return { file: fileName };
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
// What this adapter can do beyond the shared contract. Routes consult
|
|
204
|
+
// this instead of sniffing methods, and the github adapter declares the
|
|
205
|
+
// opposite (its writes commit immediately, so publish scope is moot).
|
|
206
|
+
capabilities: { deferredPublish: true, perEntryPublish: true },
|
|
207
|
+
|
|
208
|
+
async pendingChanges() {
|
|
209
|
+
const status = git(`status --porcelain ${PATHS_ARG}`);
|
|
210
|
+
const lines = status.split("\n").filter(Boolean);
|
|
211
|
+
// Porcelain line: "XY path" (rename: "XY old -> new"). Only entries under
|
|
212
|
+
// pagesDir are attributed to a collection/file; other publishPaths (e.g.
|
|
213
|
+
// navigation data) still count but stay unattributed.
|
|
214
|
+
const files = lines.map((line) => {
|
|
215
|
+
const raw = line.slice(3).split(" -> ").pop().replace(/^"|"$/g, "");
|
|
216
|
+
const rel = path.relative(pagesDir, raw);
|
|
217
|
+
const attributed = !rel.startsWith("..") && rel.includes(path.sep);
|
|
218
|
+
return attributed
|
|
219
|
+
? { path: raw, collection: path.dirname(rel), file: path.basename(rel) }
|
|
220
|
+
: { path: raw };
|
|
221
|
+
});
|
|
222
|
+
return { hasChanges: lines.length > 0, changedFiles: lines.length, files };
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Commit and push. With `entries`, only those records' files are staged
|
|
227
|
+
* and committed — other editors' unfinished drafts stay out of the
|
|
228
|
+
* commit. Without it, everything under publishPaths goes (the "publish
|
|
229
|
+
* all" button and the migration scripts).
|
|
230
|
+
*
|
|
231
|
+
* @param {string} [message]
|
|
232
|
+
* @param {{ entries?: {collection: string, file: string}[] }} [opts]
|
|
233
|
+
*/
|
|
234
|
+
async publish(message, { entries } = {}) {
|
|
235
|
+
const scoped = Array.isArray(entries) && entries.length > 0;
|
|
236
|
+
const pathsArg = scoped
|
|
237
|
+
? entries
|
|
238
|
+
.map(({ collection, file }) => path.relative(rootDir, pagePath(collection, file)))
|
|
239
|
+
.map((p) => JSON.stringify(p))
|
|
240
|
+
.join(" ")
|
|
241
|
+
: PATHS_ARG;
|
|
242
|
+
|
|
243
|
+
const status = git(`status --porcelain -- ${pathsArg}`);
|
|
244
|
+
if (!status) return { ok: false, message: "No changes to publish" };
|
|
245
|
+
|
|
246
|
+
// `git add` stages deletions too, so publishing a deleted record works.
|
|
247
|
+
git(`add -- ${pathsArg}`);
|
|
248
|
+
const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
249
|
+
const msg = message || commitMessage(timestamp);
|
|
250
|
+
git(`commit -m ${JSON.stringify(msg)}`);
|
|
251
|
+
git(`push origin HEAD:${publishBranch}`);
|
|
252
|
+
|
|
253
|
+
const shortSha = git("rev-parse --short HEAD");
|
|
254
|
+
const sha = git("rev-parse HEAD");
|
|
255
|
+
return {
|
|
256
|
+
ok: true,
|
|
257
|
+
message: `Published to ${publishBranch} (${shortSha})`,
|
|
258
|
+
sha,
|
|
259
|
+
shortSha,
|
|
260
|
+
branch: publishBranch,
|
|
261
|
+
scope: scoped ? "entries" : "all",
|
|
262
|
+
};
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
async listHistory(collection, file) {
|
|
266
|
+
if (!safeFileName(file)) return null;
|
|
267
|
+
const dir = historyDir(collection, file);
|
|
268
|
+
if (!fs.existsSync(dir)) return [];
|
|
269
|
+
return fs
|
|
270
|
+
.readdirSync(dir)
|
|
271
|
+
.filter((f) => f.endsWith(".json"))
|
|
272
|
+
.sort()
|
|
273
|
+
.reverse()
|
|
274
|
+
.map((f) => {
|
|
275
|
+
const stat = fs.statSync(path.join(dir, f));
|
|
276
|
+
return { ts: f.replace(".json", ""), size: stat.size };
|
|
277
|
+
});
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
async restoreHistory(collection, file, ts) {
|
|
281
|
+
if (!safeFileName(file)) return null;
|
|
282
|
+
const src = path.join(historyDir(collection, file), sanitize(ts) + ".json");
|
|
283
|
+
if (!fs.existsSync(src)) return false;
|
|
284
|
+
const data = JSON.parse(fs.readFileSync(src, "utf-8"));
|
|
285
|
+
// writePage saves history of current version first, then overwrites
|
|
286
|
+
await this.writePage(collection, file, data);
|
|
287
|
+
return true;
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
async writeBatch(items, _message) {
|
|
291
|
+
if (!items.length) return { ok: true, commitCount: 0 };
|
|
292
|
+
for (const { collection, file, data } of items) {
|
|
293
|
+
await this.writePage(collection, file, data);
|
|
294
|
+
}
|
|
295
|
+
return { ok: true, commitCount: items.length };
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
async listScheduled() {
|
|
299
|
+
return listScheduledDue(this);
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { sanitize, safeFileName } from "./_shared.mjs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Filesystem-backed block-template adapter. Templates are stored as JSON files
|
|
7
|
+
* under `<rootDir>/.cms-templates/<slug>.json`.
|
|
8
|
+
*
|
|
9
|
+
* @param {Object} opts
|
|
10
|
+
* @param {string} opts.rootDir
|
|
11
|
+
* @returns {import('./types.mjs').TemplatesAdapter}
|
|
12
|
+
*/
|
|
13
|
+
export function createFsTemplates({ rootDir }) {
|
|
14
|
+
const DIR = path.join(rootDir, ".cms-templates");
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
async list() {
|
|
18
|
+
if (!fs.existsSync(DIR)) return [];
|
|
19
|
+
return fs
|
|
20
|
+
.readdirSync(DIR)
|
|
21
|
+
.filter((f) => f.endsWith(".json"))
|
|
22
|
+
.map((f) => {
|
|
23
|
+
const data = JSON.parse(fs.readFileSync(path.join(DIR, f), "utf-8"));
|
|
24
|
+
return {
|
|
25
|
+
name: data.name,
|
|
26
|
+
slug: f.replace(".json", ""),
|
|
27
|
+
blockCount: (data.blocks || []).length,
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async get(slug) {
|
|
33
|
+
if (!safeFileName(slug)) return null;
|
|
34
|
+
const file = path.join(DIR, sanitize(slug) + ".json");
|
|
35
|
+
if (!fs.existsSync(file)) return null;
|
|
36
|
+
return JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
async put(slug, data) {
|
|
40
|
+
if (!safeFileName(slug)) return null;
|
|
41
|
+
if (!fs.existsSync(DIR)) fs.mkdirSync(DIR, { recursive: true });
|
|
42
|
+
fs.writeFileSync(
|
|
43
|
+
path.join(DIR, sanitize(slug) + ".json"),
|
|
44
|
+
JSON.stringify(data, null, 2),
|
|
45
|
+
"utf-8",
|
|
46
|
+
);
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
async delete(slug) {
|
|
50
|
+
if (!safeFileName(slug)) return null;
|
|
51
|
+
const file = path.join(DIR, sanitize(slug) + ".json");
|
|
52
|
+
if (!fs.existsSync(file)) return false;
|
|
53
|
+
fs.unlinkSync(file);
|
|
54
|
+
return true;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for adapters backed by the GitHub Contents API.
|
|
3
|
+
*
|
|
4
|
+
* Returns `{ apiGet, apiPut, apiDelete }` bound to one repo. Each call returns
|
|
5
|
+
* parsed JSON (or `null` on 404 for GET) and throws on other non-2xx responses.
|
|
6
|
+
*
|
|
7
|
+
* Thrown errors carry `upstreamStatus`, so callers can act on the specific
|
|
8
|
+
* failure — a 409 from a stale blob SHA is a concurrent edit, not a generic
|
|
9
|
+
* outage — instead of pattern-matching the message.
|
|
10
|
+
*/
|
|
11
|
+
function apiError(status, message) {
|
|
12
|
+
const err = new Error(message);
|
|
13
|
+
err.upstreamStatus = status;
|
|
14
|
+
return err;
|
|
15
|
+
}
|
|
16
|
+
export function createGitHubApi({ token, owner, repo }) {
|
|
17
|
+
const BASE = `https://api.github.com/repos/${owner}/${repo}`;
|
|
18
|
+
|
|
19
|
+
const headers = () => ({
|
|
20
|
+
Authorization: `Bearer ${token}`,
|
|
21
|
+
Accept: "application/vnd.github+json",
|
|
22
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
23
|
+
"User-Agent": "stelstone",
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
async function apiGet(path) {
|
|
28
|
+
const r = await fetch(`${BASE}${path}`, { headers: headers() });
|
|
29
|
+
if (r.status === 404) return null;
|
|
30
|
+
if (!r.ok) throw apiError(r.status, `GitHub API ${r.status} GET ${path}`);
|
|
31
|
+
return r.json();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function apiPut(path, body) {
|
|
35
|
+
const r = await fetch(`${BASE}${path}`, {
|
|
36
|
+
method: "PUT",
|
|
37
|
+
headers: headers(),
|
|
38
|
+
body: JSON.stringify(body),
|
|
39
|
+
});
|
|
40
|
+
if (!r.ok) {
|
|
41
|
+
const err = await r.json().catch(() => ({}));
|
|
42
|
+
throw apiError(r.status, `GitHub API ${r.status} PUT ${path}: ${err.message || ""}`);
|
|
43
|
+
}
|
|
44
|
+
return r.json();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function apiDelete(path, body) {
|
|
48
|
+
const r = await fetch(`${BASE}${path}`, {
|
|
49
|
+
method: "DELETE",
|
|
50
|
+
headers: headers(),
|
|
51
|
+
body: JSON.stringify(body),
|
|
52
|
+
});
|
|
53
|
+
if (!r.ok) throw apiError(r.status, `GitHub API ${r.status} DELETE ${path}`);
|
|
54
|
+
return r.json();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function apiPost(path, body) {
|
|
58
|
+
const r = await fetch(`${BASE}${path}`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: headers(),
|
|
61
|
+
body: JSON.stringify(body),
|
|
62
|
+
});
|
|
63
|
+
if (!r.ok) {
|
|
64
|
+
const err = await r.json().catch(() => ({}));
|
|
65
|
+
throw apiError(r.status, `GitHub API ${r.status} POST ${path}: ${err.message || ""}`);
|
|
66
|
+
}
|
|
67
|
+
return r.json();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function apiPatch(path, body) {
|
|
71
|
+
const r = await fetch(`${BASE}${path}`, {
|
|
72
|
+
method: "PATCH",
|
|
73
|
+
headers: headers(),
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
});
|
|
76
|
+
if (!r.ok) {
|
|
77
|
+
const err = await r.json().catch(() => ({}));
|
|
78
|
+
throw apiError(r.status, `GitHub API ${r.status} PATCH ${path}: ${err.message || ""}`);
|
|
79
|
+
}
|
|
80
|
+
return r.json();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function graphql(query, variables = {}) {
|
|
84
|
+
const r = await fetch("https://api.github.com/graphql", {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: {
|
|
87
|
+
Authorization: `Bearer ${token}`,
|
|
88
|
+
"User-Agent": "stelstone",
|
|
89
|
+
"Content-Type": "application/json",
|
|
90
|
+
},
|
|
91
|
+
body: JSON.stringify({ query, variables }),
|
|
92
|
+
});
|
|
93
|
+
if (!r.ok) throw new Error(`GitHub GraphQL ${r.status}`);
|
|
94
|
+
const json = await r.json();
|
|
95
|
+
if (json.errors?.length) throw new Error(json.errors[0].message);
|
|
96
|
+
return json.data;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { apiGet, apiPut, apiDelete, apiPost, apiPatch, graphql };
|
|
100
|
+
}
|