@amerged/ohmyhost-mcp 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +4 -0
  3. package/README.md +11 -0
  4. package/THIRD_PARTY_NOTICES.md +474 -0
  5. package/dist/chunk-7MV2EVXV.js +30779 -0
  6. package/dist/index.js +9 -0
  7. package/dist/stdio-main.js +5607 -0
  8. package/package.json +32 -0
  9. package/src/apps/mcp/index.ts +30 -0
  10. package/src/apps/mcp/local-client.ts +891 -0
  11. package/src/apps/mcp/local-server.ts +1512 -0
  12. package/src/apps/mcp/stdio-main.ts +26 -0
  13. package/src/apps/product-cli/bin.ts +24 -0
  14. package/src/apps/product-cli/cli.ts +2271 -0
  15. package/src/apps/product-cli/command.ts +1811 -0
  16. package/src/apps/product-cli/composition.ts +823 -0
  17. package/src/apps/product-cli/credential-store.ts +149 -0
  18. package/src/apps/product-cli/device-token-expiry.ts +89 -0
  19. package/src/apps/product-cli/file-credential-store.ts +166 -0
  20. package/src/apps/product-cli/index.ts +23 -0
  21. package/src/apps/product-cli/product-api.ts +1082 -0
  22. package/src/apps/product-cli/project-link-store.ts +30 -0
  23. package/src/apps/product-cli/psql-session.ts +29 -0
  24. package/src/apps/product-cli/repository-init.ts +1622 -0
  25. package/src/apps/product-cli/runtime-contract.ts +1747 -0
  26. package/src/apps/product-cli/user-token-file.ts +214 -0
  27. package/src/apps/product-cli/workspace.ts +92 -0
  28. package/src/packages/agent-skills/generated-skill-resources.ts +147 -0
  29. package/src/packages/agent-skills/index.ts +37 -0
  30. package/src/packages/contracts/application-root.ts +80 -0
  31. package/src/packages/contracts/billing.ts +172 -0
  32. package/src/packages/contracts/credit-pricing.ts +214 -0
  33. package/src/packages/contracts/database-access.ts +257 -0
  34. package/src/packages/contracts/database-compute.ts +72 -0
  35. package/src/packages/contracts/database-migration-admission.ts +235 -0
  36. package/src/packages/contracts/feedback.ts +96 -0
  37. package/src/packages/contracts/framework-admission.ts +280 -0
  38. package/src/packages/contracts/framework-family.ts +18 -0
  39. package/src/packages/contracts/functions.ts +76 -0
  40. package/src/packages/contracts/index.ts +245 -0
  41. package/src/packages/contracts/operation-deployment-result.ts +165 -0
  42. package/src/packages/contracts/operation-failure.ts +150 -0
  43. package/src/packages/contracts/platform-origins.ts +5 -0
  44. package/src/packages/contracts/project-context.ts +59 -0
  45. package/src/packages/contracts/project-exports.ts +102 -0
  46. package/src/packages/contracts/user-api-keys.ts +98 -0
  47. package/src/packages/sdk-ts/generated/client/client.gen.ts +277 -0
  48. package/src/packages/sdk-ts/generated/client/index.ts +27 -0
  49. package/src/packages/sdk-ts/generated/client/types.gen.ts +217 -0
  50. package/src/packages/sdk-ts/generated/client/utils.gen.ts +316 -0
  51. package/src/packages/sdk-ts/generated/client.gen.ts +24 -0
  52. package/src/packages/sdk-ts/generated/core/auth.gen.ts +48 -0
  53. package/src/packages/sdk-ts/generated/core/bodySerializer.gen.ts +82 -0
  54. package/src/packages/sdk-ts/generated/core/params.gen.ts +178 -0
  55. package/src/packages/sdk-ts/generated/core/pathSerializer.gen.ts +171 -0
  56. package/src/packages/sdk-ts/generated/core/queryKeySerializer.gen.ts +117 -0
  57. package/src/packages/sdk-ts/generated/core/serverSentEvents.gen.ts +242 -0
  58. package/src/packages/sdk-ts/generated/core/types.gen.ts +114 -0
  59. package/src/packages/sdk-ts/generated/core/utils.gen.ts +140 -0
  60. package/src/packages/sdk-ts/generated/index.ts +578 -0
  61. package/src/packages/sdk-ts/generated/sdk.gen.ts +3440 -0
  62. package/src/packages/sdk-ts/generated/types.gen.ts +5790 -0
  63. package/src/packages/sdk-ts/index.ts +7 -0
  64. package/src/packages/workos-auth-contracts/device-flow.ts +271 -0
  65. package/src/packages/workos-auth-contracts/index.ts +8 -0
  66. package/src/packages/workos-auth-contracts/management-rpc.ts +379 -0
  67. package/src/packages/workos-auth-contracts/organizations.ts +113 -0
  68. package/src/packages/workos-auth-contracts/parsing.ts +75 -0
  69. package/src/packages/workos-auth-contracts/session-status.ts +53 -0
  70. package/src/packages/workos-auth-contracts/tokens.ts +216 -0
  71. package/src/packages/workos-auth-contracts/user-api-keys.ts +85 -0
  72. package/src/packages/workos-auth-contracts/webhooks.ts +127 -0
