@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.
@@ -7,21 +7,38 @@ import {
7
7
  type ApplicationRoot,
8
8
  } from "@ohmyhost/contracts/application-root";
9
9
  import { parseOhmyhostConfigYaml, type OhmyhostConfig } from "@ohmyhost/contracts";
10
+ import { isAdmittedFrameworkBuildScript } from "@ohmyhost/contracts/framework-build-script";
10
11
  import {
11
- isAdmittedFrameworkBuildScript,
12
- PINNED_BUN_VERSION,
13
- } from "@ohmyhost/contracts/framework-build-script";
14
- import {
15
- classifyFrameworkVersion,
16
12
  FrameworkAdmissionError,
17
- managedCustomerAuthAdmissionIssue,
13
+ frameworkConversionDetail,
18
14
  resolveFrameworkConfig,
19
15
  resolvePackageManager,
20
- type AdmittedFramework,
16
+ rootFrameworkConversionDiagnostic,
17
+ type FrameworkConversionDiagnosticCode,
21
18
  type FrameworkVersionAdmission,
22
- type FrameworkVersionClassification,
19
+ type FrameworkVersionPackage,
20
+ type FrameworkVersionStatus,
23
21
  type PackageManagerDescriptor,
24
22
  } from "@ohmyhost/contracts/framework-admission";
