|
1 | | -import { createHash } from "node:crypto" |
| 1 | +import { createHash, randomBytes } from "node:crypto" |
2 | 2 | import { copyFile, mkdir, readdir, readFile, realpath, stat, writeFile } from "node:fs/promises" |
3 | | -import { createServer as createHttpServer, request as httpRequest, type IncomingHttpHeaders, type ServerResponse } from "node:http" |
| 3 | +import { createServer as createHttpServer, request as httpRequest, type IncomingHttpHeaders, type IncomingMessage, type ServerResponse } from "node:http" |
4 | 4 | import { createServer as createNetServer } from "node:net" |
5 | 5 | import { basename, dirname, join, relative, resolve } from "node:path" |
6 | 6 | import { RUNTIME_EPISODE_OBSERVATION_SCHEMA, assertRuntimeCommandAllowed, getCommandDefinition, runtimeEpisodeDigest, type PlaygroundRuntimeCommandId } from "@chubes4/wp-codebox-core" |
@@ -56,6 +56,12 @@ interface PlaygroundRunResponse { |
56 | 56 | text: string |
57 | 57 | } |
58 | 58 |
|
| 59 | +interface RuntimeWpCliBridge { |
| 60 | + url: string |
| 61 | + token: string |
| 62 | + close: () => Promise<void> |
| 63 | +} |
| 64 | + |
59 | 65 | class PlaygroundCommandError extends Error { |
60 | 66 | readonly code = "wp-codebox-playground-command-failed" |
61 | 67 |
|
@@ -850,8 +856,16 @@ class PlaygroundRuntime implements Runtime { |
850 | 856 | private async runPhp(spec: ExecutionSpec): Promise<string> { |
851 | 857 | const server = await this.bootPlayground() |
852 | 858 | const code = await this.phpCodeFromArgs(spec.args ?? []) |
853 | | - const response = await this.runPlaygroundCommand("wordpress.run-php", server, { code: this.bootstrapPhpCode(code, spec.args ?? []) }) |
854 | | - assertPlaygroundResponseOk("wordpress.run-php", response) |
| 859 | + const bridge = argValue(spec.args ?? [], "wp-cli-bridge") === "1" ? await this.createRuntimeWpCliBridge(server) : undefined |
| 860 | + let response: PlaygroundRunResponse |
| 861 | + try { |
| 862 | + response = await this.runPlaygroundCommand("wordpress.run-php", server, { code: this.bootstrapPhpCode(code, spec.args ?? [], bridge) }) |
| 863 | + assertPlaygroundResponseOk("wordpress.run-php", response) |
| 864 | + } finally { |
| 865 | + if (bridge) { |
| 866 | + await bridge.close() |
| 867 | + } |
| 868 | + } |
855 | 869 |
|
856 | 870 | return response.text |
857 | 871 | } |
@@ -969,6 +983,59 @@ class PlaygroundRuntime implements Runtime { |
969 | 983 | return this.runPlaygroundCommand("wordpress.wp-cli", server, { scriptPath }) |
970 | 984 | } |
971 | 985 |
|
| 986 | + private async createRuntimeWpCliBridge(server: PlaygroundCliServer): Promise<RuntimeWpCliBridge> { |
| 987 | + const token = randomBytes(24).toString("base64url") |
| 988 | + const bridge = createHttpServer(async (request, response) => { |
| 989 | + try { |
| 990 | + if (request.method !== "POST" || request.url !== "/execute") { |
| 991 | + writeBridgeJson(response, 404, { success: false, error: "not_found" }) |
| 992 | + return |
| 993 | + } |
| 994 | + |
| 995 | + if (request.headers.authorization !== `Bearer ${token}`) { |
| 996 | + writeBridgeJson(response, 403, { success: false, error: "forbidden" }) |
| 997 | + return |
| 998 | + } |
| 999 | + |
| 1000 | + const action = await readBridgeJson(request) |
| 1001 | + const type = typeof action.type === "string" ? action.type.trim() : "" |
| 1002 | + const command = typeof action.command === "string" ? action.command.trim() : "" |
| 1003 | + if (type !== "wp_cli" || command === "") { |
| 1004 | + writeBridgeJson(response, 400, { success: false, error: "wp_cli command is required" }) |
| 1005 | + return |
| 1006 | + } |
| 1007 | + |
| 1008 | + const argv = shellArgv(command) |
| 1009 | + if (argv[0] === "wp") { |
| 1010 | + argv.shift() |
| 1011 | + } |
| 1012 | + const started = Date.now() |
| 1013 | + const result = await this.runWpCliCommand(server, argv) |
| 1014 | + const exitCode = result.exitCode ?? 0 |
| 1015 | + writeBridgeJson(response, 200, { |
| 1016 | + type, |
| 1017 | + command: command.startsWith("wp ") ? command : `wp ${command}`, |
| 1018 | + exitCode, |
| 1019 | + stdout: cleanWpCliOutput(result.text), |
| 1020 | + stderr: result.errors ?? "", |
| 1021 | + success: exitCode === 0, |
| 1022 | + timedOut: false, |
| 1023 | + durationMs: Date.now() - started, |
| 1024 | + error: exitCode === 0 ? "" : (result.errors?.trim() || cleanWpCliOutput(result.text).trim() || "WP-CLI command failed"), |
| 1025 | + }) |
| 1026 | + } catch (error) { |
| 1027 | + writeBridgeJson(response, 500, { success: false, error: errorMessage(error) }) |
| 1028 | + } |
| 1029 | + }) |
| 1030 | + |
| 1031 | + const url = await listenLocalHttpServer(bridge) |
| 1032 | + return { |
| 1033 | + url, |
| 1034 | + token, |
| 1035 | + close: () => closeHttpServer(bridge), |
| 1036 | + } |
| 1037 | + } |
| 1038 | + |
972 | 1039 | private async runAbility(spec: ExecutionSpec): Promise<string> { |
973 | 1040 | const server = await this.bootPlayground() |
974 | 1041 | const name = argValue(spec.args ?? [], "name")?.trim() |
@@ -1148,14 +1215,17 @@ ${this.secretEnvPhp()} |
1148 | 1215 | ${phpBody(code)}` |
1149 | 1216 | } |
1150 | 1217 |
|
1151 | | - private bootstrapPhpCode(code: string, args: string[]): string { |
| 1218 | + private bootstrapPhpCode(code: string, args: string[], wpCliBridge?: RuntimeWpCliBridge): string { |
1152 | 1219 | if (argValue(args, "bootstrap") === "none") { |
1153 | 1220 | return code |
1154 | 1221 | } |
1155 | 1222 |
|
1156 | 1223 | return `<?php |
1157 | 1224 | require_once '/wordpress/wp-load.php'; |
1158 | 1225 | ${this.secretEnvPhp()} |
| 1226 | +${wpCliBridge ? `putenv(${JSON.stringify(`HOMEBOY_TERMINAL_ACTION_URL=${wpCliBridge.url}`)}); |
| 1227 | +putenv(${JSON.stringify(`HOMEBOY_TERMINAL_ACTION_TOKEN=${wpCliBridge.token}`)}); |
| 1228 | +` : ""} |
1159 | 1229 | ${phpBody(code)}` |
1160 | 1230 | } |
1161 | 1231 |
|
@@ -1784,3 +1854,51 @@ async function assertPreviewPortAvailable(port: number): Promise<void> { |
1784 | 1854 | } |
1785 | 1855 | } |
1786 | 1856 | } |
| 1857 | + |
| 1858 | +function readBridgeJson(request: IncomingMessage): Promise<Record<string, unknown>> { |
| 1859 | + return new Promise((resolve, reject) => { |
| 1860 | + let body = "" |
| 1861 | + request.on("data", (chunk) => { |
| 1862 | + body += chunk.toString() |
| 1863 | + if (body.length > 1024 * 1024) { |
| 1864 | + reject(new Error("Request body too large")) |
| 1865 | + request.destroy() |
| 1866 | + } |
| 1867 | + }) |
| 1868 | + request.on("end", () => { |
| 1869 | + try { |
| 1870 | + const parsed = body ? JSON.parse(body) : {} |
| 1871 | + resolve(parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {}) |
| 1872 | + } catch (error) { |
| 1873 | + reject(error) |
| 1874 | + } |
| 1875 | + }) |
| 1876 | + request.on("error", reject) |
| 1877 | + }) |
| 1878 | +} |
| 1879 | + |
| 1880 | +function writeBridgeJson(response: ServerResponse, statusCode: number, payload: unknown): void { |
| 1881 | + response.writeHead(statusCode, { "content-type": "application/json" }) |
| 1882 | + response.end(`${JSON.stringify(payload)}\n`) |
| 1883 | +} |
| 1884 | + |
| 1885 | +function listenLocalHttpServer(server: ReturnType<typeof createHttpServer>): Promise<string> { |
| 1886 | + return new Promise((resolveListen, rejectListen) => { |
| 1887 | + server.once("error", rejectListen) |
| 1888 | + server.listen(0, "127.0.0.1", () => { |
| 1889 | + server.off("error", rejectListen) |
| 1890 | + const address = server.address() |
| 1891 | + if (!address || typeof address === "string") { |
| 1892 | + rejectListen(new Error("Runtime WP-CLI bridge did not expose a TCP address")) |
| 1893 | + return |
| 1894 | + } |
| 1895 | + resolveListen(`http://${address.address}:${address.port}`) |
| 1896 | + }) |
| 1897 | + }) |
| 1898 | +} |
| 1899 | + |
| 1900 | +function closeHttpServer(server: ReturnType<typeof createHttpServer>): Promise<void> { |
| 1901 | + return new Promise((resolveClose, rejectClose) => { |
| 1902 | + server.close((error) => error ? rejectClose(error) : resolveClose()) |
| 1903 | + }) |
| 1904 | +} |
0 commit comments