@amerged/ohmyhost-mcp 0.1.31 → 0.1.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/{chunk-QHKLOHN4.js → chunk-YM2ZQAGS.js} +8 -8
- package/dist/index.js +1 -1
- package/dist/stdio-main.js +430 -121
- package/package.json +1 -1
- package/src/apps/mcp/package.json +1 -1
- package/src/apps/product-cli/package.json +1 -1
- package/src/apps/product-cli/src/composition.ts +2 -1
- package/src/apps/product-cli/src/repository-init.ts +443 -713
- package/src/apps/product-cli/src/runtime-contract.ts +18 -5
- package/src/packages/agent-skills/src/generated-skill-resources.ts +7 -7
- package/src/packages/contracts/package.json +10 -0
- package/src/packages/contracts/src/framework-admission-corpus.ts +487 -0
- package/src/packages/contracts/src/framework-admission.ts +641 -197
- package/src/packages/contracts/src/framework-build-script.mjs +155 -13
- package/src/packages/contracts/src/framework-build-script.ts +15 -1
- package/src/packages/contracts/src/framework-source-rules.ts +1118 -0
- package/src/packages/contracts/src/operation-failure.ts +22 -4
- package/src/packages/contracts/src/problem-policy.ts +2 -2
- package/src/packages/sdk-ts/package.json +1 -1
- package/src/packages/sdk-ts/src/generated/sdk.gen.ts +1 -1
- package/src/packages/sdk-ts/src/generated/types.gen.ts +8 -0
|
@@ -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
|
-
|
|
13
|
+
frameworkConversionDetail,
|
|
18
14
|
resolveFrameworkConfig,
|
|
19
15
|
resolvePackageManager,
|
|
20
|
-
|
|
16
|
+
rootFrameworkConversionDiagnostic,
|
|
17
|
+
type FrameworkConversionDiagnosticCode,
|
|
21
18
|
type FrameworkVersionAdmission,
|
|
22
|
-
type
|
|
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
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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?:
|
|
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:
|
|
156
|
-
readonly requiredFiles: readonly [
|
|
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
|
-
|
|
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:
|
|
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
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
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(
|
|
287
|
+
const packageManagerAdmission = packageManagerAdmissionFor(manifest, applicationFiles);
|
|
261
288
|
const packageManager = packageManagerInventory(
|
|
262
289
|
packageManagerAdmission,
|
|
263
|
-
|
|
290
|
+
manifest,
|
|
264
291
|
applicationFiles,
|
|
265
292
|
);
|
|
266
|
-
const
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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(
|
|
378
|
-
:
|
|
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 =
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
/**
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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
|
|
496
|
-
(
|
|
596
|
+
const scheduled =
|
|
597
|
+
(config?.functions?.crons.length ?? 0) > 0 &&
|
|
598
|
+
((mode === "edge" &&
|
|
497
599
|
(framework === "vite" || framework === "nextjs" || framework === "tanstack-start")) ||
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
files: readonly string[],
|
|
641
|
+
context: SourceCheckContext,
|
|
642
|
+
provider: RepositoryInitInventory["database"]["provider"],
|
|
562
643
|
): Promise<RepositoryInitBlocker[]> {
|
|
563
|
-
if (
|
|
644
|
+
if (provider === "none") return [];
|
|
564
645
|
const blockers: RepositoryInitBlocker[] = [];
|
|
565
|
-
for (const path of files) {
|
|
566
|
-
if (!
|
|
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
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
792
|
+
if (text === null) throw new TypeError("ohmyhost.yaml is not valid UTF-8");
|
|
793
|
+
parsed = parseOhmyhostConfigYaml(text);
|
|
706
794
|
} catch (error) {
|
|
707
|
-
//
|
|
708
|
-
|
|
709
|
-
|
|
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(
|
|
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(
|
|
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<
|
|
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
|
-
|
|
986
|
+
manifest: PackageManifest,
|
|
890
987
|
framework: RepositoryFramework,
|
|
891
988
|
): boolean {
|
|
892
|
-
const 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
|
-
|
|
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
|
-
|
|
990
|
-
if (!isRecord(value)) throw new TypeError();
|
|
991
|
-
return value;
|
|
1093
|
+
return UTF8.decode(bytes);
|
|
992
1094
|
} catch {
|
|
993
|
-
|
|
1095
|
+
return null;
|
|
994
1096
|
}
|
|
995
1097
|
}
|
|
996
1098
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
1207
|
+
const declaration = packageManagerDeclaration(manifest);
|
|
1045
1208
|
const match =
|
|
1046
|
-
|
|
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
|
-
|
|
1066
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
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
|
|