Skip to content

Commit 9add873

Browse files
authored
fix: require explicit plugin composer preparation (#2052)
1 parent f5b0d2d commit 9add873

7 files changed

Lines changed: 99 additions & 9 deletions

File tree

docs/recipe-contract.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,10 +201,13 @@ summary from observed hosts to declared boundary ids where host names match
201201

202202
`inputs.extra_plugins` mounts additional WordPress plugins before workflow steps
203203
run. Each entry requires `source` or `sourcePath` and may include `sourceSubdir`,
204-
`mountSlug`, `pluginFile`, `activate`, `loadAs`, and `sha256`. `sourcePath` is
205-
the source root, `sourceSubdir` is an optional plugin directory below that root,
206-
`mountSlug` is the WordPress plugin directory, and `pluginFile` is relative to
207-
the mounted plugin slug.
204+
`mountSlug`, `pluginFile`, `activate`, `loadAs`, `composer`, and `sha256`.
205+
`sourcePath` is the source root, `sourceSubdir` is an optional plugin directory
206+
below that root, `mountSlug` is the WordPress plugin directory, and `pluginFile`
207+
is relative to the mounted plugin slug. Local plugins are mounted as shipped;
208+
the presence of `composer.json` does not install dependencies or mutate the
209+
source. Set `composer` to `install` only when a source-form plugin requires a
210+
missing `vendor/autoload.php`. WP Codebox then runs Composer in a temporary copy.
208211

209212
```json
210213
{
@@ -226,7 +229,8 @@ the mounted plugin slug.
226229
"sourcePath": "../monorepo",
227230
"sourceSubdir": "plugins/example-plugin",
228231
"mountSlug": "example-plugin",
229-
"pluginFile": "example-plugin/example-plugin.php"
232+
"pluginFile": "example-plugin/example-plugin.php",
233+
"composer": "install"
230234
}
231235
]
232236
}

packages/cli/src/recipe-sources.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ export async function prepareRecipeExtraPlugins(recipe: WorkspaceRecipe, recipeD
279279
const pluginResolved = sourceSubpath ? { ...resolved, source: join(resolved.source, sourceSubpath) } : resolved
280280
const pluginFile = await resolveRecipeExtraPluginFile(plugin, recipeDirectory)
281281
const loadAs = plugin.loadAs ?? "plugin"
282-
const prepared = await prepareComposerAutoloadForPlugin(pluginResolved, slug, sourceRef, resolved.source)
282+
const prepared = await prepareComposerAutoloadForPlugin(pluginResolved, slug, sourceRef, plugin.composer, resolved.source)
283283
await assertPreparedPluginFileExists(prepared.source, pluginFile.slice(slug.length + 1), sourceRef)
284284
plugins.push({
285285
source: prepared.source,
@@ -300,11 +300,15 @@ export async function prepareRecipeExtraPlugins(recipe: WorkspaceRecipe, recipeD
300300
return plugins
301301
}
302302

303-
async function prepareComposerAutoloadForPlugin(prepared: PreparedExternalSource, slug: string, sourceRef: string, copyRoot = prepared.source): Promise<PreparedExternalSource> {
304-
if (prepared.provenance.kind !== "local") {
303+
async function prepareComposerAutoloadForPlugin(prepared: PreparedExternalSource, slug: string, sourceRef: string, strategy: WorkspaceRecipeExtraPlugin["composer"], copyRoot = prepared.source): Promise<PreparedExternalSource> {
304+
if (strategy !== "install") {
305305
return prepared
306306
}
307307

308+
if (prepared.provenance.kind !== "local") {
309+
throw new Error(`Recipe extra plugin Composer preparation only supports local sources: ${sourceRef}`)
310+
}
311+
308312
try {
309313
const composerJson = await stat(join(prepared.source, "composer.json"))
310314
if (!composerJson.isFile()) {
@@ -317,7 +321,6 @@ async function prepareComposerAutoloadForPlugin(prepared: PreparedExternalSource
317321
try {
318322
const autoload = await stat(join(prepared.source, "vendor", "autoload.php"))
319323
if (autoload.isFile()) {
320-
await writeComposerInstalledPackageAutoloader(prepared.source)
321324
return prepared
322325
}
323326
} catch {
@@ -401,6 +404,7 @@ if (is_file($wp_codebox_composer_package_classmap)) {
401404

402405
await writeFile(packageAutoloader, `<?php
403406
defined( 'ABSPATH' ) || exit;
407+
require_once __DIR__ . '/autoload.php';
404408
${classmapLoader}
405409
`)
406410
}

packages/runtime-core/src/recipe-builders.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,9 @@ function normalizeExtraPlugins(plugins: readonly WorkspaceRecipeExtraPlugin[] =
229229
if (plugin.loadAs !== undefined) {
230230
normalized.loadAs = plugin.loadAs
231231
}
232+
if (plugin.composer !== undefined) {
233+
normalized.composer = plugin.composer
234+
}
232235

233236
return normalized
234237
})

packages/runtime-core/src/recipe-schema.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,10 @@ export function createWorkspaceRecipeJsonSchema(options: WorkspaceRecipeJsonSche
801801
pluginFile: { type: "string" },
802802
activate: { type: "boolean" },
803803
loadAs: { enum: ["plugin", "mu-plugin"] },
804+
composer: {
805+
enum: ["install"],
806+
description: "Explicitly run Composer install in a staged copy of a local plugin when vendor/autoload.php is missing.",
807+
},
804808
sha256: { type: "string", pattern: "^[a-fA-F0-9]{64}$" },
805809
metadata: { $ref: "#/$defs/metadata" },
806810
},

packages/runtime-core/src/runtime-contracts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,7 @@ export interface WorkspaceRecipeExtraPlugin {
481481
activate?: boolean
482482
sha256?: string
483483
loadAs?: "plugin" | "mu-plugin"
484+
composer?: "install"
484485
metadata?: Record<string, unknown>
485486
}
486487

scripts/recipe-run-composer-autoload-extra-plugin-smoke.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ writeFileSync(recipePath, `${JSON.stringify({
133133
sourceSubpath: "plugins/woocommerce",
134134
slug: "composer-autoload-smoke",
135135
pluginFile: "composer-autoload-smoke/composer-autoload-smoke.php",
136+
composer: "install",
136137
},
137138
],
138139
},
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import assert from "node:assert/strict"
2+
import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises"
3+
import { join } from "node:path"
4+
5+
import { cleanupRecipePreparedSources, prepareRecipeExtraPlugins } from "../packages/cli/src/recipe-sources.js"
6+
import { assertWorkspaceRecipeJsonSchema, type WorkspaceRecipe } from "../packages/runtime-core/src/index.js"
7+
import { withTempDir } from "../scripts/test-kit.js"
8+
9+
async function pathExists(path: string): Promise<boolean> {
10+
try {
11+
await stat(path)
12+
return true
13+
} catch {
14+
return false
15+
}
16+
}
17+
18+
await withTempDir("wp-codebox-composer-metadata-plugin-", async (recipeDirectory) => {
19+
const source = join(recipeDirectory, "shipped-plugin")
20+
await mkdir(source, { recursive: true })
21+
await writeFile(join(source, "composer.json"), `${JSON.stringify({ name: "example/shipped-plugin", require: { "composer/installers": "^2" } }, null, 2)}\n`)
22+
await writeFile(join(source, "shipped-plugin.php"), "<?php\n/* Plugin Name: Shipped Plugin */\n")
23+
24+
const recipe: WorkspaceRecipe = {
25+
schema: "wp-codebox/workspace-recipe/v1",
26+
inputs: { extra_plugins: [{ source: "shipped-plugin", slug: "shipped-plugin", pluginFile: "shipped-plugin/shipped-plugin.php" }] },
27+
workflow: { steps: [{ command: "inspect-mounted-inputs" }] },
28+
}
29+
30+
assertWorkspaceRecipeJsonSchema(recipe)
31+
const [plugin] = await prepareRecipeExtraPlugins(recipe, recipeDirectory)
32+
assert.equal(plugin.source, source, "Composer metadata alone must not stage or prepare the plugin")
33+
assert.deepEqual(plugin.cleanupPaths, [])
34+
assert.equal(await pathExists(join(source, "vendor")), false, "the shipped plugin source must remain untouched")
35+
})
36+
37+
await withTempDir("wp-codebox-explicit-composer-plugin-", async (recipeDirectory) => {
38+
const source = join(recipeDirectory, "source-plugin")
39+
const bin = join(recipeDirectory, "bin")
40+
await mkdir(source, { recursive: true })
41+
await mkdir(bin, { recursive: true })
42+
await writeFile(join(source, "composer.json"), `${JSON.stringify({ name: "example/source-plugin", autoload: { classmap: ["src/"] } }, null, 2)}\n`)
43+
await writeFile(join(source, "source-plugin.php"), "<?php\n/* Plugin Name: Source Plugin */\n")
44+
const composer = join(bin, "composer")
45+
await writeFile(composer, "#!/bin/sh\nmkdir -p vendor/composer\nprintf '<?php\\n' > vendor/autoload.php\nprintf '[]\\n' > vendor/composer/installed.json\nprintf '<?php return array();\\n' > vendor/composer/autoload_classmap.php\n")
46+
await chmod(composer, 0o755)
47+
48+
const recipe: WorkspaceRecipe = {
49+
schema: "wp-codebox/workspace-recipe/v1",
50+
inputs: { extra_plugins: [{ source: "source-plugin", slug: "source-plugin", pluginFile: "source-plugin/source-plugin.php", composer: "install" }] },
51+
workflow: { steps: [{ command: "inspect-mounted-inputs" }] },
52+
}
53+
54+
assertWorkspaceRecipeJsonSchema(recipe)
55+
const originalPath = process.env.PATH
56+
process.env.PATH = `${bin}:${originalPath ?? ""}`
57+
let plugin: Awaited<ReturnType<typeof prepareRecipeExtraPlugins>>[number] | undefined
58+
try {
59+
;[plugin] = await prepareRecipeExtraPlugins(recipe, recipeDirectory)
60+
} finally {
61+
process.env.PATH = originalPath
62+
}
63+
64+
assert.ok(plugin)
65+
assert.notEqual(plugin.source, source, "explicit Composer preparation must use a staged copy")
66+
assert.equal(await pathExists(join(plugin.source, "vendor", "autoload.php")), true)
67+
assert.match(await readFile(join(plugin.source, "vendor", "autoload_packages.php"), "utf8"), /require_once __DIR__ \. '\/autoload\.php';/)
68+
assert.equal(await pathExists(join(source, "vendor")), false, "explicit preparation must not mutate the caller source")
69+
assert.equal(plugin.provenance.localPathCategory, "temporary-composer-autoload")
70+
await cleanupRecipePreparedSources([], [plugin])
71+
})
72+
73+
console.log("recipe extra plugin Composer preparation ok")

0 commit comments

Comments
 (0)