@amerged/ohmyhost-mcp 0.1.31 → 0.1.32

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.
@@ -1,8 +1,25 @@
1
- import { isAdmittedNextVersion, PINNED_BUN_VERSION } from "./framework-build-script.mjs";
1
+ import {
2
+ compareStableVersions,
3
+ FRAMEWORK_VERSION_POLICY,
4
+ frameworkVersionMinimum,
5
+ frameworkVersionVerdict,
6
+ parseStableVersion,
7
+ PINNED_BUN_VERSION,
8
+ type FrameworkVersionPackage,
9
+ type FrameworkVersionPolicy,
10
+ } from "./framework-build-script.mjs";
2
11
  import { managedCustomerAuthDatabaseMessage } from "./customer-auth-admission.js";
3
12
  export { managedCustomerAuthAdmissionIssue } from "./customer-auth-admission.js";
13
+ export type { FrameworkVersionPackage } from "./framework-build-script.mjs";
4
14
 
5
15
  export type PackageManagerName = "bun" | "npm" | "pnpm" | "yarn";
16
+ export type LockfileName =
17
+ | "bun.lock"
18
+ | "bun.lockb"
19
+ | "package-lock.json"
20
+ | "pnpm-lock.yaml"
21
+ | "yarn.lock";
22
+ export type FrameworkConfigBasename = "next.config" | "open-next.config" | "vite.config";
6
23
 
7
24
  export type FrameworkAdmissionReason =
8
25
  | "ambiguous_config"
@@ -13,12 +30,28 @@ export type FrameworkAdmissionReason =
13
30
  | "package_manager_lockfile_mismatch"
14
31
  | "package_manager_version_unsupported";
15
32
 
33
+ const ADMISSION_DIAGNOSTIC_CODES = Object.freeze({
34
+ ambiguous_config: "framework_config_ambiguous",
35
+ ambiguous_lockfile: "lockfile_ambiguous",
36
+ invalid_package_manager: "package_manager_invalid",
37
+ missing_lockfile: "lockfile_missing",
38
+ missing_package_manager: "package_manager_missing",
39
+ package_manager_lockfile_mismatch: "package_manager_lockfile_mismatch",
40
+ package_manager_version_unsupported: "package_manager_version_unsupported",
41
+ } as const satisfies Record<FrameworkAdmissionReason, string>);
42
+
16
43
  export class FrameworkAdmissionError extends Error {
17
44
  public readonly code = "framework_admission_failed";
45
+ /** The catalog diagnostic of the refused file, relative to the application root. */
46
+ public readonly diagnostic: FrameworkConversionDiagnostic;
18
47
 
19
- public constructor(public readonly reason: FrameworkAdmissionReason) {
48
+ public constructor(
49
+ public readonly reason: FrameworkAdmissionReason,
50
+ path: string,
51
+ ) {
20
52
  super("Framework source is not admitted");
21
53
  this.name = "FrameworkAdmissionError";
54
+ this.diagnostic = requiredDiagnostic({ code: ADMISSION_DIAGNOSTIC_CODES[reason], path });
22
55
  }
23
56
  }
24
57
 
@@ -30,18 +63,13 @@ export interface PackageManagerInput {
30
63
  export interface PackageManagerDescriptor {
31
64
  readonly manager: PackageManagerName;
32
65
  readonly version: string;
33
- readonly lockfile:
34
- | "bun.lock"
35
- | "bun.lockb"
36
- | "package-lock.json"
37
- | "pnpm-lock.yaml"
38
- | "yarn.lock";
66
+ readonly lockfile: LockfileName;
39
67
  readonly installCommand: string;
40
68
  readonly buildCommand: string;
41
69
  }
42
70
 
43
71
  interface PackageManagerContract {
44
- readonly lockfiles: readonly PackageManagerDescriptor["lockfile"][];
72
+ readonly lockfiles: readonly LockfileName[];
45
73
  readonly installCommand: string;
46
74
  readonly buildCommand: string;
47
75
  }
@@ -70,31 +98,37 @@ const PACKAGE_MANAGER_CONTRACTS: Readonly<Record<PackageManagerName, PackageMana
70
98
  }),
71
99
  });
72
100
 