@@ -0,0 +1,1622 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, open, readFile, readdir, stat } from "node:fs/promises";
3
+ import { posix, resolve } from "node:path";
4
+
5
+ import {
6
+ normalizeApplicationRoot,
7
+ type ApplicationRoot,
8
+ } from "@ohmyhost/contracts/application-root";
9
+ import { parseOhmyhostConfigYaml, type OhmyhostConfig } from "@ohmyhost/contracts";
10
+ import {
11
+ classifyFrameworkVersion,
12
+ FrameworkAdmissionError,
13
+ resolveFrameworkConfig,
14
+ resolvePackageManager,
15
+ type AdmittedFramework,
16
+ type FrameworkVersionAdmission,
17
+ type FrameworkVersionClassification,
18
+ type PackageManagerDescriptor,
19
+ } from "@ohmyhost/contracts/framework-admission";
20
+ import {
21
+ classifyFrameworkFamily,
22
+ type FrameworkFamilyClassification,
23
+ } from "@ohmyhost/contracts/framework-family";
24
+ import {
25
+ admitExpandOnlyMigrations,
26
+ DatabaseMigrationAdmissionError,
27
+ } from "@ohmyhost/contracts/database-migration-admission";
28
+
29
+ import cliPackage from "../package.json" with { type: "json" };
30
+
31
+ const MAXIMUM_PACKAGE_BYTES = 1024 * 1024;
32
+ const MAXIMUM_CONFIGURATION_BYTES = 64 * 1024;
33
+ const MAXIMUM_REPOSITORY_FILES = 20_000;
34
+ const SUPPORTED_BETTER_AUTH_VERSION = "1.7.1";
35
+ const PROJECT_PATTERN = /^[a-z](?:[a-z0-9-]*[a-z0-9])?$/u;
36
+ const IGNORED_DIRECTORIES = new Set([
37
+ ".git",
38
+ ".next",
39
+ ".supabase",
40
+ ".turbo",
41
+ ".vercel",
42
+ "coverage",
43
+ "dist",
44
+ "node_modules",
45
+ ]);
46
+
47
+ export type RepositoryFramework = Exclude<FrameworkFamilyClassification, "ambiguous"> | "functions";
48
+ const WORKER_MODULE_PATH = "src/ohmyhost/worker.ts";
49
+ const MAXIMUM_SOURCE_BYTES = 2 * 1024 * 1024;
50
+ const SOURCE_MODULE_PATTERN = /\.(?:[cm]?[jt]s|[jt]sx)$/u;
51
+ const STORAGE_CLIENT_CALL_PATTERN = /\bcreatePrivateStorageClient\s*\(/u;
52
+ // Both database mistakes of 2026-09-18 are visible in the source before anything is built: one read
53
+ // a Hyperdrive connection string, the other opened a socket driver. Neither can work in a Worker, so
54
+ // naming them here costs a customer minutes instead of a deployment that fails its health check.
55
+ const DATABASE_PRIVATE_BINDING_PATTERN =
56
+ /\bHYPERDRIVE\b|\bconnectionString\b|\bDATABASE_URL\b|postgres(?:ql)?:\/\//u;
57
+ const DATABASE_SOCKET_DRIVER_PATTERN =
58
+ /\bfrom\s*["'](?:pg|postgres|mysql2|pg-native)["']|\brequire\(\s*["'](?:pg|postgres|pg-native)["']/u;
59
+ // A comment and a type-only import name the client without ever constructing it, and the bare
60
+ // identifier of either reads like client code; drop them before looking for the call itself.
61
+ const INERT_SOURCE_PATTERN = /\/\*[\s\S]*?\*\/|\/\/[^\n]*|\bimport\s+type\b[^;]*;/gu;
62
+
63
+ function callsStorageClient(source: string): boolean {
64
+ return STORAGE_CLIENT_CALL_PATTERN.test(source.replaceAll(INERT_SOURCE_PATTERN, " "));
65
+ }
66
+
67
+ // Storage is reached through a private Service Binding plus the three values and the key that
68
+ // `createPrivateStorageClient` reads. No raw bucket binding is handed to customer code.
69
+ const STORAGE_RUNTIME_BINDINGS = Object.freeze([
70
+ "OHMYHOST_ENVIRONMENT_ID",
71
+ "OHMYHOST_PROJECT_ID",
72
+ "OHMYHOST_STORAGE_GATEWAY",
73
+ "OHMYHOST_STORAGE_GATEWAY_URL",
74
+ "OHMYHOST_STORAGE_KEY",
75
+ ]);
76
+ // `@ohmyhost/customer-runtime` is private and resolves from no registry; report the release tarball
77
+ // of this client so a bare package name never reaches a customer.
78
+ const CUSTOMER_RUNTIME_SOURCE = `https://ohmyho.st/releases/${cliPackage.version}/ohmyhost-customer-runtime-${cliPackage.version}.tgz`;
79
+ const SCHEDULED_HANDLER_PATTERN = /\bscheduled\s*(?:\(|:|,|\})/u;
80
+ // The platform imports the module's default export; handlers that are only named exports are
81
+ // never invoked, so init blocks them before a build or a runtime rejection would.
82
+ const DEFAULT_EXPORT_PATTERN = /\bexport\s+default\b/u;
83
+
84
+ function defaultExportBlocker(modulePath: string): RepositoryInitBlocker {
85
+ return {
86
+ code: "worker_module_default_export_required",
87
+ message: `${modulePath} has no default export. Export its handlers as the default export (export default { fetch, scheduled }); named exports are never invoked.`,
88
+ };
89
+ }
90
+ type TanStackRuntimeAnalysis = "ambiguous" | "edge" | "static" | null;
91
+
92
+ export interface RepositoryInitBlocker {
93
+ readonly code:
94
+ | "build_command_unsupported"
95
+ | "database_binding_private"
96
+ | "database_driver_unsupported"
97
+ | "build_script_missing"
98
+ | "better_auth_version_unsupported"
99
+ | "framework_ambiguous"
100
+ | "framework_unsupported"
101
+ | "framework_version_unsupported"
102
+ | "migration_filename_noncanonical"
103
+ | "migration_sql_not_admitted"
104
+ | "next_adapter_unconfigured"
105
+ | "package_manager_ambiguous"
106
+ | "package_manager_unpinned"
107
+ | "scheduled_functions_runtime_unsupported"
108
+ | "scheduled_handler_missing"
109
+ | "storage_client_missing"
110
+ | "worker_module_default_export_required"
111
+ | "worker_module_missing"
112
+ | "workers_runtime_incompatible";
113
+ readonly message: string;
114
+ }
115
+
116
+ export interface RepositoryInitInventory {
117
+ readonly framework: RepositoryFramework;
118
+ readonly packageManager: Readonly<{
119
+ name: "bun" | "npm" | "pnpm" | "unknown" | "yarn";
120
+ version: string | null;
121
+ lockfile: string | null;
122
+ }>;
123
+ readonly apiRouteCount: number;
124
+ readonly database: Readonly<{
125
+ provider: "none" | "postgresql" | "supabase";
126
+ migrationCount: number;
127
+ }>;
128
+ readonly auth: Readonly<{
129
+ betterAuth: boolean;
130
+ workos: boolean;
131
+ auth0: boolean;
132
+ supabase: boolean;
133
+ }>;
134
+ readonly edgeFunctions: Readonly<{ count: number }>;
135
+ readonly storage: Readonly<{ r2: boolean; supabase: boolean }>;
136
+ readonly mail: Readonly<{ detected: boolean }>;
137
+ readonly functions: Readonly<{ crons: readonly string[] }>;
138
+ }
139
+
140
+ export interface RepositoryInitRuntimePackages {
141
+ readonly betterAuth?: "1.7.1";
142
+ /** Exact install source: the package is private and cannot be resolved from a registry. */
143
+ readonly customerRuntime?: string;
144
+ }
145
+
146
+ export interface RepositoryInitViteCompanion {
147
+ readonly schemaVersion: "ohmyhost.vite-api-companion/v1";
148
+ readonly sourceEntryPoint: "src/ohmyhost/companion.ts";
149
+ readonly requiredFiles: readonly ["src/ohmyhost/companion.ts"];
150
+ readonly missingFiles: readonly string[];
151
+ readonly packages: RepositoryInitRuntimePackages;
152
+ readonly bindings: readonly string[];
153
+ readonly artifact: Readonly<{
154
+ readonly outputDirectory: ".ohmyhost/vite-companion";
155
+ readonly entryPoint: ".ohmyhost/vite-companion/worker.js";
156
+ readonly configPath: ".ohmyhost/vite-companion/wrangler.jsonc";
157
+ }>;
158
+ }
159
+
160
+ export interface RepositoryInitResult {
161
+ readonly status: "analyzed" | "initialized";
162
+ readonly written: boolean;
163
+ readonly configurationPath: "ohmyhost.yaml";
164
+ readonly configurationSha256: string;
165
+ readonly configurationYaml: string;
166
+ readonly applicationRoot: ApplicationRoot;
167
+ readonly applicationRootSource: "explicit" | "nested" | "repository";
168
+ readonly inventory: RepositoryInitInventory;
169
+ readonly compatibility: Readonly<{
170
+ classification: FrameworkVersionClassification;
171
+ frameworks: readonly Readonly<{
172
+ framework: AdmittedFramework;
173
+ version: string;
174
+ classification: FrameworkVersionAdmission["classification"];
175
+ reason: FrameworkVersionAdmission["reason"];
176
+ }>[];
177
+ }>;
178
+ readonly blockers: readonly RepositoryInitBlocker[];
179
+ readonly requirements: readonly string[];
180
+ readonly companion: RepositoryInitViteCompanion | null;
181
+ readonly worker: Readonly<{
182
+ sourceEntryPoint: "src/ohmyhost/worker.ts";
183
+ present: boolean;
184
+ bindings: readonly string[];
185
+ packages: RepositoryInitRuntimePackages;
186
+ }> | null;
187
+ }
188
+
189
+ export class RepositoryInitError extends Error {
190
+ public constructor(
191
+ public readonly code: string,
192
+ message: string,
193
+ ) {
194
+ super(message);
195
+ this.name = "RepositoryInitError";
196
+ }
197
+ }
198
+
199
+ export class RepositoryInitBlockedError extends RepositoryInitError {
200
+ public constructor(public readonly blockers: readonly RepositoryInitBlocker[]) {
201
+ super("repository_init_blocked", "Repository initialization is blocked");
202
+ this.name = "RepositoryInitBlockedError";
203
+ }
204
+ }
205
+
206
+ export class RepositoryInitConflictError extends RepositoryInitError {
207
+ public constructor() {
208
+ super("repository_configuration_exists", "ohmyhost.yaml already exists");
209
+ this.name = "RepositoryInitConflictError";
210
+ }
211
+ }
212
+
213
+ export interface InitializeRepositoryInput {
214
+ readonly directory: string;
215
+ readonly project?: string;
216
+ readonly root?: string;
217
+ /**
218
+ * The hosting region of the ohmyho.st project; storage.jurisdiction must equal it. Init runs
219
+ * offline and cannot resolve the project itself, so the default is the platform default `us`.
220
+ */
221
+ readonly region?: "us" | "eu";
222
+ readonly dryRun: boolean;
223
+ }
224
+
225
+ export async function initializeRepository(
226
+ input: InitializeRepositoryInput,
227
+ ): Promise<RepositoryInitResult> {
228
+ const root = resolve(input.directory);
229
+ await assertRepositoryRoot(root);
230
+ const files = await collectRepositoryFiles(root);
231
+ const selection = await selectApplicationRoot(root, files, input.root);
232
+ const applicationDirectory = resolve(root, selection.applicationRoot);
233
+ const applicationFiles = applicationRelativeFiles(files, selection.applicationRoot);
234
+ const packageJson = await readPackageJson(applicationDirectory);
235
+ const dependencies = dependencyNames(packageJson);
236
+ const existingConfigurationYaml = await readExistingConfiguration(
237
+ root,
238
+ files,
239
+ selection.applicationRoot,
240
+ );
241
+ const existingConfiguration =
242
+ existingConfigurationYaml === null ? null : parseOhmyhostConfigYaml(existingConfigurationYaml);
243
+ const framework = detectRepositoryFramework(
244
+ dependencies,
245
+ applicationFiles,
246
+ existingConfiguration,
247
+ );
248
+ const packageManagerAdmission = packageManagerAdmissionFor(packageJson, applicationFiles);
249
+ const packageManager = packageManagerInventory(
250
+ packageManagerAdmission,
251
+ packageJson,
252
+ applicationFiles,
253
+ );
254
+ const frameworkConfigs = frameworkConfigurations(applicationFiles);
255
+ const compatibility = frameworkCompatibility(packageJson, framework);
256
+ const nextStandaloneOutput = await detectNextStandaloneOutput(
257
+ applicationDirectory,
258
+ framework,
259
+ frameworkConfigs.next,
260
+ );
261
+ const tanStackRuntime = await detectTanStackRuntime(
262
+ applicationDirectory,
263
+ applicationFiles,
264
+ framework,
265
+ packageJson,
266
+ frameworkConfigs.vite,
267
+ );
268
+ const project = projectName(input.project, packageJson);
269
+ const repositoryAdmissionBlockers = repositoryBlockers(
270
+ packageJson,
271
+ framework,
272
+ nextStandaloneOutput,
273
+ tanStackRuntime,
274
+ applicationFiles,
275
+ packageManagerAdmission,
276
+ frameworkConfigs,
277
+ compatibility,
278
+ existingConfiguration,
279
+ );
280
+ const supabaseMigrationCount = applicationFiles.filter((path) =>
281
+ /^supabase\/migrations\/[^/]+\.sql$/u.test(path),
282
+ ).length;
283
+ const postgresMigrationCount = applicationFiles.filter((path) =>
284
+ /^postgres\/migrations\/[^/]+\.sql$/u.test(path),
285
+ ).length;
286
+ const edgeFunctionNames = new Set(
287
+ applicationFiles.flatMap((path) => {
288
+ const match = path.match(/^supabase\/functions\/([^/]+)\//u);
289
+ return match?.[1] === undefined || match[1].startsWith("_") ? [] : [match[1]];
290
+ }),
291
+ );
292
+ const apiRouteCount = applicationFiles.filter((path) =>
293
+ /^(?:src\/)?app\/api\/.+\/route\.(?:js|jsx|ts|tsx)$/u.test(path),
294
+ ).length;
295
+ const supabase = dependencies.has("@supabase/supabase-js") || dependencies.has("@supabase/ssr");
296
+ const configuredDatabase = existingConfiguration?.database?.enabled === true;
297
+ const databaseProvider =
298
+ supabaseMigrationCount > 0
299
+ ? "supabase"
300
+ : postgresMigrationCount > 0 || configuredDatabase
301
+ ? "postgresql"
302
+ : "none";
303
+ const migrationCount =
304
+ databaseProvider === "supabase" ? supabaseMigrationCount : postgresMigrationCount;
305
+ const betterAuth =
306
+ dependencies.has("better-auth") || existingConfiguration?.auth?.provider === "better-auth";
307
+ const workos = [
308
+ "@workos-inc/authkit-react",
309
+ "@workos-inc/authkit-nextjs",
310
+ "@workos/authkit-tanstack-react-start",
311
+ "@workos-inc/node",
312
+ ].some((name) => dependencies.has(name));
313
+ const auth0 = ["@auth0/auth0-react", "@auth0/auth0-spa-js", "@auth0/nextjs-auth0", "auth0"].some(
314
+ (name) => dependencies.has(name),
315
+ );
316
+ const mailDetected = [
317
+ "@aws-sdk/client-sesv2",
318
+ "@lovable.dev/email-js",
319
+ "nodemailer",
320
+ "resend",
321
+ ].some((name) => dependencies.has(name));
322
+ const r2 =
323
+ existingConfiguration?.storage?.provider === "r2" ||
324
+ (await detectR2Storage(applicationDirectory, applicationFiles));
325
+ const supabaseStorage = applicationFiles.some((path) => path.startsWith("supabase/storage/"));
326
+ const inventory: RepositoryInitInventory = Object.freeze({
327
+ framework,
328
+ packageManager: Object.freeze(packageManager),
329
+ apiRouteCount,
330
+ database: Object.freeze({ provider: databaseProvider, migrationCount }),
331
+ auth: Object.freeze({ betterAuth, workos, auth0, supabase }),
332
+ edgeFunctions: Object.freeze({ count: edgeFunctionNames.size }),
333
+ storage: Object.freeze({ r2, supabase: supabaseStorage }),
334
+ mail: Object.freeze({
335
+ detected: mailDetected || existingConfiguration?.mail?.enabled === true,
336
+ }),
337
+ functions: Object.freeze({
338
+ crons: Object.freeze([...(existingConfiguration?.functions?.crons ?? [])]),
339
+ }),
340
+ });
341
+ const targetBetterAuth = existingConfiguration?.auth?.provider === "better-auth";
342
+ const targetMail = targetBetterAuth || existingConfiguration?.mail?.enabled === true;
343
+ const targetStorage = r2 || supabaseStorage;
344
+ const configuredCompanionRequired =
345
+ existingConfiguration !== null &&
346
+ (existingConfiguration.database?.enabled === true ||
347
+ existingConfiguration.auth?.provider === "better-auth" ||
348
+ existingConfiguration.mail?.enabled === true ||
349
+ existingConfiguration.storage !== undefined ||
350
+ (existingConfiguration.functions?.crons.length ?? 0) > 0);
351
+ const companionRequired =
352
+ framework === "vite" &&
353
+ (existingConfiguration === null
354
+ ? databaseProvider !== "none" ||
355
+ targetBetterAuth ||
356
+ targetMail ||
357
+ targetStorage ||
358
+ edgeFunctionNames.size > 0 ||
359
+ applicationFiles.includes("src/ohmyhost/companion.ts")
360
+ : configuredCompanionRequired);
361
+ const generatedConfigurationYaml = renderConfiguration({
362
+ project,
363
+ framework,
364
+ packageManager,
365
+ databaseProvider,
366
+ hasHealthcheck:
367
+ applicationFiles.includes("src/app/api/health/route.ts") ||
368
+ applicationFiles.includes("app/api/health/route.ts"),
369
+ mailEnabled: targetMail,
370
+ betterAuthEnabled: targetBetterAuth,
371
+ storageEnabled: targetStorage,
372
+ storageJurisdiction: input.region ?? "us",
373
+ viteCompanionRequired: companionRequired,
374
+ tanStackRuntime,
375
+ applicationRoot: selection.applicationRoot,
376
+ });
377
+ const configurationYaml = existingConfigurationYaml ?? generatedConfigurationYaml;
378
+ const capabilities: RuntimeCapabilities = Object.freeze({
379
+ database: databaseProvider !== "none",
380
+ auth: targetBetterAuth,
381
+ mail: targetMail,
382
+ storage: targetStorage,
383
+ });
384
+ const companion = companionRequired
385
+ ? viteCompanionScaffold(applicationFiles, capabilities)
386
+ : null;
387
+ const companionSourcePresent =
388
+ companion !== null && applicationFiles.includes(companion.sourceEntryPoint);
389
+ const migrationSqlAdmissionBlockers = await migrationSqlBlockers(
390
+ applicationDirectory,
391
+ applicationFiles,
392
+ existingConfiguration?.database?.migrations,
393
+ databaseProvider,
394
+ );
395
+ const blockers = [
396
+ ...repositoryAdmissionBlockers,
397
+ ...(await workerModuleBlockers(framework, applicationDirectory, applicationFiles)),
398
+ ...(await cronBlockers(
399
+ framework,
400
+ existingConfiguration,
401
+ applicationDirectory,
402
+ applicationFiles,
403
+ )),
404
+ ...migrationFilenameBlockers(
405
+ applicationFiles,
406
+ existingConfiguration?.database?.migrations,
407
+ databaseProvider,
408
+ ),
409
+ ...migrationSqlAdmissionBlockers,
410
+ ...(await storageClientBlockers(existingConfiguration, applicationDirectory, applicationFiles)),
411
+ ...(await databaseContractBlockers(
412
+ existingConfiguration,
413
+ applicationDirectory,
414
+ applicationFiles,
415
+ )),
416
+ ].sort((left, right) => left.code.localeCompare(right.code));
417
+ const requirements = requirementsFor(
418
+ inventory,
419
+ companion !== null && !companionSourcePresent,
420
+ mailDetected,
421
+ targetBetterAuth,
422
+ );
423
+ const configurationSha256 = createHash("sha256").update(configurationYaml).digest("hex");
424
+ if (!input.dryRun) {
425
+ if (blockers.length > 0) throw new RepositoryInitBlockedError(blockers);
426
+ await createConfiguration(root, configurationYaml);
427
+ }
428
+ return Object.freeze({
429
+ status: input.dryRun ? "analyzed" : "initialized",
430
+ written: !input.dryRun,
431
+ configurationPath: "ohmyhost.yaml",
432
+ configurationSha256,
433
+ configurationYaml,
434
+ applicationRoot: selection.applicationRoot,
435
+ applicationRootSource: selection.source,
436
+ inventory,
437
+ compatibility,
438
+ blockers: Object.freeze(blockers),
439
+ requirements: Object.freeze(requirements),
440
+ companion,
441
+ worker:
442
+ framework === "functions"
443
+ ? Object.freeze({
444
+ sourceEntryPoint: WORKER_MODULE_PATH,
445
+ present: applicationFiles.includes(WORKER_MODULE_PATH),
446
+ ...runtimeContract(capabilities),
447
+ })
448
+ : null,
449
+ });
450
+ }
451
+
452
+ /** The functions runtime is selected by configuration, or by a Worker module without a framework. */
453
+ function detectRepositoryFramework(
454
+ dependencies: ReadonlySet<string>,
455
+ files: readonly string[],
456
+ existingConfiguration: OhmyhostConfig | null,
457
+ ): RepositoryFramework {
458
+ const detected = detectFramework(dependencies);
459
+ if (existingConfiguration?.runtime.mode === "functions") return "functions";
460
+ if (detected === "unknown" && files.includes(WORKER_MODULE_PATH)) return "functions";
461
+ return detected;
462
+ }
463
+
464
+ /** Declared crons need an edge or functions runtime and a scheduled handler in the Worker module. */
465
+ async function cronBlockers(
466
+ framework: RepositoryFramework,
467
+ config: OhmyhostConfig | null,
468
+ applicationDirectory: string,
469
+ files: readonly string[],
470
+ ): Promise<RepositoryInitBlocker[]> {
471
+ if ((config?.functions?.crons.length ?? 0) === 0) return [];
472
+ const mode = config?.runtime.mode;
473
+ const runtimeSupported =
474
+ (mode === "edge" &&
475
+ (framework === "vite" || framework === "nextjs" || framework === "tanstack-start")) ||
476
+ (mode === "functions" && framework === "functions");
477
+ if (!runtimeSupported) {
478
+ return [
479
+ {
480
+ code: "scheduled_functions_runtime_unsupported",
481
+ message:
482
+ "Declared crons run on the edge runtime of a Vite API companion, Next.js or TanStack Start application, or on the functions runtime.",
483
+ },
484
+ ];
485
+ }
486
+ const modulePath = framework === "vite" ? "src/ohmyhost/companion.ts" : WORKER_MODULE_PATH;
487
+ const source = files.includes(modulePath)
488
+ ? await readFile(resolve(applicationDirectory, modulePath), "utf8")
489
+ : null;
490
+ // The functions runtime reports a missing default export through workerModuleBlockers.
491
+ if (framework !== "functions" && source !== null && !DEFAULT_EXPORT_PATTERN.test(source)) {
492
+ return [defaultExportBlocker(modulePath)];
493
+ }
494
+ if (source === null || !SCHEDULED_HANDLER_PATTERN.test(source)) {
495
+ return [
496
+ {
497
+ code: "scheduled_handler_missing",
498
+ message: `Keep a scheduled handler in the default export of ${modulePath} to run the declared crons.`,
499
+ },
500
+ ];
501
+ }
502
+ return [];
503
+ }
504
+
505
+ /**
506
+ * Declared storage is reached only through the private gateway client. A storage-enabled app whose
507
+ * source never constructs that client deploys green and fails at its first upload, so name it here.
508
+ */
509
+ async function storageClientBlockers(
510
+ config: OhmyhostConfig | null,
511
+ applicationDirectory: string,
512
+ files: readonly string[],
513
+ ): Promise<RepositoryInitBlocker[]> {
514
+ if (config?.storage === undefined) return [];
515
+ for (const path of files) {
516
+ if (!SOURCE_MODULE_PATTERN.test(path)) continue;
517
+ const absolute = resolve(applicationDirectory, path);
518
+ const information = await stat(absolute);
519
+ if (!information.isFile() || information.size > MAXIMUM_SOURCE_BYTES) continue;
520
+ if (callsStorageClient(await readFile(absolute, "utf8"))) return [];
521
+ }
522
+ return [
523
+ {
524
+ code: "storage_client_missing",
525
+ message:
526
+ '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.',
527
+ },
528
+ ];
529
+ }
530
+
531
+ /**
532
+ * A managed database is reached only through `createPrivateDatabaseClient`. A customer Worker gets
533
+ * no connection string and cannot open a socket, so source that reaches for either builds green,
534
+ * deploys, and fails its health check with nothing to explain it.
535
+ */
536
+ async function databaseContractBlockers(
537
+ config: OhmyhostConfig | null,
538
+ applicationDirectory: string,
539
+ files: readonly string[],
540
+ ): Promise<RepositoryInitBlocker[]> {
541
+ if (config?.database === undefined) return [];
542
+ const blockers: RepositoryInitBlocker[] = [];
543
+ for (const path of files) {
544
+ if (!SOURCE_MODULE_PATTERN.test(path)) continue;
545
+ const absolute = resolve(applicationDirectory, path);
546
+ const information = await stat(absolute);
547
+ if (!information.isFile() || information.size > MAXIMUM_SOURCE_BYTES) continue;
548
+ const source = (await readFile(absolute, "utf8")).replaceAll(INERT_SOURCE_PATTERN, " ");
549
+ if (DATABASE_PRIVATE_BINDING_PATTERN.test(source)) {
550
+ blockers.push({
551
+ code: "database_binding_private",
552
+ message: `${path} reaches for a managed database connection string. Hyperdrive, DATABASE_URL and postgres:// URLs are platform-private and never reach a customer Worker. Call the database with createPrivateDatabaseClient from "@ohmyhost/customer-runtime/database".`,
553
+ });
554
+ }
555
+ if (DATABASE_SOCKET_DRIVER_PATTERN.test(source)) {
556
+ blockers.push({
557
+ code: "database_driver_unsupported",
558
+ 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".`,
559
+ });
560
+ }
561
+ }
562
+ return blockers;
563
+ }
564
+
565
+ /** The functions runtime is the customer's module alone; it must export its handlers by default. */
566
+ async function workerModuleBlockers(
567
+ framework: RepositoryFramework,
568
+ applicationDirectory: string,
569
+ files: readonly string[],
570
+ ): Promise<RepositoryInitBlocker[]> {
571
+ if (framework !== "functions" || !files.includes(WORKER_MODULE_PATH)) return [];
572
+ const source = await readFile(resolve(applicationDirectory, WORKER_MODULE_PATH), "utf8");
573
+ return DEFAULT_EXPORT_PATTERN.test(source) ? [] : [defaultExportBlocker(WORKER_MODULE_PATH)];
574
+ }
575
+
576
+ function migrationFilenameBlockers(
577
+ files: readonly string[],
578
+ configuredPath: string | undefined,
579
+ provider: RepositoryInitInventory["database"]["provider"],
580
+ ): RepositoryInitBlocker[] {
581
+ if (provider !== "postgresql") return [];
582
+ const root = configuredPath ?? "postgres/migrations";
583
+ const prefix = `${root}/`;
584
+ const invalid = files
585
+ .filter((path) => path.startsWith(prefix))
586
+ .map((path) => path.slice(prefix.length))
587
+ .some(
588
+ (path) => !/^(?:[A-Za-z0-9][A-Za-z0-9._-]*\/)*\d{14}_[a-z0-9][a-z0-9_-]*\.sql$/u.test(path),
589
+ );
590
+ return invalid
591
+ ? [
592
+ {
593
+ code: "migration_filename_noncanonical",
594
+ message:
595
+ "Rename every PostgreSQL migration to YYYYMMDDHHMMSS_name.sql before initialization.",
596
+ },
597
+ ]
598
+ : [];
599
+ }
600
+
601
+ async function migrationSqlBlockers(
602
+ applicationDirectory: string,
603
+ files: readonly string[],
604
+ configuredPath: string | undefined,
605
+ provider: RepositoryInitInventory["database"]["provider"],
606
+ ): Promise<RepositoryInitBlocker[]> {
607
+ if (provider !== "postgresql") return [];
608
+ const root = configuredPath ?? "postgres/migrations";
609
+ const prefix = `${root}/`;
610
+ const migrationPaths = files
611
+ .filter((path) => path.startsWith(prefix) && path.endsWith(".sql"))
612
+ .map((path) => path.slice(prefix.length));
613
+ if (
614
+ migrationPaths.some(
615
+ (path) => !/^(?:[A-Za-z0-9][A-Za-z0-9._-]*\/)*\d{14}_[a-z0-9][a-z0-9_-]*\.sql$/u.test(path),
616
+ )
617
+ ) {
618
+ return [];
619
+ }
620
+ const migrations = await Promise.all(
621
+ migrationPaths.map(async (path) =>
622
+ Object.freeze({
623
+ path,
624
+ body: new Uint8Array(await readFile(resolve(applicationDirectory, root, path))),
625
+ }),
626
+ ),
627
+ );
628
+ const blockers: RepositoryInitBlocker[] = [];
629
+ for (const migration of migrations) {
630
+ try {
631
+ admitExpandOnlyMigrations([migration]);
632
+ } catch (error) {
633
+ if (!(error instanceof DatabaseMigrationAdmissionError)) throw error;
634
+ blockers.push({
635
+ code: "migration_sql_not_admitted",
636
+ message: `PostgreSQL migration '${migration.path}' is not admitted (${error.reason}).`,
637
+ });
638
+ }
639
+ }
640
+ if (blockers.length > 0) return blockers;
641
+ try {
642
+ admitExpandOnlyMigrations(migrations);
643
+ return [];
644
+ } catch (error) {
645
+ if (!(error instanceof DatabaseMigrationAdmissionError)) throw error;
646
+ return [
647
+ {
648
+ code: "migration_sql_not_admitted",
649
+ message: `PostgreSQL migration set is not admitted (${error.reason}).`,
650
+ },
651
+ ];
652
+ }
653
+ }
654
+
655
+ async function readExistingConfiguration(
656
+ repositoryRoot: string,
657
+ repositoryFiles: readonly string[],
658
+ applicationRoot: ApplicationRoot,
659
+ ) {
660
+ if (!repositoryFiles.includes("ohmyhost.yaml")) return null;
661
+ const configurationPath = resolve(repositoryRoot, "ohmyhost.yaml");
662
+ const information = await stat(configurationPath);
663
+ if (!information.isFile() || information.size > MAXIMUM_CONFIGURATION_BYTES) {
664
+ throw new RepositoryInitError(
665
+ "repository_configuration_invalid",
666
+ "Existing ohmyhost.yaml must be one bounded regular file.",
667
+ );
668
+ }
669
+ const configuration = await readFile(configurationPath, "utf8");
670
+ let parsed;
671
+ try {
672
+ parsed = parseOhmyhostConfigYaml(configuration);
673
+ } catch {
674
+ throw new RepositoryInitError(
675
+ "repository_configuration_invalid",
676
+ "Existing ohmyhost.yaml is invalid.",
677
+ );
678
+ }
679
+ if (parsed.applicationRoot !== applicationRoot) {
680
+ throw new RepositoryInitError(
681
+ "repository_configuration_root_mismatch",
682
+ "Existing ohmyhost.yaml must select the detected application root.",
683
+ );
684
+ }
685
+ return configuration;
686
+ }
687
+
688
+ interface ApplicationRootSelection {
689
+ readonly applicationRoot: ApplicationRoot;
690
+ readonly source: RepositoryInitResult["applicationRootSource"];
691
+ }
692
+
693
+ const APPLICATION_LOCKFILES = Object.freeze([
694
+ "bun.lock",
695
+ "bun.lockb",
696
+ "package-lock.json",
697
+ "pnpm-lock.yaml",
698
+ "yarn.lock",
699
+ ]);
700
+
701
+ async function selectApplicationRoot(
702
+ repositoryRoot: string,
703
+ repositoryFiles: readonly string[],
704
+ explicitRoot: string | undefined,
705
+ ): Promise<ApplicationRootSelection> {
706
+ if (explicitRoot !== undefined) {
707
+ let applicationRoot: ApplicationRoot;
708
+ try {
709
+ applicationRoot = normalizeApplicationRoot(explicitRoot);
710
+ } catch {
711
+ throw new RepositoryInitError(
712
+ "application_root_invalid",
713
+ "Application root must be a canonical repository-relative POSIX path.",
714
+ );
715
+ }
716
+ if (!(await isRealDirectory(resolve(repositoryRoot, applicationRoot)))) {
717
+ throw new RepositoryInitError(
718
+ "application_root_missing",
719
+ `Application root '${applicationRoot}' does not exist as a real directory.`,
720
+ );
721
+ }
722
+ if (!(await isApplicationCandidate(repositoryRoot, repositoryFiles, applicationRoot))) {
723
+ throw new RepositoryInitError(
724
+ "application_root_unsupported",
725
+ `Application root '${applicationRoot}' is not a self-contained supported application.`,
726
+ );
727
+ }
728
+ return Object.freeze({ applicationRoot, source: "explicit" });
729
+ }
730
+
731
+ const repositoryApplicationRoot = normalizeApplicationRoot();
732
+ const repositoryIsCandidate = await isApplicationCandidate(
733
+ repositoryRoot,
734
+ repositoryFiles,
735
+ repositoryApplicationRoot,
736
+ );
737
+ if (
738
+ repositoryIsCandidate &&
739
+ (await hasDirectApplicationShape(repositoryRoot, repositoryFiles, repositoryApplicationRoot))
740
+ ) {
741
+ return Object.freeze({
742
+ applicationRoot: repositoryApplicationRoot,
743
+ source: "repository",
744
+ });
745
+ }
746
+ const candidates: ApplicationRoot[] = [];
747
+ for (const path of repositoryFiles) {
748
+ if (path === "package.json" || !path.endsWith("/package.json")) continue;
749
+ const candidate = candidateRootForPackage(path);
750
+ if (
751
+ candidate !== null &&
752
+ (await isApplicationCandidate(repositoryRoot, repositoryFiles, candidate))
753
+ ) {
754
+ candidates.push(candidate);
755
+ }
756
+ }
757
+ if (candidates.length === 0) {
758
+ if (repositoryIsCandidate) {
759
+ return Object.freeze({
760
+ applicationRoot: repositoryApplicationRoot,
761
+ source: "repository",
762
+ });
763
+ }
764
+ throw new RepositoryInitError(
765
+ "application_root_not_found",
766
+ "No self-contained supported application root was found.",
767
+ );
768
+ }
769
+ if (candidates.length > 1) {
770
+ throw new RepositoryInitError(
771
+ "application_root_ambiguous",
772
+ `Multiple application roots were found: ${candidates.join(", ")}. Use --root to select one.`,
773
+ );
774
+ }
775
+ const applicationRoot = candidates[0];
776
+ if (applicationRoot === undefined) {
777
+ throw new RepositoryInitError(
778
+ "application_root_not_found",
779
+ "No self-contained supported application root was found.",
780
+ );
781
+ }
782
+ return Object.freeze({ applicationRoot, source: "nested" });
783
+ }
784
+
785
+ function candidateRootForPackage(path: string): ApplicationRoot | null {
786
+ const value = path.slice(0, -"/package.json".length);
787
+ try {
788
+ return normalizeApplicationRoot(value);
789
+ } catch {
790
+ return null;
791
+ }
792
+ }
793
+
794
+ async function isApplicationCandidate(
795
+ repositoryRoot: string,
796
+ repositoryFiles: readonly string[],
797
+ applicationRoot: ApplicationRoot,
798
+ ): Promise<boolean> {
799
+ const applicationDirectory = resolve(repositoryRoot, applicationRoot);
800
+ if (!(await isRealDirectory(applicationDirectory))) return false;
801
+ const files = applicationRelativeFiles(repositoryFiles, applicationRoot);
802
+ if (!files.includes("package.json")) return false;
803
+ const packageJson = await tryReadPackageJson(applicationDirectory);
804
+ if (packageJson === null) return false;
805
+ const framework = detectFramework(dependencyNames(packageJson));
806
+ return framework !== "unknown" || files.includes(WORKER_MODULE_PATH);
807
+ }
808
+
809
+ async function hasDirectApplicationShape(
810
+ repositoryRoot: string,
811
+ repositoryFiles: readonly string[],
812
+ applicationRoot: ApplicationRoot,
813
+ ): Promise<boolean> {
814
+ const applicationDirectory = resolve(repositoryRoot, applicationRoot);
815
+ const files = applicationRelativeFiles(repositoryFiles, applicationRoot);
816
+ if (APPLICATION_LOCKFILES.filter((lockfile) => files.includes(lockfile)).length !== 1) {
817
+ return false;
818
+ }
819
+ const packageJson = await tryReadPackageJson(applicationDirectory);
820
+ if (packageJson === null) return false;
821
+ const framework = detectFramework(dependencyNames(packageJson));
822
+ if (framework === "unknown") return files.includes(WORKER_MODULE_PATH);
823
+ return hasDirectSafeBuildCommand(packageJson, framework);
824
+ }
825
+
826
+ async function isRealDirectory(path: string): Promise<boolean> {
827
+ try {
828
+ const information = await lstat(path);
829
+ return information.isDirectory() && !information.isSymbolicLink();
830
+ } catch {
831
+ return false;
832
+ }
833
+ }
834
+
835
+ function applicationRelativeFiles(
836
+ repositoryFiles: readonly string[],
837
+ applicationRoot: ApplicationRoot,
838
+ ): string[] {
839
+ if (applicationRoot === ".") return [...repositoryFiles];
840
+ const prefix = `${applicationRoot}/`;
841
+ return repositoryFiles
842
+ .filter((path) => path.startsWith(prefix))
843
+ .map((path) => path.slice(prefix.length));
844
+ }
845
+
846
+ async function tryReadPackageJson(root: string): Promise<Record<string, unknown> | null> {
847
+ try {
848
+ return await readPackageJson(root);
849
+ } catch {
850
+ return null;
851
+ }
852
+ }
853
+
854
+ function hasDirectSafeBuildCommand(
855
+ packageJson: Record<string, unknown>,
856
+ framework: RepositoryFramework,
857
+ ): boolean {
858
+ const scripts = packageJson["scripts"];
859
+ if (!isRecord(scripts) || typeof scripts["build"] !== "string") return false;
860
+ const command = scripts["build"];
861
+ if (command.includes("..") || /(?:^|\s)cd(?:\s|$)/u.test(command)) return false;
862
+ if (framework === "nextjs") return command === "next build" || command === "next build --webpack";
863
+ return command === "vite build" || command === "vite build && tsc --noEmit";
864
+ }
865
+
866
+ async function assertRepositoryRoot(root: string): Promise<void> {
867
+ let information;
868
+ try {
869
+ information = await lstat(root);
870
+ } catch {
871
+ throw new RepositoryInitError("repository_not_found", "Repository directory does not exist");
872
+ }
873
+ if (information.isSymbolicLink()) {
874
+ throw new RepositoryInitError("repository_symlink_forbidden", "Repository root is a symlink");
875
+ }
876
+ if (!information.isDirectory()) {
877
+ throw new RepositoryInitError("repository_not_directory", "Repository path is not a directory");
878
+ }
879
+ }
880
+
881
+ async function collectRepositoryFiles(root: string): Promise<string[]> {
882
+ const files: string[] = [];
883
+ const directories = [""];
884
+ while (directories.length > 0) {
885
+ const directory = directories.pop();
886
+ if (directory === undefined) break;
887
+ for (const entry of await readdir(resolve(root, directory), { withFileTypes: true })) {
888
+ const relative = directory === "" ? entry.name : posix.join(directory, entry.name);
889
+ if (entry.isSymbolicLink()) {
890
+ throw new RepositoryInitError(
891
+ "repository_symlink_forbidden",
892
+ "Repository symlinks are forbidden",
893
+ );
894
+ }
895
+ if (entry.isDirectory()) {
896
+ if (!IGNORED_DIRECTORIES.has(entry.name)) directories.push(relative);
897
+ continue;
898
+ }
899
+ if (!entry.isFile()) {
900
+ throw new RepositoryInitError("repository_entry_unsafe", "Repository entry is unsafe");
901
+ }
902
+ files.push(relative);
903
+ if (files.length > MAXIMUM_REPOSITORY_FILES) {
904
+ throw new RepositoryInitError("repository_too_large", "Repository file limit exceeded");
905
+ }
906
+ }
907
+ }
908
+ return files.sort();
909
+ }
910
+
911
+ async function readPackageJson(root: string): Promise<Record<string, unknown>> {
912
+ const path = resolve(root, "package.json");
913
+ let information;
914
+ try {
915
+ information = await stat(path);
916
+ } catch {
917
+ throw new RepositoryInitError("package_manifest_missing", "package.json is required");
918
+ }
919
+ if (!information.isFile() || information.size > MAXIMUM_PACKAGE_BYTES) {
920
+ throw new RepositoryInitError("package_manifest_invalid", "package.json is invalid");
921
+ }
922
+ try {
923
+ const value = JSON.parse(await readFile(path, "utf8")) as unknown;
924
+ if (!isRecord(value)) throw new TypeError();
925
+ return value;
926
+ } catch {
927
+ throw new RepositoryInitError("package_manifest_invalid", "package.json is invalid");
928
+ }
929
+ }
930
+
931
+ function dependencyNames(packageJson: Record<string, unknown>): Set<string> {
932
+ const names = new Set<string>();
933
+ for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
934
+ const value = packageJson[field];
935
+ if (!isRecord(value)) continue;
936
+ for (const [name, version] of Object.entries(value)) {
937
+ if (typeof version === "string" && version.length > 0) names.add(name);
938
+ }
939
+ }
940
+ return names;
941
+ }
942
+
943
+ function detectFramework(dependencies: ReadonlySet<string>): RepositoryFramework {
944
+ const classification = classifyFrameworkFamily(dependencies);
945
+ return classification === "ambiguous" ? "unknown" : classification;
946
+ }
947
+
948
+ function packageManagerAdmissionFor(
949
+ packageJson: Record<string, unknown>,
950
+ files: readonly string[],
951
+ ): PackageManagerDescriptor | FrameworkAdmissionError {
952
+ try {
953
+ return resolvePackageManager({
954
+ packageManager:
955
+ typeof packageJson["packageManager"] === "string"
956
+ ? packageJson["packageManager"]
957
+ : undefined,
958
+ files,
959
+ });
960
+ } catch (error) {
961
+ if (error instanceof FrameworkAdmissionError) return error;
962
+ throw error;
963
+ }
964
+ }
965
+
966
+ function packageManagerInventory(
967
+ admission: PackageManagerDescriptor | FrameworkAdmissionError,
968
+ packageJson: Record<string, unknown>,
969
+ files: readonly string[],
970
+ ): RepositoryInitInventory["packageManager"] {
971
+ if (!(admission instanceof FrameworkAdmissionError)) {
972
+ return {
973
+ name: admission.manager,
974
+ version: admission.version,
975
+ lockfile: admission.lockfile,
976
+ };
977
+ }
978
+ const declaration = packageJson["packageManager"];
979
+ const match =
980
+ typeof declaration === "string" ? /^(bun|npm|pnpm|yarn)@([^\s]+)$/u.exec(declaration) : null;
981
+ const lockfile = APPLICATION_LOCKFILES.find((candidate) => files.includes(candidate)) ?? null;
982
+ const inferredName =
983
+ lockfile === "package-lock.json"
984
+ ? "npm"
985
+ : lockfile === "pnpm-lock.yaml"
986
+ ? "pnpm"
987
+ : lockfile === "yarn.lock"
988
+ ? "yarn"
989
+ : lockfile === "bun.lock" || lockfile === "bun.lockb"
990
+ ? "bun"
991
+ : "unknown";
992
+ return {
993
+ name: match === null ? inferredName : (match[1] as "bun" | "npm" | "pnpm" | "yarn"),
994
+ version: match?.[2] ?? null,
995
+ lockfile,
996
+ };
997
+ }
998
+
999
+ interface FrameworkConfigurations {
1000
+ readonly next: string | null;
1001
+ readonly openNext: string | null;
1002
+ readonly vite: string | null;
1003
+ readonly ambiguous: readonly ("next" | "open-next" | "vite")[];
1004
+ }
1005
+
1006
+ function frameworkConfigurations(files: readonly string[]): FrameworkConfigurations {
1007
+ const ambiguous: ("next" | "open-next" | "vite")[] = [];
1008
+ const resolve = (basename: "next.config" | "open-next.config" | "vite.config") => {
1009
+ try {
1010
+ return resolveFrameworkConfig(basename, files);
1011
+ } catch (error) {
1012
+ if (!(error instanceof FrameworkAdmissionError) || error.reason !== "ambiguous_config") {
1013
+ throw error;
1014
+ }
1015
+ ambiguous.push(basename.slice(0, -".config".length) as "next" | "open-next" | "vite");
1016
+ return null;
1017
+ }
1018
+ };
1019
+ return Object.freeze({
1020
+ next: resolve("next.config"),
1021
+ openNext: resolve("open-next.config"),
1022
+ vite: resolve("vite.config"),
1023
+ ambiguous: Object.freeze(ambiguous),
1024
+ });
1025
+ }
1026
+
1027
+ function frameworkCompatibility(
1028
+ packageJson: Record<string, unknown>,
1029
+ framework: RepositoryFramework,
1030
+ ): RepositoryInitResult["compatibility"] {
1031
+ const targets: readonly Readonly<{ framework: AdmittedFramework; packageName: string }>[] =
1032
+ framework === "tanstack-start"
1033
+ ? [
1034
+ { framework: "tanstack-start", packageName: "@tanstack/react-start" },
1035
+ { framework: "vite", packageName: "vite" },
1036
+ ]
1037
+ : framework === "nextjs"
1038
+ ? [{ framework: "nextjs", packageName: "next" }]
1039
+ : framework === "vite"
1040
+ ? [{ framework: "vite", packageName: "vite" }]
1041
+ : [];
1042
+ const frameworks = targets.map(({ framework: targetFramework, packageName }) => {
1043
+ const version = dependencyVersion(packageJson, packageName) ?? "";
1044
+ return Object.freeze({
1045
+ framework: targetFramework,
1046
+ version,
1047
+ ...classifyFrameworkVersion(targetFramework, version),
1048
+ });
1049
+ });
1050
+ const classification: FrameworkVersionClassification = frameworks.some(
1051
+ (entry) => entry.classification === "unsupported",
1052
+ )
1053
+ ? "unsupported"
1054
+ : frameworks.some((entry) => entry.classification === "experimental")
1055
+ ? "experimental"
1056
+ : frameworks.length > 0 || framework === "functions"
1057
+ ? "verified"
1058
+ : "unsupported";
1059
+ return Object.freeze({ classification, frameworks: Object.freeze(frameworks) });
1060
+ }
1061
+
1062
+ function projectName(explicit: string | undefined, packageJson: Record<string, unknown>): string {
1063
+ const packageName = packageJson["name"];
1064
+ const inferred =
1065
+ typeof packageName === "string"
1066
+ ? (packageName.split("/").at(-1) ?? "")
1067
+ .toLowerCase()
1068
+ .replace(/[^a-z0-9]+/gu, "-")
1069
+ .replace(/^-+|-+$/gu, "")
1070
+ : packageName;
1071
+ const value = explicit ?? inferred;
1072
+ if (typeof value !== "string" || value.length > 63 || !PROJECT_PATTERN.test(value)) {
1073
+ throw new RepositoryInitError(
1074
+ "project_name_invalid",
1075
+ "Provide a lowercase project slug with --project",
1076
+ );
1077
+ }
1078
+ return value;
1079
+ }
1080
+
1081
+ function repositoryBlockers(
1082
+ packageJson: Record<string, unknown>,
1083
+ framework: RepositoryFramework,
1084
+ nextStandaloneOutput: boolean,
1085
+ tanStackRuntime: TanStackRuntimeAnalysis,
1086
+ files: readonly string[],
1087
+ packageManagerAdmission: PackageManagerDescriptor | FrameworkAdmissionError,
1088
+ frameworkConfigs: FrameworkConfigurations,
1089
+ compatibility: RepositoryInitResult["compatibility"],
1090
+ existingConfiguration: OhmyhostConfig | null,
1091
+ ): RepositoryInitBlocker[] {
1092
+ const blockers: RepositoryInitBlocker[] = [];
1093
+ if (framework === "unknown") {
1094
+ blockers.push({
1095
+ code: "framework_unsupported",
1096
+ message:
1097
+ "Declare exactly one supported Vite, TanStack Start, or Next.js framework, or add src/ohmyhost/worker.ts for the functions runtime.",
1098
+ });
1099
+ }
1100
+ if (framework === "functions" && !files.includes(WORKER_MODULE_PATH)) {
1101
+ blockers.push({
1102
+ code: "worker_module_missing",
1103
+ message: `The functions runtime needs ${WORKER_MODULE_PATH} exporting fetch and optional scheduled handlers.`,
1104
+ });
1105
+ }
1106
+ if (tanStackRuntime === "ambiguous") {
1107
+ blockers.push({
1108
+ code: "framework_ambiguous",
1109
+ message:
1110
+ "TanStack Start must explicitly enable static prerendering or the complete Cloudflare edge contract.",
1111
+ });
1112
+ }
1113
+ const relevantAmbiguousConfigs = frameworkConfigs.ambiguous.filter((config) =>
1114
+ framework === "nextjs" ? config === "next" || config === "open-next" : config === "vite",
1115
+ );
1116
+ for (const config of relevantAmbiguousConfigs) {
1117
+ blockers.push({
1118
+ code: "framework_ambiguous",
1119
+ message: `Keep exactly one supported ${config}.config.js, ${config}.config.mjs, or ${config}.config.ts file.`,
1120
+ });
1121
+ }
1122
+ if (
1123
+ packageManagerAdmission instanceof FrameworkAdmissionError &&
1124
+ packageManagerAdmission.reason === "ambiguous_lockfile"
1125
+ ) {
1126
+ blockers.push({
1127
+ code: "package_manager_ambiguous",
1128
+ message: "Keep exactly one supported lockfile in the application root.",
1129
+ });
1130
+ } else if (packageManagerAdmission instanceof FrameworkAdmissionError) {
1131
+ blockers.push({
1132
+ code: "package_manager_unpinned",
1133
+ message:
1134
+ "Declare one exact npm, pnpm, Yarn, or Bun packageManager version and commit its matching lockfile.",
1135
+ });
1136
+ } else if (
1137
+ existingConfiguration !== null &&
1138
+ (existingConfiguration.build.install !== packageManagerAdmission.installCommand ||
1139
+ ("command" in existingConfiguration.build &&
1140
+ existingConfiguration.build.command !== packageManagerAdmission.buildCommand))
1141
+ ) {
1142
+ blockers.push({
1143
+ code: "build_command_unsupported",
1144
+ message: "Use the admitted package manager's secure install and direct build commands.",
1145
+ });
1146
+ }
1147
+ if (compatibility.classification === "unsupported" && compatibility.frameworks.length > 0) {
1148
+ const unsupported = compatibility.frameworks
1149
+ .filter((entry) => entry.classification === "unsupported")
1150
+ .map((entry) => `${entry.framework}@${entry.version || "missing"}`)
1151
+ .join(", ");
1152
+ blockers.push({
1153
+ code: "framework_version_unsupported",
1154
+ message: `Framework version is outside the admitted POC window: ${unsupported}.`,
1155
+ });
1156
+ }
1157
+ const scripts = packageJson["scripts"];
1158
+ const scriptRecord = isRecord(scripts) ? scripts : {};
1159
+ if (framework === "functions") {
1160
+ // The functions runtime installs dependencies and bundles the Worker module itself.
1161
+ } else if (
1162
+ !isRecord(scripts) ||
1163
+ typeof scripts["build"] !== "string" ||
1164
+ scripts["build"].length === 0
1165
+ ) {
1166
+ blockers.push({ code: "build_script_missing", message: "Declare a non-empty build script." });
1167
+ } else if (!hasDirectSafeBuildCommand(packageJson, framework)) {
1168
+ blockers.push({
1169
+ code: "build_command_unsupported",
1170
+ message: "Use a direct framework build command without traversal or directory changes.",
1171
+ });
1172
+ }
1173
+ const betterAuthVersion = dependencyVersion(packageJson, "better-auth");
1174
+ if (
1175
+ existingConfiguration?.auth?.provider === "better-auth" &&
1176
+ betterAuthVersion !== null &&
1177
+ betterAuthVersion !== SUPPORTED_BETTER_AUTH_VERSION
1178
+ ) {
1179
+ blockers.push({
1180
+ code: "better_auth_version_unsupported",
1181
+ message: "Pin better-auth to exactly 1.7.1 before enabling portable customer auth.",
1182
+ });
1183
+ }
1184
+ if (framework === "nextjs") {
1185
+ const dependencies = isRecord(packageJson["dependencies"]) ? packageJson["dependencies"] : {};
1186
+ const nextBuild =
1187
+ scriptRecord["build"] === "next build" || scriptRecord["build"] === "next build --webpack";
1188
+ const nextPackageConfigured =
1189
+ nextBuild &&
1190
+ compatibility.frameworks[0]?.classification !== "unsupported" &&
1191
+ exactVersion(dependencies["react"]) &&
1192
+ exactVersion(dependencies["react-dom"]) &&
1193
+ frameworkConfigs.next !== null;
1194
+ const nextConfigured =
1195
+ nextPackageConfigured && frameworkConfigs.ambiguous.every((config) => config !== "open-next");
1196
+ if (nextStandaloneOutput) {
1197
+ blockers.push({
1198
+ code: "workers_runtime_incompatible",
1199
+ message:
1200
+ "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.",
1201
+ });
1202
+ }
1203
+ if (!nextConfigured) {
1204
+ blockers.push({
1205
+ code: "next_adapter_unconfigured",
1206
+ message:
1207
+ "Declare direct next build, exact Next/React versions, and one supported Next config; ohmyhost supplies the OpenNext build overlay.",
1208
+ });
1209
+ }
1210
+ }
1211
+ return blockers.sort((left, right) => left.code.localeCompare(right.code));
1212
+ }
1213
+
1214
+ function exactVersion(value: unknown): boolean {
1215
+ return typeof value === "string" && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(value);
1216
+ }
1217
+
1218
+ function dependencyVersion(packageJson: Record<string, unknown>, name: string): string | null {
1219
+ for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
1220
+ const dependencies = packageJson[field];
1221
+ if (!isRecord(dependencies)) continue;
1222
+ const version = dependencies[name];
1223
+ if (typeof version === "string" && version.length > 0) return version;
1224
+ }
1225
+ return null;
1226
+ }
1227
+
1228
+ function renderConfiguration(input: {
1229
+ readonly project: string;
1230
+ readonly framework: RepositoryFramework;
1231
+ readonly packageManager: RepositoryInitInventory["packageManager"];
1232
+ readonly databaseProvider: RepositoryInitInventory["database"]["provider"];
1233
+ readonly hasHealthcheck: boolean;
1234
+ readonly mailEnabled: boolean;
1235
+ readonly betterAuthEnabled: boolean;
1236
+ readonly storageEnabled: boolean;
1237
+ readonly storageJurisdiction: "us" | "eu";
1238
+ readonly viteCompanionRequired: boolean;
1239
+ readonly tanStackRuntime: TanStackRuntimeAnalysis;
1240
+ readonly applicationRoot: ApplicationRoot;
1241
+ }): string {
1242
+ const descriptor = packageManagerDescriptorFromInventory(input.packageManager);
1243
+ const install = descriptor?.installCommand ?? "pnpm install --frozen-lockfile --ignore-scripts";
1244
+ const command = descriptor?.buildCommand ?? "pnpm run build";
1245
+
1246
+ let output = "dist";
1247
+ if (input.framework === "nextjs") output = ".open-next/assets";
1248
+ else if (input.framework === "tanstack-start") output = "dist/client";
1249
+
1250
+ let mode = "auto";
1251
+ if (input.framework === "functions") mode = "functions";
1252
+ else if (
1253
+ input.framework === "nextjs" ||
1254
+ input.tanStackRuntime === "edge" ||
1255
+ input.viteCompanionRequired
1256
+ )
1257
+ mode = "edge";
1258
+ else if (input.framework === "vite" || input.tanStackRuntime === "static") mode = "static";
1259
+ const migrations = databaseMigrationPath(input.databaseProvider);
1260
+ return [
1261
+ "version: 1",
1262
+ `project: ${input.project}`,
1263
+ ...(input.applicationRoot === "." ? [] : [`applicationRoot: ${input.applicationRoot}`]),
1264
+ "build:",
1265
+ ` install: ${install}`,
1266
+ ...(input.framework === "functions" ? [] : [` command: ${command}`, ` output: ${output}`]),
1267
+ "runtime:",
1268
+ ` mode: ${mode}`,
1269
+ ...(input.hasHealthcheck ? [" healthcheck: /api/health"] : []),
1270
+ "database:",
1271
+ ` enabled: ${String(input.databaseProvider !== "none")}`,
1272
+ ...(migrations === null ? [] : [` migrations: ${migrations}`]),
1273
+ "auth:",
1274
+ ` provider: ${input.betterAuthEnabled ? "better-auth" : "none"}`,
1275
+ "mail:",
1276
+ ` enabled: ${String(input.mailEnabled)}`,
1277
+ ...(input.storageEnabled
1278
+ ? [
1279
+ "storage:",
1280
+ " provider: r2",
1281
+ ` jurisdiction: ${input.storageJurisdiction}`,
1282
+ " public: false",
1283
+ ]
1284
+ : []),
1285
+ "",
1286
+ ].join("\n");
1287
+ }
1288
+
1289
+ function packageManagerDescriptorFromInventory(
1290
+ packageManager: RepositoryInitInventory["packageManager"],
1291
+ ): PackageManagerDescriptor | null {
1292
+ if (
1293
+ packageManager.name === "unknown" ||
1294
+ packageManager.version === null ||
1295
+ packageManager.lockfile === null
1296
+ ) {
1297
+ return null;
1298
+ }
1299
+ try {
1300
+ return resolvePackageManager({
1301
+ packageManager: `${packageManager.name}@${packageManager.version}`,
1302
+ files: [packageManager.lockfile],
1303
+ });
1304
+ } catch {
1305
+ return null;
1306
+ }
1307
+ }
1308
+
1309
+ async function detectNextStandaloneOutput(
1310
+ root: string,
1311
+ framework: RepositoryFramework,
1312
+ nextConfigPath: string | null,
1313
+ ): Promise<boolean> {
1314
+ if (framework !== "nextjs" || nextConfigPath === null) return false;
1315
+ const path = resolve(root, nextConfigPath);
1316
+ const information = await stat(path);
1317
+ if (!information.isFile() || information.size > MAXIMUM_CONFIGURATION_BYTES) return false;
1318
+ return declaresStandaloneOutput(await readFile(path, "utf8"));
1319
+ }
1320
+
1321
+ function declaresStandaloneOutput(source: string): boolean {
1322
+ const tokens = sourceTokens(source);
1323
+ return tokens.some(
1324
+ (token, index) =>
1325
+ token === "output" && tokens[index + 1] === ":" && tokens[index + 2] === "standalone",
1326
+ );
1327
+ }
1328
+
1329
+ function sourceTokens(source: string): readonly string[] {
1330
+ const tokens: string[] = [];
1331
+ let index = 0;
1332
+ while (index < source.length) {
1333
+ const character = source[index];
1334
+ const next = source[index + 1];
1335
+ if (character === "/" && next === "/") {
1336
+ const end = source.indexOf("\n", index + 2);
1337
+ index = end === -1 ? source.length : end + 1;
1338
+ continue;
1339
+ }
1340
+ if (character === "/" && next === "*") {
1341
+ const end = source.indexOf("*/", index + 2);
1342
+ if (end === -1) return Object.freeze([]);
1343
+ index = end + 2;
1344
+ continue;
1345
+ }
1346
+ if (character === "'" || character === '"' || character === "`") {
1347
+ let value = "";
1348
+ index += 1;
1349
+ while (index < source.length && source[index] !== character) {
1350
+ if (source[index] === "\\" && source[index + 1] !== undefined) index += 1;
1351
+ value += source[index] ?? "";
1352
+ index += 1;
1353
+ }
1354
+ if (source[index] !== character) return Object.freeze([]);
1355
+ tokens.push(value);
1356
+ index += 1;
1357
+ continue;
1358
+ }
1359
+ if (character !== undefined && /[A-Za-z_$]/u.test(character)) {
1360
+ const start = index;
1361
+ index += 1;
1362
+ while (/[A-Za-z0-9_$]/u.test(source[index] ?? "")) index += 1;
1363
+ tokens.push(source.slice(start, index));
1364
+ continue;
1365
+ }
1366
+ if (character !== undefined && "{}[]():,.".includes(character)) tokens.push(character);
1367
+ index += 1;
1368
+ }
1369
+ return Object.freeze(tokens);
1370
+ }
1371
+
1372
+ function databaseMigrationPath(provider: RepositoryInitInventory["database"]["provider"]) {
1373
+ if (provider === "supabase") return "postgres/migrations";
1374
+ if (provider === "postgresql") return "postgres/migrations";
1375
+ return null;
1376
+ }
1377
+
1378
+ async function detectTanStackRuntime(
1379
+ root: string,
1380
+ files: readonly string[],
1381
+ framework: RepositoryFramework,
1382
+ packageJson: Record<string, unknown>,
1383
+ viteConfigPath: string | null,
1384
+ ): Promise<TanStackRuntimeAnalysis> {
1385
+ if (framework !== "tanstack-start") return null;
1386
+ if (viteConfigPath === null) return "ambiguous";
1387
+ const path = resolve(root, viteConfigPath);
1388
+ const information = await stat(path);
1389
+ if (!information.isFile() || information.size > MAXIMUM_PACKAGE_BYTES) return "ambiguous";
1390
+ const source = await readFile(path, "utf8");
1391
+ const tokens = sourceTokens(source);
1392
+ if (isCompleteTanStackStatic(tokens)) return "static";
1393
+ if (isCompleteTanStackEdgePackage(packageJson) && isCompleteTanStackEdgeConfig(tokens)) {
1394
+ return "edge";
1395
+ }
1396
+ return "ambiguous";
1397
+ }
1398
+
1399
+ function isCompleteTanStackStatic(tokens: readonly string[]): boolean {
1400
+ return (
1401
+ hasNamedSourceImport(tokens, "tanstackStart", "@tanstack/react-start/plugin/vite") &&
1402
+ !hasSourceModuleImport(tokens, "@cloudflare/vite-plugin") &&
1403
+ hasNestedObjectValue(tokens, "tanstackStart", "prerender", "enabled", "true")
1404
+ );
1405
+ }
1406
+
1407
+ function isCompleteTanStackEdgePackage(packageJson: Record<string, unknown>): boolean {
1408
+ return (
1409
+ dependencyVersion(packageJson, "@cloudflare/vite-plugin") !== null &&
1410
+ ["@tanstack/react-router", "@tanstack/react-start", "react", "react-dom"].every(
1411
+ (name) => dependencyVersion(packageJson, name) !== null,
1412
+ ) &&
1413
+ ["@vitejs/plugin-react", "vite"].every((name) => dependencyVersion(packageJson, name) !== null)
1414
+ );
1415
+ }
1416
+
1417
+ function isCompleteTanStackEdgeConfig(tokens: readonly string[]): boolean {
1418
+ const cloudflareCall = sourceCallIndex(tokens, "cloudflare");
1419
+ const tanStackCall = sourceCallIndex(tokens, "tanstackStart");
1420
+ return (
1421
+ hasNamedSourceImport(tokens, "cloudflare", "@cloudflare/vite-plugin") &&
1422
+ hasNamedSourceImport(tokens, "tanstackStart", "@tanstack/react-start/plugin/vite") &&
1423
+ cloudflareCall !== -1 &&
1424
+ tanStackCall !== -1 &&
1425
+ cloudflareCall < tanStackCall &&
1426
+ hasNestedObjectValue(tokens, "cloudflare", "viteEnvironment", "name", "ssr")
1427
+ );
1428
+ }
1429
+
1430
+ function hasNamedSourceImport(
1431
+ tokens: readonly string[],
1432
+ name: string,
1433
+ moduleName: string,
1434
+ ): boolean {
1435
+ for (let index = 0; index < tokens.length; index += 1) {
1436
+ if (tokens[index] !== "import") continue;
1437
+ const fromIndex = tokens.findIndex((token, candidate) => candidate > index && token === "from");
1438
+ if (fromIndex === -1) return false;
1439
+ if (tokens[fromIndex + 1] === moduleName && tokens.slice(index + 1, fromIndex).includes(name)) {
1440
+ return true;
1441
+ }
1442
+ index = fromIndex;
1443
+ }
1444
+ return false;
1445
+ }
1446
+
1447
+ function hasSourceModuleImport(tokens: readonly string[], moduleName: string): boolean {
1448
+ return tokens.some((token, index) => token === "from" && tokens[index + 1] === moduleName);
1449
+ }
1450
+
1451
+ function sourceCallIndex(tokens: readonly string[], name: string): number {
1452
+ return tokens.findIndex((token, index) => token === name && tokens[index + 1] === "(");
1453
+ }
1454
+
1455
+ function hasNestedObjectValue(
1456
+ tokens: readonly string[],
1457
+ call: string,
1458
+ outerProperty: string,
1459
+ innerProperty: string,
1460
+ expectedValue: string,
1461
+ ): boolean {
1462
+ const callIndex = sourceCallIndex(tokens, call);
1463
+ if (callIndex === -1 || tokens[callIndex + 2] !== "{") return false;
1464
+ const callEnd = matchingSourceDelimiter(tokens, callIndex + 2, "{", "}");
1465
+ const outer = directSourceObjectProperty(tokens, callIndex + 2, callEnd, outerProperty);
1466
+ if (outer === null || outer.value !== "object") return false;
1467
+ return (
1468
+ directSourceObjectProperty(tokens, outer.start, outer.end, innerProperty)?.value ===
1469
+ expectedValue
1470
+ );
1471
+ }
1472
+
1473
+ function directSourceObjectProperty(
1474
+ tokens: readonly string[],
1475
+ start: number,
1476
+ end: number,
1477
+ name: string,
1478
+ ): Readonly<{ start: number; end: number; value: string }> | null {
1479
+ let depth = 0;
1480
+ for (let index = start + 1; index < end; index += 1) {
1481
+ const value = tokens[index];
1482
+ if (value === "{" || value === "[" || value === "(") depth += 1;
1483
+ if (value === "}" || value === "]" || value === ")") depth -= 1;
1484
+ if (depth !== 0 || value !== name || tokens[index + 1] !== ":") continue;
1485
+ if (tokens[index + 2] === "{") {
1486
+ const candidateEnd = matchingSourceDelimiter(tokens, index + 2, "{", "}");
1487
+ return Object.freeze({ start: index + 2, end: candidateEnd, value: "object" });
1488
+ }
1489
+ return Object.freeze({
1490
+ start: index + 2,
1491
+ end: index + 2,
1492
+ value: tokens[index + 2] ?? "",
1493
+ });
1494
+ }
1495
+ return null;
1496
+ }
1497
+
1498
+ function matchingSourceDelimiter(
1499
+ tokens: readonly string[],
1500
+ start: number,
1501
+ open: string,
1502
+ close: string,
1503
+ ): number {
1504
+ let depth = 0;
1505
+ for (let index = start; index < tokens.length; index += 1) {
1506
+ if (tokens[index] === open) depth += 1;
1507
+ if (tokens[index] === close) depth -= 1;
1508
+ if (depth === 0) return index;
1509
+ }
1510
+ return -1;
1511
+ }
1512
+
1513
+ function requirementsFor(
1514
+ inventory: RepositoryInitInventory,
1515
+ viteCompanion: boolean,
1516
+ mailConversionRequired: boolean,
1517
+ managedAuthSelected: boolean,
1518
+ ): string[] {
1519
+ const requirements = new Set<string>();
1520
+ if (inventory.framework === "nextjs") requirements.add("nextjs-adapter");
1521
+ if (inventory.database.provider === "supabase") requirements.add("supabase-postgres-conversion");
1522
+ if (
1523
+ (inventory.auth.betterAuth && !managedAuthSelected) ||
1524
+ inventory.auth.supabase ||
1525
+ inventory.auth.workos ||
1526
+ inventory.auth.auth0
1527
+ )
1528
+ requirements.add("application-auth-review");
1529
+ if (inventory.edgeFunctions.count > 0) requirements.add("edge-function-conversion");
1530
+ if (inventory.storage.supabase) requirements.add("r2-storage-conversion");
1531
+ if (mailConversionRequired) requirements.add("transactional-email");
1532
+ if (viteCompanion) requirements.add("vite-api-companion");
1533
+ return [...requirements].sort();
1534
+ }
1535
+
1536
+ async function detectR2Storage(root: string, files: readonly string[]): Promise<boolean> {
1537
+ const configs = files.filter((path) => /(?:^|\/)wrangler\.(?:jsonc?|toml)$/u.test(path));
1538
+ for (const config of configs) {
1539
+ const path = resolve(root, config);
1540
+ const information = await stat(path);
1541
+ if (!information.isFile() || information.size > MAXIMUM_CONFIGURATION_BYTES) continue;
1542
+ const source = await readFile(path, "utf8");
1543
+ if (/\br2_buckets\b/u.test(source)) return true;
1544
+ }
1545
+ return false;
1546
+ }
1547
+
1548
+ type RuntimeCapabilities = Readonly<{
1549
+ database: boolean;
1550
+ auth: boolean;
1551
+ mail: boolean;
1552
+ storage: boolean;
1553
+ }>;
1554
+
1555
+ /** The runtime values and install sources a server module needs, identical for every server runtime. */
1556
+ function runtimeContract(capabilities: RuntimeCapabilities): Readonly<{
1557
+ bindings: readonly string[];
1558
+ packages: RepositoryInitRuntimePackages;
1559
+ }> {
1560
+ return Object.freeze({
1561
+ bindings: Object.freeze(
1562
+ [
1563
+ ...(capabilities.auth ? ["BETTER_AUTH_SECRET"] : []),
1564
+ ...(capabilities.storage ? STORAGE_RUNTIME_BINDINGS : []),
1565
+ ...(capabilities.database ? ["OHMYHOST_DATABASE"] : []),
1566
+ ...(capabilities.mail ? ["OHMYHOST_MAIL_KEY"] : []),
1567
+ ].sort(),
1568
+ ),
1569
+ packages: Object.freeze({
1570
+ ...(capabilities.auth ? { betterAuth: "1.7.1" as const } : {}),
1571
+ // A database project was told to install pg, which cannot open a socket from a Worker at all,
1572
+ // and was never offered the package that actually holds the client it needs.
1573
+ ...(capabilities.database || capabilities.storage || capabilities.mail
1574
+ ? { customerRuntime: CUSTOMER_RUNTIME_SOURCE }
1575
+ : {}),
1576
+ }),
1577
+ });
1578
+ }
1579
+
1580
+ function viteCompanionScaffold(
1581
+ files: readonly string[],
1582
+ capabilities: RuntimeCapabilities,
1583
+ ): RepositoryInitViteCompanion {
1584
+ const sourceEntryPoint = "src/ohmyhost/companion.ts" as const;
1585
+ const contract = runtimeContract(capabilities);
1586
+ return Object.freeze({
1587
+ schemaVersion: "ohmyhost.vite-api-companion/v1" as const,
1588
+ sourceEntryPoint,
1589
+ requiredFiles: Object.freeze([sourceEntryPoint] as const),
1590
+ missingFiles: Object.freeze(files.includes(sourceEntryPoint) ? [] : [sourceEntryPoint]),
1591
+ packages: contract.packages,
1592
+ bindings: contract.bindings,
1593
+ artifact: Object.freeze({
1594
+ outputDirectory: ".ohmyhost/vite-companion" as const,
1595
+ entryPoint: ".ohmyhost/vite-companion/worker.js" as const,
1596
+ configPath: ".ohmyhost/vite-companion/wrangler.jsonc" as const,
1597
+ }),
1598
+ });
1599
+ }
1600
+
1601
+ async function createConfiguration(root: string, configuration: string): Promise<void> {
1602
+ let handle;
1603
+ try {
1604
+ handle = await open(resolve(root, "ohmyhost.yaml"), "wx", 0o600);
1605
+ await handle.writeFile(configuration, "utf8");
1606
+ await handle.sync();
1607
+ } catch (error) {
1608
+ if (isNodeError(error) && error.code === "EEXIST") throw new RepositoryInitConflictError();
1609
+ if (error instanceof RepositoryInitError) throw error;
1610
+ throw new RepositoryInitError("repository_write_failed", "Could not create ohmyhost.yaml");
1611
+ } finally {
1612
+ await handle?.close();
1613
+ }
1614
+ }
1615
+
1616
+ function isRecord(value: unknown): value is Record<string, unknown> {
1617
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1618
+ }
1619
+
1620
+ function isNodeError(value: unknown): value is NodeJS.ErrnoException {
1621
+ return value instanceof Error && "code" in value;
1622
+ }