Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,24 @@ npm run wp-codebox -- schema recipe --json

The schema covers `runtime`, `inputs.mounts`, `inputs.workspaces`, `inputs.extra_plugins`, `inputs.secretEnv`, `workflow.steps`, and `artifacts`.

### `wordpress-source`

Materialize WordPress core source files on the host without booting a site. Tools that load core directly (for example PHP smoke tests that require `wp-includes/html-api/*`) get the same WordPress WP Codebox boots in Playground.

```bash
npm run wp-codebox -- wordpress-source --wp 7.1.2 --json
```

`--wp` accepts an exact version such as `7.1.2` or `latest` (the default). The query resolves through the same release resolver and validated archive cache Playground startup uses (`${version}.zip` in `WP_CODEBOX_PLAYGROUND_WORDPRESS_CACHE_DIR`, default `~/.wordpress-playground`). The archive is extracted once per version to `<archive cache>/source/<version>/wordpress`: extraction is staged in a sibling directory, verified against `wp-includes/version.php` (`$wp_version` must equal the resolved version), and renamed into place, so concurrent callers never observe a partial tree. Later calls reuse the verified tree.

Without `--json`, stdout is the source root path only. With `--json`, stdout is exactly one object:

```json
{"schema":"wp-codebox/wordpress-source/v1","version":"7.1.2","root":"/home/me/.wordpress-playground/source/7.1.2/wordpress","archive":"/home/me/.wordpress-playground/7.1.2.zip","sourceUrl":"https://wordpress.org/wordpress-7.1.2.zip"}
```

Diagnostics go to stderr. Failures (offline, unknown version, invalid archive) exit non-zero with a message on stderr and no stdout.

### `run`

