@glw907/cairn-cms 0.50.0 → 0.52.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 (80) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/components/EditPage.svelte +125 -16
  3. package/dist/components/EditPage.svelte.d.ts +4 -1
  4. package/dist/components/EditorToolbar.svelte +135 -10
  5. package/dist/components/EditorToolbar.svelte.d.ts +19 -2
  6. package/dist/components/MarkdownEditor.svelte +112 -6
  7. package/dist/components/MarkdownEditor.svelte.d.ts +4 -0
  8. package/dist/components/cairn-admin.css +69 -9
  9. package/dist/components/editor-highlight.d.ts +2 -0
  10. package/dist/components/editor-highlight.js +79 -15
  11. package/dist/components/editor-modes.d.ts +26 -0
  12. package/dist/components/editor-modes.js +92 -0
  13. package/dist/components/fonts/iAWriterMono-OFL.txt +100 -0
  14. package/dist/components/fonts/ia-writer-mono-latin-400-italic.woff2 +0 -0
  15. package/dist/components/fonts/ia-writer-mono-latin-400-normal.woff2 +0 -0
  16. package/dist/components/fonts/ia-writer-mono-latin-700-italic.woff2 +0 -0
  17. package/dist/components/fonts/ia-writer-mono-latin-700-normal.woff2 +0 -0
  18. package/dist/components/markdown-directives.d.ts +51 -0
  19. package/dist/components/markdown-directives.js +130 -1
  20. package/dist/components/preview-doc.d.ts +27 -0
  21. package/dist/components/preview-doc.js +64 -0
  22. package/dist/content/compose.js +1 -0
  23. package/dist/content/types.d.ts +33 -0
  24. package/dist/diagnostics/conditions.js +24 -0
  25. package/dist/doctor/bin.js +30 -12
  26. package/dist/doctor/check-floors.d.ts +15 -0
  27. package/dist/doctor/check-floors.js +107 -0
  28. package/dist/doctor/check-probe.d.ts +3 -0
  29. package/dist/doctor/check-probe.js +123 -0
  30. package/dist/doctor/checks-github.js +1 -1
  31. package/dist/doctor/checks-local.d.ts +1 -0
  32. package/dist/doctor/checks-local.js +28 -2
  33. package/dist/doctor/cloudflare-api.js +2 -2
  34. package/dist/doctor/index.d.ts +28 -3
  35. package/dist/doctor/index.js +47 -6
  36. package/dist/doctor/types.d.ts +2 -0
  37. package/dist/doctor/wrangler-config.d.ts +4 -0
  38. package/dist/doctor/wrangler-config.js +11 -0
  39. package/dist/env.d.ts +2 -1
  40. package/dist/env.js +9 -4
  41. package/dist/index.d.ts +1 -1
  42. package/dist/sveltekit/content-routes.d.ts +5 -1
  43. package/dist/sveltekit/content-routes.js +25 -17
  44. package/dist/sveltekit/guard.d.ts +8 -2
  45. package/dist/sveltekit/guard.js +3 -1
  46. package/dist/sveltekit/nav-routes.js +3 -9
  47. package/dist/vite/index.d.ts +16 -0
  48. package/dist/vite/index.js +57 -13
  49. package/package.json +2 -2
  50. package/src/lib/components/EditPage.svelte +125 -16
  51. package/src/lib/components/EditorToolbar.svelte +135 -10
  52. package/src/lib/components/MarkdownEditor.svelte +112 -6
  53. package/src/lib/components/cairn-admin.css +95 -5
  54. package/src/lib/components/editor-highlight.ts +91 -14
  55. package/src/lib/components/editor-modes.ts +106 -0
  56. package/src/lib/components/fonts/iAWriterMono-OFL.txt +100 -0
  57. package/src/lib/components/fonts/ia-writer-mono-latin-400-italic.woff2 +0 -0
  58. package/src/lib/components/fonts/ia-writer-mono-latin-400-normal.woff2 +0 -0
  59. package/src/lib/components/fonts/ia-writer-mono-latin-700-italic.woff2 +0 -0
  60. package/src/lib/components/fonts/ia-writer-mono-latin-700-normal.woff2 +0 -0
  61. package/src/lib/components/markdown-directives.ts +151 -1
  62. package/src/lib/components/preview-doc.ts +82 -0
  63. package/src/lib/content/compose.ts +1 -0
  64. package/src/lib/content/types.ts +32 -0
  65. package/src/lib/diagnostics/conditions.ts +24 -0
  66. package/src/lib/doctor/bin.ts +35 -10
  67. package/src/lib/doctor/check-floors.ts +124 -0
  68. package/src/lib/doctor/check-probe.ts +138 -0
  69. package/src/lib/doctor/checks-github.ts +3 -1
  70. package/src/lib/doctor/checks-local.ts +28 -2
  71. package/src/lib/doctor/cloudflare-api.ts +4 -2
  72. package/src/lib/doctor/index.ts +67 -6
  73. package/src/lib/doctor/types.ts +2 -0
  74. package/src/lib/doctor/wrangler-config.ts +11 -0
  75. package/src/lib/env.ts +9 -4
  76. package/src/lib/index.ts +2 -0
  77. package/src/lib/sveltekit/content-routes.ts +29 -17
  78. package/src/lib/sveltekit/guard.ts +4 -2
  79. package/src/lib/sveltekit/nav-routes.ts +3 -10
  80. package/src/lib/vite/index.ts +71 -17
