@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +1 -1
  3. package/dist/components/ConceptList.svelte +14 -5
  4. package/dist/components/EditPage.svelte +34 -10
  5. package/dist/components/EditorToolbar.svelte +4 -0
  6. package/dist/components/link-completion.js +10 -3
  7. package/dist/diagnostics/conditions.d.ts +8 -1
  8. package/dist/diagnostics/conditions.js +68 -1
  9. package/dist/doctor/bin.d.ts +2 -0
  10. package/dist/doctor/bin.js +44 -0
  11. package/dist/doctor/check-send.d.ts +3 -0
  12. package/dist/doctor/check-send.js +43 -0
  13. package/dist/doctor/checks-cloudflare.d.ts +5 -0
  14. package/dist/doctor/checks-cloudflare.js +200 -0
  15. package/dist/doctor/checks-github.d.ts +2 -0
  16. package/dist/doctor/checks-github.js +57 -0
  17. package/dist/doctor/checks-local.d.ts +5 -0
  18. package/dist/doctor/checks-local.js +112 -0
  19. package/dist/doctor/cloudflare-api.d.ts +7 -0
  20. package/dist/doctor/cloudflare-api.js +24 -0
  21. package/dist/doctor/index.d.ts +23 -0
  22. package/dist/doctor/index.js +68 -0
  23. package/dist/doctor/report.d.ts +5 -0
  24. package/dist/doctor/report.js +21 -0
  25. package/dist/doctor/run.d.ts +8 -0
  26. package/dist/doctor/run.js +20 -0
  27. package/dist/doctor/types.d.ts +41 -0
  28. package/dist/doctor/types.js +10 -0
  29. package/dist/doctor/wrangler-config.d.ts +12 -0
  30. package/dist/doctor/wrangler-config.js +125 -0
  31. package/dist/github/signing.d.ts +3 -1
  32. package/dist/github/signing.js +13 -5
  33. package/dist/log/events.d.ts +1 -1
  34. package/dist/sveltekit/content-routes.js +19 -11
  35. package/package.json +6 -4
  36. package/src/lib/components/ConceptList.svelte +14 -5
  37. package/src/lib/components/EditPage.svelte +34 -10
  38. package/src/lib/components/EditorToolbar.svelte +4 -0
  39. package/src/lib/components/link-completion.ts +10 -3
  40. package/src/lib/diagnostics/conditions.ts +75 -2
  41. package/src/lib/doctor/bin.ts +45 -0
  42. package/src/lib/doctor/check-send.ts +43 -0
  43. package/src/lib/doctor/checks-cloudflare.ts +222 -0
  44. package/src/lib/doctor/checks-github.ts +63 -0
  45. package/src/lib/doctor/checks-local.ts +119 -0
  46. package/src/lib/doctor/cloudflare-api.ts +33 -0
  47. package/src/lib/doctor/index.ts +93 -0
  48. package/src/lib/doctor/report.ts +30 -0
  49. package/src/lib/doctor/run.ts +23 -0
  50. package/src/lib/doctor/types.ts +52 -0
  51. package/src/lib/doctor/wrangler-config.ts +142 -0
  52. package/src/lib/github/signing.ts +13 -6
  53. package/src/lib/log/events.ts +1 -0
  54. 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. `mint` and `now` are injected so the
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 async function get(creds: AppCredentials): Promise<string> {
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 token = await mint(creds);
105
- cache.set(creds.installationId, { token, expiresAt: now() + ttlMs });
106
- return token;
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
 
@@ -13,4 +13,5 @@ export type CairnLogEvent =
13
13
  | 'entry.published'
14
14
  | 'entry.discarded'
15
15
  | 'publish.failed'
16
+ | 'github.unreachable'
16
17
  | 'guard.rejected';
@@ -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, and a
337
- // pending entry adds a main read of its own path to derive its published state.
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 pending = (await branchHeadSha(runtime.backend, branch, token)) !== null;
340
- const [raw, manifestRaw, mainRaw] = await Promise.all([
341
- readRaw(pending ? { ...runtime.backend, branch } : runtime.backend, path, token),
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 = pending ? mainRaw !== null : raw !== null;
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) throw redirect(303, listPage);
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} entries`, author: { name: editor.displayName, email: editor.email } },
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) {