@glw907/cairn-cms 0.40.0 → 0.41.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/CHANGELOG.md +34 -0
- package/README.md +1 -1
- package/dist/components/ConceptList.svelte +14 -5
- package/dist/components/EditPage.svelte +34 -10
- package/dist/components/EditorToolbar.svelte +4 -0
- package/dist/components/link-completion.js +10 -3
- package/dist/diagnostics/conditions.d.ts +8 -1
- package/dist/diagnostics/conditions.js +68 -1
- package/dist/doctor/bin.d.ts +2 -0
- package/dist/doctor/bin.js +44 -0
- package/dist/doctor/check-send.d.ts +3 -0
- package/dist/doctor/check-send.js +43 -0
- package/dist/doctor/checks-cloudflare.d.ts +5 -0
- package/dist/doctor/checks-cloudflare.js +200 -0
- package/dist/doctor/checks-github.d.ts +2 -0
- package/dist/doctor/checks-github.js +57 -0
- package/dist/doctor/checks-local.d.ts +5 -0
- package/dist/doctor/checks-local.js +112 -0
- package/dist/doctor/cloudflare-api.d.ts +7 -0
- package/dist/doctor/cloudflare-api.js +24 -0
- package/dist/doctor/index.d.ts +23 -0
- package/dist/doctor/index.js +68 -0
- package/dist/doctor/report.d.ts +5 -0
- package/dist/doctor/report.js +21 -0
- package/dist/doctor/run.d.ts +8 -0
- package/dist/doctor/run.js +20 -0
- package/dist/doctor/types.d.ts +41 -0
- package/dist/doctor/types.js +10 -0
- package/dist/doctor/wrangler-config.d.ts +12 -0
- package/dist/doctor/wrangler-config.js +125 -0
- package/dist/github/signing.d.ts +3 -1
- package/dist/github/signing.js +13 -5
- package/dist/log/events.d.ts +1 -1
- package/dist/sveltekit/content-routes.js +19 -11
- package/package.json +6 -4
- package/src/lib/components/ConceptList.svelte +14 -5
- package/src/lib/components/EditPage.svelte +34 -10
- package/src/lib/components/EditorToolbar.svelte +4 -0
- package/src/lib/components/link-completion.ts +10 -3
- package/src/lib/diagnostics/conditions.ts +75 -2
- package/src/lib/doctor/bin.ts +45 -0
- package/src/lib/doctor/check-send.ts +43 -0
- package/src/lib/doctor/checks-cloudflare.ts +222 -0
- package/src/lib/doctor/checks-github.ts +63 -0
- package/src/lib/doctor/checks-local.ts +119 -0
- package/src/lib/doctor/cloudflare-api.ts +33 -0
- package/src/lib/doctor/index.ts +93 -0
- package/src/lib/doctor/report.ts +30 -0
- package/src/lib/doctor/run.ts +23 -0
- package/src/lib/doctor/types.ts +52 -0
- package/src/lib/doctor/wrangler-config.ts +142 -0
- package/src/lib/github/signing.ts +13 -6
- package/src/lib/log/events.ts +1 -0
- package/src/lib/sveltekit/content-routes.ts +19 -10
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// A tolerant reader for the few wrangler-config facts the local checks need. It reads
|
|
2
|
+
// wrangler.jsonc or wrangler.toml through the injected readFile (jsonc wins when both exist)
|
|
3
|
+
// and returns null when neither file is present, which the checks report as a skip.
|
|
4
|
+
import type { DoctorContext } from './types.js';
|
|
5
|
+
|
|
6
|
+
export interface WranglerFacts {
|
|
7
|
+
/** A send_email binding named EMAIL is declared. */
|
|
8
|
+
hasEmailBinding: boolean;
|
|
9
|
+
/** A d1_databases binding named AUTH_DB is declared. */
|
|
10
|
+
hasAuthDb: boolean;
|
|
11
|
+
/** The AUTH_DB database_id, when declared; the D1 check queries it. */
|
|
12
|
+
authDbId?: string;
|
|
13
|
+
/** observability.enabled is true. */
|
|
14
|
+
observabilityEnabled: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function readWranglerConfig(
|
|
18
|
+
readFile: DoctorContext['readFile']
|
|
19
|
+
): Promise<WranglerFacts | null> {
|
|
20
|
+
const jsonc = await readFile('wrangler.jsonc');
|
|
21
|
+
if (jsonc !== null) return factsFromJsonc(jsonc);
|
|
22
|
+
const toml = await readFile('wrangler.toml');
|
|
23
|
+
if (toml !== null) return factsFromToml(toml);
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Strip // and /* */ comments outside string literals, character by character, so a URL
|
|
28
|
+
// inside a string survives. Trailing commas go by regex afterward; a string containing
|
|
29
|
+
// ",}" would be mangled, an accepted gap in a tolerant reader.
|
|
30
|
+
function stripJsonc(text: string): string {
|
|
31
|
+
let out = '';
|
|
32
|
+
let inString = false;
|
|
33
|
+
let i = 0;
|
|
34
|
+
while (i < text.length) {
|
|
35
|
+
const ch = text[i];
|
|
36
|
+
if (inString) {
|
|
37
|
+
out += ch;
|
|
38
|
+
if (ch === '\\') {
|
|
39
|
+
out += text[i + 1] ?? '';
|
|
40
|
+
i += 2;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (ch === '"') inString = false;
|
|
44
|
+
i += 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (ch === '"') {
|
|
48
|
+
inString = true;
|
|
49
|
+
out += ch;
|
|
50
|
+
i += 1;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
54
|
+
const end = text.indexOf('\n', i);
|
|
55
|
+
i = end === -1 ? text.length : end;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
59
|
+
const end = text.indexOf('*/', i + 2);
|
|
60
|
+
i = end === -1 ? text.length : end + 2;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
out += ch;
|
|
64
|
+
i += 1;
|
|
65
|
+
}
|
|
66
|
+
return out.replace(/,(\s*[}\]])/g, '$1');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function factsFromJsonc(text: string): WranglerFacts {
|
|
70
|
+
let config: Record<string, unknown>;
|
|
71
|
+
try {
|
|
72
|
+
config = JSON.parse(stripJsonc(text)) as Record<string, unknown>;
|
|
73
|
+
} catch {
|
|
74
|
+
// V8's SyntaxError embeds a source snippet, which would land verbatim in the report;
|
|
75
|
+
// a file that exists but does not parse is a fail with a clean message instead.
|
|
76
|
+
throw new Error('wrangler.jsonc did not parse');
|
|
77
|
+
}
|
|
78
|
+
const sendEmail = Array.isArray(config.send_email) ? config.send_email : [];
|
|
79
|
+
const hasEmailBinding = sendEmail.some(
|
|
80
|
+
(entry) => typeof entry === 'object' && entry !== null && (entry as { name?: unknown }).name === 'EMAIL'
|
|
81
|
+
);
|
|
82
|
+
const databases = Array.isArray(config.d1_databases) ? config.d1_databases : [];
|
|
83
|
+
const authDb = databases.find(
|
|
84
|
+
(entry): entry is { binding: string; database_id?: unknown } =>
|
|
85
|
+
typeof entry === 'object' && entry !== null && (entry as { binding?: unknown }).binding === 'AUTH_DB'
|
|
86
|
+
);
|
|
87
|
+
const observability = config.observability as { enabled?: unknown } | undefined;
|
|
88
|
+
const facts: WranglerFacts = {
|
|
89
|
+
hasEmailBinding,
|
|
90
|
+
hasAuthDb: authDb !== undefined,
|
|
91
|
+
observabilityEnabled: observability?.enabled === true,
|
|
92
|
+
};
|
|
93
|
+
if (typeof authDb?.database_id === 'string') facts.authDbId = authDb.database_id;
|
|
94
|
+
return facts;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The toml read is deliberately shallow: line-anchored matching for the three facts, not a
|
|
98
|
+
// TOML parser. The remediation tells the operator exactly what to add, so full fidelity
|
|
99
|
+
// buys nothing here. A table header opens a section; the relevant key lines are matched
|
|
100
|
+
// within it and the d1 table flushes on the next header.
|
|
101
|
+
function factsFromToml(text: string): WranglerFacts {
|
|
102
|
+
const facts: WranglerFacts = {
|
|
103
|
+
hasEmailBinding: false,
|
|
104
|
+
hasAuthDb: false,
|
|
105
|
+
observabilityEnabled: false,
|
|
106
|
+
};
|
|
107
|
+
let section = '';
|
|
108
|
+
let d1Binding: string | undefined;
|
|
109
|
+
let d1Id: string | undefined;
|
|
110
|
+
|
|
111
|
+
const flushD1 = () => {
|
|
112
|
+
if (d1Binding === 'AUTH_DB') {
|
|
113
|
+
facts.hasAuthDb = true;
|
|
114
|
+
if (d1Id !== undefined) facts.authDbId = d1Id;
|
|
115
|
+
}
|
|
116
|
+
d1Binding = undefined;
|
|
117
|
+
d1Id = undefined;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
for (const line of text.split('\n')) {
|
|
121
|
+
const header = line.match(/^\s*(\[\[?[\w.]+\]?\])\s*(?:#.*)?$/);
|
|
122
|
+
if (header) {
|
|
123
|
+
flushD1();
|
|
124
|
+
section = header[1];
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const kv = line.match(/^\s*(\w+)\s*=\s*(.+?)\s*$/);
|
|
128
|
+
if (!kv) continue;
|
|
129
|
+
const [, key, value] = kv;
|
|
130
|
+
const str = value.match(/^["'](.*)["']/)?.[1];
|
|
131
|
+
if (section === '[[send_email]]' && key === 'name' && str === 'EMAIL') {
|
|
132
|
+
facts.hasEmailBinding = true;
|
|
133
|
+
} else if (section === '[[d1_databases]]') {
|
|
134
|
+
if (key === 'binding') d1Binding = str;
|
|
135
|
+
if (key === 'database_id') d1Id = str;
|
|
136
|
+
} else if (section === '[observability]' && key === 'enabled' && value.startsWith('true')) {
|
|
137
|
+
facts.observabilityEnabled = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
flushD1();
|
|
141
|
+
return facts;
|
|
142
|
+
}
|
|
@@ -79,7 +79,7 @@ export async function installationToken(creds: AppCredentials): Promise<string>
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
interface CachedToken {
|
|
82
|
-
token: string
|
|
82
|
+
token: Promise<string>;
|
|
83
83
|
expiresAt: number;
|
|
84
84
|
}
|
|
85
85
|
|
|
@@ -89,7 +89,9 @@ interface CachedToken {
|
|
|
89
89
|
* instead of re-signing and re-calling GitHub on every list and commit. A cold isolate re-mints,
|
|
90
90
|
* which is always safe. This mirrors the default of @octokit/auth-app, which caches installation
|
|
91
91
|
* tokens in memory and returns them until expiry. The TTL stays under GitHub's documented one-hour
|
|
92
|
-
* lifetime, so a fixed margin avoids parsing the API expiry.
|
|
92
|
+
* lifetime, so a fixed margin avoids parsing the API expiry. The cache holds the in-flight
|
|
93
|
+
* promise, not the resolved token, so a cold isolate's parallel loads coalesce into one mint;
|
|
94
|
+
* a rejected mint evicts itself so the next call retries. `mint` and `now` are injected so the
|
|
93
95
|
* cache is testable with no network call and no real clock.
|
|
94
96
|
*/
|
|
95
97
|
export function createInstallationTokenCache(
|
|
@@ -98,12 +100,17 @@ export function createInstallationTokenCache(
|
|
|
98
100
|
ttlMs = 55 * 60 * 1000,
|
|
99
101
|
): (creds: AppCredentials) => Promise<string> {
|
|
100
102
|
const cache = new Map<string, CachedToken>();
|
|
101
|
-
return
|
|
103
|
+
return function get(creds: AppCredentials): Promise<string> {
|
|
102
104
|
const hit = cache.get(creds.installationId);
|
|
103
105
|
if (hit && hit.expiresAt > now()) return hit.token;
|
|
104
|
-
const
|
|
105
|
-
cache.set(creds.installationId,
|
|
106
|
-
|
|
106
|
+
const entry: CachedToken = { token: mint(creds), expiresAt: now() + ttlMs };
|
|
107
|
+
cache.set(creds.installationId, entry);
|
|
108
|
+
// Evict only this entry on rejection: a newer entry that replaced it must survive. The
|
|
109
|
+
// caller's await surfaces the rejection itself, so this side handler swallows nothing.
|
|
110
|
+
entry.token.catch(() => {
|
|
111
|
+
if (cache.get(creds.installationId) === entry) cache.delete(creds.installationId);
|
|
112
|
+
});
|
|
113
|
+
return entry.token;
|
|
107
114
|
};
|
|
108
115
|
}
|
|
109
116
|
|
package/src/lib/log/events.ts
CHANGED
|
@@ -178,8 +178,9 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
178
178
|
const entry = pendingEntryOf(name);
|
|
179
179
|
return entry ? [{ concept: entry.concept.id, id: entry.id }] : [];
|
|
180
180
|
});
|
|
181
|
-
} catch {
|
|
181
|
+
} catch (err) {
|
|
182
182
|
pendingEntries = null;
|
|
183
|
+
log.warn('github.unreachable', { scope: 'layout', error: String(err) });
|
|
183
184
|
}
|
|
184
185
|
return {
|
|
185
186
|
siteName: runtime.siteName,
|
|
@@ -333,17 +334,21 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
333
334
|
const datePrefix = concept.routing.dated ? concept.datePrefix : null;
|
|
334
335
|
const path = `${concept.dir}/${filenameFromId(id)}`;
|
|
335
336
|
// A pending entry reads branch-first: the editor shows the unpublished edits. The manifest
|
|
336
|
-
// (link targets and the inbound-link guard) always reads main, the authoritative copy
|
|
337
|
-
//
|
|
337
|
+
// (link targets and the inbound-link guard) always reads main, the authoritative copy.
|
|
338
|
+
// Stage 1 runs the branch probe, the main-path read, and the manifest read concurrently,
|
|
339
|
+
// so the probe does not serialize ahead of the other two; stage 2 adds the branch read
|
|
340
|
+
// only when the probe found a branch, with the stage-1 main read serving as the published
|
|
341
|
+
// signal either way.
|
|
338
342
|
const branch = pendingBranch(concept.id, id);
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
readRaw(
|
|
343
|
+
const [headSha, mainRaw, manifestRaw] = await Promise.all([
|
|
344
|
+
branchHeadSha(runtime.backend, branch, token),
|
|
345
|
+
readRaw(runtime.backend, path, token),
|
|
342
346
|
readRaw(runtime.backend, runtime.manifestPath, token),
|
|
343
|
-
pending ? readRaw(runtime.backend, path, token) : Promise.resolve(null),
|
|
344
347
|
]);
|
|
348
|
+
const pending = headSha !== null;
|
|
349
|
+
const raw = pending ? await readRaw({ ...runtime.backend, branch }, path, token) : mainRaw;
|
|
345
350
|
if (raw === null && !isNew) throw error(404, 'Entry not found');
|
|
346
|
-
const published =
|
|
351
|
+
const published = mainRaw !== null;
|
|
347
352
|
|
|
348
353
|
const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
|
|
349
354
|
const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
|
|
@@ -618,14 +623,18 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
618
623
|
next = upsertEntry(next, manifestEntryFromFile(entry.concept, { path: entry.path, raw: entry.raw }));
|
|
619
624
|
published.push({ concept: entry.concept.id, id: entry.id, branch: entry.branch, sha: entry.sha });
|
|
620
625
|
}
|
|
621
|
-
if (published.length === 0)
|
|
626
|
+
if (published.length === 0) {
|
|
627
|
+
const message = 'Nothing to publish. Every entry is already live.';
|
|
628
|
+
throw redirect(303, `${listPage}?error=${encodeURIComponent(message)}`);
|
|
629
|
+
}
|
|
622
630
|
changes.push({ path: runtime.manifestPath, content: serializeManifest(next) });
|
|
623
631
|
|
|
632
|
+
const noun = published.length === 1 ? 'entry' : 'entries';
|
|
624
633
|
try {
|
|
625
634
|
await commitFiles(
|
|
626
635
|
runtime.backend,
|
|
627
636
|
changes,
|
|
628
|
-
{ message: `Publish ${published.length}
|
|
637
|
+
{ message: `Publish ${published.length} ${noun}`, author: { name: editor.displayName, email: editor.email } },
|
|
629
638
|
token,
|
|
630
639
|
);
|
|
631
640
|
for (const entry of published) {
|