package/src/lib/env.ts CHANGED
@@ -7,25 +7,30 @@ import { CairnError } from './diagnostics/index.js';
7
7
  * The origin is always config-derived, never read from a request header, so a
8
8
  * forged Host header cannot redirect a magic link (spec 7.1, risk H3).
9
9
  *
10
- * @throws Error when `PUBLIC_ORIGIN` is unset or empty.
10
+ * @throws CairnError (`config.public-origin-invalid`) when `PUBLIC_ORIGIN` is unset or
11
+ * empty, fails to parse as a URL, or uses http on a non-local host.
11
12
  */
12
13
  export function requireOrigin(env: { PUBLIC_ORIGIN?: string }): string {
13
14
  const origin = env.PUBLIC_ORIGIN;
14
15
  if (!origin) {
15
- throw new Error('PUBLIC_ORIGIN is not configured');
16
+ throw new CairnError('config.public-origin-invalid', { message: 'PUBLIC_ORIGIN is not configured' });
16
17
  }
17
18
  let hostname: string;
18
19
  try {
19
20
  hostname = new URL(origin).hostname;
20
21
  } catch {
21
- throw new Error(`PUBLIC_ORIGIN is not a valid URL, got ${origin}`);
22
+ throw new CairnError('config.public-origin-invalid', {
23
+ message: `PUBLIC_ORIGIN is not a valid URL, got ${origin}`,
24
+ });
22
25
  }
23
26
  // The magic-link origin must be https in production so the link and the __Host- cookie are
24
27
  // origin-bound. http is allowed only for local dev on localhost or 127.0.0.1, matched exactly so
25
28
  // a lookalike host like localhost.example.com cannot skip the https requirement.
26
29
  const isLocal = hostname === 'localhost' || hostname === '127.0.0.1';
27
30
  if (!origin.startsWith('https://') && !isLocal) {
28
- throw new Error(`PUBLIC_ORIGIN must be https in production, got ${origin}`);
31
+ throw new CairnError('config.public-origin-invalid', {
32
+ message: `PUBLIC_ORIGIN must be https in production, got ${origin}`,
33
+ });
29
34
  }
30
35
  return origin;
31
36
  }
