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
1 change: 1 addition & 0 deletions packages/cli/src/recipe-dry-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ export async function planWorkspaceRecipe(recipe: WorkspaceRecipe, recipeDirecto
overlayKind: overlay.kind,
package: overlay.package,
source: overlay.source,
...(overlay.reference ? { reference: overlay.reference } : {}),
consumer: overlay.consumer,
target,
...(overlay.metadata ? { userMetadata: overlay.metadata } : {}),
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/recipe-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ async function prepareRecipeDependencyOverlay(overlay: WorkspaceRecipeDependency

const source = resolve(recipeDirectory, overlay.source)
await validateExistingDirectoryForOverlay(source, overlay.source)
const reference = await resolvedGitSourceReference(source)
const reference = overlay.reference ?? await resolvedGitSourceReference(source)
const stagingRoot = await mkdtemp(join(tmpdir(), "wp-codebox-dependency-overlay-"))
const hydratedSource = await prepareComposerBackedSource(source, stagingRoot, `dependency overlay ${overlay.package}`)
// The overlay is mounted at the consumer's vendor path for the package and is
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/recipe-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,9 @@ function validateRecipeDependencyOverlays(overlays: WorkspaceRecipeDependencyOve
if (!overlay.source || typeof overlay.source !== "string") {
throw new Error(`Recipe dependency overlays must include source: ${recipePath}`)
}
if (overlay.reference !== undefined && (typeof overlay.reference !== "string" || !/^[a-f0-9]{40,64}$/i.test(overlay.reference))) {
throw new Error(`Recipe dependency overlay reference must be a 40-64 character hexadecimal immutable reference when provided: ${recipePath}`)
}
if (!overlay.consumer || typeof overlay.consumer !== "string") {
throw new Error(`Recipe dependency overlays must include consumer: ${recipePath}`)
}
Expand Down Expand Up @@ -681,6 +684,9 @@ export async function validateWorkspaceRecipeSemantics(recipe: WorkspaceRecipe,
addIssue("invalid-composer-package", `${path}.package`, `Dependency overlay package must be a safe Composer package name: ${overlay.package}`)
continue
}
if (overlay.reference !== undefined && (typeof overlay.reference !== "string" || !/^[a-f0-9]{40,64}$/i.test(overlay.reference))) {
addIssue("invalid-dependency-overlay-reference", `${path}.reference`, "Dependency overlay reference must be a 40-64 character hexadecimal immutable reference when provided.")
}

const consumerPlugin = recipeExtraPlugins(recipe).find((plugin) => recipeExtraPluginSlug(plugin) === overlay.consumer)
const loadAs = consumerPlugin?.loadAs ?? "plugin"
Expand Down
5 changes: 5 additions & 0 deletions packages/runtime-core/src/recipe-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,11 @@ export function createWorkspaceRecipeJsonSchema(options: WorkspaceRecipeJsonSche
kind: { const: "composer-package" },
package: { type: "string", pattern: "^[a-z0-9_.-]+/[a-z0-9_.-]+$" },
source: { type: "string" },
reference: {
type: "string",
pattern: "^[a-fA-F0-9]{40,64}$",
description: "Optional immutable source reference. When omitted, a clean local Git checkout may supply the reference.",
},
consumer: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9_-]*$" },
metadata: { $ref: "#/$defs/metadata" },
},
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-core/src/runtime-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,7 @@ export interface WorkspaceRecipeDependencyOverlay {
kind: "composer-package"
package: string
source: string
reference?: string
consumer: string
metadata?: Record<string, unknown>
}
Expand Down
60 changes: 54 additions & 6 deletions scripts/composer-backed-source-hydration-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import { tmpdir } from "node:os"
import { join } from "node:path"
import { promisify } from "node:util"
import { recipeRunDependencyOverlay } from "../packages/cli/src/commands/recipe-runtime-setup.js"
import { validateWorkspaceRecipeSemantics } from "../packages/cli/src/recipe-validation.js"
import { prepareRecipeDependencyOverlays, prepareRecipeRuntimeOverlays } from "../packages/cli/src/recipe-sources.js"
import type { PreparedExtraPlugin } from "../packages/cli/src/recipe-sources.js"
import { assertWorkspaceRecipeJsonSchema } from "../packages/runtime-core/src/recipe-schema.js"
import type { WorkspaceRecipe } from "../packages/runtime-core/src/runtime-contracts.js"

const root = await mkdtemp(join(tmpdir(), "wp-codebox-runtime-overlay-hydration-"))
const overlaySource = join(root, "php-ai-client")
const dependencySource = join(root, "generic-composer-package")
const nonGitDependencySource = join(root, "non-git-composer-package")
const nonGitDependencyReference = "0123456789abcdef0123456789abcdef01234567"
const binDir = join(root, "bin")
const scoperPath = join(root, "php-scoper.phar")
const originalPath = process.env.PATH
Expand Down Expand Up @@ -149,6 +152,49 @@ const recipe: WorkspaceRecipe = {
},
}

assertWorkspaceRecipeJsonSchema({
schema: "wp-codebox/workspace-recipe/v1",
workflow: { steps: [{ command: "wordpress.run-php", args: ["<?php"] }] },
inputs: {
dependency_overlays: [{
kind: "composer-package",
package: "acme/non-git-package",
source: nonGitDependencySource,
reference: nonGitDependencyReference,
consumer: "consumer-plugin",
}],
},
})
assert.throws(() => assertWorkspaceRecipeJsonSchema({
schema: "wp-codebox/workspace-recipe/v1",
workflow: { steps: [{ command: "wordpress.run-php", args: ["<?php"] }] },
inputs: {
dependency_overlays: [{
kind: "composer-package",
package: "acme/non-git-package",
source: nonGitDependencySource,
reference: "not-an-immutable-reference",
consumer: "consumer-plugin",
}],
},
}))
assert.deepEqual((await validateWorkspaceRecipeSemantics({
workflow: { steps: [{ command: "wordpress.run-php", args: ["<?php"] }] },
inputs: {
dependency_overlays: [{
kind: "composer-package",
package: "acme/non-git-package",
source: nonGitDependencySource,
reference: "not-an-immutable-reference",
consumer: "consumer-plugin",
}],
},
}, root)).filter((issue) => issue.path === "$.inputs.dependency_overlays[0].reference"), [{
code: "invalid-dependency-overlay-reference",
path: "$.inputs.dependency_overlays[0].reference",
message: "Dependency overlay reference must be a 40-64 character hexadecimal immutable reference when provided.",
}])

const overlays = await prepareRecipeRuntimeOverlays(recipe, root)
const consumers: PreparedExtraPlugin[] = [{
source: consumerSource,
Expand All @@ -171,6 +217,7 @@ const dependencyOverlays = await prepareRecipeDependencyOverlays({
kind: "composer-package",
package: "acme/non-git-package",
source: nonGitDependencySource,
reference: nonGitDependencyReference,
consumer: "consumer-plugin",
}],
},
Expand All @@ -188,25 +235,26 @@ try {
assert.equal(dependencyOverlays[0].reference, dependencyReference.trim(), "clean Git source revision survives Composer staging")
assert.equal(dependencyOverlays[0].metadata.reference, dependencyReference.trim(), "mounted dependency metadata preserves the source revision")
assert.equal(recipeRunDependencyOverlay(dependencyOverlays[0]).reference, dependencyReference.trim(), "runtime dependency provenance exposes the source revision")
assert.equal(dependencyOverlays[1].reference, undefined, "non-Git source has no fabricated revision")
assert.equal(dependencyOverlays[1].metadata.reference, undefined, "non-Git source metadata omits the revision")
assert.equal(dependencyOverlays[1].reference, nonGitDependencyReference, "declared reference supports non-Git sources")
assert.equal(dependencyOverlays[1].metadata.reference, nonGitDependencyReference, "mounted dependency metadata preserves the declared reference")
assert.equal(recipeRunDependencyOverlay(dependencyOverlays[1]).reference, nonGitDependencyReference, "runtime dependency provenance exposes the declared reference")
assert.equal((JSON.parse(await readFile(join(consumerSource, "vendor", "composer", "installed.json"), "utf8")) as { packages: Array<{ source?: unknown }> }).packages[0].source, undefined, "original consumer Composer provenance remains unchanged")
const runtimeInstalled = JSON.parse(await readFile(join(consumers[0].source, "vendor", "composer", "installed.json"), "utf8")) as { packages: Array<{ name: string, version: string, source?: { reference?: string } }> }
assert.deepEqual(runtimeInstalled.packages[0], { name: "acme/package", version: "1.0.0+no-version-set", source: { reference: dependencyReference.trim() } }, "runtime Composer dependency provenance includes the immutable source reference")
assert.deepEqual(runtimeInstalled.packages[1], { name: "acme/non-git-package", version: "1.0.0+no-version-set" }, "runtime Composer provenance omits unresolved source references")
assert.deepEqual(runtimeInstalled.packages[1], { name: "acme/non-git-package", version: "1.0.0+no-version-set", source: { reference: nonGitDependencyReference } }, "runtime Composer provenance uses the declared source reference")
const { stdout: runtimeInstalledPhp } = await execFile("php", ["-r", "echo json_encode(require $argv[1]);", join(consumers[0].source, "vendor", "composer", "installed.php")])
const runtimePhpVersions = JSON.parse(runtimeInstalledPhp) as { versions: Record<string, { reference?: string | null }> }
assert.equal(runtimePhpVersions.versions["acme/package"]?.reference, dependencyReference.trim(), "Composer runtime metadata includes the immutable source reference")
assert.equal(runtimePhpVersions.versions["acme/non-git-package"]?.reference, null, "Composer runtime metadata leaves unresolved references unchanged")
assert.equal(runtimePhpVersions.versions["acme/non-git-package"]?.reference, nonGitDependencyReference, "Composer runtime metadata includes the declared source reference")
const runtimePackageRow = runtimePhpVersions.versions["acme/package"]
assert.deepEqual(runtimePackageRow, {
pretty_version: "1.0.0+no-version-set",
reference: dependencyReference.trim(),
}, "the final PHP-visible package row exposes the clean Git reference")
assert.deepEqual(runtimePhpVersions.versions["acme/non-git-package"], {
pretty_version: "1.0.0+no-version-set",
reference: null,
}, "the final PHP-visible package row leaves an unavailable reference unchanged")
reference: nonGitDependencyReference,
}, "the final PHP-visible package row exposes the declared reference")
assert.match(await readFile(join(overlays[0].source, "src", "Client.php"), "utf8"), /WordPress\\AiClientDependencies\\Psr\\Log\\LoggerInterface/)
} finally {
await Promise.all([...overlays, ...dependencyOverlays, ...consumers].flatMap((overlay) => overlay.cleanupPaths).map((path) => rm(path, { recursive: true, force: true })))
Expand Down