@loucompanion/forge-bridge 0.1.3-dev.d1ca67076aa9 → 0.1.3-dev.e7b62d8fdd33
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 +127 -0
- package/dist/bridge/bridge.base.d.ts +8 -1
- package/dist/bridge/bridge.service.d.ts +93 -3
- package/dist/bridge/bridge.service.js +1358 -47
- package/dist/bridge/bridge.types.d.ts +202 -1
- package/dist/bridge/runner-host-capabilities.d.ts +15 -0
- package/dist/bridge/runner-host-capabilities.js +93 -0
- package/dist/cleanup/cleanup.service.d.ts +14 -2
- package/dist/cleanup/cleanup.service.js +496 -3
- package/dist/cleanup/cleanup.types.d.ts +10 -0
- package/dist/cleanup/index.d.ts +1 -1
- package/dist/cleanup/index.js +1 -1
- package/dist/cleanup/internals.d.ts +2 -2
- package/dist/cleanup/internals.js +1 -1
- package/dist/cli/cli.help.js +47 -1
- package/dist/cli/cli.service.d.ts +45 -2
- package/dist/cli/cli.service.js +378 -26
- package/dist/cli/local-config.d.ts +7 -0
- package/dist/cli/local-config.js +51 -3
- package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
- package/dist/runtime/aspnet-runtime-prebuild.js +340 -0
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
- package/dist/runtime/attempt-node-runtime-launcher.js +105 -0
- package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
- package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
- package/dist/runtime/attempt-process-supervisor.d.ts +97 -0
- package/dist/runtime/attempt-process-supervisor.js +386 -0
- package/dist/runtime/docker-engine-probe.d.ts +83 -0
- package/dist/runtime/docker-engine-probe.js +300 -0
- package/dist/runtime/generated-output-reset.d.ts +10 -0
- package/dist/runtime/generated-output-reset.js +103 -0
- package/dist/runtime/host-listener-inspector.d.ts +46 -0
- package/dist/runtime/host-listener-inspector.js +349 -0
- package/dist/runtime/host-port-broker.d.ts +76 -0
- package/dist/runtime/host-port-broker.js +333 -0
- package/dist/runtime/host-runtime-contract.d.ts +47 -0
- package/dist/runtime/host-runtime-contract.js +700 -0
- package/dist/runtime/host-runtime-lease.d.ts +197 -0
- package/dist/runtime/host-runtime-lease.js +2203 -0
- package/dist/runtime/host-runtime-promotion.d.ts +23 -0
- package/dist/runtime/host-runtime-promotion.js +159 -0
- package/dist/runtime/memory-capacity.d.ts +68 -0
- package/dist/runtime/memory-capacity.js +121 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +54 -0
- package/dist/runtime/node-host-runtime-adapter.js +284 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +21 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +231 -0
- package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
- package/dist/runtime/runtime-isolation-lease.js +158 -0
- package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
- package/dist/runtime/workspace-resource-estimator.js +174 -0
- package/dist/sandbox/command-runner.d.ts +4 -0
- package/dist/sandbox/command-runner.js +47 -0
- package/dist/sandbox/contract.d.ts +5 -0
- package/dist/sandbox/contract.js +276 -0
- package/dist/sandbox/event-budget.d.ts +11 -0
- package/dist/sandbox/event-budget.js +27 -0
- package/dist/sandbox/image-policy.d.ts +35 -0
- package/dist/sandbox/image-policy.js +247 -0
- package/dist/sandbox/index.d.ts +5 -0
- package/dist/sandbox/index.js +5 -0
- package/dist/sandbox/lease-watchdog.d.ts +18 -0
- package/dist/sandbox/lease-watchdog.js +67 -0
- package/dist/sandbox/preflight.d.ts +84 -0
- package/dist/sandbox/preflight.js +521 -0
- package/dist/sandbox/process.d.ts +20 -0
- package/dist/sandbox/process.js +209 -0
- package/dist/sandbox/provider-auth/codex.d.ts +26 -0
- package/dist/sandbox/provider-auth/codex.js +212 -0
- package/dist/sandbox/repository-lock.d.ts +5 -0
- package/dist/sandbox/repository-lock.js +121 -0
- package/dist/sandbox/runtime.d.ts +297 -0
- package/dist/sandbox/runtime.js +2338 -0
- package/dist/sandbox/sandbox.types.d.ts +93 -0
- package/dist/sandbox/sandbox.types.js +1 -0
- package/dist/sandbox/smoke.d.ts +76 -0
- package/dist/sandbox/smoke.js +380 -0
- package/dist/sandbox/worker-supervisor.d.ts +13 -0
- package/dist/sandbox/worker-supervisor.js +36 -0
- package/dist/sandbox/worker.d.ts +1 -0
- package/dist/sandbox/worker.js +225 -0
- package/dist/sandbox/workspace.service.d.ts +13 -0
- package/dist/sandbox/workspace.service.js +72 -0
- package/dist/session/managed-attempt-tools.d.ts +21 -0
- package/dist/session/managed-attempt-tools.js +912 -0
- package/dist/session/managed-build-coordinator.d.ts +5 -0
- package/dist/session/managed-build-coordinator.js +105 -0
- package/dist/session/managed-build-receipt.d.ts +21 -0
- package/dist/session/managed-build-receipt.js +335 -0
- package/dist/session/phase-completion.artifacts.d.ts +17 -0
- package/dist/session/phase-completion.artifacts.js +256 -26
- package/dist/session/phase-completion.d.ts +12 -2
- package/dist/session/phase-completion.helpers.d.ts +5 -0
- package/dist/session/phase-completion.helpers.js +51 -10
- package/dist/session/phase-completion.js +14 -3
- package/dist/session/phase-completion.prompts.d.ts +3 -3
- package/dist/session/phase-completion.prompts.js +24 -8
- package/dist/session/phase-completion.runtime.d.ts +13 -1
- package/dist/session/phase-completion.runtime.js +189 -8
- package/dist/session/phase-completion.schemas.js +117 -4
- package/dist/session/phase-completion.validation.d.ts +4 -1
- package/dist/session/phase-completion.validation.js +153 -22
- package/dist/session/pre-publication-validation.d.ts +46 -0
- package/dist/session/pre-publication-validation.js +208 -0
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +16 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +110 -0
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +3 -2
- package/dist/session/provider-cli/provider-cli.artifacts.js +65 -7
- package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
- package/dist/session/provider-cli/provider-cli.deterministic.d.ts +2 -0
- package/dist/session/provider-cli/provider-cli.deterministic.js +68 -9
- package/dist/session/provider-cli/provider-cli.process.d.ts +26 -0
- package/dist/session/provider-cli/provider-cli.process.js +474 -23
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +16 -2
- package/dist/session/provider-cli/provider-cli.prompts.js +224 -24
- package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
- package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
- package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
- package/dist/session/provider-cli/provider-cli.runtime.js +6 -3
- package/dist/session/provider-cli/provider-cli.service.d.ts +26 -3
- package/dist/session/provider-cli/provider-cli.service.js +650 -108
- package/dist/session/provider-cli/provider-cli.types.d.ts +6 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
- package/dist/session/provider-cli/provider-output-state.js +91 -16
- package/dist/session/provider-cli/provider-usage.d.ts +7 -0
- package/dist/session/provider-cli/provider-usage.js +28 -5
- package/dist/session/session-redaction.d.ts +3 -0
- package/dist/session/session-redaction.js +38 -0
- package/dist/session/session.base.d.ts +6 -1
- package/dist/session/session.service.d.ts +8 -2
- package/dist/session/session.service.js +549 -58
- package/dist/session/session.types.d.ts +124 -1
- package/dist/session/session.types.js +231 -4
- package/dist/shared/format/format-bytes.d.ts +1 -0
- package/dist/shared/format/format-bytes.js +9 -0
- package/dist/shared/path/path.d.ts +1 -0
- package/dist/shared/path/path.js +21 -1
- package/dist/shared/process/dotnet-toolchain.d.ts +14 -0
- package/dist/shared/process/dotnet-toolchain.js +179 -0
- package/dist/shared/process/host-process-recovery.d.ts +12 -0
- package/dist/shared/process/host-process-recovery.js +78 -0
- package/dist/shared/process/host-process-supervisor.d.ts +59 -0
- package/dist/shared/process/host-process-supervisor.js +330 -0
- package/dist/shared/process/process.d.ts +1 -0
- package/dist/shared/process/process.js +1 -0
- package/dist/shared/process/windows-launch.d.ts +1 -1
- package/dist/shared/process/windows-launch.js +6 -3
- package/dist/shared/provider-capabilities.d.ts +1 -0
- package/dist/shared/provider-capabilities.js +3 -0
- package/dist/shared/redaction/redaction.js +16 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +3 -2
- package/dist/worktree/artifact-injector.js +43 -4
- package/dist/worktree/dependency-warmer.d.ts +39 -1
- package/dist/worktree/dependency-warmer.js +351 -8
- package/dist/worktree/index.d.ts +2 -1
- package/dist/worktree/index.js +1 -0
- package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
- package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
- package/dist/worktree/worktree-paths.d.ts +4 -0
- package/dist/worktree/worktree-paths.js +30 -0
- package/dist/worktree/worktree.base.d.ts +3 -0
- package/dist/worktree/worktree.service.d.ts +13 -2
- package/dist/worktree/worktree.service.js +238 -47
- package/dist/worktree/worktree.types.d.ts +5 -1
- package/package.json +8 -4
- package/runtime/windows-job-supervisor.ps1 +444 -0
- package/runtime/windows-listener-owner.ps1 +33 -0
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { isAlias, parseDocument, visit } from "yaml";
|
|
5
|
+
export const hostRuntimeContractPath = ".forge/forge.runtime.yml";
|
|
6
|
+
const maximumContractBytes = 64 * 1024;
|
|
7
|
+
const maximumDepth = 12;
|
|
8
|
+
const ignoredDiscoveryDirectories = new Set([
|
|
9
|
+
".forge",
|
|
10
|
+
".forge-designer",
|
|
11
|
+
".git",
|
|
12
|
+
".local",
|
|
13
|
+
"bin",
|
|
14
|
+
"coverage",
|
|
15
|
+
"dist",
|
|
16
|
+
"node_modules",
|
|
17
|
+
"obj",
|
|
18
|
+
]);
|
|
19
|
+
const topLevelKeys = new Set(["schemaVersion", "runtime"]);
|
|
20
|
+
const runtimeKeys = new Set(["hostConcurrency", "ports", "playwright", "browserProfile", "singletonResources"]);
|
|
21
|
+
const portKeys = new Set(["adapter", "launch", "env", "baseUrlEnv", "format"]);
|
|
22
|
+
const launchKeys = new Set(["command", "args", "workingDirectory", "environment"]);
|
|
23
|
+
const playwrightKeys = new Set(["config", "baseUrlPort", "workers"]);
|
|
24
|
+
export async function loadHostRuntimeContract(worktreePath) {
|
|
25
|
+
const target = path.join(path.resolve(worktreePath), hostRuntimeContractPath);
|
|
26
|
+
let targetStat;
|
|
27
|
+
try {
|
|
28
|
+
targetStat = await fs.stat(target);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (isFileNotFound(error))
|
|
32
|
+
return await discoverHostRuntimeContract(worktreePath);
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
if (!targetStat.isFile() || targetStat.size < 1 || targetStat.size > maximumContractBytes) {
|
|
36
|
+
throw new Error(`runtime_contract_invalid: forge.runtime.yml must be a regular 1-${maximumContractBytes} byte file.`);
|
|
37
|
+
}
|
|
38
|
+
const [realWorktree, realTarget] = await Promise.all([fs.realpath(worktreePath), fs.realpath(target)]);
|
|
39
|
+
const relative = path.relative(realWorktree, realTarget);
|
|
40
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
41
|
+
throw new Error("runtime_contract_invalid: forge.runtime.yml must remain inside the prepared worktree.");
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
contract: validateHostRuntimeContract(await fs.readFile(realTarget, "utf8")),
|
|
45
|
+
source: "project",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
async function discoverHostRuntimeContract(worktreePath) {
|
|
49
|
+
const root = path.resolve(worktreePath);
|
|
50
|
+
const [nodeDiscovery, aspNetDiscovery] = await Promise.all([
|
|
51
|
+
discoverNodeRuntimes(root),
|
|
52
|
+
discoverAspNetRuntimes(root),
|
|
53
|
+
]);
|
|
54
|
+
if (nodeDiscovery.ambiguous || aspNetDiscovery.ambiguous) {
|
|
55
|
+
return ambiguousRuntimeDiscovery();
|
|
56
|
+
}
|
|
57
|
+
const candidates = [...nodeDiscovery.candidates, ...aspNetDiscovery.candidates];
|
|
58
|
+
if (candidates.length === 1) {
|
|
59
|
+
const candidate = candidates[0];
|
|
60
|
+
return {
|
|
61
|
+
contract: candidate.contract,
|
|
62
|
+
source: "derived",
|
|
63
|
+
discovery: {
|
|
64
|
+
status: "derived",
|
|
65
|
+
reasonCode: candidate.reasonCode,
|
|
66
|
+
summary: candidate.summary,
|
|
67
|
+
candidateFingerprint: candidate.candidateFingerprint,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (candidates.length === 2
|
|
72
|
+
&& candidates.some(candidate => candidate.kind === "browser")
|
|
73
|
+
&& candidates.some(candidate => candidate.kind === "api")
|
|
74
|
+
&& candidates.find(candidate => candidate.kind === "browser")?.contract.runtime.playwright !== null) {
|
|
75
|
+
const browser = candidates.find(candidate => candidate.kind === "browser");
|
|
76
|
+
const api = candidates.find(candidate => candidate.kind === "api");
|
|
77
|
+
const combined = combineBrowserAndApiRuntime(browser, api);
|
|
78
|
+
return {
|
|
79
|
+
contract: combined.contract,
|
|
80
|
+
source: "derived",
|
|
81
|
+
discovery: {
|
|
82
|
+
status: "derived",
|
|
83
|
+
reasonCode: combined.reasonCode,
|
|
84
|
+
summary: combined.summary,
|
|
85
|
+
candidateFingerprint: combined.candidateFingerprint,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (candidates.length > 1) {
|
|
90
|
+
return ambiguousRuntimeDiscovery();
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
contract: defaultHostRuntimeContract(),
|
|
94
|
+
source: "default",
|
|
95
|
+
discovery: {
|
|
96
|
+
status: "unsupported",
|
|
97
|
+
reasonCode: "runtime_discovery_unsupported",
|
|
98
|
+
summary: "Forge did not find one deterministic supported application entrypoint; host application runs remain serialized.",
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
async function discoverNodeRuntimes(root) {
|
|
103
|
+
const manifests = [...new Set([
|
|
104
|
+
path.join(root, "package.json"),
|
|
105
|
+
...await findFiles(root, file => path.basename(file).toLowerCase() === "package.json", 4, 500),
|
|
106
|
+
])];
|
|
107
|
+
const candidates = (await Promise.all(manifests.map(async (manifestPath) => await discoverNodeRuntime(root, manifestPath)))).filter((candidate) => candidate !== null);
|
|
108
|
+
return { candidates: candidates.length === 1 ? candidates : [], ambiguous: candidates.length > 1 };
|
|
109
|
+
}
|
|
110
|
+
async function discoverNodeRuntime(root, manifestPath) {
|
|
111
|
+
const source = await readBoundedOptionalFile(manifestPath, 256 * 1024);
|
|
112
|
+
if (source === null)
|
|
113
|
+
return null;
|
|
114
|
+
let manifest;
|
|
115
|
+
try {
|
|
116
|
+
manifest = JSON.parse(source);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
const scripts = objectOrEmpty(manifest.scripts);
|
|
122
|
+
const dependencies = { ...objectOrEmpty(manifest.dependencies), ...objectOrEmpty(manifest.devDependencies) };
|
|
123
|
+
const dev = typeof scripts.dev === "string" ? scripts.dev.trim() : "";
|
|
124
|
+
const start = typeof scripts.start === "string" ? scripts.start.trim() : "";
|
|
125
|
+
const scriptName = dev ? "dev" : start ? "start" : null;
|
|
126
|
+
const script = scriptName ? String(scripts[scriptName]).trim() : "";
|
|
127
|
+
if (!scriptName || !script || script.length > 1024 || /(?:&&|\|\||;|\n|\r)/u.test(script))
|
|
128
|
+
return null;
|
|
129
|
+
const vite = Object.hasOwn(dependencies, "vite") || /(^|\s)vite(?:\s|$)/u.test(script);
|
|
130
|
+
const next = Object.hasOwn(dependencies, "next") || /(^|\s)next(?:\s|$)/u.test(script);
|
|
131
|
+
if (vite === next)
|
|
132
|
+
return null;
|
|
133
|
+
const packageManager = await nearestPackageManager(root, path.dirname(manifestPath));
|
|
134
|
+
if (!packageManager)
|
|
135
|
+
return null;
|
|
136
|
+
const adapter = vite ? "vite" : "next";
|
|
137
|
+
const playwrightConfigs = [...new Set((await Promise.all([
|
|
138
|
+
...["playwright.config.ts", "playwright.config.js", "playwright.config.mjs", "playwright.config.cjs"]
|
|
139
|
+
.flatMap(file => [file, `e2e/${file}`, `tests/e2e/${file}`])
|
|
140
|
+
.map(async (candidate) => await optionalFileExists(path.join(root, candidate)) ? path.join(root, candidate) : null),
|
|
141
|
+
findFiles(root, file => /^playwright\.config\.(?:ts|js|mjs|cjs)$/iu.test(path.basename(file)), 4, 2_000),
|
|
142
|
+
])).flat().filter((value) => value !== null))]
|
|
143
|
+
.map(config => portableRelativePath(root, config));
|
|
144
|
+
const playwright = playwrightConfigs.length === 1
|
|
145
|
+
? { config: playwrightConfigs[0], baseUrlPort: "web", workers: 1 }
|
|
146
|
+
: null;
|
|
147
|
+
const workingDirectory = portableRelativePath(root, path.dirname(manifestPath)) || ".";
|
|
148
|
+
const contract = {
|
|
149
|
+
schemaVersion: 1,
|
|
150
|
+
runtime: {
|
|
151
|
+
hostConcurrency: "parallel",
|
|
152
|
+
ports: [{
|
|
153
|
+
name: "web",
|
|
154
|
+
adapter,
|
|
155
|
+
launch: {
|
|
156
|
+
command: packageManager,
|
|
157
|
+
args: packageManager === "npm" ? ["run", scriptName, "--"] : ["run", scriptName],
|
|
158
|
+
workingDirectory,
|
|
159
|
+
},
|
|
160
|
+
env: "PORT",
|
|
161
|
+
baseUrlEnv: "APP_BASE_URL",
|
|
162
|
+
format: "http://{host}:{port}",
|
|
163
|
+
}],
|
|
164
|
+
playwright,
|
|
165
|
+
browserProfile: "isolated",
|
|
166
|
+
singletonResources: [],
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
return {
|
|
170
|
+
kind: "browser",
|
|
171
|
+
contract,
|
|
172
|
+
reasonCode: `runtime_discovery_${adapter}`,
|
|
173
|
+
summary: `Forge derived one ${adapter === "vite" ? "Vite" : "Next.js"} application entrypoint from package.json.`,
|
|
174
|
+
candidateFingerprint: detectorFingerprint({
|
|
175
|
+
manifest: portableRelativePath(root, manifestPath),
|
|
176
|
+
source,
|
|
177
|
+
packageManager,
|
|
178
|
+
scriptName,
|
|
179
|
+
script,
|
|
180
|
+
adapter,
|
|
181
|
+
playwrightConfigs,
|
|
182
|
+
}),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
async function discoverAspNetRuntimes(root) {
|
|
186
|
+
const projects = await findFiles(root, file => file.toLowerCase().endsWith(".csproj"), 3, 200);
|
|
187
|
+
const webProjects = [];
|
|
188
|
+
for (const projectPath of projects) {
|
|
189
|
+
const source = await readBoundedOptionalFile(projectPath, 512 * 1024);
|
|
190
|
+
if (source && /<Project\s+Sdk=["']Microsoft\.NET\.Sdk\.Web["']/iu.test(source)) {
|
|
191
|
+
webProjects.push({ relativePath: path.relative(root, projectPath).replaceAll("\\", "/"), source });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (webProjects.length !== 1)
|
|
195
|
+
return { candidates: [], ambiguous: webProjects.length > 1 };
|
|
196
|
+
const project = webProjects[0];
|
|
197
|
+
const launchProfile = await discoverAspNetLaunchProfile(root, project.relativePath);
|
|
198
|
+
const contract = {
|
|
199
|
+
schemaVersion: 1,
|
|
200
|
+
runtime: {
|
|
201
|
+
hostConcurrency: "parallel",
|
|
202
|
+
ports: [{
|
|
203
|
+
name: "web",
|
|
204
|
+
adapter: "aspnetcore",
|
|
205
|
+
launch: {
|
|
206
|
+
command: "dotnet",
|
|
207
|
+
args: ["run", "--project", project.relativePath],
|
|
208
|
+
workingDirectory: ".",
|
|
209
|
+
...(Object.keys(launchProfile.environment).length > 0
|
|
210
|
+
? { environment: launchProfile.environment }
|
|
211
|
+
: {}),
|
|
212
|
+
},
|
|
213
|
+
env: "ASPNETCORE_URLS",
|
|
214
|
+
baseUrlEnv: "APP_BASE_URL",
|
|
215
|
+
format: "http://{host}:{port}",
|
|
216
|
+
}],
|
|
217
|
+
playwright: null,
|
|
218
|
+
browserProfile: "isolated",
|
|
219
|
+
singletonResources: [],
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
return { candidates: [{
|
|
223
|
+
kind: "api",
|
|
224
|
+
contract,
|
|
225
|
+
reasonCode: "runtime_discovery_aspnetcore",
|
|
226
|
+
summary: "Forge derived one ASP.NET Core application entrypoint from the repository project metadata.",
|
|
227
|
+
candidateFingerprint: detectorFingerprint({
|
|
228
|
+
project: project.relativePath,
|
|
229
|
+
source: project.source,
|
|
230
|
+
launchSettings: launchProfile.source,
|
|
231
|
+
launchProfile: launchProfile.name,
|
|
232
|
+
}),
|
|
233
|
+
}], ambiguous: false };
|
|
234
|
+
}
|
|
235
|
+
function combineBrowserAndApiRuntime(browser, api) {
|
|
236
|
+
const browserPort = browser.contract.runtime.ports.find(port => port.launch !== null);
|
|
237
|
+
const apiPort = api.contract.runtime.ports.find(port => port.launch !== null);
|
|
238
|
+
const combinedBrowserPort = {
|
|
239
|
+
...browserPort,
|
|
240
|
+
name: "web",
|
|
241
|
+
env: "WEB_PORT",
|
|
242
|
+
baseUrlEnv: "WEB_BASE_URL",
|
|
243
|
+
launch: browserPort.launch ? {
|
|
244
|
+
...browserPort.launch,
|
|
245
|
+
environment: {
|
|
246
|
+
...browserPort.launch.environment,
|
|
247
|
+
FORGE_API_PROXY_TARGET: "${FORGE_API_URL}",
|
|
248
|
+
FORGE_DEV_PUBLIC_ORIGIN: "${FORGE_RUNTIME_BASE_URL}",
|
|
249
|
+
},
|
|
250
|
+
} : null,
|
|
251
|
+
};
|
|
252
|
+
const combinedApiPort = {
|
|
253
|
+
...apiPort,
|
|
254
|
+
name: "api",
|
|
255
|
+
env: "ASPNETCORE_URLS",
|
|
256
|
+
baseUrlEnv: "API_BASE_URL",
|
|
257
|
+
};
|
|
258
|
+
return {
|
|
259
|
+
kind: "browser",
|
|
260
|
+
contract: {
|
|
261
|
+
schemaVersion: 1,
|
|
262
|
+
runtime: {
|
|
263
|
+
hostConcurrency: "parallel",
|
|
264
|
+
ports: [combinedApiPort, combinedBrowserPort],
|
|
265
|
+
playwright: browser.contract.runtime.playwright
|
|
266
|
+
? { ...browser.contract.runtime.playwright, baseUrlPort: "web" }
|
|
267
|
+
: null,
|
|
268
|
+
browserProfile: "isolated",
|
|
269
|
+
singletonResources: [],
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
reasonCode: "runtime_discovery_full_stack",
|
|
273
|
+
summary: "Forge derived one supervised API and browser application pair from repository metadata.",
|
|
274
|
+
candidateFingerprint: detectorFingerprint({
|
|
275
|
+
browser: browser.candidateFingerprint,
|
|
276
|
+
api: api.candidateFingerprint,
|
|
277
|
+
}),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function ambiguousRuntimeDiscovery() {
|
|
281
|
+
return {
|
|
282
|
+
contract: defaultHostRuntimeContract(),
|
|
283
|
+
source: "default",
|
|
284
|
+
discovery: {
|
|
285
|
+
status: "ambiguous",
|
|
286
|
+
reasonCode: "runtime_discovery_multiple_applications",
|
|
287
|
+
summary: "Forge found more than one supported application entrypoint, so host application runs remain serialized.",
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
async function nearestPackageManager(root, start) {
|
|
292
|
+
let directory = path.resolve(start);
|
|
293
|
+
const resolvedRoot = path.resolve(root);
|
|
294
|
+
while (directory === resolvedRoot || directory.startsWith(`${resolvedRoot}${path.sep}`)) {
|
|
295
|
+
const lockfiles = (await Promise.all([
|
|
296
|
+
optionalFileExists(path.join(directory, "pnpm-lock.yaml")).then(exists => exists ? "pnpm" : null),
|
|
297
|
+
optionalFileExists(path.join(directory, "yarn.lock")).then(exists => exists ? "yarn" : null),
|
|
298
|
+
optionalFileExists(path.join(directory, "package-lock.json")).then(exists => exists ? "npm" : null),
|
|
299
|
+
optionalFileExists(path.join(directory, "npm-shrinkwrap.json")).then(exists => exists ? "npm" : null),
|
|
300
|
+
])).filter((value) => value !== null);
|
|
301
|
+
const packageManagers = [...new Set(lockfiles)];
|
|
302
|
+
if (packageManagers.length > 1)
|
|
303
|
+
return null;
|
|
304
|
+
if (packageManagers.length === 1)
|
|
305
|
+
return packageManagers[0];
|
|
306
|
+
if (directory === resolvedRoot)
|
|
307
|
+
break;
|
|
308
|
+
directory = path.dirname(directory);
|
|
309
|
+
}
|
|
310
|
+
return "npm";
|
|
311
|
+
}
|
|
312
|
+
function portableRelativePath(root, target) {
|
|
313
|
+
return path.relative(root, target).replaceAll("\\", "/");
|
|
314
|
+
}
|
|
315
|
+
async function discoverAspNetLaunchProfile(root, projectRelativePath) {
|
|
316
|
+
const target = path.join(root, path.dirname(projectRelativePath), "Properties", "launchSettings.json");
|
|
317
|
+
const source = await readBoundedOptionalFile(target, 512 * 1024);
|
|
318
|
+
if (!source)
|
|
319
|
+
return { name: null, source: null, environment: {} };
|
|
320
|
+
try {
|
|
321
|
+
const document = objectOrEmpty(JSON.parse(source));
|
|
322
|
+
const profiles = objectOrEmpty(document.profiles);
|
|
323
|
+
const candidates = Object.entries(profiles)
|
|
324
|
+
.map(([name, value]) => ({ name, profile: objectOrEmpty(value) }))
|
|
325
|
+
.filter(candidate => String(candidate.profile.commandName ?? "").toLowerCase() === "project")
|
|
326
|
+
.sort((left, right) => aspNetProfilePriority(left.name) - aspNetProfilePriority(right.name)
|
|
327
|
+
|| left.name.localeCompare(right.name));
|
|
328
|
+
const selected = candidates[0];
|
|
329
|
+
if (!selected)
|
|
330
|
+
return { name: null, source, environment: {} };
|
|
331
|
+
const environmentVariables = objectOrEmpty(selected.profile.environmentVariables);
|
|
332
|
+
const environment = Object.fromEntries(Object.entries(environmentVariables)
|
|
333
|
+
.filter((entry) => safeAspNetLaunchEnvironment(entry[0], entry[1]))
|
|
334
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
335
|
+
.slice(0, 64));
|
|
336
|
+
return { name: selected.name, source, environment };
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return { name: null, source, environment: {} };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function aspNetProfilePriority(name) {
|
|
343
|
+
if (name.toLowerCase() === "http")
|
|
344
|
+
return 0;
|
|
345
|
+
if (name.toLowerCase() === "https")
|
|
346
|
+
return 1;
|
|
347
|
+
return 2;
|
|
348
|
+
}
|
|
349
|
+
function safeAspNetLaunchEnvironment(name, value) {
|
|
350
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/u.test(name)
|
|
351
|
+
|| typeof value !== "string"
|
|
352
|
+
|| value.length === 0
|
|
353
|
+
|| value.length > 8192)
|
|
354
|
+
return false;
|
|
355
|
+
const normalized = name.toUpperCase();
|
|
356
|
+
return !normalized.startsWith("FORGE_")
|
|
357
|
+
&& !new Set([
|
|
358
|
+
"ASPNETCORE_URLS",
|
|
359
|
+
"ASPNETCORE_HTTP_PORTS",
|
|
360
|
+
"ASPNETCORE_HTTPS_PORTS",
|
|
361
|
+
"DOTNET_URLS",
|
|
362
|
+
"HOST",
|
|
363
|
+
"PORT",
|
|
364
|
+
"PATH",
|
|
365
|
+
"HOME",
|
|
366
|
+
"TMP",
|
|
367
|
+
"TEMP",
|
|
368
|
+
"TMPDIR",
|
|
369
|
+
]).has(normalized);
|
|
370
|
+
}
|
|
371
|
+
async function findFiles(root, accepts, maximumDepth, maximumEntries) {
|
|
372
|
+
const results = [];
|
|
373
|
+
let examined = 0;
|
|
374
|
+
const visitDirectory = async (directory, depth) => {
|
|
375
|
+
if (depth > maximumDepth || examined >= maximumEntries)
|
|
376
|
+
return;
|
|
377
|
+
const entries = await fs.readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
378
|
+
for (const entry of entries) {
|
|
379
|
+
if (examined++ >= maximumEntries)
|
|
380
|
+
return;
|
|
381
|
+
if (entry.isDirectory() && ignoredDiscoveryDirectories.has(entry.name.toLowerCase()))
|
|
382
|
+
continue;
|
|
383
|
+
const target = path.join(directory, entry.name);
|
|
384
|
+
if (entry.isDirectory())
|
|
385
|
+
await visitDirectory(target, depth + 1);
|
|
386
|
+
else if (entry.isFile() && accepts(target))
|
|
387
|
+
results.push(target);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
await visitDirectory(root, 0);
|
|
391
|
+
return results.sort((left, right) => left.localeCompare(right));
|
|
392
|
+
}
|
|
393
|
+
async function readBoundedOptionalFile(target, maximumBytes) {
|
|
394
|
+
try {
|
|
395
|
+
const stat = await fs.stat(target);
|
|
396
|
+
if (!stat.isFile() || stat.size < 1 || stat.size > maximumBytes)
|
|
397
|
+
return null;
|
|
398
|
+
return await fs.readFile(target, "utf8");
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
if (isFileNotFound(error))
|
|
402
|
+
return null;
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async function optionalFileExists(target) {
|
|
407
|
+
try {
|
|
408
|
+
return (await fs.stat(target)).isFile();
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
if (isFileNotFound(error))
|
|
412
|
+
return false;
|
|
413
|
+
throw error;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function objectOrEmpty(value) {
|
|
417
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
418
|
+
? value
|
|
419
|
+
: {};
|
|
420
|
+
}
|
|
421
|
+
function detectorFingerprint(value) {
|
|
422
|
+
return `sha256:${createHash("sha256").update(JSON.stringify({ adapterVersion: 2, value })).digest("hex")}`;
|
|
423
|
+
}
|
|
424
|
+
export function defaultHostRuntimeContract() {
|
|
425
|
+
return {
|
|
426
|
+
schemaVersion: 1,
|
|
427
|
+
runtime: {
|
|
428
|
+
hostConcurrency: "serial",
|
|
429
|
+
ports: [],
|
|
430
|
+
playwright: null,
|
|
431
|
+
browserProfile: "isolated",
|
|
432
|
+
singletonResources: [],
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
export function validateHostRuntimeContract(source) {
|
|
437
|
+
const byteLength = Buffer.byteLength(source, "utf8");
|
|
438
|
+
if (byteLength === 0 || byteLength > maximumContractBytes) {
|
|
439
|
+
throw new Error(`runtime_contract_invalid: forge.runtime.yml must be 1-${maximumContractBytes} bytes.`);
|
|
440
|
+
}
|
|
441
|
+
if (Array.from(source).some(character => {
|
|
442
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
443
|
+
return codePoint === 127 || (codePoint < 32 && codePoint !== 9 && codePoint !== 10 && codePoint !== 13);
|
|
444
|
+
})) {
|
|
445
|
+
throw new Error("runtime_contract_invalid: control characters are not allowed.");
|
|
446
|
+
}
|
|
447
|
+
const document = parseDocument(source, { uniqueKeys: true, prettyErrors: false, strict: true });
|
|
448
|
+
if (document.errors.length > 0) {
|
|
449
|
+
throw new Error(`runtime_contract_invalid: ${document.errors[0]?.message ?? "invalid YAML"}`);
|
|
450
|
+
}
|
|
451
|
+
visit(document, {
|
|
452
|
+
Node(_key, node) {
|
|
453
|
+
if (isAlias(node) || node.anchor) {
|
|
454
|
+
throw new Error("runtime_contract_invalid: YAML aliases and anchors are not allowed.");
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
const parsed = document.toJS({ maxAliasCount: 0 });
|
|
459
|
+
assertDepth(parsed, 0);
|
|
460
|
+
const root = object(parsed, "contract");
|
|
461
|
+
rejectUnknownKeys(root, topLevelKeys, "contract");
|
|
462
|
+
if (root.schemaVersion !== 1)
|
|
463
|
+
throw new Error("runtime_contract_invalid: schemaVersion must be 1.");
|
|
464
|
+
const runtime = object(root.runtime, "runtime");
|
|
465
|
+
rejectUnknownKeys(runtime, runtimeKeys, "runtime");
|
|
466
|
+
const hostConcurrency = runtime.hostConcurrency ?? "serial";
|
|
467
|
+
if (hostConcurrency !== "serial" && hostConcurrency !== "parallel" && hostConcurrency !== "disabled") {
|
|
468
|
+
throw new Error("runtime_contract_invalid: runtime.hostConcurrency must be serial, parallel, or disabled.");
|
|
469
|
+
}
|
|
470
|
+
const browserProfile = runtime.browserProfile ?? "isolated";
|
|
471
|
+
if (browserProfile !== "isolated") {
|
|
472
|
+
throw new Error("runtime_contract_invalid: runtime.browserProfile must be isolated.");
|
|
473
|
+
}
|
|
474
|
+
const ports = parsePorts(runtime.ports);
|
|
475
|
+
return {
|
|
476
|
+
schemaVersion: 1,
|
|
477
|
+
runtime: {
|
|
478
|
+
hostConcurrency,
|
|
479
|
+
ports,
|
|
480
|
+
playwright: parsePlaywright(runtime.playwright, ports),
|
|
481
|
+
browserProfile,
|
|
482
|
+
singletonResources: parseSingletonResources(runtime.singletonResources),
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
function parsePlaywright(value, ports) {
|
|
487
|
+
if (value === undefined || value === null)
|
|
488
|
+
return null;
|
|
489
|
+
const playwright = object(value, "runtime.playwright");
|
|
490
|
+
rejectUnknownKeys(playwright, playwrightKeys, "runtime.playwright");
|
|
491
|
+
const config = containedPortablePath(playwright.config, "runtime.playwright.config");
|
|
492
|
+
const baseUrlPort = requiredString(playwright.baseUrlPort, "runtime.playwright.baseUrlPort");
|
|
493
|
+
const port = ports.find(entry => entry.name === baseUrlPort);
|
|
494
|
+
if (!port?.launch || !port.baseUrlEnv || !port.format) {
|
|
495
|
+
throw new Error("runtime_contract_invalid: runtime.playwright.baseUrlPort must reference a launched HTTP port.");
|
|
496
|
+
}
|
|
497
|
+
const workers = playwright.workers === undefined || playwright.workers === null ? 1 : playwright.workers;
|
|
498
|
+
if (!Number.isSafeInteger(workers) || workers < 1 || workers > 16) {
|
|
499
|
+
throw new Error("runtime_contract_invalid: runtime.playwright.workers must be an integer between 1 and 16.");
|
|
500
|
+
}
|
|
501
|
+
return { config, baseUrlPort, workers: workers };
|
|
502
|
+
}
|
|
503
|
+
export function hostPortEnvironment(config, hostIp, hostPort) {
|
|
504
|
+
if (!Number.isSafeInteger(hostPort) || hostPort < 1 || hostPort > 65535) {
|
|
505
|
+
throw new Error("runtime_contract_invalid: assigned host port must be valid.");
|
|
506
|
+
}
|
|
507
|
+
const baseUrl = config.format
|
|
508
|
+
?.replaceAll("{host}", hostIp)
|
|
509
|
+
.replaceAll("{port}", String(hostPort));
|
|
510
|
+
const environment = {
|
|
511
|
+
[config.env]: config.adapter === "aspnetcore" && baseUrl ? baseUrl : String(hostPort),
|
|
512
|
+
};
|
|
513
|
+
if (config.baseUrlEnv && config.format) {
|
|
514
|
+
environment[config.baseUrlEnv] = baseUrl;
|
|
515
|
+
}
|
|
516
|
+
return environment;
|
|
517
|
+
}
|
|
518
|
+
function parsePorts(value) {
|
|
519
|
+
if (value === undefined || value === null)
|
|
520
|
+
return [];
|
|
521
|
+
const ports = object(value, "runtime.ports");
|
|
522
|
+
if (Object.keys(ports).length > 16) {
|
|
523
|
+
throw new Error("runtime_contract_invalid: runtime.ports cannot contain more than 16 entries.");
|
|
524
|
+
}
|
|
525
|
+
const environmentNames = new Set();
|
|
526
|
+
const parsed = Object.entries(ports).sort(([left], [right]) => left.localeCompare(right)).map(([name, raw]) => {
|
|
527
|
+
if (!/^[a-z][a-z0-9_-]{0,63}$/u.test(name)) {
|
|
528
|
+
throw new Error(`runtime_contract_invalid: port name '${name}' is invalid.`);
|
|
529
|
+
}
|
|
530
|
+
const port = object(raw, `runtime.ports.${name}`);
|
|
531
|
+
rejectUnknownKeys(port, portKeys, `runtime.ports.${name}`);
|
|
532
|
+
const adapter = parsePortAdapter(port.adapter, name);
|
|
533
|
+
const launch = parseLaunch(port.launch, adapter, name);
|
|
534
|
+
const env = environmentName(port.env, `runtime.ports.${name}.env`);
|
|
535
|
+
if (adapter === "aspnetcore" && env !== "ASPNETCORE_URLS") {
|
|
536
|
+
throw new Error(`runtime_contract_invalid: runtime.ports.${name}.env must be ASPNETCORE_URLS for the aspnetcore adapter.`);
|
|
537
|
+
}
|
|
538
|
+
const baseUrlEnv = port.baseUrlEnv === undefined || port.baseUrlEnv === null
|
|
539
|
+
? null
|
|
540
|
+
: environmentName(port.baseUrlEnv, `runtime.ports.${name}.baseUrlEnv`);
|
|
541
|
+
if (environmentNames.has(env) || (baseUrlEnv !== null && environmentNames.has(baseUrlEnv)) || env === baseUrlEnv) {
|
|
542
|
+
throw new Error("runtime_contract_invalid: port environment names must be unique.");
|
|
543
|
+
}
|
|
544
|
+
environmentNames.add(env);
|
|
545
|
+
if (baseUrlEnv)
|
|
546
|
+
environmentNames.add(baseUrlEnv);
|
|
547
|
+
const format = parseFormat(port.format, baseUrlEnv, name);
|
|
548
|
+
if (launch && (!baseUrlEnv || !format)) {
|
|
549
|
+
throw new Error(`runtime_contract_invalid: runtime.ports.${name}.launch requires an HTTP baseUrlEnv and format.`);
|
|
550
|
+
}
|
|
551
|
+
return { name, adapter, launch, env, baseUrlEnv, format };
|
|
552
|
+
});
|
|
553
|
+
return parsed;
|
|
554
|
+
}
|
|
555
|
+
function parsePortAdapter(value, name) {
|
|
556
|
+
if (value === undefined || value === null)
|
|
557
|
+
return null;
|
|
558
|
+
if (value !== "node" && value !== "vite" && value !== "next" && value !== "aspnetcore") {
|
|
559
|
+
throw new Error(`runtime_contract_invalid: runtime.ports.${name}.adapter must be node, vite, next, or aspnetcore.`);
|
|
560
|
+
}
|
|
561
|
+
return value;
|
|
562
|
+
}
|
|
563
|
+
function parseLaunch(value, adapter, name) {
|
|
564
|
+
if (value === undefined || value === null)
|
|
565
|
+
return null;
|
|
566
|
+
if (!adapter) {
|
|
567
|
+
throw new Error(`runtime_contract_invalid: runtime.ports.${name}.launch requires an explicit adapter.`);
|
|
568
|
+
}
|
|
569
|
+
const launch = object(value, `runtime.ports.${name}.launch`);
|
|
570
|
+
rejectUnknownKeys(launch, launchKeys, `runtime.ports.${name}.launch`);
|
|
571
|
+
const command = boundedString(launch.command, 1024, `runtime.ports.${name}.launch.command`);
|
|
572
|
+
const args = launch.args === undefined || launch.args === null ? [] : launch.args;
|
|
573
|
+
if (!Array.isArray(args) || args.length > 128) {
|
|
574
|
+
throw new Error(`runtime_contract_invalid: runtime.ports.${name}.launch.args must contain at most 128 strings.`);
|
|
575
|
+
}
|
|
576
|
+
const parsedArgs = args.map((argument, index) => boundedString(argument, 4096, `runtime.ports.${name}.launch.args[${index}]`));
|
|
577
|
+
const portableDirectory = launch.workingDirectory === undefined || launch.workingDirectory === null
|
|
578
|
+
? "."
|
|
579
|
+
: containedPortablePath(launch.workingDirectory, `runtime.ports.${name}.launch.workingDirectory`);
|
|
580
|
+
const environment = parseLaunchEnvironment(launch.environment, name);
|
|
581
|
+
return {
|
|
582
|
+
command,
|
|
583
|
+
args: parsedArgs,
|
|
584
|
+
workingDirectory: portableDirectory,
|
|
585
|
+
...(environment ? { environment } : {}),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
function parseLaunchEnvironment(value, portName) {
|
|
589
|
+
if (value === undefined || value === null)
|
|
590
|
+
return undefined;
|
|
591
|
+
const environment = object(value, `runtime.ports.${portName}.launch.environment`);
|
|
592
|
+
if (Object.keys(environment).length > 64) {
|
|
593
|
+
throw new Error(`runtime_contract_invalid: runtime.ports.${portName}.launch.environment cannot contain more than 64 entries.`);
|
|
594
|
+
}
|
|
595
|
+
return Object.fromEntries(Object.entries(environment).map(([name, raw]) => {
|
|
596
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/u.test(name)) {
|
|
597
|
+
throw new Error(`runtime_contract_invalid: launch environment name '${name}' is invalid.`);
|
|
598
|
+
}
|
|
599
|
+
return [name, boundedString(raw, 8192, `runtime.ports.${portName}.launch.environment.${name}`)];
|
|
600
|
+
}));
|
|
601
|
+
}
|
|
602
|
+
function containedPortablePath(value, label) {
|
|
603
|
+
const original = boundedString(value, 1024, label);
|
|
604
|
+
const portable = original.replaceAll("\\", "/");
|
|
605
|
+
if (path.posix.isAbsolute(portable)
|
|
606
|
+
|| path.win32.isAbsolute(original)
|
|
607
|
+
|| portable.split("/").some(segment => segment === "..")) {
|
|
608
|
+
throw new Error(`runtime_contract_invalid: ${label} must stay inside the worktree.`);
|
|
609
|
+
}
|
|
610
|
+
return portable;
|
|
611
|
+
}
|
|
612
|
+
function parseFormat(value, baseUrlEnv, name) {
|
|
613
|
+
if (baseUrlEnv === null) {
|
|
614
|
+
if (value !== undefined && value !== null) {
|
|
615
|
+
throw new Error(`runtime_contract_invalid: runtime.ports.${name}.format requires baseUrlEnv.`);
|
|
616
|
+
}
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
const format = value === undefined || value === null ? "http://{host}:{port}" : requiredString(value, `runtime.ports.${name}.format`);
|
|
620
|
+
if (format.length > 512 || !format.includes("{port}")) {
|
|
621
|
+
throw new Error(`runtime_contract_invalid: runtime.ports.${name}.format must include {port}.`);
|
|
622
|
+
}
|
|
623
|
+
const rendered = format.replaceAll("{host}", "127.0.0.1").replaceAll("{port}", "12345");
|
|
624
|
+
let url;
|
|
625
|
+
try {
|
|
626
|
+
url = new URL(rendered);
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
throw new Error(`runtime_contract_invalid: runtime.ports.${name}.format must produce a URL.`);
|
|
630
|
+
}
|
|
631
|
+
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.hostname !== "127.0.0.1" || url.port !== "12345") {
|
|
632
|
+
throw new Error(`runtime_contract_invalid: runtime.ports.${name}.format must produce an HTTP loopback URL using the assigned port.`);
|
|
633
|
+
}
|
|
634
|
+
return format;
|
|
635
|
+
}
|
|
636
|
+
function parseSingletonResources(value) {
|
|
637
|
+
if (value === undefined || value === null)
|
|
638
|
+
return [];
|
|
639
|
+
if (!Array.isArray(value) || value.length > 32 || value.some(entry => typeof entry !== "string")) {
|
|
640
|
+
throw new Error("runtime_contract_invalid: runtime.singletonResources must be a string array with at most 32 entries.");
|
|
641
|
+
}
|
|
642
|
+
const resources = value.map((entry, index) => {
|
|
643
|
+
const resource = requiredString(entry, `runtime.singletonResources[${index}]`).toLowerCase();
|
|
644
|
+
if (!/^[a-z][a-z0-9_.:-]{0,127}$/u.test(resource)) {
|
|
645
|
+
throw new Error(`runtime_contract_invalid: singleton resource '${resource}' is invalid.`);
|
|
646
|
+
}
|
|
647
|
+
return resource;
|
|
648
|
+
});
|
|
649
|
+
if (new Set(resources).size !== resources.length) {
|
|
650
|
+
throw new Error("runtime_contract_invalid: runtime.singletonResources cannot contain duplicates.");
|
|
651
|
+
}
|
|
652
|
+
return resources.sort((left, right) => left.localeCompare(right));
|
|
653
|
+
}
|
|
654
|
+
function environmentName(value, label) {
|
|
655
|
+
const name = requiredString(value, label);
|
|
656
|
+
if (!/^[A-Z_][A-Z0-9_]{0,127}$/u.test(name) || name.startsWith("FORGE_") || name === "PATH" || name === "HOME") {
|
|
657
|
+
throw new Error(`runtime_contract_invalid: ${label} is reserved or invalid.`);
|
|
658
|
+
}
|
|
659
|
+
return name;
|
|
660
|
+
}
|
|
661
|
+
function object(value, label) {
|
|
662
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
663
|
+
throw new Error(`runtime_contract_invalid: ${label} must be an object.`);
|
|
664
|
+
}
|
|
665
|
+
return value;
|
|
666
|
+
}
|
|
667
|
+
function rejectUnknownKeys(value, allowed, label) {
|
|
668
|
+
const unknown = Object.keys(value).find(key => !allowed.has(key));
|
|
669
|
+
if (unknown)
|
|
670
|
+
throw new Error(`runtime_contract_invalid: unknown ${label} key '${unknown}'.`);
|
|
671
|
+
}
|
|
672
|
+
function requiredString(value, label) {
|
|
673
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
674
|
+
throw new Error(`runtime_contract_invalid: ${label} must be a non-empty string.`);
|
|
675
|
+
}
|
|
676
|
+
return value.trim();
|
|
677
|
+
}
|
|
678
|
+
function boundedString(value, maximumLength, label) {
|
|
679
|
+
const text = requiredString(value, label);
|
|
680
|
+
if (text.length > maximumLength || Array.from(text).some(character => {
|
|
681
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
682
|
+
return codePoint < 32 || codePoint === 127;
|
|
683
|
+
})) {
|
|
684
|
+
throw new Error(`runtime_contract_invalid: ${label} is too long or contains control characters.`);
|
|
685
|
+
}
|
|
686
|
+
return text;
|
|
687
|
+
}
|
|
688
|
+
function assertDepth(value, depth) {
|
|
689
|
+
if (depth > maximumDepth)
|
|
690
|
+
throw new Error(`runtime_contract_invalid: YAML nesting exceeds ${maximumDepth}.`);
|
|
691
|
+
if (Array.isArray(value)) {
|
|
692
|
+
value.forEach(entry => assertDepth(entry, depth + 1));
|
|
693
|
+
}
|
|
694
|
+
else if (value && typeof value === "object") {
|
|
695
|
+
Object.values(value).forEach(entry => assertDepth(entry, depth + 1));
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
function isFileNotFound(error) {
|
|
699
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
700
|
+
}
|