package/src/lib/index.ts CHANGED
@@ -20,6 +20,8 @@ export type {
20
20
  BackendConfig,
21
21
  SenderConfig,
22
22
  NavMenuConfig,
23
+ PreviewConfig,
24
+ ResolvedPreview,
23
25
  AssetConfig,
24
26
  RoutingRule,
25
27
  ConceptDescriptor,
@@ -16,8 +16,9 @@ import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest,
16
16
  import { isConflict } from '../github/types.js';
17
17
  import { log } from '../log/index.js';
18
18
  import { issueCsrfToken } from './csrf.js';
19
+ import { requireSession } from './guard.js';
19
20
  import type { CookieJar, EventBase } from './types.js';
20
- import type { CairnRuntime, ConceptDescriptor, FrontmatterField } from '../content/types.js';
21
+ import type { CairnRuntime, ConceptDescriptor, FrontmatterField, PreviewConfig, ResolvedPreview } from '../content/types.js';
21
22
  import type { Editor, Role } from '../auth/types.js';
22
23
 
23
24
  /** A sidebar concept entry: just enough to render the nav without shipping validators to the client. */
@@ -100,6 +101,10 @@ export interface EditData {
100
101
  publishedFlash: boolean;
101
102
  /** True after a discard redirect (`?discarded=1`), for the confirmation strip. */
102
103
  discardedFlash: boolean;
104
+ /** The adapter's preview knob resolved for this entry's concept (its `byConcept` override,
105
+ * when one exists, applied over the top-level values); null when the site sets none, which
106
+ * leaves the frame rendering unstyled markup behind a hint. */
107
+ preview: ResolvedPreview | null;
103
108
  }
104
109
 
105
110
  /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
@@ -148,11 +153,17 @@ export interface RenameFailure {
148
153
  * keys identify which guard refused. */
149
154
  export type ContentFormFailure = Partial<SaveFailure & DeleteRefusal & RenameFailure>;
150
155
 
151
- /** The signed-in editor the guard resolved, or a login redirect. Kept local to decouple event shapes. */
152
- function sessionOf(event: ContentEvent): Editor {
153
- const editor = event.locals.editor;
154
- if (!editor) throw redirect(303, '/admin/login');
155
- return editor;
156
+ /** Resolve the effective preview for one concept: its `byConcept` override wins per key, with
157
+ * nullish coalescing so an override key that is present but undefined keeps the top-level value.
158
+ * Stylesheets are always shared, and the `byConcept` map never reaches the client. */
159
+ function resolvePreview(preview: PreviewConfig | undefined, conceptId: string): ResolvedPreview | null {
160
+ if (!preview) return null;
161
+ const override = preview.byConcept?.[conceptId];
162
+ return {
163
+ stylesheets: preview.stylesheets,
164
+ bodyClass: override?.bodyClass ?? preview.bodyClass,
165
+ containerClass: override?.containerClass ?? preview.containerClass,
166
+ };
156
167
  }
157
168
 
158
169
  /** Look up the concept named by the `[concept]` route param, or a 404. */
@@ -188,7 +199,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
188
199
  /** Layout load for every admin page: the nav, the user, the active path, the resolved theme,
189
200
  * and the pending entries behind the topbar's publish-all action. */
190
201
  async function layoutLoad(event: ContentEvent): Promise<LayoutData> {
191
- const editor = sessionOf(event);
202
+ const editor = requireSession(event);
192
203
  const cookieTheme = event.cookies?.get('cairn-admin-theme');
193
204
  const theme = cookieTheme === 'cairn-admin-dark' ? 'cairn-admin-dark' : 'cairn-admin';
194
205
  const cookieCollapsed = event.cookies?.get('cairn-admin-nav-collapsed');
@@ -282,7 +293,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
282
293
  * with no manifest row appends a `new` row read from its branch. A listing failure degrades
283
294
  * to an inline error, not a thrown 500. */
284
295
  async function listLoad(event: ContentEvent): Promise<ListData> {
285
- sessionOf(event);
296
+ requireSession(event);
286
297
  const concept = conceptOf(runtime, event.params);
287
298
  const formError = event.url.searchParams.get('error');
288
299
  const publishedAllRaw = event.url.searchParams.get('publishedAll');
@@ -333,7 +344,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
333
344
 
334
345
  /** Create a new entry: validate the slug, compose a dated id when the concept is dated, refuse to clobber. */
335
346
  async function createAction(event: ContentEvent): Promise<never> {
336
- sessionOf(event);
347
+ requireSession(event);
337
348
  const concept = conceptOf(runtime, event.params);
338
349
  const form = await event.request.formData();
339
350
  const slug = String(form.get('slug') ?? '').trim() || slugify(String(form.get('title') ?? ''));
@@ -378,7 +389,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
378
389
 
379
390
  /** Open a file for editing. A `?new=1` miss yields a blank document; any other miss is a 404. */
380
391
  async function editLoad(event: ContentEvent): Promise<EditData> {
381
- sessionOf(event);
392
+ requireSession(event);
382
393
  const concept = conceptOf(runtime, event.params);
383
394
  const id = event.params.id ?? '';
384
395
  if (!isValidId(id)) throw error(400, 'Invalid entry id');
@@ -440,6 +451,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
440
451
  published,
441
452
  publishedFlash: event.url.searchParams.get('published') === '1',
442
453
  discardedFlash: event.url.searchParams.get('discarded') === '1',
454
+ preview: resolvePreview(runtime.preview, concept.id),
443
455
  };
444
456
  }
445
457
 
@@ -578,7 +590,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
578
590
  /** Save an edit: validate, then commit to the entry's pending branch with the session editor
579
591
  * as author. Main and its manifest stay untouched until publish. Fails safe on 409. */
580
592
  async function saveAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
581
- const editor = sessionOf(event);
593
+ const editor = requireSession(event);
582
594
  const concept = conceptOf(runtime, event.params);
583
595
  const id = event.params.id ?? '';
584
596
  // Confine the commit path to the concept dir, built from a validated id (the App token can
@@ -599,7 +611,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
599
611
  * The branch is deleted only when its head still matches the commit this action made; a
600
612
  * concurrent save moved it, so the entry stays pending and the next publish picks it up. */
601
613
  async function publishAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
602
- const editor = sessionOf(event);
614
+ const editor = requireSession(event);
603
615
  const concept = conceptOf(runtime, event.params);
604
616
  const id = event.params.id ?? '';
605
617
  if (!isValidId(id)) throw error(400, 'Invalid entry id');
@@ -638,7 +650,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
638
650
  * Mounted on the concept list shim, but the topbar posts here from anywhere, so the route's
639
651
  * concept param is ignored and the redirect lands on the first configured concept. */
640
652
  async function publishAllAction(event: ContentEvent): Promise<never> {
641
- const editor = sessionOf(event);
653
+ const editor = requireSession(event);
642
654
  const first = runtime.concepts[0];
643
655
  if (!first) throw error(404, 'No content types configured');
644
656
  const token = await mintToken(event.platform?.env ?? {});
@@ -725,7 +737,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
725
737
  /** Discard an entry's pending edits: delete the branch (tolerant of already-gone) and return to
726
738
  * the edit page when the entry lives on main, else to the list (the entry is gone entirely). */
727
739
  async function discardAction(event: ContentEvent): Promise<never> {
728
- const editor = sessionOf(event);
740
+ const editor = requireSession(event);
729
741
  const concept = conceptOf(runtime, event.params);
730
742
  const id = event.params.id ?? '';
731
743
  if (!isValidId(id)) throw error(400, 'Invalid entry id');
@@ -806,7 +818,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
806
818
 
807
819
  /** Delete an entry from its editor. The id comes from the route param. */
808
820
  async function deleteAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
809
- const editor = sessionOf(event);
821
+ const editor = requireSession(event);
810
822
  const concept = conceptOf(runtime, event.params);
811
823
  const id = event.params.id ?? '';
812
824
  if (!isValidId(id)) throw error(400, 'Invalid entry id');
@@ -815,7 +827,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
815
827
 
816
828
  /** Delete an entry from the concept list. The id comes from the form body. */
817
829
  async function listDeleteAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
818
- const editor = sessionOf(event);
830
+ const editor = requireSession(event);
819
831
  const concept = conceptOf(runtime, event.params);
820
832
  const form = await event.request.formData();
821
833
  const id = String(form.get('id') ?? '');
@@ -828,7 +840,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
828
840
  * are the authoritative gate. The same last-writer-wins manifest race as save and delete applies,
829
841
  * caught by the build's fail-closed backstop. */
830
842
  async function renameAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
831
- const editor = sessionOf(event);
843
+ const editor = requireSession(event);
832
844
  const concept = conceptOf(runtime, event.params);
833
845
  const id = event.params.id ?? '';
834
846
  if (!isValidId(id)) throw error(400, 'Invalid entry id');
@@ -93,8 +93,10 @@ export function createAuthGuard() {
93
93
  };
94
94
  }
95
95
 
96
- /** For a protected load/action: the session the guard already resolved, or a login redirect. */
97
- export function requireSession(event: RequestContext): Editor {
96
+ /** For a protected load/action: the session the guard already resolved, or a login redirect.
97
+ * The parameter is the minimal structural need (just `locals`), so every engine event shape
98
+ * (RequestContext, the content routes' ContentEvent) and a real RequestEvent all satisfy it. */
99
+ export function requireSession(event: { locals: { editor?: Editor | null } }): Editor {
98
100
  const editor = event.locals.editor;
99
101
  if (!editor) throw redirect(303, '/admin/login');
100
102
  return editor;
@@ -8,9 +8,9 @@ import { listMarkdown, readRaw, commitFile } from '../github/repo.js';
8
8
  import { isConflict } from '../github/types.js';
9
9
  import { log } from '../log/index.js';
10
10
  import { parseSiteConfig, extractMenu, validateNavTree, setMenu, type NavNode } from '../nav/site-config.js';
11
+ import { requireSession } from './guard.js';
11
12
  import type { CairnRuntime } from '../content/types.js';
12
13
  import type { ContentEvent } from './content-routes.js';
13
- import type { Editor } from '../auth/types.js';
14
14
 
15
15
  /** One page option for the URL picker datalist. */
16
16
  export interface NavPageOption {
@@ -34,13 +34,6 @@ export interface NavRoutesDeps {
34
34
  mintToken?: (env: GithubKeyEnv) => string | Promise<string>;
35
35
  }
36
36
 
37
- /** The signed-in editor the guard resolved, or a login redirect. */
38
- function sessionOf(event: ContentEvent): Editor {
39
- const editor = event.locals.editor;
40
- if (!editor) throw redirect(303, '/admin/login');
41
- return editor;
42
- }
43
-
44
37
  export function createNavRoutes(runtime: CairnRuntime, deps: NavRoutesDeps = {}) {
45
38
  const mintToken =
46
39
  deps.mintToken ?? ((env: GithubKeyEnv) => cachedInstallationToken(appCredentials(runtime.backend, env)));
@@ -63,7 +56,7 @@ export function createNavRoutes(runtime: CairnRuntime, deps: NavRoutesDeps = {})
63
56
 
64
57
  /** Load the nav editor. A missing or unparsable config degrades to an empty tree so it still opens. */
65
58
  async function navLoad(event: ContentEvent): Promise<NavLoadData> {
66
- sessionOf(event);
59
+ requireSession(event);
67
60
  const config = runtime.navMenu;
68
61
  if (!config) throw error(404, 'No navigation menu configured');
69
62
  const maxDepth = config.maxDepth ?? 2;
@@ -109,7 +102,7 @@ export function createNavRoutes(runtime: CairnRuntime, deps: NavRoutesDeps = {})
109
102
 
110
103
  /** Save the nav tree: validate, then read-modify-commit the one menu with the session editor as author. */
111
104
  async function navSave(event: ContentEvent): Promise<never> {
112
- const editor = sessionOf(event);
105
+ const editor = requireSession(event);
113
106
  const config = runtime.navMenu;
114
107
  if (!config) throw error(404, 'No navigation menu configured');
115
108
  const maxDepth = config.maxDepth ?? 2;
@@ -61,21 +61,17 @@ export const result = ${resultExpr};
61
61
  `;
62
62
  }
63
63
 
64
- /** Evaluate the virtual module in the given mode inside the consumer's own Vite resolution, then
65
- * return the module's `result`. It reuses the consumer's loaded config (so `$lib`, the config
66
- * module, `import.meta.glob`, and `?raw` resolve exactly as the build does) and strips the
67
- * cairnManifest plugin from the nested server's plugin list, so its buildStart never recurses.
68
- * This runs at build time and in the bin, never in the request lifecycle. */
69
- async function evalVirtual(
70
- opts: CairnManifestOptions,
71
- mode: 'verify' | 'write',
72
- root: string,
73
- ): Promise<string> {
64
+ /** Evaluate a virtual module source inside the consumer's own Vite resolution, then return the
65
+ * module's `result`. It reuses the consumer's loaded config (so `$lib`, the config module,
66
+ * `import.meta.glob`, and `?raw` resolve exactly as the build does) and strips the cairnManifest
67
+ * plugin from the nested server's plugin list, so its buildStart never recurses. This runs at
68
+ * build time and in the bins, never in the request lifecycle. */
69
+ async function evalVirtual(source: string, root: string): Promise<string> {
74
70
  const { createServer, loadConfigFromFile } = await import('vite');
75
71
  // Load the consumer's real Vite config so the nested server inherits SvelteKit's resolution
76
72
  // (the $lib alias, the app root, the ?raw and import.meta.glob handling). Drop cairnManifest from
77
73
  // it so the nested server's buildStart does not recurse, and add a plugin that serves only the
78
- // virtual module in the requested mode.
74
+ // given virtual module source.
79
75
  const loaded = await loadConfigFromFile({ command: 'build', mode: 'production' }, undefined, root);
80
76
  const inlineConfig = loaded?.config ?? {};
81
77
  const server = await createServer({
@@ -84,7 +80,7 @@ async function evalVirtual(
84
80
  configFile: false,
85
81
  logLevel: 'silent',
86
82
  server: { middlewareMode: true, hmr: false, watch: null },
87
- plugins: [...stripCairnManifest(inlineConfig.plugins ?? []), cairnVirtualOnly(opts, mode)],
83
+ plugins: [...stripCairnManifest(inlineConfig.plugins ?? []), cairnVirtualOnly(source)],
88
84
  });
89
85
  try {
90
86
  const mod = (await server.ssrLoadModule(VIRTUAL_ID)) as { result: string };
@@ -115,13 +111,13 @@ export function stripCairnManifest(plugins: PluginOption | PluginOption[]): Plug
115
111
  /** Verify the committed manifest against the corpus from a Vite context, throwing on drift. The bin
116
112
  * and the plugin share this; the spike proved it runs cleanly inside the consumer's config. */
117
113
  export async function verifyManifestFromVite(opts: CairnManifestOptions, root: string): Promise<void> {
118
- await evalVirtual(opts, 'verify', root);
114
+ await evalVirtual(virtualSource(opts, 'verify'), root);
119
115
  }
120
116
 
121
117
  /** Regenerate the serialized manifest from the corpus in a Vite context, sharing the build's
122
118
  * resolution. The cairn-manifest bin (a later task) will call this and write the result. */
123
119
  export async function buildManifestFromVite(opts: CairnManifestOptions, root: string): Promise<string> {
124
- return evalVirtual(opts, 'write', root);
120
+ return evalVirtual(virtualSource(opts, 'write'), root);
125
121
  }
126
122
 
127
123
  /** The cairnManifest plugin. It serves the verify virtual module to the app graph and, in
@@ -198,16 +194,74 @@ function findCairnOptions(plugins: unknown): CairnManifestOptions | null {
198
194
  return null;
199
195
  }
200
196
 
201
- /** A minimal plugin that serves only the virtual module in one mode, for the nested SSR load. It
197
+ /** A minimal plugin that serves only the given virtual module source, for the nested SSR load. It
202
198
  * carries no buildStart, so the nested server never recurses into the verify. */
203
- function cairnVirtualOnly(opts: CairnManifestOptions, mode: 'verify' | 'write'): Plugin {
199
+ function cairnVirtualOnly(source: string): Plugin {
204
200
  return {
205
201
  name: 'cairn-manifest-virtual',
206
202
  resolveId(id) {
207
203
  if (id === VIRTUAL_ID) return RESOLVED_ID;
208
204
  },
209
205
  load(id) {
210
- if (id === RESOLVED_ID) return virtualSource(opts, mode);
206
+ if (id === RESOLVED_ID) return source;
211
207
  },
212
208
  };
213
209
  }
210
+
211
+ /** The repo and sender facts cairn-doctor derives off the consumer's adapter. */
212
+ export interface AdapterFacts {
213
+ /** `cairn.backend.owner`. */
214
+ owner?: string;
215
+ /** `cairn.backend.repo`. */
216
+ repo?: string;
217
+ /** `cairn.sender.from`. */
218
+ from?: string;
219
+ }
220
+
221
+ /** Build the virtual module that reads only the adapter facts the doctor derives. It imports the
222
+ * configured config module and exports the string-typed `owner`, `repo`, and `from` as JSON, so
223
+ * nothing else of the adapter (least of all a secret) crosses the boundary. */
224
+ function adapterFactsSource(opts: CairnManifestOptions): string {
225
+ return `
226
+ import { cairn } from ${JSON.stringify(opts.configModule)};
227
+ const backend = cairn?.backend ?? {};
228
+ const sender = cairn?.sender ?? {};
229
+ const facts = {};
230
+ if (typeof backend.owner === 'string') facts.owner = backend.owner;
231
+ if (typeof backend.repo === 'string') facts.repo = backend.repo;
232
+ if (typeof sender.from === 'string') facts.from = sender.from;
233
+ export const result = JSON.stringify(facts);
234
+ `;
235
+ }
236
+
237
+ /** Read `{ owner, repo, from }` off the consumer's adapter by evaluating a tiny virtual module
238
+ * through the consumer's own Vite resolution, the same machinery the cairn-manifest bin uses.
239
+ * cairn-doctor calls this to fill inputs the operator did not pass. Derivation is best-effort:
240
+ * any failure (no Vite config, no cairnManifest plugin, a config module that throws) returns
241
+ * null, so the doctor degrades to flags instead of crashing. This runs only on the bin path,
242
+ * never in a Worker. */
243
+ export async function readAdapterFacts(cwd: string = process.cwd()): Promise<AdapterFacts | null> {
244
+ try {
245
+ const { loadConfigFromFile } = await import('vite');
246
+ const loaded = await loadConfigFromFile(
247
+ { command: 'build', mode: 'production' },
248
+ undefined,
249
+ cwd,
250
+ 'silent',
251
+ );
252
+ if (!loaded) return null;
253
+ const opts = findCairnOptions(loaded.config.plugins);
254
+ if (!opts) return null;
255
+ const parsed = JSON.parse(await evalVirtual(adapterFactsSource(opts), cwd)) as Record<
256
+ string,
257
+ unknown
258
+ >;
259
+ const facts: AdapterFacts = {};
260
+ if (typeof parsed.owner === 'string') facts.owner = parsed.owner;
261
+ if (typeof parsed.repo === 'string') facts.repo = parsed.repo;
262
+ if (typeof parsed.from === 'string') facts.from = parsed.from;
263
+ return facts;
264
+ } catch {
265
+ return null;
266
+ }
267
+ }