73
- const ALL_LOCKFILES = Object.freeze(
101
+ const ALL_LOCKFILES: readonly string[] = Object.freeze(
74
102
  Object.values(PACKAGE_MANAGER_CONTRACTS).flatMap(({ lockfiles }) => lockfiles),
75
103
  );
76
- const EXACT_VERSION = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/u;
104
+ const PACKAGE_MANIFEST = "package.json";
77
105
  const PACKAGE_MANAGER_DECLARATION =
78
106
  /^(bun|npm|pnpm|yarn)@((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))$/u;
79
107
 
80
108
  export function resolvePackageManager(input: PackageManagerInput): PackageManagerDescriptor {
81
109
  const presentLockfiles = ALL_LOCKFILES.filter((lockfile) => input.files.includes(lockfile));
82
- if (presentLockfiles.length === 0) throw new FrameworkAdmissionError("missing_lockfile");
83
- if (presentLockfiles.length !== 1) throw new FrameworkAdmissionError("ambiguous_lockfile");
110
+ if (presentLockfiles.length === 0) {
111
+ throw new FrameworkAdmissionError("missing_lockfile", PACKAGE_MANIFEST);
112
+ }
113
+ if (presentLockfiles.length !== 1) {
114
+ throw new FrameworkAdmissionError("ambiguous_lockfile", PACKAGE_MANIFEST);
115
+ }
84
116
  if (input.packageManager === undefined) {
85
- throw new FrameworkAdmissionError("missing_package_manager");
117
+ throw new FrameworkAdmissionError("missing_package_manager", PACKAGE_MANIFEST);
86
118
  }
87
119
  const declaration = PACKAGE_MANAGER_DECLARATION.exec(input.packageManager);
88
- if (declaration === null) throw new FrameworkAdmissionError("invalid_package_manager");
120
+ if (declaration === null) {
121
+ throw new FrameworkAdmissionError("invalid_package_manager", PACKAGE_MANIFEST);
122
+ }
89
123
  const manager = declaration[1] as PackageManagerName;
90
124
  const version = declaration[2] as string;
91
125
  const contract = PACKAGE_MANAGER_CONTRACTS[manager];
92
- const lockfile = presentLockfiles[0] as PackageManagerDescriptor["lockfile"];
126
+ const lockfile = presentLockfiles[0] as LockfileName;
93
127
  if (!contract.lockfiles.includes(lockfile)) {
94
- throw new FrameworkAdmissionError("package_manager_lockfile_mismatch");
128
+ throw new FrameworkAdmissionError("package_manager_lockfile_mismatch", lockfile);
95
129
  }
96
130
  if (manager === "bun" && version !== PINNED_BUN_VERSION) {
97
- throw new FrameworkAdmissionError("package_manager_version_unsupported");
131
+ throw new FrameworkAdmissionError("package_manager_version_unsupported", PACKAGE_MANIFEST);
98
132
  }
99
133
  return Object.freeze({
100
134
  manager,
@@ -105,236 +139,646 @@ export function resolvePackageManager(input: PackageManagerInput): PackageManage
105
139
  });
106
140
  }
107
141
 
108
- export function resolveFrameworkConfig(basename: string, files: readonly string[]): string | null {
142
+ export function resolveFrameworkConfig(
143
+ basename: FrameworkConfigBasename,
144
+ files: readonly string[],
145
+ ): string | null {
109
146
  const supported = [`${basename}.js`, `${basename}.mjs`, `${basename}.ts`] as const;
110
147
  const matches = supported.filter((candidate) => files.includes(candidate));
111
- if (matches.length > 1) throw new FrameworkAdmissionError("ambiguous_config");
148
+ if (matches.length > 1) {
149
+ throw new FrameworkAdmissionError("ambiguous_config", matches[0] as string);
150
+ }
112
151
  return matches[0] ?? null;
113
152
  }
114
153
 
115
- export type AdmittedFramework = "nextjs" | "tanstack-start" | "vite";
116
- export type FrameworkVersionClassification = "experimental" | "unsupported" | "verified";
117
- export type FrameworkVersionReason =
118
- | "inside_admitted_window"
119
- | "invalid_version"
120
- | "outside_admitted_window"
121
- | "range_inside_admitted_window"
122
- | "verified_fixture";
154
+ export type FrameworkVersionStatus = "admitted" | "pending" | "unsupported";
123
155
 
156
+ /** One framework package judged by its lockfile resolution or, without one, its declaration. */
124
157
  export interface FrameworkVersionAdmission {
125
- readonly classification: FrameworkVersionClassification;
126
- readonly reason: FrameworkVersionReason;
158
+ readonly package: FrameworkVersionPackage;
159
+ readonly status: FrameworkVersionStatus;
160
+ readonly declared: string;
161
+ readonly resolved: string | null;
162
+ /** The lowest admitted release for the line of the judged version; it names the required change. */
163
+ readonly minimum: string;
127
164
  }
128
165
 
129
- const VERIFIED_VERSIONS: Readonly<Record<AdmittedFramework, ReadonlySet<string>>> = Object.freeze({
130
- vite: new Set(["5.4.21", "8.0.16", "8.2.2"]),
131
- "tanstack-start": new Set(["1.168.26", "1.168.49"]),
132
- nextjs: new Set(["15.5.26", "16.3.8"]),
133
- });
166
+ type VersionTuple = readonly [major: number, minor: number, patch: number];
167
+
168
+ const STABLE_VERSION_SOURCE =
169
+ "(?:0|[1-9][0-9]{0,5})\\.(?:0|[1-9][0-9]{0,5})\\.(?:0|[1-9][0-9]{0,5})";
170
+ const VERSION_RANGE = new RegExp(`^([~^])(${STABLE_VERSION_SOURCE})$`, "u");
134
171
 
172
+ /**
173
+ * A non-null lockfile resolution decides with the shared verdict. Without one, an exact declaration
174
+ * decides. A ^ range (below the next major) or ~ range (below the next minor) is admitted when every
175
+ * version it allows passes, unsupported when none can, and otherwise pending: planning admits it and
176
+ * the build's installed-version check decides. Every other declaration form is unsupported.
177
+ */
135
178
  export function classifyFrameworkVersion(
136
- framework: AdmittedFramework,
137
- version: string,
179
+ packageName: FrameworkVersionPackage,
180
+ declared: string,
181
+ resolved: string | null = null,
182
+ policy: FrameworkVersionPolicy = FRAMEWORK_VERSION_POLICY,
138
183
  ): FrameworkVersionAdmission {
139
- const parsed = parseVersionSpec(version);
140
- if (parsed === null) return versionAdmission("unsupported", "invalid_version");
141
- if (!insideAdmittedWindow(framework, parsed.version)) {
142
- return versionAdmission("unsupported", "outside_admitted_window");
143
- }
144
- if (!parsed.exact) {
145
- return versionAdmission("experimental", "range_inside_admitted_window");
184
+ const judged = resolved ?? declared;
185
+ const range = resolved === null ? VERSION_RANGE.exec(declared) : null;
186
+ let status: FrameworkVersionStatus;
187
+ if (range === null) {
188
+ status =
189
+ frameworkVersionVerdict(packageName, judged, policy) === "admitted"
190
+ ? "admitted"
191
+ : "unsupported";
192
+ } else {
193
+ status = rangeStatus(packageName, range[1] === "^", range[2] as string, policy);
146
194
  }
147
- return VERIFIED_VERSIONS[framework].has(version)
148
- ? versionAdmission("verified", "verified_fixture")
149
- : versionAdmission("experimental", "inside_admitted_window");
195
+ return Object.freeze({
196
+ package: packageName,
197
+ status,
198
+ declared,
199
+ resolved,
200
+ minimum: frameworkVersionMinimum(packageName, judged, policy),
201
+ });
150
202
  }
151
203
 
152
- type VersionTuple = readonly [major: number, minor: number, patch: number];
204
+ function rangeStatus(
205
+ packageName: FrameworkVersionPackage,
206
+ caret: boolean,
207
+ base: string,
208
+ policy: FrameworkVersionPolicy,
209
+ ): FrameworkVersionStatus {
210
+ const lower = policyVersion(base);
211
+ const upper: VersionTuple = caret ? [lower[0] + 1, 0, 0] : [lower[0], lower[1] + 1, 0];
212
+ if (
213
+ frameworkVersionVerdict(packageName, base, policy) === "admitted" &&
214
+ !policy[packageName].denied.some(([introduced, fixed]) => {
215
+ const from = parseStableVersion(introduced);
216
+ const to = fixed === null ? null : parseStableVersion(fixed);
217
+ return (
218
+ from !== null &&
219
+ compareStableVersions(from, upper) < 0 &&
220
+ (to === null || compareStableVersions(to, lower) > 0)
221
+ );
222
+ })
223
+ )
224
+ return "admitted";
225
+ // The lowest release inside the range that can pass: past the floor and every fixed denied range.
226
+ const minimum = policyVersion(frameworkVersionMinimum(packageName, base, policy));
227
+ const candidate = compareStableVersions(minimum, lower) > 0 ? minimum : lower;
228
+ return compareStableVersions(candidate, upper) < 0 &&
229
+ frameworkVersionVerdict(packageName, candidate.join("."), policy) === "admitted"
230
+ ? "pending"
231
+ : "unsupported";
232
+ }
153
233
 
154
- function parseVersionSpec(
155
- version: string,
156
- ): Readonly<{ exact: boolean; version: VersionTuple }> | null {
157
- const exact = EXACT_VERSION.exec(version);
158
- const match =
159
- exact ?? (/^[~^](.*)$/u.exec(version)?.[1] ? EXACT_VERSION.exec(version.slice(1)) : null);
160
- if (match === null) return null;
161
- const tuple = [Number(match[1]), Number(match[2]), Number(match[3])] as const;
162
- return tuple.every(Number.isSafeInteger)
163
- ? Object.freeze({ exact: exact !== null, version: tuple })
164
- : null;
234
+ function policyVersion(version: string): VersionTuple {
235
+ const parsed = parseStableVersion(version);
236
+ if (parsed === null) throw new TypeError("Framework version policy is invalid");
237
+ return parsed;
165
238
  }
166
239
 
167
- function insideAdmittedWindow(framework: AdmittedFramework, version: VersionTuple): boolean {
168
- switch (framework) {
169
- case "vite":
170
- return compareVersions(version, [5, 4, 0]) >= 0 && compareVersions(version, [8, 2, 2]) <= 0;
171
- case "tanstack-start":
172
- return (
173
- compareVersions(version, [1, 168, 26]) >= 0 && compareVersions(version, [1, 168, 49]) <= 0
174
- );
175
- case "nextjs":
176
- return isAdmittedNextVersion(version.join("."));
177
- }
240
+ const RESOLVED_VERSION_SOURCE = `${STABLE_VERSION_SOURCE}(?:-[0-9A-Za-z.-]{1,32})?`;
241
+ const RESOLVED_VERSION_FIELD = anchoredPattern(RESOLVED_VERSION_SOURCE);
242
+ const DECLARED_VERSION_FIELD = anchoredPattern(`[~^]?${RESOLVED_VERSION_SOURCE}`);
243
+ const STABLE_VERSION_FIELD = anchoredPattern(STABLE_VERSION_SOURCE);
244
+
245
+ /** A version the catalog names as resolved: X.Y.Z or X.Y.Z-<up to 32 of [0-9A-Za-z.-]>. */
246
+ export function isResolvedFrameworkVersion(value: unknown): value is string {
247
+ return typeof value === "string" && RESOLVED_VERSION_FIELD.test(value);
178
248
  }
179
249
 
180
- function compareVersions(left: VersionTuple, right: VersionTuple): number {
181
- const majorDifference = left[0] - right[0];
182
- if (majorDifference !== 0) return majorDifference;
183
- const minorDifference = left[1] - right[1];
184
- return minorDifference !== 0 ? minorDifference : left[2] - right[2];
250
+ /**
251
+ * The typed refusal of an unsupported admission, naming the file that decided it: the lockfile for
252
+ * a resolved version, package.json for a declaration. A declaration outside the exact, ^ and ~ forms
253
+ * asks for one of them. Admitted and pending admissions are not refused.
254
+ */
255
+ export function frameworkVersionDiagnostic(
256
+ admission: FrameworkVersionAdmission,
257
+ lockfile: LockfileName | null,
258
+ ): FrameworkConversionDiagnostic | null {
259
+ if (admission.status !== "unsupported") return null;
260
+ if (admission.resolved !== null) {
261
+ if (lockfile === null) throw new TypeError("Framework version lockfile is required");
262
+ return requiredDiagnostic({
263
+ code: "framework_version_unsupported",
264
+ path: lockfile,
265
+ package: admission.package,
266
+ version: admission.resolved,
267
+ minimum: admission.minimum,
268
+ });
269
+ }
270
+ return DECLARED_VERSION_FIELD.test(admission.declared)
271
+ ? requiredDiagnostic({
272
+ code: "framework_version_unsupported",
273
+ path: PACKAGE_MANIFEST,
274
+ package: admission.package,
275
+ version: admission.declared,
276
+ minimum: admission.minimum,
277
+ })
278
+ : requiredDiagnostic({
279
+ code: "framework_version_declaration_unsupported",
280
+ path: PACKAGE_MANIFEST,
281
+ package: admission.package,
282
+ });
185
283
  }
186
284
 
187
- function versionAdmission(
188
- classification: FrameworkVersionClassification,
189
- reason: FrameworkVersionReason,
190
- ): FrameworkVersionAdmission {
191
- return Object.freeze({ classification, reason });
285
+ /**
286
+ * The file a diagnostic names. Every class but repository_config is relative to the application
287
+ * and may carry its application-root prefix; ohmyhost.yaml always sits at the repository root.
288
+ */
289
+ export type FrameworkConversionPathClass =
290
+ | "framework_config"
291
+ | "lockfile"
292
+ | "manifest"
293
+ | "module"
294
+ | "repository_config";
295
+
296
+ /** A field value grammar: a closed set of values or an anchored pattern. */
297
+ type FieldGrammar = readonly string[] | RegExp;
298
+
299
+ interface DerivedText {
300
+ /** `path` selects by path class; any other name selects by that field's value. */
301
+ readonly from: string;
302
+ readonly text: Readonly<Record<string, string>>;
192
303
  }
193
304
 
194
- export type FrameworkConversionDiagnosticCode =
195
- | "managed_auth_database_required"
196
- | "database_binding_private"
197
- | "database_driver_unsupported"
198
- | "source_syntax_invalid"
199
- | "worker_module_default_export_required"
200
- | "scheduled_handler_required"
201
- | "framework_base_path_unsupported"
202
- | "tanstack_prerender_required"
203
- | "tanstack_vite_plugins_required"
204
- | "next_cache_components_unsupported";
205
-
206
- export interface FrameworkConversionDiagnostic {
207
- readonly code: FrameworkConversionDiagnosticCode;
208
- readonly path: string;
305
+ interface DetailDefinition {
306
+ readonly paths: readonly FrameworkConversionPathClass[];
307
+ readonly fields?: Readonly<Record<string, FieldGrammar>>;
308
+ readonly derived?: Readonly<Record<string, DerivedText>>;
309
+ /** The sentence; `{{name}}` places the path, a field or a derived text. */
310
+ readonly template: string;
209
311
  }
210
312
 
313
+ const FRAMEWORK_VERSION_PACKAGES = Object.freeze([
314
+ "next",
315
+ "vite",
316
+ "@tanstack/react-start",
317
+ ] as const satisfies readonly FrameworkVersionPackage[]);
318
+ const FRAMEWORK_DEPENDENCY_PACKAGES = Object.freeze([
319
+ "next",
320
+ "react",
321
+ "react-dom",
322
+ "vite",
323
+ "@tanstack/react-start",
324
+ "@tanstack/react-router",
325
+ "@vitejs/plugin-react",
326
+ "@cloudflare/vite-plugin",
327
+ ] as const);
328
+ const REACT_PACKAGES = Object.freeze(["react", "react-dom"] as const);
329
+ const BUILD_FRAMEWORKS = Object.freeze(["nextjs", "vite-static", "tanstack-start"] as const);
330
+
211
331
  const PUSH_AND_PLAN = "Commit and push the change, then plan the new commit.";
332
+ const VITE_BUILD_SCRIPT =
333
+ '"vite build", optionally joined with && to one "tsc", "tsc -b", "tsc --build" or "tsc --noEmit" stage';
212
334
 
213
- // Detail texts are the customer-facing sentence for one diagnostic; parseFrameworkConversionDetail
214
- // recognizes exactly these sentences so a client can map a detail back to its typed diagnostic.
215
- const FRAMEWORK_CONVERSION_DETAILS: Readonly<
216
- Record<
217
- FrameworkConversionDiagnosticCode,
218
- Readonly<{ render(path: string): string; pattern: RegExp }>
219
- >
220
- > = Object.freeze({
221
- managed_auth_database_required: Object.freeze({
222
- render: managedCustomerAuthDatabaseMessage,
223
- pattern:
224
- /^"([^"]+)" selects the managed Better Auth bridge, which requires the managed PostgreSQL database\. Set database\.enabled: true with an admitted migrations path, or set auth\.provider: none and keep the application-owned authentication configuration\. Commit and push the change, then plan the new commit\.$/u,
225
- }),
226
- database_binding_private: Object.freeze({
227
- render: (path: string) =>
228
- `"${path}" reads a database connection string (DATABASE_URL, HYPERDRIVE, connectionString or a postgres:// URL); none reaches a customer Worker. Call the database with createPrivateDatabaseClient from "@ohmyhost/customer-runtime/database". ${PUSH_AND_PLAN}`,
229
- pattern:
230
- /^"([^"]+)" reads a database connection string \(DATABASE_URL, HYPERDRIVE, connectionString or a postgres:\/\/ URL\); none reaches a customer Worker\. Call the database with createPrivateDatabaseClient from "@ohmyhost\/customer-runtime\/database"\. Commit and push the change, then plan the new commit\.$/u,
231
- }),
232
- database_driver_unsupported: Object.freeze({
233
- render: (path: string) =>
234
- `"${path}" imports a database socket driver (pg, postgres, pg-native or mysql2); customer Workers cannot open sockets. Call the database with createPrivateDatabaseClient from "@ohmyhost/customer-runtime/database". ${PUSH_AND_PLAN}`,
235
- pattern:
236
- /^"([^"]+)" imports a database socket driver \(pg, postgres, pg-native or mysql2\); customer Workers cannot open sockets\. Call the database with createPrivateDatabaseClient from "@ohmyhost\/customer-runtime\/database"\. Commit and push the change, then plan the new commit\.$/u,
237
- }),
238
- source_syntax_invalid: Object.freeze({
239
- render: (path: string) =>
240
- `Invalid JavaScript or TypeScript in "${path}". Fix the file, commit and push the change, then plan the new commit.`,
241
- pattern:
242
- /^Invalid JavaScript or TypeScript in "([^"]+)"\. Fix the file, commit and push the change, then plan the new commit\.$/u,
243
- }),
244
- worker_module_default_export_required: Object.freeze({
245
- render: (path: string) =>
246
- `Worker module "${path}" has no default export. Export default { fetch, scheduled } with the handlers ohmyho.st invokes; named exports are never called. ${PUSH_AND_PLAN}`,
247
- pattern:
248
- /^Worker module "([^"]+)" has no default export\. Export default \{ fetch, scheduled \} with the handlers ohmyho\.st invokes; named exports are never called\. Commit and push the change, then plan the new commit\.$/u,
249
- }),
250
- scheduled_handler_required: Object.freeze({
251
- render: (path: string) =>
252
- `functions.crons is declared but "${path}" exports no scheduled handler. Add scheduled(controller, env, ctx) to its default export, commit and push the change, then plan the new commit.`,
253
- pattern:
254
- /^functions\.crons is declared but "([^"]+)" exports no scheduled handler\. Add scheduled\(controller, env, ctx\) to its default export, commit and push the change, then plan the new commit\.$/u,
255
- }),
256
- framework_base_path_unsupported: Object.freeze({
257
- render: (path: string) =>
258
- `"${path}" sets a Vite base. ohmyho.st serves the app at the root origin: remove base or set base: "/". ${PUSH_AND_PLAN}`,
259
- pattern:
260
- /^"([^"]+)" sets a Vite base\. ohmyho\.st serves the app at the root origin: remove base or set base: "\/"\. Commit and push the change, then plan the new commit\.$/u,
261
- }),
262
- tanstack_prerender_required: Object.freeze({
263
- render: (path: string) =>
264
- `"${path}" does not call tanstackStart({ prerender: { enabled: true } }), which the static TanStack Start runtime needs; enable prerender, or set runtime.mode: edge with the Cloudflare Vite plugin. ${PUSH_AND_PLAN}`,
265
- pattern:
266
- /^"([^"]+)" does not call tanstackStart\(\{ prerender: \{ enabled: true \} \}\), which the static TanStack Start runtime needs; enable prerender, or set runtime\.mode: edge with the Cloudflare Vite plugin\. Commit and push the change, then plan the new commit\.$/u,
267
- }),
268
- tanstack_vite_plugins_required: Object.freeze({
269
- render: (path: string) =>
270
- `"${path}" does not follow the TanStack Start plugin contract: import tanstackStart from "@tanstack/react-start/plugin/vite"; on runtime edge also import cloudflare from "@cloudflare/vite-plugin" and call cloudflare({ viteEnvironment: { name: "ssr" } }) before tanstackStart(); on runtime static do not import the Cloudflare plugin. ${PUSH_AND_PLAN}`,
271
- pattern:
272
- /^"([^"]+)" does not follow the TanStack Start plugin contract: import tanstackStart from "@tanstack\/react-start\/plugin\/vite"; on runtime edge also import cloudflare from "@cloudflare\/vite-plugin" and call cloudflare\(\{ viteEnvironment: \{ name: "ssr" \} \}\) before tanstackStart\(\); on runtime static do not import the Cloudflare plugin\. Commit and push the change, then plan the new commit\.$/u,
273
- }),
274
- next_cache_components_unsupported: Object.freeze({
275
- render: (path: string) =>
276
- `"${path}" sets cacheComponents: true, which ohmyho.st does not support; remove it or set it to false. ${PUSH_AND_PLAN}`,
277
- pattern:
278
- /^"([^"]+)" sets cacheComponents: true, which ohmyho\.st does not support; remove it or set it to false\. Commit and push the change, then plan the new commit\.$/u,
279
- }),
280
- });
335
+ // Each sentence names the file, the rule and the exact change. Render and parse both derive from
336
+ // the template, so a client maps a detail back to exactly one typed diagnostic.
337
+ const FRAMEWORK_CONVERSION_DETAILS = Object.freeze({
338
+ managed_auth_database_required: {
339
+ paths: ["repository_config"],
340
+ template: managedCustomerAuthDatabaseMessage("{{path}}"),
341
+ },
342
+ database_binding_private: {
343
+ paths: ["module"],
344
+ template: `"{{path}}" reads a database connection string (DATABASE_URL, HYPERDRIVE, connectionString or a postgres:// URL); none reaches a customer Worker. Call the database with createPrivateDatabaseClient from "@ohmyhost/customer-runtime/database". ${PUSH_AND_PLAN}`,
345
+ },
346
+ database_driver_unsupported: {
347
+ paths: ["module"],
348
+ template: `"{{path}}" imports a database socket driver (pg, postgres, pg-native or mysql2); customer Workers cannot open sockets. Call the database with createPrivateDatabaseClient from "@ohmyhost/customer-runtime/database". ${PUSH_AND_PLAN}`,
349
+ },
350
+ source_syntax_invalid: {
351
+ paths: ["module"],
352
+ template:
353
+ 'Invalid JavaScript or TypeScript in "{{path}}". Fix the file, commit and push the change, then plan the new commit.',
354
+ },
355
+ worker_module_default_export_required: {
356
+ paths: ["module"],
357
+ template: `Worker module "{{path}}" has no default export. Export default { fetch, scheduled } with the handlers ohmyho.st invokes; named exports are never called. ${PUSH_AND_PLAN}`,
358
+ },
359
+ scheduled_handler_required: {
360
+ paths: ["module"],
361
+ template:
362
+ 'functions.crons is declared but "{{path}}" exports no scheduled handler. Add scheduled(controller, env, ctx) to its default export, commit and push the change, then plan the new commit.',
363
+ },
364
+ framework_base_path_unsupported: {
365
+ paths: ["framework_config"],
366
+ template: `"{{path}}" sets a Vite base. ohmyho.st serves the app at the root origin: remove base or set base: "/". ${PUSH_AND_PLAN}`,
367
+ },
368
+ tanstack_prerender_required: {
369
+ paths: ["framework_config"],
370
+ template: `"{{path}}" does not call tanstackStart({ prerender: { enabled: true } }), which the static TanStack Start runtime needs; enable prerender, or set runtime.mode: edge with the Cloudflare Vite plugin. ${PUSH_AND_PLAN}`,
371
+ },
372
+ tanstack_vite_plugins_required: {
373
+ paths: ["framework_config"],
374
+ template: `"{{path}}" does not follow the TanStack Start plugin contract: import tanstackStart from "@tanstack/react-start/plugin/vite"; on runtime edge also import cloudflare from "@cloudflare/vite-plugin" and call cloudflare({ viteEnvironment: { name: "ssr" } }) before tanstackStart(); on runtime static do not import the Cloudflare plugin. ${PUSH_AND_PLAN}`,
375
+ },
376
+ next_cache_components_unsupported: {
377
+ paths: ["framework_config"],
378
+ template: `"{{path}}" sets cacheComponents: true, which ohmyho.st does not support; remove it or set it to false. ${PUSH_AND_PLAN}`,
379
+ },
380
+ framework_version_unsupported: {
381
+ paths: ["manifest", "lockfile"],
382
+ fields: {
383
+ package: FRAMEWORK_VERSION_PACKAGES,
384
+ version: DECLARED_VERSION_FIELD,
385
+ minimum: STABLE_VERSION_FIELD,
386
+ },
387
+ derived: { verb: { from: "path", text: { manifest: "declares", lockfile: "resolves" } } },
388
+ template:
389
+ '"{{path}}" {{verb}} {{package}} {{version}}, which ohmyho.st does not admit; use {{package}} {{minimum}} or a later stable release, update package.json and the lockfile, commit and push the change, then plan the new commit.',
390
+ },
391
+ framework_version_declaration_unsupported: {
392
+ paths: ["manifest"],
393
+ fields: { package: FRAMEWORK_VERSION_PACKAGES },
394
+ template:
395
+ '"{{path}}" declares {{package}} in a form ohmyho.st cannot check against its security minimum; declare an exact X.Y.Z version or a ^X.Y.Z or ~X.Y.Z range, update the lockfile, commit and push the change, then plan the new commit.',
396
+ },
397
+ framework_dependency_required: {
398
+ paths: ["manifest"],
399
+ fields: { package: FRAMEWORK_DEPENDENCY_PACKAGES },
400
+ template:
401
+ '"{{path}}" does not declare {{package}} in dependencies, which this framework and runtime need. Add it with the package manager so the lockfile records it, commit and push the change, then plan the new commit.',
402
+ },
403
+ react_version_exact_required: {
404
+ paths: ["manifest"],
405
+ fields: { package: REACT_PACKAGES },
406
+ template:
407
+ '"{{path}}" must declare {{package}} in dependencies at an exact version such as 19.1.0, without ^ or ~, for Next.js. Pin the version the lockfile installs, commit and push the change, then plan the new commit.',
408
+ },
409
+ build_script_unsupported: {
410
+ paths: ["manifest"],
411
+ fields: { framework: BUILD_FRAMEWORKS },
412
+ derived: {
413
+ script: {
414
+ from: "framework",
415
+ text: {
416
+ nextjs:
417
+ 'exactly "next build" or "next build --webpack"; ohmyho.st adds the OpenNext build itself',
418
+ "vite-static": VITE_BUILD_SCRIPT,
419
+ "tanstack-start": VITE_BUILD_SCRIPT,
420
+ },
421
+ },
422
+ },
423
+ template:
424
+ '"{{path}}" scripts.build is missing or not admitted for the {{framework}} adapter: it must be {{script}}. Move other steps into their own scripts, commit and push the change, then plan the new commit.',
425
+ },
426
+ better_auth_version_required: {
427
+ paths: ["manifest"],
428
+ template:
429
+ '"{{path}}" must declare better-auth at exactly 1.7.1 in dependencies because ohmyhost.yaml sets auth.provider: better-auth. Pin that version or set auth.provider: none, commit and push the change, then plan the new commit.',
430
+ },
431
+ package_manifest_invalid: {
432
+ paths: ["manifest"],
433
+ template:
434
+ '"{{path}}" is missing or is not one JSON object. Add package.json in the applicationRoot that ohmyhost.yaml names, or fix its syntax so the whole file parses as one object; commit and push the change, then plan the new commit.',
435
+ },
436
+ framework_unsupported: {
437
+ paths: ["manifest"],
438
+ template: `"{{path}}" declares none of next, vite or @tanstack/react-start. Add the app's framework to dependencies, or set runtime.mode: functions in ohmyhost.yaml and add src/ohmyhost/worker.ts. ${PUSH_AND_PLAN}`,
439
+ },
440
+ framework_ambiguous: {
441
+ paths: ["manifest"],
442
+ template:
443
+ '"{{path}}" declares next together with vite or @tanstack/react-start, so ohmyho.st cannot choose one framework. Remove the packages of the framework this application does not use, commit and push the change, then plan the new commit.',
444
+ },
445
+ lockfile_missing: {
446
+ paths: ["manifest"],
447
+ template:
448
+ '"{{path}}" has no lockfile beside it. Install with the packageManager it names so package-lock.json, pnpm-lock.yaml, yarn.lock or bun.lock is written, commit and push the change, then plan the new commit.',
449
+ },
450
+ lockfile_ambiguous: {
451
+ paths: ["manifest"],
452
+ template:
453
+ '"{{path}}" has more than one lockfile beside it. Keep only the lockfile of the packageManager it names and delete the others, commit and push the change, then plan the new commit.',
454
+ },
455
+ package_manager_missing: {
456
+ paths: ["manifest"],
457
+ template:
458
+ '"{{path}}" has no packageManager field. Set it to name@X.Y.Z for the npm, pnpm, yarn or bun version that wrote the lockfile, commit and push the change, then plan the new commit.',
459
+ },
460
+ package_manager_invalid: {
461
+ paths: ["manifest"],
462
+ template:
463
+ '"{{path}}" sets packageManager to a value ohmyho.st cannot use. Write npm, pnpm, yarn or bun, then @ and an exact X.Y.Z version without a +sha suffix, matching the lockfile; commit and push the change, then plan the new commit.',
464
+ },
465
+ package_manager_lockfile_mismatch: {
466
+ paths: ["lockfile"],
467
+ template:
468
+ '"{{path}}" was not written by the packageManager that package.json names. Set packageManager to the manager of this lockfile, or replace the lockfile with one from the named manager; commit and push the change, then plan the new commit.',
469
+ },
470
+ package_manager_version_unsupported: {
471
+ paths: ["manifest"],
472
+ template: `"{{path}}" pins a Bun version other than ${PINNED_BUN_VERSION}, the only Bun ohmyho.st builds with. Set packageManager to bun@${PINNED_BUN_VERSION}, reinstall to update the lockfile, commit and push the change, then plan the new commit.`,
473
+ },
474
+ repository_configuration_invalid: {
475
+ paths: ["repository_config"],
476
+ template:
477
+ '"{{path}}" does not match the ohmyho.st configuration schema. Run ohmyhost init --dry-run --json locally to see the exact field and fix, commit and push the change, then plan the new commit.',
478
+ },
479
+ runtime_mode_unsupported: {
480
+ paths: ["repository_config"],
481
+ fields: { framework: BUILD_FRAMEWORKS },
482
+ derived: {
483
+ mode: {
484
+ from: "framework",
485
+ text: {
486
+ nextjs: "runtime.mode: edge",
487
+ "vite-static":
488
+ "runtime.mode: edge with src/ohmyhost/companion.ts when database, auth, mail, storage or functions.crons is set, and static otherwise",
489
+ "tanstack-start": "runtime.mode: edge, or static without functions.crons and storage",
490
+ },
491
+ },
492
+ },
493
+ template: `"{{path}}" sets a runtime.mode that the {{framework}} adapter cannot run with this configuration; use {{mode}}. ${PUSH_AND_PLAN}`,
494
+ },
495
+ build_output_unsupported: {
496
+ paths: ["repository_config"],
497
+ fields: { framework: BUILD_FRAMEWORKS },
498
+ derived: {
499
+ output: {
500
+ from: "framework",
501
+ text: {
502
+ nextjs: ".open-next/assets",
503
+ "vite-static": "dist",
504
+ "tanstack-start": "dist/client",
505
+ },
506
+ },
507
+ },
508
+ template: `"{{path}}" sets a build.output that the {{framework}} adapter does not produce; set build.output: {{output}}. ${PUSH_AND_PLAN}`,
509
+ },
510
+ build_command_mismatch: {
511
+ paths: ["repository_config"],
512
+ template:
513
+ '"{{path}}" build.install or build.command differs from the commands of the packageManager in package.json (for npm: npm ci --ignore-scripts and npm run build). ohmyhost init --dry-run --json prints the exact values; set them, commit and push the change, then plan the new commit.',
514
+ },
515
+ build_option_unsupported: {
516
+ paths: ["repository_config"],
517
+ template:
518
+ '"{{path}}" sets a build option this runtime ignores: build.ssg_cache_max_mib applies only to Next.js, and runtime.mode: functions takes only build.install. Remove the option, commit and push the change, then plan the new commit.',
519
+ },
520
+ application_root_missing: {
521
+ paths: ["repository_config"],
522
+ template:
523
+ '"{{path}}" sets applicationRoot to a directory that is missing or empty in this commit. Set it to the directory that holds the app\'s package.json, or "." for the repository root; commit and push the change, then plan the new commit.',
524
+ },
525
+ framework_config_required: {
526
+ paths: ["framework_config"],
527
+ template:
528
+ 'This application has no "{{path}}". Add one framework config with that name and a .js, .mjs or .ts extension that exports its config as default, commit and push the change, then plan the new commit.',
529
+ },
530
+ framework_config_ambiguous: {
531
+ paths: ["framework_config"],
532
+ template:
533
+ '"{{path}}" exists beside another variant with the same name and a .js, .mjs or .ts extension. Keep exactly one and delete the others, commit and push the change, then plan the new commit.',
534
+ },
535
+ framework_config_too_large: {
536
+ paths: ["framework_config"],
537
+ template:
538
+ '"{{path}}" is larger than the 256 KiB ohmyho.st inspects. Keep the config small and move large data into imported modules, commit and push the change, then plan the new commit.',
539
+ },
540
+ framework_config_default_export_required: {
541
+ paths: ["framework_config"],
542
+ template:
543
+ '"{{path}}" does not export its config as default. Use export default (next.config.js may assign module.exports instead), commit and push the change, then plan the new commit.',
544
+ },
545
+ next_output_unsupported: {
546
+ paths: ["framework_config"],
547
+ template:
548
+ '"{{path}}" sets output: "export" or output: "standalone", which the Next.js edge runtime on ohmyho.st cannot serve. Remove the output setting, commit and push the change, then plan the new commit.',
549
+ },
550
+ next_base_path_unsupported: {
551
+ paths: ["framework_config"],
552
+ template: `"{{path}}" sets basePath or assetPrefix. ohmyho.st serves the app at the root origin: remove both or set them to "". ${PUSH_AND_PLAN}`,
553
+ },
554
+ companion_entry_required: {
555
+ paths: ["module"],
556
+ template:
557
+ '"{{path}}" is missing. A Vite app with runtime.mode: edge or with database, auth, mail, storage or functions.crons needs this Worker module with export default { fetch }. Add it, or use runtime.mode: static without those settings; commit and push the change, then plan the new commit.',
558
+ },
559
+ worker_module_required: {
560
+ paths: ["module"],
561
+ template:
562
+ '"{{path}}" is missing. runtime.mode: functions and functions.crons on Next.js or TanStack Start need this Worker module; export default { fetch, scheduled } with the handlers ohmyho.st invokes. Add it, commit and push the change, then plan the new commit.',
563
+ },
564
+ worker_module_fetch_handler_required: {
565
+ paths: ["module"],
566
+ template: `Worker module "{{path}}" exports no fetch handler; runtime.mode: functions serves every request and the health check through it. Add a fetch handler that receives request, env and ctx to its default export. ${PUSH_AND_PLAN}`,
567
+ },
568
+ } as const satisfies Readonly<Record<string, DetailDefinition>>);
281
569
 
