@amerged/ohmyhost-mcp 0.1.31 → 0.1.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/{chunk-QHKLOHN4.js → chunk-YM2ZQAGS.js} +8 -8
- package/dist/index.js +1 -1
- package/dist/stdio-main.js +430 -121
- package/package.json +1 -1
- package/src/apps/mcp/package.json +1 -1
- package/src/apps/product-cli/package.json +1 -1
- package/src/apps/product-cli/src/composition.ts +2 -1
- package/src/apps/product-cli/src/repository-init.ts +443 -713
- package/src/apps/product-cli/src/runtime-contract.ts +18 -5
- package/src/packages/agent-skills/src/generated-skill-resources.ts +7 -7
- package/src/packages/contracts/package.json +10 -0
- package/src/packages/contracts/src/framework-admission-corpus.ts +487 -0
- package/src/packages/contracts/src/framework-admission.ts +641 -197
- package/src/packages/contracts/src/framework-build-script.mjs +155 -13
- package/src/packages/contracts/src/framework-build-script.ts +15 -1
- package/src/packages/contracts/src/framework-source-rules.ts +1118 -0
- package/src/packages/contracts/src/operation-failure.ts +22 -4
- package/src/packages/contracts/src/problem-policy.ts +2 -2
- package/src/packages/sdk-ts/package.json +1 -1
- package/src/packages/sdk-ts/src/generated/sdk.gen.ts +1 -1
- package/src/packages/sdk-ts/src/generated/types.gen.ts +8 -0
|
@@ -1,8 +1,25 @@
|
|
|
1
|
-
import {
|
|
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(
|
|
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
|
|
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
|
|
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)
|
|
83
|
-
|
|
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)
|
|
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
|
|
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(
|
|
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)
|
|
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
|
|
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
|
|
126
|
-
readonly
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
137
|
-
|
|
179
|
+
packageName: FrameworkVersionPackage,
|
|
180
|
+
declared: string,
|
|
181
|
+
resolved: string | null = null,
|
|
182
|
+
policy: FrameworkVersionPolicy = FRAMEWORK_VERSION_POLICY,
|
|
138
183
|
): FrameworkVersionAdmission {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
|
155
|
-
version
|
|
156
|
-
)
|
|
157
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
//
|
|
214
|
-
//
|
|
215
|
-
const FRAMEWORK_CONVERSION_DETAILS
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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_tailwind_turbopack_only: {
|
|
377
|
+
paths: ["framework_config"],
|
|
378
|
+
template: `"{{path}}" loads Tailwind CSS through the Turbopack rule "@tailwindcss/turbopack", which the Webpack build on ohmyho.st ignores, and the application root has no PostCSS configuration that Webpack reads, so the site would deploy without its Tailwind classes. Add postcss.config.mjs to the application root with export default { plugins: { "@tailwindcss/postcss": {} } } and remove the Turbopack rule. Use the package manager to add @tailwindcss/postcss to devDependencies at the same version as tailwindcss and to remove @tailwindcss/turbopack, so the lockfile records both. ${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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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(
|
|
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
|
|
311
|
-
const
|
|
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
|
-
|
|
315
|
-
|
|
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(
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
+
}
|