@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.
@@ -0,0 +1,1101 @@
1
+ /**
2
+ * Customer source rules ohmyho.st judges from file text before any install: package.json, its
3
+ * lockfile, ohmyhost.yaml and the framework config. The framework adapters throw the first finding
4
+ * and `ohmyhost init` lists them all, so both answer with the same catalog diagnostics. Findings are
5
+ * ordered by rule group: package.json (build script, dependencies, framework versions, React pins),
6
+ * ohmyhost.yaml with the Worker modules it requires, the better-auth pin, then the framework config.
7
+ * Paths are relative to the application; ohmyhost.yaml stays at the repository root. The Worker
8
+ * handler and database contract rules judge one module's source the same way for both.
9
+ */
10
+ import { parseDocument } from "yaml";
11
+
12
+ import {
13
+ classifyFrameworkVersion,
14
+ FrameworkAdmissionError,
15
+ frameworkVersionDiagnostic,
16
+ isResolvedFrameworkVersion,
17
+ managedCustomerAuthAdmissionIssue,
18
+ parseFrameworkConversionDiagnostic,
19
+ resolveFrameworkConfig,
20
+ resolvePackageManager,
21
+ type FrameworkConversionDiagnostic,
22
+ type FrameworkVersionAdmission,
23
+ type FrameworkVersionPackage,
24
+ type LockfileName,
25
+ type PackageManagerDescriptor,
26
+ } from "./framework-admission.js";
27
+ import {
28
+ FRAMEWORK_VERSION_POLICY,
29
+ isAdmittedFrameworkBuildScript,
30
+ } from "./framework-build-script.mjs";
31
+ import { classifyFrameworkFamily } from "./framework-family.js";
32
+ import { parseOhmyhostConfigYaml, type OhmyhostConfig } from "./index.js";
33
+
34
+ /** The only better-auth release the managed auth bridge installs. */
35
+ export const MANAGED_BETTER_AUTH_VERSION = "1.7.1" as const;
36
+ /** The largest framework config the rules inspect, in UTF-8 bytes. */
37
+ export const MAXIMUM_FRAMEWORK_CONFIG_BYTES = 256 * 1024;
38
+ /** The customer Worker module of the functions runtime and of Next.js or TanStack Start crons. */
39
+ export const WORKER_MODULE_PATH = "src/ohmyhost/worker.ts" as const;
40
+ /** The Worker module of a Vite application with a server runtime. */
41
+ export const VITE_COMPANION_ENTRY_PATH = "src/ohmyhost/companion.ts" as const;
42
+
43
+ export type FrameworkSourceFramework = "functions" | "nextjs" | "tanstack-start" | "vite-static";
44
+ export type PackageManifest = Readonly<Record<string, unknown>>;
45
+ /**
46
+ * Where package.json may declare a dependency: `runtime` only in dependencies, `build` also in
47
+ * devDependencies, `any` also in optionalDependencies.
48
+ */
49
+ export type DependencyPlacement = "any" | "build" | "runtime";
50
+ export type FrameworkSourceLockfile = Readonly<{ name: LockfileName; text: string }>;
51
+ export type FrameworkSourceConfig = Readonly<{ path: string; text: string }>;
52
+ export type TanStackRuntime = "edge" | "static";
53
+ export type DatabaseContractCode = "database_binding_private" | "database_driver_unsupported";
54
+
55
+ export interface FrameworkSourceRuleInput {
56
+ readonly framework: FrameworkSourceFramework;
57
+ /** Every file of the application, relative to its root. */
58
+ readonly files: readonly string[];
59
+ readonly configuration: OhmyhostConfig;
60
+ readonly manifest: PackageManifest;
61
+ /** The admitted package manager; null when package.json or its lockfile was refused. */
62
+ readonly packageManager: PackageManagerDescriptor | null;
63
+ /** The decoded lockfile; null leaves every framework version to its declaration. */
64
+ readonly lockfile: FrameworkSourceLockfile | null;
65
+ /** The resolved framework config; null when the application has none. */
66
+ readonly frameworkConfig: FrameworkSourceConfig | null;
67
+ }
68
+
69
+ export interface FrameworkSourceFindings {
70
+ /** Ordered refusals; empty when the source is admitted. Adapters throw the first. */
71
+ readonly findings: readonly FrameworkConversionDiagnostic[];
72
+ /** Every declared governed package; a pending one is admitted and decided after the install. */
73
+ readonly versions: readonly FrameworkVersionAdmission[];
74
+ }
75
+
76
+ export interface RepositorySourceFindings extends FrameworkSourceFindings {
77
+ /** The framework ohmyho.st selects; null when the source is refused before the selection. */
78
+ readonly framework: FrameworkSourceFramework | null;
79
+ }
80
+
81
+ const PACKAGE_MANIFEST = "package.json";
82
+ const REPOSITORY_CONFIG = "ohmyhost.yaml";
83
+ const TANSTACK_START_PLUGIN = "@tanstack/react-start/plugin/vite";
84
+ const CLOUDFLARE_PLUGIN = "@cloudflare/vite-plugin";
85
+ const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u;
86
+ const DEPENDENCY_FIELDS = Object.freeze({
87
+ runtime: Object.freeze(["dependencies"]),
88
+ build: Object.freeze(["dependencies", "devDependencies"]),
89
+ any: Object.freeze(["dependencies", "devDependencies", "optionalDependencies"]),
90
+ } as const satisfies Record<DependencyPlacement, readonly string[]>);
91
+ // Next.js and TanStack Start load their runtime packages in the server bundle, so package.json
92
+ // declares them in dependencies; Vite and its plugins only build, so devDependencies suffice.
93
+ const REQUIRED_DEPENDENCIES = Object.freeze({
94
+ nextjs: Object.freeze([
95
+ ["next", "runtime"],
96
+ ["react", "runtime"],
97
+ ["react-dom", "runtime"],
98
+ ] as const),
99
+ "vite-static": Object.freeze([["vite", "build"]] as const),
100
+ "tanstack-start": Object.freeze([
101
+ ["@tanstack/react-start", "runtime"],
102
+ ["@tanstack/react-router", "runtime"],
103
+ ["react", "runtime"],
104
+ ["react-dom", "runtime"],
105
+ ["@vitejs/plugin-react", "build"],
106
+ ["vite", "build"],
107
+ ] as const),
108
+ functions: Object.freeze([] as const),
109
+ } satisfies Record<FrameworkSourceFramework, readonly (readonly [string, DependencyPlacement])[]>);
110
+ const TANSTACK_EDGE_DEPENDENCY = Object.freeze([CLOUDFLARE_PLUGIN, "build"] as const);
111
+ const BUILD_SCRIPT_FRAMEWORKS = Object.freeze({
112
+ nextjs: "nextjs",
113
+ "vite-static": "vite",
114
+ "tanstack-start": "tanstack-start",
115
+ } as const);
116
+ const BUILD_OUTPUTS: Readonly<Partial<Record<FrameworkSourceFramework, string>>> = Object.freeze({
117
+ "vite-static": "dist",
118
+ "tanstack-start": "dist/client",
119
+ });
120
+ const REQUIRED_CONFIG_PATHS: Readonly<Partial<Record<FrameworkSourceFramework, string>>> =
121
+ Object.freeze({ nextjs: "next.config.ts", "tanstack-start": "vite.config.ts" });
122
+ const MAXIMUM_LOCKFILE_LENGTH = 5 * 1024 * 1024;
123
+ const MAXIMUM_PNPM_LOCKFILE_HEAD = 256 * 1024;
124
+ const PNPM_LOCKFILE_BODY = /^(?:packages|snapshots):/mu;
125
+ const PNPM_LOCKFILE_VERSION = /^(?:6|9)\.[0-9]{1,3}$/u;
126
+ const PNPM_RESOLVED_VERSION = /^([^()]+)(?:\(.*\))?$/u;
127
+ // Only the default export's handlers are invoked; fetch counts as a key or method, never a call.
128
+ const DEFAULT_EXPORT_PATTERN = /\bexport\s+default\b/u;
129
+ const FETCH_HANDLER_PATTERN = /(?:[{,;}]|\basync)\s*fetch\s*(?:\(|:|,|\})/u;
130
+ const SCHEDULED_HANDLER_PATTERN = /\bscheduled\s*(?:\(|:|,|\})/u;
131
+ // No connection string reaches a customer Worker, and a Worker cannot open a socket.
132
+ const DATABASE_CONTRACT_PATTERNS = Object.freeze([
133
+ [
134
+ /\bHYPERDRIVE\b|\bconnectionString\b|\bDATABASE_URL\b|postgres(?:ql)?:\/\//u,
135
+ "database_binding_private",
136
+ ],
137
+ [
138
+ /\bfrom\s*["'](?:pg|postgres|mysql2|pg-native)["']|\brequire\(\s*["'](?:pg|postgres|pg-native)["']/u,
139
+ "database_driver_unsupported",
140
+ ],
141
+ ] as const satisfies readonly (readonly [RegExp, DatabaseContractCode])[]);
142
+
143
+ /** The parsed package.json, or null when the text or value is not one JSON object. */
144
+ export function parsePackageManifest(value: unknown): PackageManifest | null {
145
+ let parsed = value;
146
+ if (typeof value === "string") {
147
+ try {
148
+ parsed = JSON.parse(value) as unknown;
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+ return isRecord(parsed) ? parsed : null;
154
+ }
155
+
156
+ /** The packageManager declaration that resolvePackageManager judges. */
157
+ export function packageManagerDeclaration(manifest: PackageManifest): string | undefined {
158
+ const value = manifest["packageManager"];
159
+ return typeof value === "string" ? value : undefined;
160
+ }
161
+
162
+ /**
163
+ * The declared version of a dependency: the first non-empty string among the fields its placement
164
+ * admits, read in the order dependencies, devDependencies, optionalDependencies.
165
+ */
166
+ export function declaredDependency(
167
+ manifest: PackageManifest,
168
+ name: string,
169
+ placement: DependencyPlacement,
170
+ ): string | null {
171
+ for (const field of DEPENDENCY_FIELDS[placement]) {
172
+ const version = ownValue(manifest[field], name);
173
+ if (typeof version === "string" && version.length > 0) return version;
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /** Every dependency name declared with a non-empty version; it selects the framework family. */
179
+ export function declaredDependencyNames(manifest: PackageManifest): ReadonlySet<string> {
180
+ const names = new Set<string>();
181
+ for (const field of DEPENDENCY_FIELDS.any) {
182
+ const dependencies = manifest[field];
183
+ if (!isRecord(dependencies)) continue;
184
+ for (const [name, version] of Object.entries(dependencies))
185
+ if (typeof version === "string" && version.length > 0) names.add(name);
186
+ }
187
+ return names;
188
+ }
189
+
190
+ /** A Vite application with a server runtime or any managed capability runs the API companion. */
191
+ export function viteCompanionRequired(configuration: OhmyhostConfig): boolean {
192
+ return (
193
+ configuration.runtime.mode === "edge" ||
194
+ configuration.database?.enabled === true ||
195
+ configuration.auth?.provider === "better-auth" ||
196
+ configuration.mail?.enabled === true ||
197
+ configuration.storage !== undefined ||
198
+ (configuration.functions?.crons.length ?? 0) > 0
199
+ );
200
+ }
201
+
202
+ /**
203
+ * The admission of every governed framework package the application declares where the framework
204
+ * needs it. The lockfile resolution decides when the lockfile names one; otherwise the declaration.
205
+ */
206
+ export function frameworkVersionAdmissions(
207
+ framework: FrameworkSourceFramework,
208
+ manifest: PackageManifest,
209
+ lockfile: FrameworkSourceLockfile | null,
210
+ ): readonly FrameworkVersionAdmission[] {
211
+ const admissions: FrameworkVersionAdmission[] = [];
212
+ let locked: ((packageName: string) => string | null) | undefined;
213
+ for (const [name, placement] of REQUIRED_DEPENDENCIES[framework]) {
214
+ if (!isFrameworkVersionPackage(name)) continue;
215
+ const declared = declaredDependency(manifest, name, placement);
216
+ if (declared === null) continue;
217
+ locked ??= lockfile === null ? () => null : lockedPackageVersions(lockfile);
218
+ admissions.push(classifyFrameworkVersion(name, declared, locked(name)));
219
+ }
220
+ return Object.freeze(admissions);
221
+ }
222
+
223
+ /**
224
+ * The versions a trusted lockfile installs, parsed once: the node_modules entries of
225
+ * package-lock.json v2/v3, or the root importer of pnpm-lock.yaml v6/v9 without its peer suffix,
226
+ * read from the head before the top-level packages and snapshots. Other lockfiles, text over 5 Mi
227
+ * characters, a pnpm head over 256 Ki characters, parse failures and versions outside the catalog
228
+ * grammar (links, aliases, workspace, file and URL sources) resolve to null, so the declaration
229
+ * decides. Never throws for customer text.
230
+ */
231
+ export function lockedPackageVersions(
232
+ lockfile: Readonly<{ name: string; text: string }>,
233
+ ): (packageName: string) => string | null {
234
+ const { name, text } = lockfile;
235
+ const npm = name === "package-lock.json";
236
+ if (
237
+ (!npm && name !== "pnpm-lock.yaml") ||
238
+ typeof text !== "string" ||
239
+ text.length > MAXIMUM_LOCKFILE_LENGTH
240
+ )
241
+ return () => null;
242
+ const document = npm ? parseJsonLockfile(text) : parsePnpmLockfileHead(text);
243
+ return (packageName) => {
244
+ const version = npm
245
+ ? npmLockedVersion(document, packageName)
246
+ : pnpmLockedVersion(document, packageName);
247
+ return isResolvedFrameworkVersion(version) ? version : null;
248
+ };
249
+ }
250
+
251
+ /** Every rule of one framework over the application's package.json, configuration and config. */
252
+ export function frameworkSourceFindings(input: FrameworkSourceRuleInput): FrameworkSourceFindings {
253
+ const versions = frameworkVersionAdmissions(input.framework, input.manifest, input.lockfile);
254
+ return Object.freeze({
255
+ findings: Object.freeze([
256
+ ...manifestFindings(input, versions),
257
+ ...configurationFindings(input),
258
+ ...betterAuthFindings(input.configuration, input.manifest),
259
+ ...frameworkConfigFindings(input),
260
+ ]),
261
+ versions,
262
+ });
263
+ }
264
+
265
+ /**
266
+ * The whole pure admission of one commit, in the order the platform judges it: ohmyhost.yaml, the
267
+ * application root, package.json, the framework family, the package manager, the framework config
268
+ * file, then frameworkSourceFindings. `files` maps every repository-relative path to its UTF-8 text,
269
+ * or to null when the bytes are not text; only ohmyhost.yaml, package.json, the lockfile and the
270
+ * framework config are read. Worker handlers and database calls (workerModuleFindings,
271
+ * databaseContractCodes) and reachable runtime sources are not judged here.
272
+ */
273
+ export function repositorySourceFindings(
274
+ files: ReadonlyMap<string, string | null>,
275
+ ): RepositorySourceFindings {
276
+ const configuration = repositoryConfiguration(files.get(REPOSITORY_CONFIG));
277
+ if (configuration === null) return refused("repository_configuration_invalid", REPOSITORY_CONFIG);
278
+ const prefix = configuration.applicationRoot === "." ? "" : `${configuration.applicationRoot}/`;
279
+ const applicationFiles = [...files.keys()]
280
+ .filter((path) => path.length > prefix.length && path.startsWith(prefix))
281
+ .map((path) => path.slice(prefix.length));
282
+ if (applicationFiles.length === 0) return refused("application_root_missing", REPOSITORY_CONFIG);
283
+ const text = (path: string) => files.get(`${prefix}${path}`) ?? null;
284
+ const manifest = parsePackageManifest(text(PACKAGE_MANIFEST));
285
+ if (manifest === null) return refused("package_manifest_invalid", PACKAGE_MANIFEST);
286
+ const family =
287
+ configuration.runtime.mode === "functions"
288
+ ? "functions"
289
+ : classifyFrameworkFamily(declaredDependencyNames(manifest));
290
+ if (family === "ambiguous") return refused("framework_ambiguous", PACKAGE_MANIFEST);
291
+ if (family === "unknown") return refused("framework_unsupported", PACKAGE_MANIFEST);
292
+ const framework = family === "vite" ? "vite-static" : family;
293
+ const findings: FrameworkConversionDiagnostic[] = [];
294
+ const packageManager =
295
+ admitted(findings, () =>
296
+ resolvePackageManager({
297
+ packageManager: packageManagerDeclaration(manifest),
298
+ files: applicationFiles,
299
+ }),
300
+ ) ?? null;
301
+ const configPath = frameworkConfigPath(framework, applicationFiles, findings);
302
+ const configText = configPath === null ? null : text(configPath);
303
+ if (configPath !== null && configText === null)
304
+ findings.push(diagnostic({ code: "source_syntax_invalid", path: configPath }));
305
+ const lockfileText = packageManager === null ? null : text(packageManager.lockfile);
306
+ const result = frameworkSourceFindings({
307
+ framework,
308
+ files: applicationFiles,
309
+ configuration,
310
+ manifest,
311
+ packageManager,
312
+ lockfile:
313
+ packageManager === null || lockfileText === null
314
+ ? null
315
+ : Object.freeze({ name: packageManager.lockfile, text: lockfileText }),
316
+ frameworkConfig:
317
+ configPath === null || configText === null
318
+ ? null
319
+ : Object.freeze({ path: configPath, text: configText }),
320
+ });
321
+ return Object.freeze({
322
+ framework,
323
+ findings: Object.freeze([...findings, ...result.findings]),
324
+ versions: result.versions,
325
+ });
326
+ }
327
+
328
+ /**
329
+ * The Next.js config rules: inspection size, comment and string syntax, the root origin
330
+ * (basePath, assetPrefix), output, cacheComponents and the default export; next.config.js may
331
+ * assign module.exports instead.
332
+ */
333
+ export function nextConfigFindings(
334
+ text: string,
335
+ path: string,
336
+ ): readonly FrameworkConversionDiagnostic[] {
337
+ const lexed = lexFrameworkConfig(text, path);
338
+ if ("finding" in lexed) return Object.freeze([lexed.finding]);
339
+ const { tokens } = lexed;
340
+ const findings: FrameworkConversionDiagnostic[] = [];
341
+ if (hasNonRootProperty(tokens, ["basePath", "assetPrefix"], ["", "/"]))
342
+ findings.push(diagnostic({ code: "next_base_path_unsupported", path }));
343
+ if (hasProperty(tokens, "output", "export") || hasProperty(tokens, "output", "standalone"))
344
+ findings.push(diagnostic({ code: "next_output_unsupported", path }));
345
+ if (hasProperty(tokens, "cacheComponents", "true"))
346
+ findings.push(diagnostic({ code: "next_cache_components_unsupported", path }));
347
+ if (
348
+ !hasExportDefault(tokens) &&
349
+ !(path.slice(path.lastIndexOf("/") + 1) === "next.config.js" && hasModuleExports(tokens))
350
+ )
351
+ findings.push(diagnostic({ code: "framework_config_default_export_required", path }));
352
+ return Object.freeze(findings);
353
+ }
354
+
355
+ /** The Vite config rules: inspection size, syntax and a base at the root origin. */
356
+ export function viteConfigFindings(
357
+ text: string,
358
+ path: string,
359
+ ): readonly FrameworkConversionDiagnostic[] {
360
+ const lexed = lexFrameworkConfig(text, path);
361
+ if ("finding" in lexed) return Object.freeze([lexed.finding]);
362
+ return Object.freeze(
363
+ hasNonRootProperty(lexed.tokens, ["base"], ["/"])
364
+ ? [diagnostic({ code: "framework_base_path_unsupported", path })]
365
+ : [],
366
+ );
367
+ }
368
+
369
+ /**
370
+ * The TanStack Start Vite config rules: the Vite rules plus the plugin contract of the runtime.
371
+ * Static imports tanstackStart and calls it with prerender enabled, without the Cloudflare plugin;
372
+ * edge also imports cloudflare and calls cloudflare({ viteEnvironment: { name: "ssr" } }) before
373
+ * tanstackStart(). A null runtime judges only the Vite rules.
374
+ */
375
+ export function tanStackViteConfigFindings(
376
+ text: string,
377
+ path: string,
378
+ runtime: TanStackRuntime | null,
379
+ ): readonly FrameworkConversionDiagnostic[] {
380
+ const lexed = lexFrameworkConfig(text, path);
381
+ if ("finding" in lexed) return Object.freeze([lexed.finding]);
382
+ const findings: FrameworkConversionDiagnostic[] = [];
383
+ if (hasNonRootProperty(lexed.tokens, ["base"], ["/"]))
384
+ findings.push(diagnostic({ code: "framework_base_path_unsupported", path }));
385
+ if (runtime === null) return Object.freeze(findings);
386
+ const contract = tanStackPluginContract(lexed.tokens, runtime);
387
+ if (contract === null)
388
+ return Object.freeze([diagnostic({ code: "source_syntax_invalid", path })]);
389
+ if (!contract.plugins)
390
+ findings.push(diagnostic({ code: "tanstack_vite_plugins_required", path }));
391
+ if (!contract.prerender) findings.push(diagnostic({ code: "tanstack_prerender_required", path }));
392
+ return Object.freeze(findings);
393
+ }
394
+
395
+ /** The TanStack Start runtime whose complete plugin contract the config follows, if any. */
396
+ export function tanStackViteConfigRuntime(text: string): TanStackRuntime | null {
397
+ const tokens = exceedsConfigBytes(text) ? null : configTokens(text);
398
+ if (tokens === null) return null;
399
+ for (const runtime of ["static", "edge"] as const) {
400
+ const contract = tanStackPluginContract(tokens, runtime);
401
+ if (contract?.plugins === true && contract.prerender) return runtime;
402
+ }
403
+ return null;
404
+ }
405
+
406
+ /**
407
+ * The database contract a module's source breaks once the configuration enables the managed
408
+ * database: it reads a connection string or imports a socket driver. Comments are ignored and
409
+ * strings count. Planning refuses the first code; `ohmyhost init` lists every one.
410
+ */
411
+ export function databaseContractCodes(source: string): readonly DatabaseContractCode[] {
412
+ const code = sourceWithoutComments(source);
413
+ return Object.freeze(
414
+ DATABASE_CONTRACT_PATTERNS.filter(([pattern]) => pattern.test(code)).map(([, found]) => found),
415
+ );
416
+ }
417
+
418
+ /**
419
+ * The handler rules a Worker module breaks, ignoring comments: a default export, with fetch when
420
+ * the module serves requests (runtime.mode: functions) and scheduled when crons are declared.
421
+ * Without a default export no handler is judged, because named exports are never invoked.
422
+ */
423
+ export function workerModuleFindings(
424
+ worker: Readonly<{
425
+ path: typeof VITE_COMPANION_ENTRY_PATH | typeof WORKER_MODULE_PATH;
426
+ source: string;
427
+ fetch: boolean;
428
+ scheduled: boolean;
429
+ }>,
430
+ ): readonly FrameworkConversionDiagnostic[] {
431
+ const { path } = worker;
432
+ const code = sourceWithoutComments(worker.source);
433
+ if (!DEFAULT_EXPORT_PATTERN.test(code))
434
+ return Object.freeze([diagnostic({ code: "worker_module_default_export_required", path })]);
435
+ const findings: FrameworkConversionDiagnostic[] = [];
436
+ if (worker.fetch && !FETCH_HANDLER_PATTERN.test(code))
437
+ findings.push(diagnostic({ code: "worker_module_fetch_handler_required", path }));
438
+ if (worker.scheduled && !SCHEDULED_HANDLER_PATTERN.test(code))
439
+ findings.push(diagnostic({ code: "scheduled_handler_required", path }));
440
+ return Object.freeze(findings);
441
+ }
442
+
443
+ /**
444
+ * Source text with every comment blanked and every string kept, so a URL keeps its "//". A quoted
445
+ * string also ends at a line break, so an apostrophe in JSX text never hides the comments after it,
446
+ * and a slash escaped by a backslash, as in a regex literal, opens no comment.
447
+ */
448
+ export function sourceWithoutComments(source: string): string {
449
+ let result = "";
450
+ let quote: "'" | '"' | "`" | null = null;
451
+ for (let index = 0; index < source.length; index += 1) {
452
+ const character = source[index] as string;
453
+ const next = source[index + 1];
454
+ if (quote !== null) {
455
+ result += character;
456
+ if (character === "\\") {
457
+ index += 1;
458
+ result += source[index] ?? "";
459
+ } else if (character === quote || (character === "\n" && quote !== "`")) quote = null;
460
+ continue;
461
+ }
462
+ if (character === "'" || character === '"' || character === "`") {
463
+ quote = character;
464
+ result += character;
465
+ continue;
466
+ }
467
+ if (character === "/" && next === "/" && source[index - 1] !== "\\") {
468
+ while (index < source.length && source[index] !== "\n") {
469
+ result += " ";
470
+ index += 1;
471
+ }
472
+ result += "\n";
473
+ continue;
474
+ }
475
+ if (character === "/" && next === "*" && source[index - 1] !== "\\") {
476
+ result += " ";
477
+ index += 2;
478
+ while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) {
479
+ result += source[index] === "\n" ? "\n" : " ";
480
+ index += 1;
481
+ }
482
+ // Blank the closing "*/" as well; the loop then continues after it.
483
+ result += " ";
484
+ index += 1;
485
+ continue;
486
+ }
487
+ result += character;
488
+ }
489
+ return result;
490
+ }
491
+
492
+ /**
493
+ * Whether the database contract judges a module, in planning and `ohmyhost init` alike: the source
494
+ * extensions planning reads, without TypeScript declaration files, which never run.
495
+ */
496
+ export function isDatabaseContractModule(path: string): boolean {
497
+ return /\.(?:ts|tsx|js|jsx|mjs|mts)$/u.test(path) && !/\.d\.[cm]?ts$/u.test(path);
498
+ }
499
+
500
+ function manifestFindings(
501
+ input: FrameworkSourceRuleInput,
502
+ versions: readonly FrameworkVersionAdmission[],
503
+ ): FrameworkConversionDiagnostic[] {
504
+ const { configuration, framework, manifest } = input;
505
+ const findings: FrameworkConversionDiagnostic[] = [];
506
+ if (
507
+ framework !== "functions" &&
508
+ !isAdmittedFrameworkBuildScript(
509
+ BUILD_SCRIPT_FRAMEWORKS[framework],
510
+ ownValue(manifest["scripts"], "build"),
511
+ )
512
+ )
513
+ findings.push(
514
+ diagnostic({ code: "build_script_unsupported", path: PACKAGE_MANIFEST, framework }),
515
+ );
516
+ const required =
517
+ framework === "tanstack-start" && configuration.runtime.mode === "edge"
518
+ ? [...REQUIRED_DEPENDENCIES[framework], TANSTACK_EDGE_DEPENDENCY]
519
+ : REQUIRED_DEPENDENCIES[framework];
520
+ for (const [name, placement] of required)
521
+ if (declaredDependency(manifest, name, placement) === null)
522
+ findings.push(
523
+ diagnostic({
524
+ code: "framework_dependency_required",
525
+ path: PACKAGE_MANIFEST,
526
+ package: name,
527
+ }),
528
+ );
529
+ for (const admission of versions) {
530
+ const finding = frameworkVersionDiagnostic(admission, input.lockfile?.name ?? null);
531
+ if (finding !== null) findings.push(finding);
532
+ }
533
+ if (framework === "nextjs")
534
+ for (const name of ["react", "react-dom"]) {
535
+ const version = declaredDependency(manifest, name, "runtime");
536
+ if (version !== null && !EXACT_VERSION.test(version))
537
+ findings.push(
538
+ diagnostic({
539
+ code: "react_version_exact_required",
540
+ path: PACKAGE_MANIFEST,
541
+ package: name,
542
+ }),
543
+ );
544
+ }
545
+ return findings;
546
+ }
547
+
548
+ function configurationFindings(input: FrameworkSourceRuleInput): FrameworkConversionDiagnostic[] {
549
+ const { configuration, files, framework, packageManager } = input;
550
+ const build = "command" in configuration.build ? configuration.build : null;
551
+ const crons = (configuration.functions?.crons.length ?? 0) > 0;
552
+ const findings: FrameworkConversionDiagnostic[] = [];
553
+ // runtime.mode functions selects the functions framework, whose build is only build.install.
554
+ if (framework !== "functions" && !runtimeModeAdmitted(framework, configuration, crons))
555
+ findings.push(
556
+ diagnostic({ code: "runtime_mode_unsupported", path: REPOSITORY_CONFIG, framework }),
557
+ );
558
+ if (build !== null && framework !== "nextjs" && build.ssg_cache_max_mib !== undefined)
559
+ findings.push(diagnostic({ code: "build_option_unsupported", path: REPOSITORY_CONFIG }));
560
+ const output = BUILD_OUTPUTS[framework];
561
+ if (build !== null && output !== undefined && build.output !== output)
562
+ findings.push(
563
+ diagnostic({ code: "build_output_unsupported", path: REPOSITORY_CONFIG, framework }),
564
+ );
565
+ // A Next.js plan always runs the commands of its package manager, whatever ohmyhost.yaml says.
566
+ if (
567
+ packageManager !== null &&
568
+ framework !== "nextjs" &&
569
+ (configuration.build.install !== packageManager.installCommand ||
570
+ (build !== null && build.command !== packageManager.buildCommand))
571
+ )
572
+ findings.push(diagnostic({ code: "build_command_mismatch", path: REPOSITORY_CONFIG }));
573
+ if (
574
+ framework === "vite-static" &&
575
+ viteCompanionRequired(configuration) &&
576
+ !files.includes(VITE_COMPANION_ENTRY_PATH)
577
+ )
578
+ findings.push(
579
+ diagnostic({ code: "companion_entry_required", path: VITE_COMPANION_ENTRY_PATH }),
580
+ );
581
+ if (
582
+ (framework === "functions" ||
583
+ (crons &&
584
+ (framework === "nextjs" ||
585
+ (framework === "tanstack-start" && configuration.runtime.mode === "edge")))) &&
586
+ !files.includes(WORKER_MODULE_PATH)
587
+ )
588
+ findings.push(diagnostic({ code: "worker_module_required", path: WORKER_MODULE_PATH }));
589
+ if (managedCustomerAuthAdmissionIssue(configuration) !== null)
590
+ findings.push(diagnostic({ code: "managed_auth_database_required", path: REPOSITORY_CONFIG }));
591
+ return findings;
592
+ }
593
+
594
+ function runtimeModeAdmitted(
595
+ framework: Exclude<FrameworkSourceFramework, "functions">,
596
+ configuration: OhmyhostConfig,
597
+ crons: boolean,
598
+ ): boolean {
599
+ const mode = configuration.runtime.mode;
600
+ if (framework === "nextjs") return mode === "edge";
601
+ if (framework === "vite-static")
602
+ return mode === (viteCompanionRequired(configuration) ? "edge" : "static");
603
+ return mode === "edge" || (mode === "static" && !crons && configuration.storage === undefined);
604
+ }
605
+
606
+ function betterAuthFindings(
607
+ configuration: OhmyhostConfig,
608
+ manifest: PackageManifest,
609
+ ): FrameworkConversionDiagnostic[] {
610
+ return configuration.auth?.provider === "better-auth" &&
611
+ declaredDependency(manifest, "better-auth", "any") !== MANAGED_BETTER_AUTH_VERSION
612
+ ? [diagnostic({ code: "better_auth_version_required", path: PACKAGE_MANIFEST })]
613
+ : [];
614
+ }
615
+
616
+ function frameworkConfigFindings(
617
+ input: FrameworkSourceRuleInput,
618
+ ): readonly FrameworkConversionDiagnostic[] {
619
+ const { frameworkConfig, framework } = input;
620
+ if (frameworkConfig === null || framework === "functions") return [];
621
+ const { path, text } = frameworkConfig;
622
+ if (framework === "nextjs") return nextConfigFindings(text, path);
623
+ if (framework === "vite-static") return viteConfigFindings(text, path);
624
+ const mode = input.configuration.runtime.mode;
625
+ return tanStackViteConfigFindings(text, path, mode === "edge" || mode === "static" ? mode : null);
626
+ }
627
+
628
+ function repositoryConfiguration(text: string | null | undefined): OhmyhostConfig | null {
629
+ if (typeof text !== "string") return null;
630
+ try {
631
+ return parseOhmyhostConfigYaml(text);
632
+ } catch (error) {
633
+ if (error instanceof TypeError) return null;
634
+ throw error;
635
+ }
636
+ }
637
+
638
+ /** The framework config file; ambiguous variants and a missing required config are findings. */
639
+ function frameworkConfigPath(
640
+ framework: FrameworkSourceFramework,
641
+ files: readonly string[],
642
+ findings: FrameworkConversionDiagnostic[],
643
+ ): string | null {
644
+ if (framework === "functions") return null;
645
+ const path = admitted(findings, () =>
646
+ resolveFrameworkConfig(framework === "nextjs" ? "next.config" : "vite.config", files),
647
+ );
648
+ if (framework === "nextjs")
649
+ admitted(findings, () => resolveFrameworkConfig("open-next.config", files));
650
+ const required = REQUIRED_CONFIG_PATHS[framework];
651
+ if (path === null && required !== undefined)
652
+ findings.push(diagnostic({ code: "framework_config_required", path: required }));
653
+ return path ?? null;
654
+ }
655
+
656
+ /** The result of a contracts admission, or undefined after recording its typed refusal. */
657
+ function admitted<T>(findings: FrameworkConversionDiagnostic[], admit: () => T): T | undefined {
658
+ try {
659
+ return admit();
660
+ } catch (error) {
661
+ if (!(error instanceof FrameworkAdmissionError)) throw error;
662
+ findings.push(error.diagnostic);
663
+ return undefined;
664
+ }
665
+ }
666
+
667
+ function refused(
668
+ code:
669
+ | "application_root_missing"
670
+ | "framework_ambiguous"
671
+ | "framework_unsupported"
672
+ | "package_manifest_invalid"
673
+ | "repository_configuration_invalid",
674
+ path: string,
675
+ ): RepositorySourceFindings {
676
+ return Object.freeze({
677
+ framework: null,
678
+ findings: Object.freeze([diagnostic({ code, path })]),
679
+ versions: Object.freeze([]),
680
+ });
681
+ }
682
+
683
+ function diagnostic(value: unknown): FrameworkConversionDiagnostic {
684
+ const parsed = parseFrameworkConversionDiagnostic(value);
685
+ if (parsed === null) throw new TypeError("Framework source diagnostic is invalid");
686
+ return parsed;
687
+ }
688
+
689
+ function isFrameworkVersionPackage(name: string): name is FrameworkVersionPackage {
690
+ return Object.hasOwn(FRAMEWORK_VERSION_POLICY, name);
691
+ }
692
+
693
+ function parseJsonLockfile(text: string): unknown {
694
+ try {
695
+ return JSON.parse(text) as unknown;
696
+ } catch {
697
+ return null;
698
+ }
699
+ }
700
+
701
+ /** The importers sit in the head of pnpm-lock.yaml, before its top-level packages and snapshots. */
702
+ function parsePnpmLockfileHead(text: string): unknown {
703
+ const end = text.search(PNPM_LOCKFILE_BODY);
704
+ const head = end === -1 ? text : text.slice(0, end);
705
+ if (head.length > MAXIMUM_PNPM_LOCKFILE_HEAD) return null;
706
+ try {
707
+ const document = parseDocument(head, {
708
+ customTags: [],
709
+ logLevel: "error",
710
+ merge: false,
711
+ prettyErrors: false,
712
+ resolveKnownTags: false,
713
+ schema: "core",
714
+ strict: true,
715
+ stringKeys: true,
716
+ // The parser compares every key with every earlier one; pnpm refuses repeated keys itself.
717
+ uniqueKeys: false,
718
+ version: "1.2",
719
+ });
720
+ // pnpm never writes aliases, so a single alias already exceeds the limit.
721
+ return document.errors.length === 0 && document.warnings.length === 0
722
+ ? document.toJS({ maxAliasCount: 0 })
723
+ : null;
724
+ } catch {
725
+ return null;
726
+ }
727
+ }
728
+
729
+ function npmLockedVersion(document: unknown, packageName: string): unknown {
730
+ if (
731
+ !isRecord(document) ||
732
+ (document["lockfileVersion"] !== 2 && document["lockfileVersion"] !== 3)
733
+ )
734
+ return null;
735
+ const entry = ownValue(document["packages"], `node_modules/${packageName}`);
736
+ // npm records the real name of an npm: alias and marks workspace and file: directories as links.
737
+ return isRecord(entry) &&
738
+ !Object.hasOwn(entry, "link") &&
739
+ (entry["name"] === undefined || entry["name"] === packageName)
740
+ ? entry["version"]
741
+ : null;
742
+ }
743
+
744
+ function pnpmLockedVersion(document: unknown, packageName: string): unknown {
745
+ if (!isRecord(document)) return null;
746
+ const lockfileVersion = document["lockfileVersion"];
747
+ if (typeof lockfileVersion !== "string" || !PNPM_LOCKFILE_VERSION.test(lockfileVersion))
748
+ return null;
749
+ const importers = document["importers"];
750
+ // Lockfile v6 inlines a single root project at the top level; v9 always lists its importers.
751
+ const root =
752
+ importers === undefined && lockfileVersion.startsWith("6.")
753
+ ? document
754
+ : ownValue(importers, ".");
755
+ if (!isRecord(root)) return null;
756
+ for (const group of ["dependencies", "devDependencies", "optionalDependencies"]) {
757
+ const dependencies = root[group];
758
+ if (!isRecord(dependencies) || !Object.hasOwn(dependencies, packageName)) continue;
759
+ const entry = dependencies[packageName];
760
+ return isRecord(entry) && typeof entry["version"] === "string"
761
+ ? (PNPM_RESOLVED_VERSION.exec(entry["version"])?.[1] ?? null)
762
+ : null;
763
+ }
764
+ return null;
765
+ }
766
+
767
+ function ownValue(record: unknown, key: string): unknown {
768
+ return isRecord(record) && Object.hasOwn(record, key) ? record[key] : undefined;
769
+ }
770
+
771
+ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
772
+ return typeof value === "object" && value !== null && !Array.isArray(value);
773
+ }
774
+
775
+ interface ConfigToken {
776
+ readonly kind: "identifier" | "number" | "punctuation" | "string";
777
+ readonly value: string;
778
+ }
779
+
780
+ type ConfigLexing =
781
+ | Readonly<{ tokens: readonly ConfigToken[] }>
782
+ | Readonly<{ finding: FrameworkConversionDiagnostic }>;
783
+
784
+ const PUNCTUATION = "{}[]():,.;?=-!";
785
+
786
+ /** The config tokens, or its size or syntax finding. The path must be a framework config path. */
787
+ function lexFrameworkConfig(text: string, path: string): ConfigLexing {
788
+ const tooLarge = diagnostic({ code: "framework_config_too_large", path });
789
+ if (exceedsConfigBytes(text)) return { finding: tooLarge };
790
+ const tokens = configTokens(text);
791
+ return tokens === null
792
+ ? { finding: diagnostic({ code: "source_syntax_invalid", path }) }
793
+ : { tokens };
794
+ }
795
+
796
+ /** UTF-8 never takes fewer bytes than UTF-16 code units, so long text is refused unencoded. */
797
+ function exceedsConfigBytes(text: string): boolean {
798
+ return (
799
+ text.length > MAXIMUM_FRAMEWORK_CONFIG_BYTES ||
800
+ new TextEncoder().encode(text).byteLength > MAXIMUM_FRAMEWORK_CONFIG_BYTES
801
+ );
802
+ }
803
+
804
+ /**
805
+ * Identifiers, numbers, string contents and punctuation of config source, without comments and
806
+ * without evaluating it; null when a block comment or a string is unterminated.
807
+ */
808
+ function configTokens(source: string): readonly ConfigToken[] | null {
809
+ const tokens: ConfigToken[] = [];
810
+ let index = 0;
811
+ while (index < source.length) {
812
+ const character = source[index] as string;
813
+ const next = source[index + 1];
814
+ if (character === "/" && next === "/") {
815
+ const end = source.indexOf("\n", index + 2);
816
+ index = end === -1 ? source.length : end + 1;
817
+ } else if (character === "/" && next === "*") {
818
+ const end = source.indexOf("*/", index + 2);
819
+ if (end === -1) return null;
820
+ index = end + 2;
821
+ } else if (character === "'" || character === '"' || character === "`") {
822
+ const string = readString(source, index, character);
823
+ if (string === null) return null;
824
+ tokens.push(string.token);
825
+ index = string.nextIndex;
826
+ } else if (isIdentifierStart(character) || isDigit(character)) {
827
+ const start = index;
828
+ const part = isDigit(character) ? isDigit : isIdentifierPart;
829
+ index += 1;
830
+ while (index < source.length && part(source[index] as string)) index += 1;
831
+ tokens.push({
832
+ kind: isDigit(character) ? "number" : "identifier",
833
+ value: source.slice(start, index),
834
+ });
835
+ } else {
836
+ if (PUNCTUATION.includes(character)) tokens.push({ kind: "punctuation", value: character });
837
+ index += 1;
838
+ }
839
+ }
840
+ return tokens;
841
+ }
842
+
843
+ function readString(
844
+ source: string,
845
+ index: number,
846
+ quote: "'" | '"' | "`",
847
+ ): Readonly<{ token: ConfigToken; nextIndex: number }> | null {
848
+ let cursor = index + 1;
849
+ let value = "";
850
+ while (cursor < source.length) {
851
+ const character = source[cursor] as string;
852
+ if (character === quote) return { token: { kind: "string", value }, nextIndex: cursor + 1 };
853
+ if (character === "\\") {
854
+ const escaped = source[cursor + 1];
855
+ if (escaped === undefined) return null;
856
+ value += escaped;
857
+ cursor += 2;
858
+ } else {
859
+ value += character;
860
+ cursor += 1;
861
+ }
862
+ }
863
+ return null;
864
+ }
865
+
866
+ function isIdentifierStart(character: string): boolean {
867
+ return (
868
+ (character >= "A" && character <= "Z") ||
869
+ (character >= "a" && character <= "z") ||
870
+ character === "_" ||
871
+ character === "$"
872
+ );
873
+ }
874
+
875
+ function isIdentifierPart(character: string): boolean {
876
+ return isIdentifierStart(character) || isDigit(character);
877
+ }
878
+
879
+ function isDigit(character: string): boolean {
880
+ return character >= "0" && character <= "9";
881
+ }
882
+
883
+ /**
884
+ * Whether an object key in `names`, direct or computed, has a value other than undefined or one of
885
+ * the root strings. Shorthand and computed values cannot be judged and count as non-root.
886
+ */
887
+ function hasNonRootProperty(
888
+ tokens: readonly ConfigToken[],
889
+ names: readonly string[],
890
+ rootStrings: readonly string[],
891
+ ): boolean {
892
+ let objectDepth = 0;
893
+ for (let index = 0; index < tokens.length; index += 1) {
894
+ const token = tokens[index] as ConfigToken;
895
+ if (token.value === "{" || token.value === "}") {
896
+ objectDepth += token.value === "{" ? 1 : -1;
897
+ continue;
898
+ }
899
+ const before = tokens[index - 1]?.value;
900
+ const directKey = before === "{" || before === ",";
901
+ const computedKey =
902
+ before === "[" &&
903
+ tokens[index + 1]?.value === "]" &&
904
+ (tokens[index - 2]?.value === "{" || tokens[index - 2]?.value === ",");
905
+ if (objectDepth < 1 || !names.includes(token.value) || (!directKey && !computedKey)) continue;
906
+ const separator = tokens[index + (computedKey ? 2 : 1)]?.value;
907
+ const value = tokens[index + (computedKey ? 3 : 2)];
908
+ const root =
909
+ separator === ":" &&
910
+ ((value?.kind === "string" && rootStrings.includes(value.value)) ||
911
+ (value?.kind === "identifier" && value.value === "undefined"));
912
+ if (!root) return true;
913
+ }
914
+ return false;
915
+ }
916
+
917
+ function hasProperty(tokens: readonly ConfigToken[], name: string, value: string): boolean {
918
+ return tokens.some(
919
+ (token, index) =>
920
+ token.value === name &&
921
+ tokens[index + 1]?.value === ":" &&
922
+ tokens[index + 2]?.value === value,
923
+ );
924
+ }
925
+
926
+ function hasExportDefault(tokens: readonly ConfigToken[]): boolean {
927
+ return tokens.some(
928
+ (token, index) => token.value === "export" && tokens[index + 1]?.value === "default",
929
+ );
930
+ }
931
+
932
+ /** module.exports = … or module["exports"] = …, but not a comparison. */
933
+ function hasModuleExports(tokens: readonly ConfigToken[]): boolean {
934
+ return tokens.some((token, index) => {
935
+ if (token.kind !== "identifier" || token.value !== "module") return false;
936
+ const property = tokens[index + 2];
937
+ const direct =
938
+ tokens[index + 1]?.value === "." &&
939
+ property?.kind === "identifier" &&
940
+ property.value === "exports";
941
+ const computed =
942
+ tokens[index + 1]?.value === "[" &&
943
+ property?.kind === "string" &&
944
+ property.value === "exports" &&
945
+ tokens[index + 3]?.value === "]";
946
+ const assignment = index + (computed ? 4 : 3);
947
+ return (
948
+ (direct || computed) &&
949
+ tokens[assignment]?.value === "=" &&
950
+ tokens[assignment + 1]?.value !== "="
951
+ );
952
+ });
953
+ }
954
+
955
+ /**
956
+ * Whether the config follows the plugin contract of the runtime and, for static, enables prerender;
957
+ * null when the options object of a plugin call is not closed.
958
+ */
959
+ function tanStackPluginContract(
960
+ tokens: readonly ConfigToken[],
961
+ runtime: TanStackRuntime,
962
+ ): Readonly<{ plugins: boolean; prerender: boolean }> | null {
963
+ const closing = closingBraces(tokens);
964
+ const imported = hasNamedImport(tokens, "tanstackStart", TANSTACK_START_PLUGIN);
965
+ if (runtime === "static") {
966
+ const calls = callOptions(tokens, closing, "tanstackStart");
967
+ return calls === null
968
+ ? null
969
+ : {
970
+ plugins: imported && !hasModuleImport(tokens, CLOUDFLARE_PLUGIN),
971
+ prerender: calls.some(
972
+ ([start, end]) =>
973
+ nestedPropertyValue(tokens, closing, start, end, "prerender", "enabled") === "true",
974
+ ),
975
+ };
976
+ }
977
+ const calls = callOptions(tokens, closing, "cloudflare");
978
+ return calls === null
979
+ ? null
980
+ : {
981
+ plugins:
982
+ imported &&
983
+ hasNamedImport(tokens, "cloudflare", CLOUDFLARE_PLUGIN) &&
984
+ calls.some(
985
+ ([start, end]) =>
986
+ nestedPropertyValue(tokens, closing, start, end, "viteEnvironment", "name") === "ssr",
987
+ ) &&
988
+ callIndex(tokens, "cloudflare") < callIndex(tokens, "tanstackStart"),
989
+ prerender: true,
990
+ };
991
+ }
992
+
993
+ /** For every `{`, the index of the `}` that closes it, or -1 when none does. */
994
+ function closingBraces(tokens: readonly ConfigToken[]): readonly number[] {
995
+ const closing = tokens.map(() => -1);
996
+ const open: number[] = [];
997
+ tokens.forEach((token, index) => {
998
+ if (token.value === "{") open.push(index);
999
+ if (token.value !== "}") return;
1000
+ const start = open.pop();
1001
+ if (start !== undefined) closing[start] = index;
1002
+ });
1003
+ return closing;
1004
+ }
1005
+
1006
+ /** The options object of every `name({ … })` call; null when one is not closed. */
1007
+ function callOptions(
1008
+ tokens: readonly ConfigToken[],
1009
+ closing: readonly number[],
1010
+ name: string,
1011
+ ): readonly (readonly [number, number])[] | null {
1012
+ const calls: (readonly [number, number])[] = [];
1013
+ for (let index = 0; index < tokens.length - 2; index += 1) {
1014
+ if (
1015
+ tokens[index]?.value !== name ||
1016
+ tokens[index + 1]?.value !== "(" ||
1017
+ tokens[index + 2]?.value !== "{"
1018
+ )
1019
+ continue;
1020
+ const end = closing[index + 2] ?? -1;
1021
+ if (end === -1) return null;
1022
+ calls.push([index + 2, end]);
1023
+ }
1024
+ return calls;
1025
+ }
1026
+
1027
+ /** The value token of `outer: { inner: value }` directly inside an object, if present. */
1028
+ function nestedPropertyValue(
1029
+ tokens: readonly ConfigToken[],
1030
+ closing: readonly number[],
1031
+ start: number,
1032
+ end: number,
1033
+ outer: string,
1034
+ inner: string,
1035
+ ): string | null {
1036
+ const object = directProperty(tokens, closing, start, end, outer);
1037
+ if (object?.value !== "{") return null;
1038
+ return directProperty(tokens, closing, object.start, object.end, inner)?.value ?? null;
1039
+ }
1040
+
1041
+ /**
1042
+ * The first value token of a property at the top level of the object between start and end, with
1043
+ * the range of an object value. Nested objects are skipped whole; brackets and parentheses nest.
1044
+ */
1045
+ function directProperty(
1046
+ tokens: readonly ConfigToken[],
1047
+ closing: readonly number[],
1048
+ start: number,
1049
+ end: number,
1050
+ name: string,
1051
+ ): Readonly<{ start: number; end: number; value: string }> | null {
1052
+ let depth = 0;
1053
+ for (let index = start + 1; index < end; index += 1) {
1054
+ const value = tokens[index]?.value;
1055
+ if (value === "{") {
1056
+ index = Math.max(index, closing[index] ?? -1);
1057
+ continue;
1058
+ }
1059
+ if (value === "[" || value === "(") depth += 1;
1060
+ if (value === "]" || value === ")") depth -= 1;
1061
+ if (depth !== 0 || value !== name || tokens[index + 1]?.value !== ":") continue;
1062
+ const candidate = tokens[index + 2]?.value ?? "";
1063
+ const close = candidate === "{" ? (closing[index + 2] ?? -1) : index + 2;
1064
+ return { start: index + 2, end: close, value: candidate };
1065
+ }
1066
+ return null;
1067
+ }
1068
+
1069
+ function callIndex(tokens: readonly ConfigToken[], name: string): number {
1070
+ return tokens.findIndex(
1071
+ (token, index) => token.value === name && tokens[index + 1]?.value === "(",
1072
+ );
1073
+ }
1074
+
1075
+ /** Whether an import statement names `name` and loads it from `moduleName`. */
1076
+ function hasNamedImport(tokens: readonly ConfigToken[], name: string, moduleName: string): boolean {
1077
+ for (let index = 0; index < tokens.length; index += 1) {
1078
+ if (tokens[index]?.value !== "import") continue;
1079
+ let from = index + 1;
1080
+ while (from < tokens.length && tokens[from]?.value !== "from") from += 1;
1081
+ if (from === tokens.length) return false;
1082
+ const source = tokens[from + 1];
1083
+ if (
1084
+ source?.kind === "string" &&
1085
+ source.value === moduleName &&
1086
+ tokens.slice(index + 1, from).some((token) => token.value === name)
1087
+ )
1088
+ return true;
1089
+ index = from;
1090
+ }
1091
+ return false;
1092
+ }
1093
+
1094
+ function hasModuleImport(tokens: readonly ConfigToken[], moduleName: string): boolean {
1095
+ return tokens.some(
1096
+ (token, index) =>
1097
+ token.value === "from" &&
1098
+ tokens[index + 1]?.kind === "string" &&
1099
+ tokens[index + 1]?.value === moduleName,
1100
+ );
1101
+ }