23
+ import {
24
+ databaseContractCodes,
25
+ declaredDependencyNames,
26
+ isDatabaseContractModule,
27
+ MANAGED_BETTER_AUTH_VERSION,
28
+ packageManagerDeclaration,
29
+ parsePackageManifest,
30
+ repositorySourceFindings,
31
+ sourceWithoutComments,
32
+ tanStackViteConfigRuntime,
33
+ VITE_COMPANION_ENTRY_PATH,
34
+ viteCompanionRequired,
35
+ WORKER_MODULE_PATH,
36
+ workerModuleFindings,
37
+ type DatabaseContractCode,
38
+ type FrameworkSourceLockfile,
39
+ type PackageManifest,
40
+ type TanStackRuntime,
41
+ } from "@ohmyhost/contracts/framework-source-rules";
25
42
  import {
26
43
  classifyFrameworkFamily,
27
44
  type FrameworkFamilyClassification,
@@ -36,38 +53,40 @@ import cliPackage from "../package.json" with { type: "json" };
36
53
 
37
54
  const MAXIMUM_PACKAGE_BYTES = 1024 * 1024;
38
55
  const MAXIMUM_CONFIGURATION_BYTES = 64 * 1024;
56
+ // The largest source file ohmyho.st inspects; a larger lockfile resolves nothing.
57
+ const MAXIMUM_INSPECTED_FILE_BYTES = 5 * 1024 * 1024;
39
58
  const MAXIMUM_REPOSITORY_FILES = 20_000;
40
- const SUPPORTED_BETTER_AUTH_VERSION = "1.7.1";
41
59
  const PROJECT_PATTERN = /^[a-z](?:[a-z0-9-]*[a-z0-9])?$/u;
42
60
  const IGNORED_DIRECTORIES = new Set([
43
61
  ".git",
44
62
  ".next",
63
+ ".open-next",
45
64
  ".supabase",
46
65
  ".turbo",
47
66
  ".vercel",
67
+ ".wrangler",
48
68
  "coverage",
49
69
  "dist",
50
70
  "node_modules",
51
71
  ]);
72
+ const UTF8 = new TextDecoder("utf-8", { fatal: true });
52
73
 
53
74
  export type RepositoryFramework = Exclude<FrameworkFamilyClassification, "ambiguous"> | "functions";
54
- const WORKER_MODULE_PATH = "src/ohmyhost/worker.ts";
55
75
  const MAXIMUM_SOURCE_BYTES = 2 * 1024 * 1024;
56
76
  const SOURCE_MODULE_PATTERN = /\.(?:[cm]?[jt]s|[jt]sx)$/u;
57
77
  const STORAGE_CLIENT_CALL_PATTERN = /\bcreatePrivateStorageClient\s*\(/u;
58
- // Both database mistakes of 2026-09-18 are visible in the source before anything is built: one read
59
- // a retired database binding, the other opened a socket driver. Neither can work in a customer Worker, so
60
- // naming them here costs a customer minutes instead of a deployment that fails its health check.
61
- const DATABASE_PRIVATE_BINDING_PATTERN =
62
- /\bHYPERDRIVE\b|\bconnectionString\b|\bDATABASE_URL\b|postgres(?:ql)?:\/\//u;
63
- const DATABASE_SOCKET_DRIVER_PATTERN =
64
- /\bfrom\s*["'](?:pg|postgres|mysql2|pg-native)["']|\brequire\(\s*["'](?:pg|postgres|pg-native)["']/u;
65
- // A comment and a type-only import name the client without ever constructing it, and the bare
66
- // identifier of either reads like client code; drop them before looking for the call itself.
67
- const INERT_SOURCE_PATTERN = /\/\*[\s\S]*?\*\/|\/\/[^\n]*|\bimport\s+type\b[^;]*;/gu;
68
-
78
+ // The catalog names a module only by a path of its grammar; a file name with a space, for example,
79
+ // is not one, so its blocker carries the path beside a sentence that does not quote it.
80
+ const UNNAMED_MODULE_MESSAGES: Readonly<Record<DatabaseContractCode, string>> = Object.freeze({
81
+ database_binding_private:
82
+ 'The module at 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". Commit and push the change, then plan the new commit.',
83
+ database_driver_unsupported:
84
+ 'The module at 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". Commit and push the change, then plan the new commit.',
85
+ });
86
+
87
+ // Only a call constructs the client; a comment or a type-only import never does.
69
88
  function callsStorageClient(source: string): boolean {
70
- return STORAGE_CLIENT_CALL_PATTERN.test(source.replaceAll(INERT_SOURCE_PATTERN, " "));
89
+ return STORAGE_CLIENT_CALL_PATTERN.test(sourceWithoutComments(source));
71
90
  }
72
91
 
73
92
  // Storage is reached through a private Service Binding plus the three values and the key that
@@ -82,50 +101,68 @@ const STORAGE_RUNTIME_BINDINGS = Object.freeze([
82
101
  // The runtime release is published unchanged as `@amerged/ohmyhost-runtime`; this pinned alias keeps
83
102
  // its import name and, unlike the website archive each release removes, stays installable.
84
103
  const CUSTOMER_RUNTIME_SOURCE = `npm:@amerged/ohmyhost-runtime@${cliPackage.version}`;
85
- const SCHEDULED_HANDLER_PATTERN = /\bscheduled\s*(?:\(|:|,|\})/u;
86
- // The platform imports the module's default export; handlers that are only named exports are
87
- // never invoked, so init blocks them before a build or a runtime rejection would.
88
- const DEFAULT_EXPORT_PATTERN = /\bexport\s+default\b/u;
89
104
 
90
- function defaultExportBlocker(modulePath: string): RepositoryInitBlocker {
91
- return {
92
- code: "worker_module_default_export_required",
93
- message: `${modulePath} has no default export. Export its handlers as the default export (export default { fetch, scheduled }); named exports are never invoked.`,
94
- };
95
- }
96
- type TanStackRuntimeAnalysis = "ambiguous" | "edge" | "static" | null;
105
+ /** A catalog code, which planning answers with the same sentence, or a check only init runs. */
106
+ export type RepositoryInitBlockerCode =
107
+ | FrameworkConversionDiagnosticCode
108
+ | "migration_filename_noncanonical"
109
+ | "migration_sql_not_admitted"
110
+ | "storage_client_missing";
97
111
 
98
112
  export interface RepositoryInitBlocker {
99
- readonly code:
100
- | "build_command_unsupported"
101
- | "database_binding_private"
102
- | "database_driver_unsupported"
103
- | "build_script_missing"
104
- | "better_auth_version_unsupported"
105
- | "managed_auth_database_required"
106
- | "framework_ambiguous"
107
- | "framework_unsupported"
108
- | "framework_version_unsupported"
109
- | "migration_filename_noncanonical"
110
- | "migration_sql_not_admitted"
111
- | "next_adapter_unconfigured"
112
- | "package_manager_ambiguous"
113
- | "package_manager_unpinned"
114
- | "scheduled_functions_runtime_unsupported"
115
- | "scheduled_handler_missing"
116
- | "storage_client_missing"
117
- | "worker_module_default_export_required"
118
- | "worker_module_missing"
119
- | "workers_runtime_incompatible";
113
+ readonly code: RepositoryInitBlockerCode;
114
+ /** The file to change, relative to the repository root; null when no single file is at fault. */
115
+ readonly path: string | null;
116
+ readonly message: string;
117
+ }
118
+
119
+ /** One governed framework package, judged by its lockfile resolution or else its declaration. */
120
+ export interface RepositoryInitFrameworkVersion {
121
+ readonly package: FrameworkVersionPackage;
122
+ readonly declared: string;
123
+ /** The version package-lock.json or pnpm-lock.yaml resolves; null when none resolves it. */
124
+ readonly resolved: string | null;
125
+ /** A pending range is admitted; the build checks the version its frozen install installs. */
126
+ readonly status: FrameworkVersionStatus;
127
+ /** The lowest admitted release of the judged version's line. */
128
+ readonly minimum: string;
129
+ }
130
+
131
+ export type RepositoryInitPendingCode =
132
+ | "framework_build"
133
+ | "frozen_install"
134
+ | "installed_version"
135
+ | "project_state"
136
+ | "reachable_runtime_sources";
137
+
138
+ /** A check only ohmyho.st runs. It never blocks init and never changes its exit code. */
139
+ export interface RepositoryInitPending {
140
+ readonly code: RepositoryInitPendingCode;
120
141
  readonly message: string;
121
142
  }
122
143
 
144
+ const PENDING_CHECKS: Readonly<Record<RepositoryInitPendingCode, string>> = Object.freeze({
145
+ installed_version:
146
+ "compatibility.frameworks lists a version range that no lockfile ohmyho.st reads (package-lock.json v2/v3 or pnpm-lock.yaml v6/v9) resolves. Planning admits it; after the frozen install, the build checks the installed version against the same security minimum.",
147
+ frozen_install:
148
+ "The build installs dependencies from the committed lockfile with the frozen install of the packageManager in package.json, without lifecycle scripts; init installs nothing.",
149
+ framework_build:
150
+ "The build itself (scripts.build with the ohmyho.st adapter build, or the Worker bundle of the functions runtime) runs only on ohmyho.st; a clean init does not prove that it succeeds.",
151
+ reachable_runtime_sources:
152
+ "Planning walks the runtime sources the build reaches for Workers-incompatible imports, database socket drivers and syntax errors; init does not walk them.",
153
+ project_state:
154
+ "Planning also checks the project itself, such as its region against storage.jurisdiction, shared data, the mail sender domain and credits; init runs offline without it.",
155
+ });
156
+
123
157
  export interface RepositoryInitInventory {
124
158
  readonly framework: RepositoryFramework;
125
159
  readonly packageManager: Readonly<{
126
160
  name: "bun" | "npm" | "pnpm" | "unknown" | "yarn";
127
161
  version: string | null;
128
162
  lockfile: string | null;
163
+ /** The commands ohmyho.st runs and build.install and build.command name; null when refused. */
164
+ installCommand: string | null;
165
+ buildCommand: string | null;
129
166
  }>;
130
167
  readonly apiRouteCount: number;
131
168
  readonly database: Readonly<{
@@ -145,15 +182,15 @@ export interface RepositoryInitInventory {
145
182
  }
146
183
 
147
184
  export interface RepositoryInitRuntimePackages {
148
- readonly betterAuth?: "1.7.1";
185
+ readonly betterAuth?: typeof MANAGED_BETTER_AUTH_VERSION;
149
186
  /** Exact install source: an npm alias pinned to the published runtime of this client version. */
150
187
  readonly customerRuntime?: string;
151
188
  }
152
189
 
153
190
  export interface RepositoryInitViteCompanion {
154
191
  readonly schemaVersion: "ohmyhost.vite-api-companion/v1";
155
- readonly sourceEntryPoint: "src/ohmyhost/companion.ts";
156
- readonly requiredFiles: readonly ["src/ohmyhost/companion.ts"];
192
+ readonly sourceEntryPoint: typeof VITE_COMPANION_ENTRY_PATH;
193
+ readonly requiredFiles: readonly [typeof VITE_COMPANION_ENTRY_PATH];
157
194
  readonly missingFiles: readonly string[];
158
195
  readonly packages: RepositoryInitRuntimePackages;
159
196
  readonly bindings: readonly string[];
@@ -173,20 +210,14 @@ export interface RepositoryInitResult {
173
210
  readonly applicationRoot: ApplicationRoot;
174
211
  readonly applicationRootSource: "explicit" | "nested" | "repository";
175
212
  readonly inventory: RepositoryInitInventory;
176
- readonly compatibility: Readonly<{
177
- classification: FrameworkVersionClassification;
178
- frameworks: readonly Readonly<{
179
- framework: AdmittedFramework;
180
- version: string;
181
- classification: FrameworkVersionAdmission["classification"];
182
- reason: FrameworkVersionAdmission["reason"];
183
- }>[];
184
- }>;
213
+ readonly compatibility: Readonly<{ frameworks: readonly RepositoryInitFrameworkVersion[] }>;
214
+ /** Ordered as planning judges the source, so the first blocker is the refusal planning answers. */
185
215
  readonly blockers: readonly RepositoryInitBlocker[];
216
+ readonly pending: readonly RepositoryInitPending[];
186
217
  readonly requirements: readonly string[];
187
218
  readonly companion: RepositoryInitViteCompanion | null;
188
219
  readonly worker: Readonly<{
189
- sourceEntryPoint: "src/ohmyhost/worker.ts";
220
+ sourceEntryPoint: typeof WORKER_MODULE_PATH;
190
221
  present: boolean;
191
222
  bindings: readonly string[];
192
223
  packages: RepositoryInitRuntimePackages;
@@ -244,51 +275,27 @@ export async function initializeRepository(
244
275
  const applicationDirectory = resolve(root, selection.applicationRoot);
245
276
  const applicationFiles = applicationRelativeFiles(files, selection.applicationRoot);
246
277
  const packageJson = await readPackageJson(applicationDirectory);
247
- const dependencies = dependencyNames(packageJson);
248
- const existingConfigurationYaml = await readExistingConfiguration(
249
- root,
250
- files,
251
- selection.applicationRoot,
252
- );
253
- const existingConfiguration =
254
- existingConfigurationYaml === null ? null : parseOhmyhostConfigYaml(existingConfigurationYaml);
278
+ const manifest = packageJson.manifest;
279
+ const dependencies = declaredDependencyNames(manifest);
280
+ const existing = await readExistingConfiguration(root, files, selection.applicationRoot);
281
+ const existingConfiguration = existing?.configuration ?? null;
255
282
  const framework = detectRepositoryFramework(
256
283
  dependencies,
257
284
  applicationFiles,
258
285
  existingConfiguration,
259
286
  );
260
- const packageManagerAdmission = packageManagerAdmissionFor(packageJson, applicationFiles);
287
+ const packageManagerAdmission = packageManagerAdmissionFor(manifest, applicationFiles);
261
288
  const packageManager = packageManagerInventory(
262
289
  packageManagerAdmission,
263
- packageJson,
290
+ manifest,
264
291
  applicationFiles,
265
292
  );
266
- const frameworkConfigs = frameworkConfigurations(applicationFiles);
267
- const compatibility = frameworkCompatibility(packageJson, framework);
268
- const nextStandaloneOutput = await detectNextStandaloneOutput(
269
- applicationDirectory,
270
- framework,
271
- frameworkConfigs.next,
272
- );
273
- const tanStackRuntime = await detectTanStackRuntime(
293
+ const frameworkConfig = await readFrameworkConfig(
274
294
  applicationDirectory,
275
295
  applicationFiles,
276
296
  framework,
277
- packageJson,
278
- frameworkConfigs.vite,
279
- );
280
- const project = projectName(input.project, packageJson);
281
- const repositoryAdmissionBlockers = repositoryBlockers(
282
- packageJson,
283
- framework,
284
- nextStandaloneOutput,
285
- tanStackRuntime,
286
- applicationFiles,
287
- packageManagerAdmission,
288
- frameworkConfigs,
289
- compatibility,
290
- existingConfiguration,
291
297
  );
298
+ const project = projectName(input.project, manifest);
292
299
  const supabaseMigrationCount = applicationFiles.filter((path) =>
293
300
  /^supabase\/migrations\/[^/]+\.sql$/u.test(path),
294
301
  ).length;
@@ -359,13 +366,13 @@ export async function initializeRepository(
359
366
  // Mail stays an explicit choice: Better Auth or a detected mail package never enables it.
360
367
  const targetMail = existingConfiguration?.mail?.enabled === true;
361
368
  const targetStorage = r2 || supabaseStorage;
362
- const configuredCompanionRequired =
363
- existingConfiguration !== null &&
364
- (existingConfiguration.database?.enabled === true ||
365
- existingConfiguration.auth?.provider === "better-auth" ||
366
- existingConfiguration.mail?.enabled === true ||
367
- existingConfiguration.storage !== undefined ||
368
- (existingConfiguration.functions?.crons.length ?? 0) > 0);
369
+ // TanStack Start binds storage only on edge, as a Vite app does through its companion.
370
+ const tanStackRuntime =
371
+ framework !== "tanstack-start"
372
+ ? null
373
+ : targetStorage
374
+ ? "edge"
375
+ : proposedTanStackRuntime(frameworkConfig, dependencies);
369
376
  const companionRequired =
370
377
  framework === "vite" &&
371
378
  (existingConfiguration === null
@@ -374,8 +381,8 @@ export async function initializeRepository(
374
381
  targetMail ||
375
382
  targetStorage ||
376
383
  edgeFunctionNames.size > 0 ||
377
- applicationFiles.includes("src/ohmyhost/companion.ts")
378
- : configuredCompanionRequired);
384
+ applicationFiles.includes(VITE_COMPANION_ENTRY_PATH)
385
+ : viteCompanionRequired(existingConfiguration));
379
386
  const generatedConfigurationYaml = renderConfiguration({
380
387
  project,
381
388
  framework,
@@ -392,7 +399,7 @@ export async function initializeRepository(
392
399
  tanStackRuntime,
393
400
  applicationRoot: selection.applicationRoot,
394
401
  });
395
- const configurationYaml = existingConfigurationYaml ?? generatedConfigurationYaml;
402
+ const configurationYaml = existing?.yaml ?? generatedConfigurationYaml;
396
403
  const capabilities: RuntimeCapabilities = Object.freeze({
397
404
  database: selectedDatabaseProvider !== "none",
398
405
  auth: targetBetterAuth,
@@ -404,36 +411,27 @@ export async function initializeRepository(
404
411
  : null;
405
412
  const companionSourcePresent =
406
413
  companion !== null && applicationFiles.includes(companion.sourceEntryPoint);
407
- const migrationSqlAdmissionBlockers = await migrationSqlBlockers(
408
- applicationDirectory,
409
- applicationFiles,
410
- existingConfiguration?.database?.migrations,
411
- selectedDatabaseProvider,
412
- );
413
- const blockers = [
414
- ...repositoryAdmissionBlockers,
415
- ...(await workerModuleBlockers(framework, applicationDirectory, applicationFiles)),
416
- ...(await cronBlockers(
417
- framework,
418
- existingConfiguration,
419
- applicationDirectory,
420
- applicationFiles,
421
- )),
422
- ...(selectedDatabaseProvider === "none"
423
- ? []
424
- : migrationFilenameBlockers(
425
- applicationFiles,
426
- existingConfiguration?.database?.migrations,
427
- selectedDatabaseProvider,
428
- )),
429
- ...migrationSqlAdmissionBlockers,
430
- ...(await storageClientBlockers(existingConfiguration, applicationDirectory, applicationFiles)),
431
- ...(await databaseContractBlockers(
432
- existingConfiguration,
433
- applicationDirectory,
434
- applicationFiles,
435
- )),
436
- ].sort((left, right) => left.code.localeCompare(right.code));
414
+ // Planning stops at an invalid ohmyhost.yaml, and so does init: nothing else can be judged.
415
+ const { blockers, versions } =
416
+ existing !== null && existing.blocker !== null
417
+ ? { blockers: [existing.blocker], versions: [] }
418
+ : await judgeSource({
419
+ framework,
420
+ config: existingConfiguration,
421
+ applicationDirectory,
422
+ files: applicationFiles,
423
+ applicationRoot: selection.applicationRoot,
424
+ provider: selectedDatabaseProvider,
425
+ texts: sourceTexts(files, selection.applicationRoot, configurationYaml, [
426
+ ["package.json", packageJson.text],
427
+ ...lockfileTexts(
428
+ await readResolvingLockfile(applicationDirectory, packageManagerAdmission),
429
+ ),
430
+ ...(frameworkConfig === null
431
+ ? []
432
+ : [[frameworkConfig.path, frameworkConfig.text] as const]),
433
+ ]),
434
+ });
437
435
  const requirements = requirementsFor(
438
436
  inventory,
439
437
  companion !== null && !companionSourcePresent,
@@ -454,8 +452,21 @@ export async function initializeRepository(
454
452
  applicationRoot: selection.applicationRoot,
455
453
  applicationRootSource: selection.source,
456
454
  inventory,
457
- compatibility,
455
+ compatibility: Object.freeze({
456
+ frameworks: Object.freeze(
457
+ versions.map((version) =>
458
+ Object.freeze({
459
+ package: version.package,
460
+ declared: version.declared,
461
+ resolved: version.resolved,
462
+ status: version.status,
463
+ minimum: version.minimum,
464
+ }),
465
+ ),
466
+ ),
467
+ }),
458
468
  blockers: Object.freeze(blockers),
469
+ pending: pendingChecks(versions),
459
470
  requirements: Object.freeze(requirements),
460
471
  companion,
461
472
  worker:
@@ -471,6 +482,47 @@ export async function initializeRepository(
471
482
  });
472
483
  }
473
484
 
485
+ /** What the local source checks read: the application, its files and the parsed configuration. */
486
+ interface SourceCheckContext {
487
+ readonly framework: RepositoryFramework;
488
+ readonly config: OhmyhostConfig | null;
489
+ readonly applicationDirectory: string;
490
+ /** Every application file, relative to the application root. */
491
+ readonly files: readonly string[];
492
+ readonly applicationRoot: ApplicationRoot;
493
+ }
494
+
495
+ /**
496
+ * Every blocker of a source with a valid or proposed configuration, in the order planning judges:
497
+ * migration filenames, the shared source rules, the Worker module handlers and the database
498
+ * contract; init's own storage client and migration SQL checks come last.
499
+ */
500
+ async function judgeSource(
501
+ context: SourceCheckContext &
502
+ Readonly<{
503
+ provider: RepositoryInitInventory["database"]["provider"];
504
+ texts: ReadonlyMap<string, string | null>;
505
+ }>,
506
+ ): Promise<
507
+ Readonly<{
508
+ blockers: readonly RepositoryInitBlocker[];
509
+ versions: readonly FrameworkVersionAdmission[];
510
+ }>
511
+ > {
512
+ const source = repositorySourceFindings(context.texts);
513
+ return {
514
+ blockers: [
515
+ ...migrationFilenameBlockers(context, context.provider),
516
+ ...source.findings.map((finding) => requiredCatalogBlocker(finding, context.applicationRoot)),
517
+ ...(await workerModuleBlockers(context)),
518
+ ...(await databaseContractBlockers(context, context.provider)),
519
+ ...(await storageClientBlockers(context)),
520
+ ...(await migrationSqlBlockers(context, context.provider)),
521
+ ],
522
+ versions: source.versions,
523
+ };
524
+ }
525
+
474
526
  /** The functions runtime is selected by configuration, or by a Worker module without a framework. */
475
527
  function detectRepositoryFramework(
476
528
  dependencies: ReadonlySet<string>,
@@ -483,45 +535,75 @@ function detectRepositoryFramework(
483
535
  return detected;
484
536
  }
485
537
 
486
- /** Declared crons need an edge or functions runtime and a scheduled handler in the Worker module. */
487
- async function cronBlockers(
488
- framework: RepositoryFramework,
489
- config: OhmyhostConfig | null,
490
- applicationDirectory: string,
538
+ /**
539
+ * The texts the shared source rules read, keyed by repository path: the configuration init judges
540
+ * and the given application files. Every other file counts by its path alone.
541
+ */
542
+ function sourceTexts(
491
543
  files: readonly string[],
492
- ): Promise<RepositoryInitBlocker[]> {
493
- if ((config?.functions?.crons.length ?? 0) === 0) return [];
544
+ applicationRoot: ApplicationRoot,
545
+ configurationYaml: string,
546
+ applicationTexts: readonly (readonly [string, string | null])[],
547
+ ): ReadonlyMap<string, string | null> {
548
+ const texts = new Map<string, string | null>(files.map((path) => [path, null]));
549
+ texts.set("ohmyhost.yaml", configurationYaml);
550
+ for (const [path, text] of applicationTexts)
551
+ texts.set(repositoryPath(applicationRoot, path), text);
552
+ return texts;
553
+ }
554
+
555
+ function lockfileTexts(
556
+ lockfile: FrameworkSourceLockfile | null,
557
+ ): readonly (readonly [string, string])[] {
558
+ return lockfile === null ? [] : [[lockfile.name, lockfile.text]];
559
+ }
560
+
561
+ /** The path of an application file relative to the repository root. */
562
+ function repositoryPath(applicationRoot: ApplicationRoot, path: string): string {
563
+ return applicationRoot === "." ? path : `${applicationRoot}/${path}`;
564
+ }
565
+
566
+ /** A catalog diagnostic as a blocker: its repository-relative file and the sentence planning uses. */
567
+ function catalogBlocker(
568
+ diagnostic: unknown,
569
+ applicationRoot: ApplicationRoot,
570
+ ): RepositoryInitBlocker | null {
571
+ const rooted = rootFrameworkConversionDiagnostic(diagnostic, applicationRoot);
572
+ const message = frameworkConversionDetail(rooted);
573
+ return rooted === null || message === null
574
+ ? null
575
+ : Object.freeze({ code: rooted.code, path: rooted.path, message });
576
+ }
577
+
578
+ /** The blocker of a diagnostic whose file always has a catalog path, such as a shared finding. */
579
+ function requiredCatalogBlocker(
580
+ diagnostic: unknown,
581
+ applicationRoot: ApplicationRoot,
582
+ ): RepositoryInitBlocker {
583
+ const blocker = catalogBlocker(diagnostic, applicationRoot);
584
+ if (blocker === null) throw new TypeError("Repository init diagnostic is invalid");
585
+ return blocker;
586
+ }
587
+
588
+ /**
589
+ * The Worker module handlers planning requires, judged by the same shared rule: the functions
590
+ * runtime serves requests through its module, and declared crons need a scheduled handler in the
591
+ * module that runs them. The shared source rules name a missing module or an unsupported runtime.
592
+ */
593
+ async function workerModuleBlockers(context: SourceCheckContext): Promise<RepositoryInitBlocker[]> {
594
+ const { config, framework, files } = context;
494
595
  const mode = config?.runtime.mode;
495
- const runtimeSupported =
496
- (mode === "edge" &&
596
+ const scheduled =
597
+ (config?.functions?.crons.length ?? 0) > 0 &&
598
+ ((mode === "edge" &&
497
599
  (framework === "vite" || framework === "nextjs" || framework === "tanstack-start")) ||
498
- (mode === "functions" && framework === "functions");
499
- if (!runtimeSupported) {
500
- return [
501
- {
502
- code: "scheduled_functions_runtime_unsupported",
503
- message:
504
- "functions.crons needs runtime.mode edge (Next.js, TanStack Start, or Vite with src/ohmyhost/companion.ts) or functions (src/ohmyhost/worker.ts) in ohmyhost.yaml. Change runtime.mode, or remove functions.crons.",
505
- },
506
- ];
507
- }
508
- const modulePath = framework === "vite" ? "src/ohmyhost/companion.ts" : WORKER_MODULE_PATH;
509
- const source = files.includes(modulePath)
510
- ? await readFile(resolve(applicationDirectory, modulePath), "utf8")
511
- : null;
512
- // The functions runtime reports a missing default export through workerModuleBlockers.
513
- if (framework !== "functions" && source !== null && !DEFAULT_EXPORT_PATTERN.test(source)) {
514
- return [defaultExportBlocker(modulePath)];
515
- }
516
- if (source === null || !SCHEDULED_HANDLER_PATTERN.test(source)) {
517
- return [
518
- {
519
- code: "scheduled_handler_missing",
520
- message: `Keep a scheduled handler in the default export of ${modulePath} to run the declared crons.`,
521
- },
522
- ];
523
- }
524
- return [];
600
+ (mode === "functions" && framework === "functions"));
601
+ const path = framework === "vite" ? VITE_COMPANION_ENTRY_PATH : WORKER_MODULE_PATH;
602
+ if ((framework !== "functions" && !scheduled) || !files.includes(path)) return [];
603
+ const source = await readFile(resolve(context.applicationDirectory, path), "utf8");
604
+ return workerModuleFindings({ path, source, fetch: framework === "functions", scheduled }).map(
605
+ (finding) => requiredCatalogBlocker(finding, context.applicationRoot),
606
+ );
525
607
  }
526
608
 
527
609
  /**
@@ -529,14 +611,12 @@ async function cronBlockers(
529
611
  * source never constructs that client deploys green and fails at its first upload, so name it here.
530
612
  */
531
613
  async function storageClientBlockers(
532
- config: OhmyhostConfig | null,
533
- applicationDirectory: string,
534
- files: readonly string[],
614
+ context: SourceCheckContext,
535
615
  ): Promise<RepositoryInitBlocker[]> {
536
- if (config?.storage === undefined) return [];
537
- for (const path of files) {
616
+ if (context.config?.storage === undefined) return [];
617
+ for (const path of context.files) {
538
618
  if (!SOURCE_MODULE_PATTERN.test(path)) continue;
539
- const absolute = resolve(applicationDirectory, path);
619
+ const absolute = resolve(context.applicationDirectory, path);
540
620
  const information = await stat(absolute);
541
621
  if (!information.isFile() || information.size > MAXIMUM_SOURCE_BYTES) continue;
542
622
  if (callsStorageClient(await readFile(absolute, "utf8"))) return [];
@@ -544,6 +624,7 @@ async function storageClientBlockers(
544
624
  return [
545
625
  {
546
626
  code: "storage_client_missing",
627
+ path: null,
547
628
  message:
548
629
  'ohmyhost.yaml declares storage, but no application source calls createPrivateStorageClient. Import it from "@ohmyhost/customer-runtime/storage" and build it in server code from OHMYHOST_STORAGE_GATEWAY, OHMYHOST_STORAGE_GATEWAY_URL, OHMYHOST_PROJECT_ID, OHMYHOST_ENVIRONMENT_ID and OHMYHOST_STORAGE_KEY, then reserve an upload, PUT the bytes to the returned signed URL, complete it, and serve reads with createSignedRead. There is no FILES bucket binding.',
549
630
  },
@@ -553,57 +634,41 @@ async function storageClientBlockers(
553
634
  /**
554
635
  * A managed database is reached only through `createPrivateDatabaseClient`. A customer Worker gets
555
636
  * no connection string and cannot open a socket, so source that reaches for either builds green,
556
- * deploys, and fails its health check with nothing to explain it.
637
+ * deploys, and fails its health check with nothing to explain it. Once the judged configuration
638
+ * enables the database, init scans every module of the kinds planning judges, reachable or not.
557
639
  */
558
640
  async function databaseContractBlockers(
559
- config: OhmyhostConfig | null,
560
- applicationDirectory: string,
561
- files: readonly string[],
641
+ context: SourceCheckContext,
642
+ provider: RepositoryInitInventory["database"]["provider"],
562
643
  ): Promise<RepositoryInitBlocker[]> {
563
- if (config?.database === undefined) return [];
644
+ if (provider === "none") return [];
564
645
  const blockers: RepositoryInitBlocker[] = [];
565
- for (const path of files) {
566
- if (!SOURCE_MODULE_PATTERN.test(path)) continue;
567
- const absolute = resolve(applicationDirectory, path);
646
+ for (const path of context.files) {
647
+ if (!isDatabaseContractModule(path)) continue;
648
+ const absolute = resolve(context.applicationDirectory, path);
568
649
  const information = await stat(absolute);
569
650
  if (!information.isFile() || information.size > MAXIMUM_SOURCE_BYTES) continue;
570
- const source = (await readFile(absolute, "utf8")).replaceAll(INERT_SOURCE_PATTERN, " ");
571
- if (DATABASE_PRIVATE_BINDING_PATTERN.test(source)) {
572
- blockers.push({
573
- code: "database_binding_private",
574
- message: `${path} reaches for a managed database connection string. HYPERDRIVE is retired; DATABASE_URL and postgres:// URLs never reach a customer Worker. Call the database with createPrivateDatabaseClient from "@ohmyhost/customer-runtime/database".`,
575
- });
576
- }
577
- if (DATABASE_SOCKET_DRIVER_PATTERN.test(source)) {
578
- blockers.push({
579
- code: "database_driver_unsupported",
580
- message: `${path} imports a PostgreSQL socket driver. Outbound connect() is disabled for customer Workers, so the driver can never open a connection. Call the database with createPrivateDatabaseClient from "@ohmyhost/customer-runtime/database".`,
581
- });
582
- }
651
+ for (const code of databaseContractCodes(await readFile(absolute, "utf8")))
652
+ blockers.push(
653
+ catalogBlocker({ code, path }, context.applicationRoot) ??
654
+ Object.freeze({
655
+ code,
656
+ path: repositoryPath(context.applicationRoot, path),
657
+ message: UNNAMED_MODULE_MESSAGES[code],
658
+ }),
659
+ );
583
660
  }
584
661
  return blockers;
585
662
  }
586
663
 
587
- /** The functions runtime is the customer's module alone; it must export its handlers by default. */
588
- async function workerModuleBlockers(
589
- framework: RepositoryFramework,
590
- applicationDirectory: string,
591
- files: readonly string[],
592
- ): Promise<RepositoryInitBlocker[]> {
593
- if (framework !== "functions" || !files.includes(WORKER_MODULE_PATH)) return [];
594
- const source = await readFile(resolve(applicationDirectory, WORKER_MODULE_PATH), "utf8");
595
- return DEFAULT_EXPORT_PATTERN.test(source) ? [] : [defaultExportBlocker(WORKER_MODULE_PATH)];
596
- }
597
-
598
664
  function migrationFilenameBlockers(
599
- files: readonly string[],
600
- configuredPath: string | undefined,
665
+ context: SourceCheckContext,
601
666
  provider: RepositoryInitInventory["database"]["provider"],
602
667
  ): RepositoryInitBlocker[] {
603
668
  if (provider !== "postgresql") return [];
604
- const root = configuredPath ?? "postgres/migrations";
669
+ const root = context.config?.database?.migrations ?? "postgres/migrations";
605
670
  const prefix = `${root}/`;
606
- const invalid = files
671
+ const invalid = context.files
607
672
  .filter((path) => path.startsWith(prefix))
608
673
  .map((path) => path.slice(prefix.length))
609
674
  .find(
@@ -614,6 +679,7 @@ function migrationFilenameBlockers(
614
679
  : [
615
680
  {
616
681
  code: "migration_filename_noncanonical",
682
+ path: repositoryPath(context.applicationRoot, `${prefix}${invalid}`),
617
683
  message: `Migration file '${invalid}' in ${root} is not named YYYYMMDDHHMMSS_name.sql (14 digits, an underscore, then lowercase letters, digits, _ or -). Every file in that directory must match, README and .gitkeep included: rename or move it, keeping the run order.`,
618
684
  },
619
685
  ];
@@ -632,15 +698,13 @@ const MIGRATION_REJECTION: Readonly<Record<DatabaseMigrationAdmissionReason, str
632
698
  };
633
699
 
634
700
  async function migrationSqlBlockers(
635
- applicationDirectory: string,
636
- files: readonly string[],
637
- configuredPath: string | undefined,
701
+ context: SourceCheckContext,
638
702
  provider: RepositoryInitInventory["database"]["provider"],
639
703
  ): Promise<RepositoryInitBlocker[]> {
640
704
  if (provider !== "postgresql") return [];
641
- const root = configuredPath ?? "postgres/migrations";
705
+ const root = context.config?.database?.migrations ?? "postgres/migrations";
642
706
  const prefix = `${root}/`;
643
- const migrationPaths = files
707
+ const migrationPaths = context.files
644
708
  .filter((path) => path.startsWith(prefix) && path.endsWith(".sql"))
645
709
  .map((path) => path.slice(prefix.length));
646
710
  if (
@@ -654,7 +718,7 @@ async function migrationSqlBlockers(
654
718
  migrationPaths.map(async (path) =>
655
719
  Object.freeze({
656
720
  path,
657
- body: new Uint8Array(await readFile(resolve(applicationDirectory, root, path))),
721
+ body: new Uint8Array(await readFile(resolve(context.applicationDirectory, root, path))),
658
722
  }),
659
723
  ),
660
724
  );
@@ -666,6 +730,7 @@ async function migrationSqlBlockers(
666
730
  if (!(error instanceof DatabaseMigrationAdmissionError)) throw error;
667
731
  blockers.push({
668
732
  code: "migration_sql_not_admitted",
733
+ path: repositoryPath(context.applicationRoot, `${prefix}${migration.path}`),
669
734
  message: `PostgreSQL migration '${migration.path}' is not admitted (${error.reason}): ${MIGRATION_REJECTION[error.reason]} Migrations are expand-only. Fix the file before its first deployment; never edit a migration a deployment already applied.`,
670
735
  });
671
736
  }
@@ -679,17 +744,37 @@ async function migrationSqlBlockers(
679
744
  return [
680
745
  {
681
746
  code: "migration_sql_not_admitted",
747
+ path: null,
682
748
  message: `The migration set is not admitted (${error.reason}): ${MIGRATION_REJECTION[error.reason]}`,
683
749
  },
684
750
  ];
685
751
  }
686
752
  }
687
753
 
754
+ /** The pending checks of one analysis: the fixed list, led by the unresolved versions if any. */
755
+ function pendingChecks(
756
+ versions: readonly FrameworkVersionAdmission[],
757
+ ): readonly RepositoryInitPending[] {
758
+ const codes: readonly RepositoryInitPendingCode[] = [
759
+ ...(versions.some(({ status }) => status === "pending") ? ["installed_version" as const] : []),
760
+ "frozen_install",
761
+ "framework_build",
762
+ "reachable_runtime_sources",
763
+ "project_state",
764
+ ];
765
+ return Object.freeze(codes.map((code) => Object.freeze({ code, message: PENDING_CHECKS[code] })));
766
+ }
767
+
768
+ type ExistingConfiguration = Readonly<
769
+ | { yaml: string; configuration: OhmyhostConfig; blocker: null }
770
+ | { yaml: string; configuration: null; blocker: RepositoryInitBlocker }
771
+ >;
772
+
688
773
  async function readExistingConfiguration(
689
774
  repositoryRoot: string,
690
775
  repositoryFiles: readonly string[],
691
776
  applicationRoot: ApplicationRoot,
692
- ) {
777
+ ): Promise<ExistingConfiguration | null> {
693
778
  if (!repositoryFiles.includes("ohmyhost.yaml")) return null;
694
779
  const configurationPath = resolve(repositoryRoot, "ohmyhost.yaml");
695
780
  const information = await stat(configurationPath);
@@ -699,16 +784,28 @@ async function readExistingConfiguration(
699
784
  "ohmyhost.yaml must be one regular file of at most 64 KiB",
700
785
  );
701
786
  }
702
- const configuration = await readFile(configurationPath, "utf8");
787
+ const bytes = await readFile(configurationPath);
788
+ const text = utf8(bytes);
789
+ const yaml = text ?? new TextDecoder().decode(bytes);
703
790
  let parsed;
704
791
  try {
705
- parsed = parseOhmyhostConfigYaml(configuration);
792
+ if (text === null) throw new TypeError("ohmyhost.yaml is not valid UTF-8");
793
+ parsed = parseOhmyhostConfigYaml(text);
706
794
  } catch (error) {
707
- // The parser names the offending key; dropping it left the agent nothing to fix.
708
- throw new RepositoryInitError(
709
- "repository_configuration_invalid",
710
- error instanceof TypeError ? error.message : "ohmyhost.yaml is invalid",
711
- );
795
+ // Planning refuses a non-UTF-8 or schema-invalid ohmyhost.yaml and points here: the blocker
796
+ // keeps the reason, such as the offending field the parser names.
797
+ const reason = (
798
+ error instanceof TypeError ? error.message : "ohmyhost.yaml is invalid"
799
+ ).replace(/\.$/u, "");
800
+ return Object.freeze({
801
+ yaml,
802
+ configuration: null,
803
+ blocker: Object.freeze({
804
+ code: "repository_configuration_invalid",
805
+ path: "ohmyhost.yaml",
806
+ message: `${reason}. Fix that in ohmyhost.yaml and run init again; init never rewrites an existing ohmyhost.yaml, so do not delete it.`,
807
+ }),
808
+ });
712
809
  }
713
810
  if (parsed.applicationRoot !== applicationRoot) {
714
811
  throw new RepositoryInitError(
@@ -716,7 +813,7 @@ async function readExistingConfiguration(
716
813
  `ohmyhost.yaml sets applicationRoot to '${parsed.applicationRoot}', but init selected '${applicationRoot}'. Run 'ohmyhost init --root ${parsed.applicationRoot} --dry-run --json', or correct applicationRoot.`,
717
814
  );
718
815
  }
719
- return configuration;
816
+ return Object.freeze({ yaml, configuration: parsed, blocker: null });
720
817
  }
721
818
 
722
819
  interface ApplicationRootSelection {
@@ -836,7 +933,7 @@ async function isApplicationCandidate(
836
933
  if (!files.includes("package.json")) return false;
837
934
  const packageJson = await tryReadPackageJson(applicationDirectory);
838
935
  if (packageJson === null) return false;
839
- const framework = detectFramework(dependencyNames(packageJson));
936
+ const framework = detectFramework(declaredDependencyNames(packageJson.manifest));
840
937
  return framework !== "unknown" || files.includes(WORKER_MODULE_PATH);
841
938
  }
842
939
 
@@ -852,9 +949,9 @@ async function hasDirectApplicationShape(
852
949
  }
853
950
  const packageJson = await tryReadPackageJson(applicationDirectory);
854
951
  if (packageJson === null) return false;
855
- const framework = detectFramework(dependencyNames(packageJson));
952
+ const framework = detectFramework(declaredDependencyNames(packageJson.manifest));
856
953
  if (framework === "unknown") return files.includes(WORKER_MODULE_PATH);
857
- return hasDirectSafeBuildCommand(packageJson, framework);
954
+ return hasDirectSafeBuildCommand(packageJson.manifest, framework);
858
955
  }
859
956
 
860
957
  async function isRealDirectory(path: string): Promise<boolean> {
@@ -877,7 +974,7 @@ function applicationRelativeFiles(
877
974
  .map((path) => path.slice(prefix.length));
878
975
  }
879
976
 
880
- async function tryReadPackageJson(root: string): Promise<Record<string, unknown> | null> {
977
+ async function tryReadPackageJson(root: string): Promise<PackageJson | null> {
881
978
  try {
882
979
  return await readPackageJson(root);
883
980
  } catch {
@@ -886,10 +983,10 @@ async function tryReadPackageJson(root: string): Promise<Record<string, unknown>
886
983
  }
887
984
 
888
985
  function hasDirectSafeBuildCommand(
889
- packageJson: Record<string, unknown>,
986
+ manifest: PackageManifest,
890
987
  framework: RepositoryFramework,
891
988
  ): boolean {
892
- const scripts = packageJson["scripts"];
989
+ const scripts = manifest["scripts"];
893
990
  if (!isRecord(scripts) || typeof scripts["build"] !== "string") return false;
894
991
  return (
895
992
  (framework === "nextjs" || framework === "vite" || framework === "tanstack-start") &&
@@ -897,14 +994,6 @@ function hasDirectSafeBuildCommand(
897
994
  );
898
995
  }
899
996
 
900
- /** The package.json build-script blocker, quoting at most 200 characters of the rejected script. */
901
- function buildScriptMessage(framework: RepositoryFramework, command: string): string {
902
- const quoted = command.slice(0, 200);
903
- if (framework === "nextjs")
904
- return `package.json scripts.build must be exactly "next build" or "next build --webpack", not "${quoted}". Move other steps into their own scripts, then commit and push.`;
905
- return `package.json scripts.build must be "vite build", optionally joined with && to exactly one "tsc", "tsc -b", "tsc --build" or "tsc --noEmit" stage before or after it, not "${quoted}". Move other steps into their own scripts, then commit and push.`;
906
- }
907
-
908
997
  async function assertRepositoryRoot(root: string): Promise<void> {
909
998
  let information;
910
999
  try {
@@ -974,7 +1063,13 @@ async function collectRepositoryFiles(root: string): Promise<string[]> {
974
1063
  return files.sort();
975
1064
  }
976
1065
 
977
- async function readPackageJson(root: string): Promise<Record<string, unknown>> {
1066
+ /** package.json as the shared rules read it: its UTF-8 text and the object it parses to. */
1067
+ interface PackageJson {
1068
+ readonly text: string;
1069
+ readonly manifest: PackageManifest;
1070
+ }
1071
+
1072
+ async function readPackageJson(root: string): Promise<PackageJson> {
978
1073
  const path = resolve(root, "package.json");
979
1074
  let information;
980
1075
  try {
@@ -985,25 +1080,97 @@ async function readPackageJson(root: string): Promise<Record<string, unknown>> {
985
1080
  if (!information.isFile() || information.size > MAXIMUM_PACKAGE_BYTES) {
986
1081
  throw new RepositoryInitError("package_manifest_invalid", "package.json is invalid");
987
1082
  }
1083
+ const text = utf8(await readFile(path));
1084
+ const manifest = text === null ? null : parsePackageManifest(text);
1085
+ if (text === null || manifest === null) {
1086
+ throw new RepositoryInitError("package_manifest_invalid", "package.json is invalid");
1087
+ }
1088
+ return Object.freeze({ text, manifest });
1089
+ }
1090
+
1091
+ function utf8(bytes: Uint8Array): string | null {
988
1092
  try {
989
- const value = JSON.parse(await readFile(path, "utf8")) as unknown;
990
- if (!isRecord(value)) throw new TypeError();
991
- return value;
1093
+ return UTF8.decode(bytes);
992
1094
  } catch {
993
- throw new RepositoryInitError("package_manifest_invalid", "package.json is invalid");
1095
+ return null;
994
1096
  }
995
1097
  }
996
1098
 
997
- function dependencyNames(packageJson: Record<string, unknown>): Set<string> {
998
- const names = new Set<string>();
999
- for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
1000
- const value = packageJson[field];
1001
- if (!isRecord(value)) continue;
1002
- for (const [name, version] of Object.entries(value)) {
1003
- if (typeof version === "string" && version.length > 0) names.add(name);
1004
- }
1099
+ /** At most the inspection bound of a file's leading bytes, and whether they are the whole file. */
1100
+ async function readLeadingBytes(
1101
+ path: string,
1102
+ ): Promise<Readonly<{ bytes: Uint8Array; complete: boolean }>> {
1103
+ const handle = await open(path, "r");
1104
+ try {
1105
+ const { size } = await handle.stat();
1106
+ const bytes = new Uint8Array(Math.min(size, MAXIMUM_INSPECTED_FILE_BYTES));
1107
+ const { bytesRead } = await handle.read(bytes, 0, bytes.byteLength, 0);
1108
+ return Object.freeze({ bytes: bytes.subarray(0, bytesRead), complete: bytesRead === size });
1109
+ } finally {
1110
+ await handle.close();
1111
+ }
1112
+ }
1113
+
1114
+ /**
1115
+ * The lockfile whose resolution decides the framework versions: package-lock.json or
1116
+ * pnpm-lock.yaml of the admitted package manager. Any other lockfile, a larger one or one that is
1117
+ * not UTF-8 leaves every version to its declaration; lockfile content never blocks.
1118
+ */
1119
+ async function readResolvingLockfile(
1120
+ applicationDirectory: string,
1121
+ admission: PackageManagerDescriptor | FrameworkAdmissionError,
1122
+ ): Promise<FrameworkSourceLockfile | null> {
1123
+ if (
1124
+ admission instanceof FrameworkAdmissionError ||
1125
+ (admission.lockfile !== "package-lock.json" && admission.lockfile !== "pnpm-lock.yaml")
1126
+ )
1127
+ return null;
1128
+ const { bytes, complete } = await readLeadingBytes(
1129
+ resolve(applicationDirectory, admission.lockfile),
1130
+ );
1131
+ const text = complete ? utf8(bytes) : null;
1132
+ return text === null ? null : Object.freeze({ name: admission.lockfile, text });
1133
+ }
1134
+
1135
+ /** The framework config the shared rules judge: its application-relative path and UTF-8 text. */
1136
+ interface FrameworkConfigText {
1137
+ readonly path: string;
1138
+ /** null when the bytes are not UTF-8, which the rules refuse as invalid source. */
1139
+ readonly text: string | null;
1140
+ }
1141
+
1142
+ async function readFrameworkConfig(
1143
+ applicationDirectory: string,
1144
+ files: readonly string[],
1145
+ framework: RepositoryFramework,
1146
+ ): Promise<FrameworkConfigText | null> {
1147
+ if (framework !== "nextjs" && framework !== "vite" && framework !== "tanstack-start") return null;
1148
+ let path: string | null;
1149
+ try {
1150
+ path = resolveFrameworkConfig(framework === "nextjs" ? "next.config" : "vite.config", files);
1151
+ } catch (error) {
1152
+ // The shared rules name the ambiguous variants; none of them is judged.
1153
+ if (error instanceof FrameworkAdmissionError) return null;
1154
+ throw error;
1005
1155
  }
1006
- return names;
1156
+ if (path === null) return null;
1157
+ const { bytes, complete } = await readLeadingBytes(resolve(applicationDirectory, path));
1158
+ // A config beyond the inspection bound is refused by its size alone, which its leading text has.
1159
+ return Object.freeze({ path, text: complete ? utf8(bytes) : new TextDecoder().decode(bytes) });
1160
+ }
1161
+
1162
+ /**
1163
+ * The TanStack Start runtime init proposes: the one whose plugin contract the Vite config follows,
1164
+ * otherwise edge when package.json declares the Cloudflare Vite plugin and static without it. The
1165
+ * shared rules then name what the config still lacks for that runtime.
1166
+ */
1167
+ function proposedTanStackRuntime(
1168
+ config: FrameworkConfigText | null,
1169
+ dependencies: ReadonlySet<string>,
1170
+ ): TanStackRuntime {
1171
+ const followed =
1172
+ config === null || config.text === null ? null : tanStackViteConfigRuntime(config.text);
1173
+ return followed ?? (dependencies.has("@cloudflare/vite-plugin") ? "edge" : "static");
1007
1174
  }
1008
1175
 
1009
1176
  function detectFramework(dependencies: ReadonlySet<string>): RepositoryFramework {
@@ -1012,17 +1179,11 @@ function detectFramework(dependencies: ReadonlySet<string>): RepositoryFramework
1012
1179
  }
1013
1180
 
1014
1181
  function packageManagerAdmissionFor(
1015
- packageJson: Record<string, unknown>,
1182
+ manifest: PackageManifest,
1016
1183
  files: readonly string[],
1017
1184
  ): PackageManagerDescriptor | FrameworkAdmissionError {
1018
1185
  try {
1019
- return resolvePackageManager({
1020
- packageManager:
1021
- typeof packageJson["packageManager"] === "string"
1022
- ? packageJson["packageManager"]
1023
- : undefined,
1024
- files,
1025
- });
1186
+ return resolvePackageManager({ packageManager: packageManagerDeclaration(manifest), files });
1026
1187
  } catch (error) {
1027
1188
  if (error instanceof FrameworkAdmissionError) return error;
1028
1189
  throw error;
@@ -1031,7 +1192,7 @@ function packageManagerAdmissionFor(
1031
1192
 
1032
1193
  function packageManagerInventory(
1033
1194
  admission: PackageManagerDescriptor | FrameworkAdmissionError,
1034
- packageJson: Record<string, unknown>,
1195
+ manifest: PackageManifest,
1035
1196
  files: readonly string[],
1036
1197
  ): RepositoryInitInventory["packageManager"] {
1037
1198
  if (!(admission instanceof FrameworkAdmissionError)) {
@@ -1039,11 +1200,13 @@ function packageManagerInventory(
1039
1200
  name: admission.manager,
1040
1201
  version: admission.version,
1041
1202
  lockfile: admission.lockfile,
1203
+ installCommand: admission.installCommand,
1204
+ buildCommand: admission.buildCommand,
1042
1205
  };
1043
1206
  }
1044
- const declaration = packageJson["packageManager"];
1207
+ const declaration = packageManagerDeclaration(manifest);
1045
1208
  const match =
1046
- typeof declaration === "string" ? /^(bun|npm|pnpm|yarn)@([^\s]+)$/u.exec(declaration) : null;
1209
+ declaration === undefined ? null : /^(bun|npm|pnpm|yarn)@([^\s]+)$/u.exec(declaration);
1047
1210
  const lockfile = APPLICATION_LOCKFILES.find((candidate) => files.includes(candidate)) ?? null;
1048
1211
  const inferredName =
1049
1212
  lockfile === "package-lock.json"
@@ -1059,81 +1222,13 @@ function packageManagerInventory(
1059
1222
  name: match === null ? inferredName : (match[1] as "bun" | "npm" | "pnpm" | "yarn"),
1060
1223
  version: match?.[2] ?? null,
1061
1224
  lockfile,
1225
+ installCommand: null,
1226
+ buildCommand: null,
1062
1227
  };
1063
1228
  }
1064
1229
 
1065
- interface FrameworkConfigurations {
1066
- readonly next: string | null;
1067
- readonly openNext: string | null;
1068
- readonly vite: string | null;
1069
- readonly ambiguous: readonly ("next" | "open-next" | "vite")[];
1070
- }
1071
-
1072
- function frameworkConfigurations(files: readonly string[]): FrameworkConfigurations {
1073
- const ambiguous: ("next" | "open-next" | "vite")[] = [];
1074
- const resolve = (basename: "next.config" | "open-next.config" | "vite.config") => {
1075
- try {
1076
- return resolveFrameworkConfig(basename, files);
1077
- } catch (error) {
1078
- if (!(error instanceof FrameworkAdmissionError) || error.reason !== "ambiguous_config") {
1079
- throw error;
1080
- }
1081
- ambiguous.push(basename.slice(0, -".config".length) as "next" | "open-next" | "vite");
1082
- return null;
1083
- }
1084
- };
1085
- return Object.freeze({
1086
- next: resolve("next.config"),
1087
- openNext: resolve("open-next.config"),
1088
- vite: resolve("vite.config"),
1089
- ambiguous: Object.freeze(ambiguous),
1090
- });
1091
- }
1092
-
1093
- /** The package.json dependency that carries each admitted framework's version. */
1094
- const FRAMEWORK_PACKAGE: Readonly<Record<AdmittedFramework, string>> = {
1095
- nextjs: "next",
1096
- "tanstack-start": "@tanstack/react-start",
1097
- vite: "vite",
1098
- };
1099
-
1100
- function frameworkCompatibility(
1101
- packageJson: Record<string, unknown>,
1102
- framework: RepositoryFramework,
1103
- ): RepositoryInitResult["compatibility"] {
1104
- const targets: readonly Readonly<{ framework: AdmittedFramework; packageName: string }>[] =
1105
- framework === "tanstack-start"
1106
- ? [
1107
- { framework: "tanstack-start", packageName: FRAMEWORK_PACKAGE["tanstack-start"] },
1108
- { framework: "vite", packageName: FRAMEWORK_PACKAGE.vite },
1109
- ]
1110
- : framework === "nextjs"
1111
- ? [{ framework: "nextjs", packageName: FRAMEWORK_PACKAGE.nextjs }]
1112
- : framework === "vite"
1113
- ? [{ framework: "vite", packageName: FRAMEWORK_PACKAGE.vite }]
1114
- : [];
1115
- const frameworks = targets.map(({ framework: targetFramework, packageName }) => {
1116
- const version = dependencyVersion(packageJson, packageName) ?? "";
1117
- return Object.freeze({
1118
- framework: targetFramework,
1119
- version,
1120
- ...classifyFrameworkVersion(targetFramework, version),
1121
- });
1122
- });
1123
- const classification: FrameworkVersionClassification = frameworks.some(
1124
- (entry) => entry.classification === "unsupported",
1125
- )
1126
- ? "unsupported"
1127
- : frameworks.some((entry) => entry.classification === "experimental")
1128
- ? "experimental"
1129
- : frameworks.length > 0 || framework === "functions"
1130
- ? "verified"
1131
- : "unsupported";
1132
- return Object.freeze({ classification, frameworks: Object.freeze(frameworks) });
1133
- }
1134
-
1135
- function projectName(explicit: string | undefined, packageJson: Record<string, unknown>): string {
1136
- const packageName = packageJson["name"];
1230
+ function projectName(explicit: string | undefined, manifest: PackageManifest): string {
1231
+ const packageName = manifest["name"];
1137
1232
  const inferred =
1138
1233
  typeof packageName === "string"
1139
1234
  ? (packageName.split("/").at(-1) ?? "")
@@ -1151,173 +1246,6 @@ function projectName(explicit: string | undefined, packageJson: Record<string, u
1151
1246
  return value;
1152
1247
  }
1153
1248
 
1154
- function repositoryBlockers(
1155
- packageJson: Record<string, unknown>,
1156
- framework: RepositoryFramework,
1157
- nextStandaloneOutput: boolean,
1158
- tanStackRuntime: TanStackRuntimeAnalysis,
1159
- files: readonly string[],
1160
- packageManagerAdmission: PackageManagerDescriptor | FrameworkAdmissionError,
1161
- frameworkConfigs: FrameworkConfigurations,
1162
- compatibility: RepositoryInitResult["compatibility"],
1163
- existingConfiguration: OhmyhostConfig | null,
1164
- ): RepositoryInitBlocker[] {
1165
- const blockers: RepositoryInitBlocker[] = [];
1166
- if (
1167
- framework !== "nextjs" &&
1168
- existingConfiguration !== null &&
1169
- "command" in existingConfiguration.build &&
1170
- existingConfiguration.build.ssg_cache_max_mib !== undefined
1171
- ) {
1172
- blockers.push({
1173
- code: "framework_unsupported",
1174
- message:
1175
- "build.ssg_cache_max_mib is supported only by Next.js; remove it from this application's configuration.",
1176
- });
1177
- }
1178
- if (framework === "unknown") {
1179
- blockers.push({
1180
- code: "framework_unsupported",
1181
- message:
1182
- "Declare exactly one supported Vite, TanStack Start, or Next.js framework, or add src/ohmyhost/worker.ts for the functions runtime.",
1183
- });
1184
- }
1185
- if (framework === "functions" && !files.includes(WORKER_MODULE_PATH)) {
1186
- blockers.push({
1187
- code: "worker_module_missing",
1188
- message: `The functions runtime needs ${WORKER_MODULE_PATH} exporting fetch and optional scheduled handlers.`,
1189
- });
1190
- }
1191
- if (tanStackRuntime === "ambiguous") {
1192
- blockers.push({
1193
- code: "framework_ambiguous",
1194
- message:
1195
- 'TanStack Start needs one vite.config.js, .mjs or .ts that selects a mode. Static: tanstackStart({ prerender: { enabled: true } }) from "@tanstack/react-start/plugin/vite" and no "@cloudflare/vite-plugin". Server: cloudflare({ viteEnvironment: { name: "ssr" } }) from "@cloudflare/vite-plugin" listed before tanstackStart() in plugins, with @cloudflare/vite-plugin, @tanstack/react-router, @tanstack/react-start, react, react-dom, @vitejs/plugin-react and vite in package.json.',
1196
- });
1197
- }
1198
- const relevantAmbiguousConfigs = frameworkConfigs.ambiguous.filter((config) =>
1199
- framework === "nextjs" ? config === "next" || config === "open-next" : config === "vite",
1200
- );
1201
- for (const config of relevantAmbiguousConfigs) {
1202
- blockers.push({
1203
- code: "framework_ambiguous",
1204
- message: `Keep exactly one supported ${config}.config.js, ${config}.config.mjs, or ${config}.config.ts file.`,
1205
- });
1206
- }
1207
- if (
1208
- packageManagerAdmission instanceof FrameworkAdmissionError &&
1209
- packageManagerAdmission.reason === "ambiguous_lockfile"
1210
- ) {
1211
- blockers.push({
1212
- code: "package_manager_ambiguous",
1213
- message: "Keep exactly one supported lockfile in the application root.",
1214
- });
1215
- } else if (packageManagerAdmission instanceof FrameworkAdmissionError) {
1216
- blockers.push({
1217
- code: "package_manager_unpinned",
1218
- message:
1219
- packageManagerAdmission.reason === "package_manager_version_unsupported"
1220
- ? `This build image runs Bun ${PINNED_BUN_VERSION}; pin packageManager to bun@${PINNED_BUN_VERSION} and commit its matching lockfile. Repeating the unchanged version cannot work.`
1221
- : "Declare one exact npm, pnpm, Yarn, or Bun packageManager version and commit its matching lockfile.",
1222
- });
1223
- } else if (
1224
- existingConfiguration !== null &&
1225
- (existingConfiguration.build.install !== packageManagerAdmission.installCommand ||
1226
- ("command" in existingConfiguration.build &&
1227
- existingConfiguration.build.command !== packageManagerAdmission.buildCommand))
1228
- ) {
1229
- blockers.push({
1230
- code: "build_command_unsupported",
1231
- message: `ohmyhost.yaml build.install must be exactly "${packageManagerAdmission.installCommand}"${
1232
- "command" in existingConfiguration.build
1233
- ? ` and build.command "${packageManagerAdmission.buildCommand}"`
1234
- : ""
1235
- } for the packageManager in package.json. Set them, then commit and push.`,
1236
- });
1237
- }
1238
- if (compatibility.classification === "unsupported" && compatibility.frameworks.length > 0) {
1239
- const unsupported = compatibility.frameworks
1240
- .filter((entry) => entry.classification === "unsupported")
1241
- .map((entry) => `${FRAMEWORK_PACKAGE[entry.framework]}@${entry.version || "missing"}`)
1242
- .join(", ");
1243
- blockers.push({
1244
- code: "framework_version_unsupported",
1245
- message: `${unsupported} is outside the versions ohmyho.st builds: next 15.5.26+ (15.5.x) or 16.3.8, vite 5.4.0–8.2.2, @tanstack/react-start 1.168.26–1.168.49, as an exact version or a ^ or ~ range whose base version is inside. Pin a version in that range in package.json, update the lockfile, commit and push.`,
1246
- });
1247
- }
1248
- const scripts = packageJson["scripts"];
1249
- const scriptRecord = isRecord(scripts) ? scripts : {};
1250
- if (framework === "functions") {
1251
- // The functions runtime installs dependencies and bundles the Worker module itself.
1252
- } else if (
1253
- !isRecord(scripts) ||
1254
- typeof scripts["build"] !== "string" ||
1255
- scripts["build"].length === 0
1256
- ) {
1257
- blockers.push({ code: "build_script_missing", message: "Declare a non-empty build script." });
1258
- } else if (!hasDirectSafeBuildCommand(packageJson, framework)) {
1259
- blockers.push({
1260
- code: "build_command_unsupported",
1261
- message: buildScriptMessage(framework, scripts["build"]),
1262
- });
1263
- }
1264
- const betterAuthVersion = dependencyVersion(packageJson, "better-auth");
1265
- const managedAuthIssue = managedCustomerAuthAdmissionIssue(existingConfiguration);
1266
- if (managedAuthIssue !== null) blockers.push(managedAuthIssue);
1267
- if (
1268
- existingConfiguration?.auth?.provider === "better-auth" &&
1269
- betterAuthVersion !== null &&
1270
- betterAuthVersion !== SUPPORTED_BETTER_AUTH_VERSION
1271
- ) {
1272
- blockers.push({
1273
- code: "better_auth_version_unsupported",
1274
- message: "Pin better-auth to exactly 1.7.1 before enabling portable customer auth.",
1275
- });
1276
- }
1277
- if (framework === "nextjs") {
1278
- const dependencies = isRecord(packageJson["dependencies"]) ? packageJson["dependencies"] : {};
1279
- const nextBuild =
1280
- scriptRecord["build"] === "next build" || scriptRecord["build"] === "next build --webpack";
1281
- const nextPackageConfigured =
1282
- nextBuild &&
1283
- compatibility.frameworks[0]?.classification !== "unsupported" &&
1284
- exactVersion(dependencies["react"]) &&
1285
- exactVersion(dependencies["react-dom"]) &&
1286
- frameworkConfigs.next !== null;
1287
- const nextConfigured =
1288
- nextPackageConfigured && frameworkConfigs.ambiguous.every((config) => config !== "open-next");
1289
- if (nextStandaloneOutput) {
1290
- blockers.push({
1291
- code: "workers_runtime_incompatible",
1292
- message:
1293
- "Next.js output: standalone does not function on Workers. Remove the output setting from the Next.js config, commit the change, then create a new plan.",
1294
- });
1295
- }
1296
- if (!nextConfigured) {
1297
- blockers.push({
1298
- code: "next_adapter_unconfigured",
1299
- message:
1300
- 'Next.js needs scripts.build exactly "next build" or "next build --webpack", react and react-dom at exact versions such as "19.1.0" (no ^ or ~), next within 15.5.26+ (15.5.x) or 16.3.8, and exactly one next.config.js, .mjs or .ts, with at most one open-next.config file. ohmyho.st adds the OpenNext build itself; do not make it the build script.',
1301
- });
1302
- }
1303
- }
1304
- return blockers.sort((left, right) => left.code.localeCompare(right.code));
1305
- }
1306
-
1307
- function exactVersion(value: unknown): boolean {
1308
- return typeof value === "string" && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(value);
1309
- }
1310
-
1311
- function dependencyVersion(packageJson: Record<string, unknown>, name: string): string | null {
1312
- for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
1313
- const dependencies = packageJson[field];
1314
- if (!isRecord(dependencies)) continue;
1315
- const version = dependencies[name];
1316
- if (typeof version === "string" && version.length > 0) return version;
1317
- }
1318
- return null;
1319
- }
1320
-
1321
1249
  function renderConfiguration(input: {
1322
1250
  readonly project: string;
1323
1251
  readonly framework: RepositoryFramework;
@@ -1329,7 +1257,7 @@ function renderConfiguration(input: {
1329
1257
  readonly storageEnabled: boolean;
1330
1258
  readonly storageJurisdiction: "us" | "eu";
1331
1259
  readonly viteCompanionRequired: boolean;
1332
- readonly tanStackRuntime: TanStackRuntimeAnalysis;
1260
+ readonly tanStackRuntime: TanStackRuntime | null;
1333
1261
  readonly applicationRoot: ApplicationRoot;
1334
1262
  }): string {
1335
1263
  const descriptor = packageManagerDescriptorFromInventory(input.packageManager);
@@ -1399,210 +1327,12 @@ function packageManagerDescriptorFromInventory(
1399
1327
  }
1400
1328
  }
1401
1329
 
1402
- async function detectNextStandaloneOutput(
1403
- root: string,
1404
- framework: RepositoryFramework,
1405
- nextConfigPath: string | null,
1406
- ): Promise<boolean> {
1407
- if (framework !== "nextjs" || nextConfigPath === null) return false;
1408
- const path = resolve(root, nextConfigPath);
1409
- const information = await stat(path);
1410
- if (!information.isFile() || information.size > MAXIMUM_CONFIGURATION_BYTES) return false;
1411
- return declaresStandaloneOutput(await readFile(path, "utf8"));
1412
- }
1413
-
1414
- function declaresStandaloneOutput(source: string): boolean {
1415
- const tokens = sourceTokens(source);
1416
- return tokens.some(
1417
- (token, index) =>
1418
- token === "output" && tokens[index + 1] === ":" && tokens[index + 2] === "standalone",
1419
- );
1420
- }
1421
-
1422
- function sourceTokens(source: string): readonly string[] {
1423
- const tokens: string[] = [];
1424
- let index = 0;
1425
- while (index < source.length) {
1426
- const character = source[index];
1427
- const next = source[index + 1];
1428
- if (character === "/" && next === "/") {
1429
- const end = source.indexOf("\n", index + 2);
1430
- index = end === -1 ? source.length : end + 1;
1431
- continue;
1432
- }
1433
- if (character === "/" && next === "*") {
1434
- const end = source.indexOf("*/", index + 2);
1435
- if (end === -1) return Object.freeze([]);
1436
- index = end + 2;
1437
- continue;
1438
- }
1439
- if (character === "'" || character === '"' || character === "`") {
1440
- let value = "";
1441
- index += 1;
1442
- while (index < source.length && source[index] !== character) {
1443
- if (source[index] === "\\" && source[index + 1] !== undefined) index += 1;
1444
- value += source[index] ?? "";
1445
- index += 1;
1446
- }
1447
- if (source[index] !== character) return Object.freeze([]);
1448
- tokens.push(value);
1449
- index += 1;
1450
- continue;
1451
- }
1452
- if (character !== undefined && /[A-Za-z_$]/u.test(character)) {
1453
- const start = index;
1454
- index += 1;
1455
- while (/[A-Za-z0-9_$]/u.test(source[index] ?? "")) index += 1;
1456
- tokens.push(source.slice(start, index));
1457
- continue;
1458
- }
1459
- if (character !== undefined && "{}[]():,.".includes(character)) tokens.push(character);
1460
- index += 1;
1461
- }
1462
- return Object.freeze(tokens);
1463
- }
1464
-
1465
1330
  function databaseMigrationPath(provider: RepositoryInitInventory["database"]["provider"]) {
1466
1331
  if (provider === "supabase") return "postgres/migrations";
1467
1332
  if (provider === "postgresql") return "postgres/migrations";
1468
1333
  return null;
1469
1334
  }
1470
1335
 
1471
- async function detectTanStackRuntime(
1472
- root: string,
1473
- files: readonly string[],
1474
- framework: RepositoryFramework,
1475
- packageJson: Record<string, unknown>,
1476
- viteConfigPath: string | null,
1477
- ): Promise<TanStackRuntimeAnalysis> {
1478
- if (framework !== "tanstack-start") return null;
1479
- if (viteConfigPath === null) return "ambiguous";
1480
- const path = resolve(root, viteConfigPath);
1481
- const information = await stat(path);
1482
- if (!information.isFile() || information.size > MAXIMUM_PACKAGE_BYTES) return "ambiguous";
1483
- const source = await readFile(path, "utf8");
1484
- const tokens = sourceTokens(source);
1485
- if (isCompleteTanStackStatic(tokens)) return "static";
1486
- if (isCompleteTanStackEdgePackage(packageJson) && isCompleteTanStackEdgeConfig(tokens)) {
1487
- return "edge";
1488
- }
1489
- return "ambiguous";
1490
- }
1491
-
1492
- function isCompleteTanStackStatic(tokens: readonly string[]): boolean {
1493
- return (
1494
- hasNamedSourceImport(tokens, "tanstackStart", "@tanstack/react-start/plugin/vite") &&
1495
- !hasSourceModuleImport(tokens, "@cloudflare/vite-plugin") &&
1496
- hasNestedObjectValue(tokens, "tanstackStart", "prerender", "enabled", "true")
1497
- );
1498
- }
1499
-
1500
- function isCompleteTanStackEdgePackage(packageJson: Record<string, unknown>): boolean {
1501
- return (
1502
- dependencyVersion(packageJson, "@cloudflare/vite-plugin") !== null &&
1503
- ["@tanstack/react-router", "@tanstack/react-start", "react", "react-dom"].every(
1504
- (name) => dependencyVersion(packageJson, name) !== null,
1505
- ) &&
1506
- ["@vitejs/plugin-react", "vite"].every((name) => dependencyVersion(packageJson, name) !== null)
1507
- );
1508
- }
1509
-
1510
- function isCompleteTanStackEdgeConfig(tokens: readonly string[]): boolean {
1511
- const cloudflareCall = sourceCallIndex(tokens, "cloudflare");
1512
- const tanStackCall = sourceCallIndex(tokens, "tanstackStart");
1513
- return (
1514
- hasNamedSourceImport(tokens, "cloudflare", "@cloudflare/vite-plugin") &&
1515
- hasNamedSourceImport(tokens, "tanstackStart", "@tanstack/react-start/plugin/vite") &&
1516
- cloudflareCall !== -1 &&
1517
- tanStackCall !== -1 &&
1518
- cloudflareCall < tanStackCall &&
1519
- hasNestedObjectValue(tokens, "cloudflare", "viteEnvironment", "name", "ssr")
1520
- );
1521
- }
1522
-
1523
- function hasNamedSourceImport(
1524
- tokens: readonly string[],
1525
- name: string,
1526
- moduleName: string,
1527
- ): boolean {
1528
- for (let index = 0; index < tokens.length; index += 1) {
1529
- if (tokens[index] !== "import") continue;
1530
- const fromIndex = tokens.findIndex((token, candidate) => candidate > index && token === "from");
1531
- if (fromIndex === -1) return false;
1532
- if (tokens[fromIndex + 1] === moduleName && tokens.slice(index + 1, fromIndex).includes(name)) {
1533
- return true;
1534
- }
1535
- index = fromIndex;
1536
- }
1537
- return false;
1538
- }
1539
-
1540
- function hasSourceModuleImport(tokens: readonly string[], moduleName: string): boolean {
1541
- return tokens.some((token, index) => token === "from" && tokens[index + 1] === moduleName);
1542
- }
1543
-
1544
- function sourceCallIndex(tokens: readonly string[], name: string): number {
1545
- return tokens.findIndex((token, index) => token === name && tokens[index + 1] === "(");
1546
- }
1547
-
1548
- function hasNestedObjectValue(
1549
- tokens: readonly string[],
1550
- call: string,
1551
- outerProperty: string,
1552
- innerProperty: string,
1553
- expectedValue: string,
1554
- ): boolean {
1555
- const callIndex = sourceCallIndex(tokens, call);
1556
- if (callIndex === -1 || tokens[callIndex + 2] !== "{") return false;
1557
- const callEnd = matchingSourceDelimiter(tokens, callIndex + 2, "{", "}");
1558
- const outer = directSourceObjectProperty(tokens, callIndex + 2, callEnd, outerProperty);
1559
- if (outer === null || outer.value !== "object") return false;
1560
- return (
1561
- directSourceObjectProperty(tokens, outer.start, outer.end, innerProperty)?.value ===
1562
- expectedValue
1563
- );
1564
- }
1565
-
1566
- function directSourceObjectProperty(
1567
- tokens: readonly string[],
1568
- start: number,
1569
- end: number,
1570
- name: string,
1571
- ): Readonly<{ start: number; end: number; value: string }> | null {
1572
- let depth = 0;
1573
- for (let index = start + 1; index < end; index += 1) {
1574
- const value = tokens[index];
1575
- if (value === "{" || value === "[" || value === "(") depth += 1;
1576
- if (value === "}" || value === "]" || value === ")") depth -= 1;
1577
- if (depth !== 0 || value !== name || tokens[index + 1] !== ":") continue;
1578
- if (tokens[index + 2] === "{") {
1579
- const candidateEnd = matchingSourceDelimiter(tokens, index + 2, "{", "}");
1580
- return Object.freeze({ start: index + 2, end: candidateEnd, value: "object" });
1581
- }
1582
- return Object.freeze({
1583
- start: index + 2,
1584
- end: index + 2,
1585
- value: tokens[index + 2] ?? "",
1586
- });
1587
- }
1588
- return null;
1589
- }
1590
-
1591
- function matchingSourceDelimiter(
1592
- tokens: readonly string[],
1593
- start: number,
1594
- open: string,
1595
- close: string,
1596
- ): number {
1597
- let depth = 0;
1598
- for (let index = start; index < tokens.length; index += 1) {
1599
- if (tokens[index] === open) depth += 1;
1600
- if (tokens[index] === close) depth -= 1;
1601
- if (depth === 0) return index;
1602
- }
1603
- return -1;
1604
- }
1605
-
1606
1336
  function requirementsFor(
1607
1337
  inventory: RepositoryInitInventory,
1608
1338
  viteCompanion: boolean,
@@ -1660,7 +1390,7 @@ function runtimeContract(capabilities: RuntimeCapabilities): Readonly<{
1660
1390
  ].sort(),
1661
1391
  ),
1662
1392
  packages: Object.freeze({
1663
- ...(capabilities.auth ? { betterAuth: "1.7.1" as const } : {}),
1393
+ ...(capabilities.auth ? { betterAuth: MANAGED_BETTER_AUTH_VERSION } : {}),
1664
1394
  // A database project was told to install pg, which cannot open a socket from a Worker at all,
1665
1395
  // and was never offered the package that actually holds the client it needs.
1666
1396
  ...(capabilities.database || capabilities.storage || capabilities.mail
@@ -1674,7 +1404,7 @@ function viteCompanionScaffold(
1674
1404
  files: readonly string[],
1675
1405
  capabilities: RuntimeCapabilities,
1676
1406
  ): RepositoryInitViteCompanion {
1677
- const sourceEntryPoint = "src/ohmyhost/companion.ts" as const;
1407
+ const sourceEntryPoint = VITE_COMPANION_ENTRY_PATH;
1678
1408
  const contract = runtimeContract(capabilities);
1679
1409
  return Object.freeze({
1680
1410
  schemaVersion: "ohmyhost.vite-api-companion/v1" as const,
@@ -1706,7 +1436,7 @@ async function createConfiguration(root: string, configuration: string): Promise
1706
1436
  }
1707
1437
  }
1708
1438
 
1709
- function isRecord(value: unknown): value is Record<string, unknown> {
1439
+ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
1710
1440
  return typeof value === "object" && value !== null && !Array.isArray(value);
1711
1441
  }
1712
1442