282
- /**
283
- * An adapter rule that `init` does not check. It stays a TypeError, so every existing caller that
284
- * treats an adapter refusal as a TypeError keeps working, and carries the typed diagnostic whose
285
- * detail names the file and its fix.
286
- */
287
- export class FrameworkConversionDiagnosticError extends TypeError {
288
- public readonly diagnostic: FrameworkConversionDiagnostic;
570
+ type DetailDefinitions = typeof FRAMEWORK_CONVERSION_DETAILS;
571
+ export type FrameworkConversionDiagnosticCode = keyof DetailDefinitions;
572
+ type FieldValue<Grammar> = Grammar extends readonly (infer Value extends string)[] ? Value : string;
573
+ type DiagnosticFields<Definition> = Definition extends { readonly fields: infer Fields }
574
+ ? { readonly [Field in keyof Fields]: FieldValue<Fields[Field]> }
575
+ : unknown;
289
576
 
290
- public constructor(diagnostic: unknown) {
291
- const parsed = parseFrameworkConversionDiagnostic(diagnostic);
292
- if (parsed === null) throw new TypeError("Framework conversion diagnostic is invalid");
293
- super(FRAMEWORK_CONVERSION_DETAILS[parsed.code].render(parsed.path));
294
- this.name = "FrameworkConversionDiagnosticError";
295
- this.diagnostic = parsed;
577
+ /** A typed refusal: its code, the file it names and the bounded fields its sentence needs. */
578
+ export type FrameworkConversionDiagnostic = {
579
+ [Code in FrameworkConversionDiagnosticCode]: Readonly<{ code: Code; path: string }> &
580
+ DiagnosticFields<DetailDefinitions[Code]>;
581
+ }[FrameworkConversionDiagnosticCode];
582
+
583
+ const DETAIL_DEFINITIONS: Readonly<Record<FrameworkConversionDiagnosticCode, DetailDefinition>> =
584
+ FRAMEWORK_CONVERSION_DETAILS;
585
+ const PLACEHOLDER = /\{\{([a-z]+)\}\}/gu;
586
+ const PATH_PATTERN = "[A-Za-z0-9_@()[\\]./${}-]{1,240}";
587
+ const SAFE_PATH = new RegExp(`^${PATH_PATTERN}$`, "u");
588
+ const MODULE_PATH = /^[A-Za-z0-9_@()[\]./${}-]+\.(?:[cm]?[jt]sx?)$/u;
589
+ const FRAMEWORK_CONFIG_NAME = /^(?:next|open-next|vite)\.config\.(?:js|mjs|ts)$/u;
590
+ // The public problem detail bound; every rendered sentence stays far below it.
591
+ const MAXIMUM_DETAIL_LENGTH = 2_000;
592
+ const DETAIL_PATTERNS = Object.freeze(
593
+ Object.entries(DETAIL_DEFINITIONS).map(([code, definition]) =>
594
+ Object.freeze({
595
+ code: code as FrameworkConversionDiagnosticCode,
596
+ pattern: detailPattern(definition),
597
+ }),
598
+ ),
599
+ );
600
+
601
+ function anchoredPattern(source: string): RegExp {
602
+ return new RegExp(`^(?:${source})$`, "u");
603
+ }
604
+
605
+ function escapePattern(text: string): string {
606
+ return text.replace(/[\\^$.*+?()[\]{}|]/gu, "\\$&");
607
+ }
608
+
609
+ function detailPattern(definition: DetailDefinition): RegExp {
610
+ const placed = new Set<string>();
611
+ let pattern = "^";
612
+ let offset = 0;
613
+ for (const match of definition.template.matchAll(PLACEHOLDER)) {
614
+ const name = match[1] as string;
615
+ pattern += escapePattern(definition.template.slice(offset, match.index));
616
+ pattern += placed.has(name)
617
+ ? `\\k<${name}>`
618
+ : `(?<${name}>${placeholderPattern(definition, name)})`;
619
+ placed.add(name);
620
+ offset = match.index + match[0].length;
296
621
  }
622
+ return new RegExp(`${pattern}${escapePattern(definition.template.slice(offset))}$`, "u");
623
+ }
624
+
625
+ function placeholderPattern(definition: DetailDefinition, name: string): string {
626
+ if (name === "path") return PATH_PATTERN;
627
+ const grammar = definition.fields?.[name];
628
+ if (grammar instanceof RegExp) return grammar.source.slice(1, -1);
629
+ const values = grammar ?? Object.values(definition.derived?.[name]?.text ?? {});
630
+ if (values.length === 0) throw new TypeError("Framework conversion detail template is invalid");
631
+ return `(?:${[...new Set(values)].map(escapePattern).join("|")})`;
632
+ }
633
+
634
+ function pathClassOf(
635
+ definition: DetailDefinition,
636
+ path: string,
637
+ ): FrameworkConversionPathClass | null {
638
+ if (path === "ohmyhost.yaml" && definition.paths.includes("repository_config"))
639
+ return "repository_config";
640
+ if (
641
+ !SAFE_PATH.test(path) ||
642
+ path.split("/").some((segment) => segment === "" || segment === "." || segment === "..")
643
+ )
644
+ return null;
645
+ const leaf = path.slice(path.lastIndexOf("/") + 1);
646
+ return (
647
+ definition.paths.find(
648
+ (pathClass) =>
649
+ (pathClass === "module" && MODULE_PATH.test(path)) ||
650
+ (pathClass === "manifest" && leaf === PACKAGE_MANIFEST) ||
651
+ (pathClass === "lockfile" && ALL_LOCKFILES.includes(leaf)) ||
652
+ (pathClass === "framework_config" && FRAMEWORK_CONFIG_NAME.test(leaf)),
653
+ ) ?? null
654
+ );
655
+ }
656
+
657
+ function fieldMatches(grammar: FieldGrammar, value: unknown): boolean {
658
+ if (typeof value !== "string") return false;
659
+ return grammar instanceof RegExp ? grammar.test(value) : grammar.includes(value);
297
660
  }
298
661
 
299
662
  function isFrameworkConversionDiagnosticCode(
300
663
  value: unknown,
301
664
  ): value is FrameworkConversionDiagnosticCode {
302
- return typeof value === "string" && Object.hasOwn(FRAMEWORK_CONVERSION_DETAILS, value);
665
+ return typeof value === "string" && Object.hasOwn(DETAIL_DEFINITIONS, value);
303
666
  }
304
667
 
668
+ /** Accepts exactly a catalog code, a path of that code's classes and its bounded fields. */
305
669
  export function parseFrameworkConversionDiagnostic(
306
670
  value: unknown,
307
671
  ): FrameworkConversionDiagnostic | null {
308
672
  if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
309
- const record = value as Record<string, unknown>;
310
- const path = record["path"];
311
- const code = record["code"];
673
+ const record = value as Readonly<Record<string, unknown>>;
674
+ const code = Object.hasOwn(record, "code") ? record["code"] : undefined;
675
+ const path = Object.hasOwn(record, "path") ? record["path"] : undefined;
676
+ if (!isFrameworkConversionDiagnosticCode(code) || typeof path !== "string") return null;
677
+ const definition = DETAIL_DEFINITIONS[code];
678
+ const fields = Object.entries(definition.fields ?? {}).map(
679
+ ([name, grammar]) =>
680
+ [name, grammar, Object.hasOwn(record, name) ? record[name] : undefined] as const,
681
+ );
312
682
  if (
313
- Object.keys(record).length !== 2 ||
314
- !isFrameworkConversionDiagnosticCode(code) ||
315
- typeof path !== "string" ||
316
- path.length > 240 ||
317
- (code === "managed_auth_database_required"
318
- ? path !== "ohmyhost.yaml"
319
- : !/^[A-Za-z0-9_@()[\]./-]+\.(?:[cm]?[jt]sx?)$/u.test(path)) ||
320
- path.split("/").some((segment) => segment === "" || segment === "." || segment === "..")
683
+ Object.keys(record).length !== fields.length + 2 ||
684
+ pathClassOf(definition, path) === null ||
685
+ fields.some(([, grammar, field]) => !fieldMatches(grammar, field))
321
686
  )
322
687
  return null;
323
- return Object.freeze({ code, path });
688
+ return Object.freeze(
689
+ Object.fromEntries([
690
+ ["code", code],
691
+ ["path", path],
692
+ ...fields.map(([name, , field]) => [name, field]),
693
+ ]),
694
+ ) as FrameworkConversionDiagnostic;
695
+ }
696
+
697
+ function requiredDiagnostic(value: unknown): FrameworkConversionDiagnostic {
698
+ const diagnostic = parseFrameworkConversionDiagnostic(value);
699
+ if (diagnostic === null) throw new TypeError("Framework conversion diagnostic is invalid");
700
+ return diagnostic;
701
+ }
702
+
703
+ function renderDetail(diagnostic: FrameworkConversionDiagnostic): string {
704
+ const definition = DETAIL_DEFINITIONS[diagnostic.code];
705
+ const values: Readonly<Record<string, unknown>> = diagnostic;
706
+ const pathClass = pathClassOf(definition, diagnostic.path);
707
+ return definition.template.replace(PLACEHOLDER, (_placeholder, name: string) => {
708
+ const derived = definition.derived?.[name];
709
+ const value =
710
+ derived === undefined
711
+ ? values[name]
712
+ : derived.text[String(derived.from === "path" ? pathClass : values[derived.from])];
713
+ if (typeof value !== "string")
714
+ throw new TypeError("Framework conversion diagnostic is invalid");
715
+ return value;
716
+ });
717
+ }
718
+
719
+ /**
720
+ * A customer source refusal: its message is the catalog detail of the typed diagnostic, which names
721
+ * the file and its fix. It stays a TypeError, so callers that treat an adapter refusal as a
722
+ * TypeError keep working.
723
+ */
724
+ export class FrameworkConversionDiagnosticError extends TypeError {
725
+ public readonly diagnostic: FrameworkConversionDiagnostic;
726
+
727
+ public constructor(diagnostic: unknown) {
728
+ const parsed = requiredDiagnostic(diagnostic);
729
+ super(renderDetail(parsed));
730
+ this.name = "FrameworkConversionDiagnosticError";
731
+ this.diagnostic = parsed;
732
+ }
324
733
  }
325
734
 
326
735
  export function frameworkConversionDetail(value: unknown): string | null {
327
736
  const diagnostic = parseFrameworkConversionDiagnostic(value);
328
- return diagnostic === null
329
- ? null
330
- : FRAMEWORK_CONVERSION_DETAILS[diagnostic.code].render(diagnostic.path);
737
+ return diagnostic === null ? null : renderDetail(diagnostic);
331
738
  }
332
739
 
333
- export function parseFrameworkConversionDetail(value: unknown): string | null {
334
- if (typeof value !== "string") return null;
335
- for (const [code, detail] of Object.entries(FRAMEWORK_CONVERSION_DETAILS)) {
336
- const match = detail.pattern.exec(value);
337
- if (match !== null) return frameworkConversionDetail({ code, path: match[1] });
740
+ /** The typed diagnostic whose rendering is exactly this detail, or null for any other text. */
741
+ export function frameworkConversionDiagnosticFromDetail(
742
+ value: unknown,
743
+ ): FrameworkConversionDiagnostic | null {
744
+ if (typeof value !== "string" || value.length > MAXIMUM_DETAIL_LENGTH) return null;
745
+ for (const { code, pattern } of DETAIL_PATTERNS) {
746
+ const groups = pattern.exec(value)?.groups;
747
+ if (groups === undefined) continue;
748
+ const diagnostic = parseFrameworkConversionDiagnostic({
749
+ code,
750
+ path: groups["path"],
751
+ ...Object.fromEntries(
752
+ Object.keys(DETAIL_DEFINITIONS[code].fields ?? {}).map((name) => [name, groups[name]]),
753
+ ),
754
+ });
755
+ if (diagnostic !== null && renderDetail(diagnostic) === value) return diagnostic;
338
756
  }
339
757
  return null;
340
758
  }
759
+
760
+ export function parseFrameworkConversionDetail(value: unknown): string | null {
761
+ const diagnostic = frameworkConversionDiagnosticFromDetail(value);
762
+ return diagnostic === null ? null : renderDetail(diagnostic);
763
+ }
764
+
765
+ /**
766
+ * Places an application-relative diagnostic under its application root for the repository-wide
767
+ * answer; ohmyhost.yaml stays at the repository root. Null when the rooted path is not admitted.
768
+ */
769
+ export function rootFrameworkConversionDiagnostic(
770
+ value: unknown,
771
+ applicationRoot: string,
772
+ ): FrameworkConversionDiagnostic | null {
773
+ const diagnostic = parseFrameworkConversionDiagnostic(value);
774
+ if (
775
+ diagnostic === null ||
776
+ applicationRoot === "." ||
777
+ DETAIL_DEFINITIONS[diagnostic.code].paths.includes("repository_config")
778
+ )
779
+ return diagnostic;
780
+ return parseFrameworkConversionDiagnostic({
781
+ ...diagnostic,
782
+ path: `${applicationRoot}/${diagnostic.path}`,
783
+ });
784
+ }