Run one command in a disposable runtime.
Expand Down
1 change: 1 addition & 0 deletions npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/cli/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { runMcpRenderClientConfigsCommand } from "./commands/mcp.js"
import { runPreviewLeaseReleaseCommand, runPreviewLeaseStatusCommand } from "./commands/preview-lease.js"
import { runBootCommand, runRunCommand, runValidateBlueprintCommand } from "./commands/runtime.js"
import { runFuzzDescriptorCommand, runFuzzMinimizeCaseCommand, runFuzzReadinessCommand, runFuzzSuiteCommand, runWordPressWorkloadCommand } from "./commands/wordpress-runtime.js"
import { runWordPressSourceCommand } from "./commands/wordpress-source.js"
import { runRunsArtifactsCommand, runRunsCancelCommand, runRunsStatusCommand } from "./commands/runs.js"
import { runTargetProvisionCommand } from "./commands/target.js"
import { runWorkspacePolicyCheckCommand } from "./commands/workspace-policy.js"
Expand All @@ -28,6 +29,7 @@ export async function runCli(args: string[]): Promise<number> {
runFuzzSuite: runFuzzSuiteCommand,
fuzzMinimizeCase: runFuzzMinimizeCaseCommand,
runWordPressWorkload: runWordPressWorkloadCommand,
wordpressSource: runWordPressSourceCommand,
fuzzDescriptor: runFuzzDescriptorCommand,
fuzzReadiness: runFuzzReadinessCommand,
adversarialRun: runAdversarialRecipeCommand,
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/command-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const cliCommandRoutes = {
"run-fuzz-suite": "runFuzzSuite",
"fuzz-minimize-case": "fuzzMinimizeCase",
"run-wordpress-workload": "runWordPressWorkload",
"wordpress-source": "wordpressSource",
fuzz: {
descriptor: "fuzzDescriptor",
readiness: "fuzzReadiness",
Expand Down
64 changes: 64 additions & 0 deletions packages/cli/src/commands/wordpress-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { materializePlaygroundWordPressSource, type PlaygroundWordPressSource, type PlaygroundWordPressSourceOptions } from "@automattic/wp-codebox-playground"

interface WordPressSourceCommandOptions {
wp?: string
json: boolean
}

type WordPressSourceMaterializer = (versionQuery: string | undefined, options: PlaygroundWordPressSourceOptions) => Promise<PlaygroundWordPressSource>

/**
* `wp-codebox wordpress-source [--wp <version-query>] [--json]`
*
* Prints the host path of WordPress core source extracted from the validated
* Playground archive cache. stdout carries only the result (root path, or one
* JSON object with --json); diagnostics and failures go to stderr.
*/
export async function runWordPressSourceCommand(args: string[], materialize: WordPressSourceMaterializer = materializePlaygroundWordPressSource): Promise<number> {
let options: WordPressSourceCommandOptions
try {
options = parseWordPressSourceOptions(args)
} catch (error) {
process.stderr.write(`wp-codebox wordpress-source: ${errorMessage(error)}\n`)
return 2
}

let source: PlaygroundWordPressSource
try {
source = await materialize(options.wp, { log: (message) => process.stderr.write(`[wp-codebox] ${message}\n`) })
} catch (error) {
process.stderr.write(`wp-codebox wordpress-source: failed to materialize WordPress ${options.wp ?? "latest"} source: ${errorMessage(error)}\n`)
return 1
}

const output = options.json
? JSON.stringify({ schema: source.schema, version: source.version, root: source.root, archive: source.archive, sourceUrl: source.sourceUrl })
: source.root
process.stdout.write(`${output}\n`)
return 0
}

function parseWordPressSourceOptions(args: string[]): WordPressSourceCommandOptions {
const options: WordPressSourceCommandOptions = { json: false }
for (let index = 0; index < args.length; index++) {
const arg = args[index]
if (arg === "--json") {
options.json = true
continue
}
if (arg === "--wp" || arg.startsWith("--wp=")) {
const value = arg === "--wp" ? args[++index] : arg.slice("--wp=".length)
if (!value || value.startsWith("--")) {
throw new Error("--wp requires a version query such as 7.1.2 or latest")
}
options.wp = value === "latest" ? undefined : value
continue
}
throw new Error(`Unknown option: ${arg}`)
}
return options
}

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
8 changes: 8 additions & 0 deletions packages/cli/src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,7 @@ export function printHelp(): void {
wp-codebox adversarial replay --recipe <path> --replay <path> [recipe-run options]
wp-codebox fuzz-minimize-case --input-file <path> [--format=json] [--dry-run]
wp-codebox run-wordpress-workload --input-file <path> [--format=json] [--dry-run]
wp-codebox wordpress-source [--wp <version-query>] [--json]
wp-codebox run-agent-task --input-file <path> [--json] [--preview-hold-seconds <n>] [--preview-hold-blocking] [--preview-port <port>] [--preview-bind <host>] [--preview-public-url <url>] [--preview-lease-json <json>]
wp-codebox agent-task-run --input-file <path> [--json] [--result-file <path>] [--preview-hold-seconds <n>] [--preview-hold-blocking] [--preview-port <port>] [--preview-bind <host>] [--preview-public-url <url>] [--preview-lease-json <json>]
wp-codebox validate-blueprint --blueprint <json|file> [options]
Expand Down Expand Up @@ -675,6 +676,13 @@ Doctor and cleanup:
--run-registry <dir> Run registry checked for active preview leases and recent run heartbeats. Repeatable.
Defaults to ./artifacts/runs; additional defaults may be set with WP_CODEBOX_RUN_REGISTRY_ROOTS.

WordPress source:
wordpress-source Resolve --wp (exact version or latest; default latest) through the Playground
archive cache and print the host directory containing WordPress core source
(wp-includes/version.php). Extracted once per version to
<archive cache>/source/<version>/wordpress. --json prints one
wp-codebox/wordpress-source/v1 object; diagnostics go to stderr.

Workspace policy:
--workspace-root <dir>
Workspace root to inspect. Defaults to the current directory.
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"dependencies": {
"@automattic/wp-codebox-core": "file:../runtime-core",
"@php-wasm/node": "3.1.46",
"@php-wasm/stream-compression": "3.1.46",
"@php-wasm/universal": "3.1.46",
"@wp-playground/blueprints": "3.1.46",
"@wp-playground/cli": "3.1.46",
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-playground/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export { browserStorageStateCookieHostSummary, browserStorageStateFromWordPressA
export { createHostCommandTool, type HostCommandToolConfig } from "./host-command-tool.js"
export { PlaygroundRuntimeBackend, createPlaygroundRuntimeBackend, playgroundRuntimeBackendProvider } from "./playground-runtime.js"
export { maintainPlaygroundCustomArchiveCache, playgroundWordPressArchiveCacheDirectory, type PlaygroundCustomArchiveCacheMaintenance, type PlaygroundCustomArchiveCacheMaintenanceOptions, type PlaygroundCustomArchiveCachePolicy } from "./playground-wordpress-archive-cache.js"
export { WORDPRESS_SOURCE_SCHEMA, materializePlaygroundWordPressSource, playgroundWordPressSourceDirectory, type PlaygroundWordPressSource, type PlaygroundWordPressSourceOptions } from "./playground-wordpress-source.js"
export { collectBrowserArtifactMetrics, collectWordPressEpisodeArtifacts, collectWordPressRuntimeArtifacts, createWordPressEpisode, createWordPressRuntime, runWordPressEpisodeActions, type WordPressEpisodeSpec, type WordPressRuntimeActionHooks, type WordPressRuntimeSpec } from "./public.js"
export { preflightPhpWasmRuntimeAssets, assertPhpWasmExtensionAbi, phpWasmExtensionMissingAbiSymbols, PhpWasmRuntimeAssetIntegrityError, PhpWasmExtensionAbiError, type PhpWasmRuntimeAssetPreflight, type PhpWasmRuntimeAssetPreflightOptions } from "./php-wasm-preflight.js"
export { assertPlaywrightBrowserReady, playwrightBrowserProvenance, playwrightBrowserReadiness, type PlaywrightBrowserProvenance, type PlaywrightBrowserReadiness } from "./playwright-browser-provenance.js"
Expand Down
187 changes: 187 additions & 0 deletions packages/runtime-playground/src/playground-wordpress-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { randomUUID } from "node:crypto"
import { existsSync, openAsBlob } from "node:fs"
import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"
import { dirname, isAbsolute, join, resolve, sep } from "node:path"
import { decodeZip } from "@php-wasm/stream-compression"
import { resolvePlaygroundWordPressStartupAsset } from "./playground-cli-runner.js"
import { playgroundWordPressArchiveCacheDirectory } from "./playground-wordpress-archive-cache.js"

export const WORDPRESS_SOURCE_SCHEMA = "wp-codebox/wordpress-source/v1"

/**
* WordPress core source materialized on the host from the same validated
* archive cache WP Codebox uses to boot Playground.
*/
export interface PlaygroundWordPressSource {
schema: typeof WORDPRESS_SOURCE_SCHEMA
version: string
/** Absolute directory containing `wp-includes/version.php`. */
root: string
/** Absolute path of the validated cached `${version}.zip` archive. */
archive: string
sourceUrl: string
}

export interface PlaygroundWordPressSourceOptions {
cacheDirectory?: string
/** Receives human-readable progress diagnostics. */
log?: (message: string) => void
}

/**
* Resolves a WordPress version query, ensures the validated cached archive,
* and extracts it once to `<cache>/source/<version>/wordpress`.
*
* Extraction is staged in a sibling directory, verified against
* `wp-includes/version.php`, and renamed into place, so concurrent callers
* only ever observe a complete, verified tree.
*/
export async function materializePlaygroundWordPressSource(versionQuery: string | undefined, options: PlaygroundWordPressSourceOptions = {}): Promise<PlaygroundWordPressSource> {
const cacheDirectory = resolve(options.cacheDirectory ?? playgroundWordPressArchiveCacheDirectory())
const log = options.log ?? (() => undefined)
assertReleaseVersionQuery(versionQuery)

const asset = await resolvePlaygroundWordPressStartupAsset(versionQuery, undefined, cacheDirectory)
await asset.archiveReference?.release()
const version = asset.cacheValidation.version
const archive = asset.localPath
if (!archive) {
throw new Error(`WordPress ${version} archive was not cached locally from ${asset.cacheValidation.sourceUrl}`)
}

const source = { schema: WORDPRESS_SOURCE_SCHEMA, version, archive: resolve(archive), sourceUrl: asset.cacheValidation.sourceUrl } as const
const versionDirectory = playgroundWordPressSourceDirectory(cacheDirectory, version)
const root = join(versionDirectory, "wordpress")

if (await verifiedWordPressRootError(root, version) === undefined) {
log(`Reusing WordPress ${version} source at ${root}`)
return { ...source, root }
}

log(`Extracting WordPress ${version} source from ${source.archive}`)
const stagingDirectory = join(dirname(versionDirectory), `.${version}.${process.pid}.${randomUUID()}.partial`)
try {
await mkdir(stagingDirectory, { recursive: true })
await stageWordPressSource(source.archive, stagingDirectory, version)
await publishStagedSource(stagingDirectory, versionDirectory, version)
} finally {
await rm(stagingDirectory, { recursive: true, force: true })
}

const error = await verifiedWordPressRootError(root, version)
if (error) {
throw new Error(`WordPress ${version} source at ${root} failed verification: ${error}`)
}
log(`Materialized WordPress ${version} source at ${root}`)
return { ...source, root }
}

export function playgroundWordPressSourceDirectory(cacheDirectory: string, version: string): string {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(version)) {
throw new Error(`Refusing to materialize WordPress source for unsafe version identifier: ${version}`)
}
return join(resolve(cacheDirectory), "source", version)
}

/** Returns undefined when `root/wp-includes/version.php` declares `version`, otherwise the reason it does not. */
export async function verifiedWordPressRootError(root: string, version: string): Promise<string | undefined> {
let contents: string
try {
contents = await readFile(join(root, "wp-includes", "version.php"), "utf8")
} catch {
return "wp-includes/version.php is missing"
}
const declared = /^\$wp_version\s*=\s*['"]([^'"]+)['"]\s*;/m.exec(contents)?.[1]
if (!declared) {
return "wp-includes/version.php does not declare $wp_version"
}
return declared === version ? undefined : `wp-includes/version.php declares $wp_version '${declared}', expected '${version}'`
}

function assertReleaseVersionQuery(versionQuery: string | undefined): void {
if (versionQuery && (/^(trunk|nightly)$/i.test(versionQuery) || /^https?:\/\//i.test(versionQuery))) {
throw new Error(`wordpress-source requires a WordPress release version or "latest"; received "${versionQuery}"`)
}
}

async function stageWordPressSource(archive: string, stagingDirectory: string, version: string): Promise<void> {
const extracted = join(stagingDirectory, "extracted")
await extractZip(archive, extracted)
const wordpressRoot = await locateWordPressRoot(extracted)
if (!wordpressRoot) {
throw new Error(`WordPress archive ${archive} does not contain wp-includes/version.php`)
}
const staged = join(stagingDirectory, "wordpress")
await rename(wordpressRoot, staged)
await rm(extracted, { recursive: true, force: true })
const error = await verifiedWordPressRootError(staged, version)
if (error) {
throw new Error(`WordPress archive ${archive} failed verification: ${error}`)
}
}

async function publishStagedSource(stagingDirectory: string, versionDirectory: string, version: string): Promise<void> {
await mkdir(dirname(versionDirectory), { recursive: true })
try {
await rename(stagingDirectory, versionDirectory)
return
} catch (error) {
if (!isDestinationOccupied(error)) {
throw error
}
}

// Another caller published first; reuse its tree when it verifies.
if (await verifiedWordPressRootError(join(versionDirectory, "wordpress"), version) === undefined) {
return
}

// Replace an unverifiable tree by moving it aside first, so the final path
// only ever transitions between complete trees.
const discarded = `${stagingDirectory}.discarded`
await rename(versionDirectory, discarded)
try {
await rename(stagingDirectory, versionDirectory)
} finally {
await rm(discarded, { recursive: true, force: true })
}
}

async function extractZip(archive: string, destination: string): Promise<void> {
await mkdir(destination, { recursive: true })
const blob = await openAsBlob(archive)
for await (const entry of decodeZip(blob.stream() as ReadableStream<Uint8Array>)) {
const target = safeEntryPath(destination, entry.name)
if (entry.name.endsWith("/")) {
await mkdir(target, { recursive: true })
continue
}
await mkdir(dirname(target), { recursive: true })
await writeFile(target, new Uint8Array(await entry.arrayBuffer()), { flag: "wx" })
}
}

function safeEntryPath(destination: string, name: string): string {
const target = resolve(destination, name)
if (!name || isAbsolute(name) || name.includes("\\") || name.split("/").includes("..") || (target !== destination && !target.startsWith(`${destination}${sep}`))) {
throw new Error(`WordPress archive contains an unsafe entry path: ${name}`)
}
return target
}

async function locateWordPressRoot(extracted: string): Promise<string | undefined> {
if (existsSync(join(extracted, "wp-includes", "version.php"))) {
return extracted
}
const children = await readdir(extracted)
if (children.length !== 1) {
return undefined
}
const child = join(extracted, children[0])
const childStat = await lstat(child)
return childStat.isDirectory() && existsSync(join(child, "wp-includes", "version.php")) ? child : undefined
}

function isDestinationOccupied(error: unknown): boolean {
return Boolean(error && typeof error === "object" && "code" in error && (error.code === "ENOTEMPTY" || error.code === "EEXIST"))
}
Loading
Loading