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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"devDependencies": {
"@biomejs/biome": "^2.4.4",
"turbo": "^2.5.8",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"yaml": "^2.9.0"
}
}
8 changes: 8 additions & 0 deletions packages/cli/src/init.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,13 @@ function checksList(checks) {
return lines.join("\n");
}

// The provision command is interpolated into a YAML block scalar, not a bare
// scalar: a command containing ": " (and the no-provision fallback below does)
// otherwise renders a workflow GitHub cannot parse.
function provisionRun(command) {
return command.split("\n").map((line) => ` ${line}`).join("\n");
}

function checksRun(checks) {
if (checks.length) return checks.map((command) => ` ${command}`).join("\n");
return [
Expand Down Expand Up @@ -406,6 +413,7 @@ export async function init(flags, pkgRoot, version) {
CODEX_ARCHITECT_REPO_LANE: "true",
CODEX_BUILDER_REPO_LANE: "true",
PROVISION_CMD: provisionCmd,
PROVISION_RUN: provisionRun(provisionCmd),
CHECKS_INLINE: checksInline,
CHECKS_RUN: checksRun(checks),
CHECKS_LIST: checksList(checks),
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/templates/workflows/facility-address-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ jobs:
# Provisioned job site, so the agent can verify before pushing.
- name: Provision environment
if: steps.workflow-change.outputs.changed != 'true'
run: {{PROVISION_CMD}}
run: |
{{PROVISION_RUN}}

{{ANTHROPIC_AUTH_SETUP_CONDITIONAL}}

Expand Down
3 changes: 2 additions & 1 deletion packages/cli/templates/workflows/facility-codex.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ jobs:
{{TOOLCHAIN_STEPS}}
- name: Provision environment
if: steps.resolve.outputs.run == 'true'
run: {{PROVISION_CMD}}
run: |
{{PROVISION_RUN}}

- name: Install pinned Codex CLI
if: steps.resolve.outputs.run == 'true'
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/templates/workflows/facility-crew.yml
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@ jobs:
# cannot verify will defer; a provisioned one finishes.
- name: Provision environment
if: steps.requested-agent.outputs.run == 'true'
run: {{PROVISION_CMD}}
run: |
{{PROVISION_RUN}}

- name: Capture /builder delivery baseline
id: builder-start
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/templates/workflows/facility-doctor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ jobs:
# The doctor verifies before pushing, so it gets the same provisioned
# world as the crew.
- name: Provision environment
run: {{PROVISION_CMD}}
run: |
{{PROVISION_RUN}}

{{ANTHROPIC_AUTH_SETUP}}

Expand Down
3 changes: 2 additions & 1 deletion packages/cli/templates/workflows/facility-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ jobs:
{{TOOLCHAIN_STEPS_CONDITIONAL}}
- name: Provision environment
if: steps.workflow-change.outputs.changed != 'true'
run: {{PROVISION_CMD}}
run: |
{{PROVISION_RUN}}

- name: Capture review baseline
id: review-start
Expand Down
66 changes: 65 additions & 1 deletion packages/cli/test/init.test.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { lstatSync, mkdtempSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs";
import { lstatSync, mkdtempSync, readFileSync, readdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
import { createHash } from "node:crypto";
import { pathToFileURL } from "node:url";
import { tmpdir } from "node:os";
import { join, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";

const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const cli = join(pkgRoot, "bin", "facility.mjs");
Expand Down Expand Up @@ -583,3 +584,66 @@ test("local commands reject unknown, valueless, and conflicting flags", () => {
"--local cannot be combined with platform target options",
);
});

test("every rendered workflow parses whatever the provision command contains", async (t) => {
// A bare `run:` scalar ends at its first ": ", so the no-provision fallback
// text — and any command containing ": " — renders a workflow GitHub cannot
// parse, and the trigger that workflow carries never runs.
const scenarios = [
{ label: "no provision command detected", setup: false, flags: [], run: /^echo "facility: / },
{
label: "provision command containing \": \"",
setup: true,
flags: ['--provision=docker compose up -d && echo "db: ready"'],
run: 'docker compose up -d && echo "db: ready"',
},
{ label: "ordinary provision command", setup: true, flags: ["--provision=npm run setup"], run: "npm run setup" },
];

for (const scenario of scenarios) {
const dir = mkdtempSync(join(tmpdir(), "facility-provision-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
execFileSync("git", ["init", "-b", "main"], { cwd: dir });
writeFileSync(
join(dir, "package.json"),
JSON.stringify(
{
name: "demo-app",
private: true,
// `setup` is what detection proposes as the provision command, so
// omitting it is what exercises the fallback.
scripts: scenario.setup ? { test: "vitest run", setup: "docker compose up -d" } : { test: "vitest run" },
},
null,
2
) + "\n"
);
writeFileSync(join(dir, "package-lock.json"), "{}\n");

const result = runCli(["init", "--yes", `--dir=${dir}`, ...scenario.flags], dir);
assert.equal(result.status, 0, `${scenario.label}: ${result.stdout}${result.stderr}`);

let provisionSteps = 0;
for (const entry of readdirSync(join(dir, ".github/workflows"))) {
const source = readFileSync(join(dir, ".github/workflows", entry), "utf8");
let document;
assert.doesNotThrow(() => {
document = parseYaml(source);
}, `${scenario.label}: ${entry} must parse as YAML`);

for (const job of Object.values(document.jobs ?? {})) {
for (const step of job.steps ?? []) {
if (step.name !== "Provision environment") continue;
provisionSteps += 1;
const run = step.run.trimEnd();
if (scenario.run instanceof RegExp) {
assert.match(run, scenario.run, `${scenario.label}: ${entry} provision command`);
} else {
assert.equal(run, scenario.run, `${scenario.label}: ${entry} provision command`);
}
}
}
}
assert.equal(provisionSteps, 5, `${scenario.label}: every workflow with a provision step must be covered`);
}
});
12 changes: 12 additions & 0 deletions packages/core/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,17 @@ function checksList(checks: string[]): string {
return lines.join("\n");
}

// The provision command is interpolated into a YAML block scalar, not a bare
// scalar: a command containing ": " (and the no-provision fallback does)
// otherwise renders a workflow GitHub cannot parse. Mirrors provisionRun in
// packages/cli/src/init.mjs — the byte-for-byte parity test holds them equal.
function provisionRun(command: string): string {
return command
.split("\n")
.map((line) => ` ${line}`)
.join("\n");
}

function checksRun(checks: string[]): string {
if (checks.length) return checks.map((command) => ` ${command}`).join("\n");
return [
Expand Down Expand Up @@ -453,6 +464,7 @@ export async function renderFacilityInit(
? "false"
: "true",
PROVISION_CMD: provision,
PROVISION_RUN: provisionRun(provision),
CHECKS_INLINE: checks.length ? checks.join(" ; ") : "the checks configured in STANDARD.md",
CHECKS_LIST: checksList(checks),
CHECKS_RUN: checksRun(checks),
Expand Down
127 changes: 72 additions & 55 deletions packages/core/test/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,63 +280,80 @@ describe("fingerprints", () => {
});

describe("render", () => {
it("matches the real CLI init output byte-for-byte", async () => {
const dir = mkdtempSync(join(tmpdir(), "facility-core-render-"));
writeFileSync(
join(dir, "package.json"),
`${JSON.stringify(
{
scripts: {
setup: "node setup.mjs",
typecheck: "tsc --noEmit",
test: "vitest run",
// The provision command reaches the workflows through a YAML block scalar,
// so a command containing ": " renders differently from an ordinary one and
// the no-provision fallback contains ": " itself. Both renderers have to
// agree on all three shapes, not just the easy one.
const provisionShapes = [
{ name: "an ordinary provision command", provision: "pnpm run setup", setupScript: true },
{
name: 'a provision command containing ": "',
provision: 'docker compose up -d && echo "db: ready"',
setupScript: true,
},
{ name: "no provision command, so both fall back", provision: undefined, setupScript: false },
];

for (const shape of provisionShapes) {
it(`matches the real CLI init output byte-for-byte with ${shape.name}`, async () => {
const dir = mkdtempSync(join(tmpdir(), "facility-core-render-"));
writeFileSync(
join(dir, "package.json"),
`${JSON.stringify(
{
scripts: {
// `setup` is what CLI detection proposes as the provision
// command, so omitting it is what exercises the fallback.
...(shape.setupScript ? { setup: "node setup.mjs" } : {}),
typecheck: "tsc --noEmit",
test: "vitest run",
},
},
},
null,
2,
)}\n`,
);
writeFileSync(join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
mkdirSync(join(dir, "migrations"), { recursive: true });
writeFileSync(join(dir, "migrations/001.sql"), "select 1;\n");
execFileSync("node", [
join(process.cwd(), "../cli/bin/facility.mjs"),
"init",
"--yes",
"--dir",
dir,
"--branch",
"main",
"--provision",
"pnpm run setup",
"--checks",
"pnpm run typecheck, pnpm run test",
"--modules",
"database",
]);
const cliFiles = collect(dir);
const rendered = await renderFacilityInit({
defaultBranch: "main",
provisionCmd: "pnpm run setup",
checkCmds: ["pnpm run typecheck", "pnpm run test"],
modules: ["database"],
packageManager: "pnpm",
workflowNames: [],
null,
2,
)}\n`,
);
writeFileSync(join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
mkdirSync(join(dir, "migrations"), { recursive: true });
writeFileSync(join(dir, "migrations/001.sql"), "select 1;\n");
execFileSync("node", [
join(process.cwd(), "../cli/bin/facility.mjs"),
"init",
"--yes",
"--dir",
dir,
"--branch",
"main",
...(shape.provision ? ["--provision", shape.provision] : []),
"--checks",
"pnpm run typecheck, pnpm run test",
"--modules",
"database",
]);
const cliFiles = collect(dir);
const rendered = await renderFacilityInit({
defaultBranch: "main",
provisionCmd: shape.provision,
checkCmds: ["pnpm run typecheck", "pnpm run test"],
modules: ["database"],
packageManager: "pnpm",
workflowNames: [],
});
const coreFiles = new Map(
rendered.files.map((file) => [
file.path,
{
mode: file.mode ?? (file.executable ? "100755" : "100644"),
content: file.content,
},
]),
);
expect([...coreFiles.keys()].sort()).toEqual([...cliFiles.keys()].sort());
for (const [path, cliFile] of cliFiles) {
expect(coreFiles.get(path), path).toEqual(cliFile);
}
});
const coreFiles = new Map(
rendered.files.map((file) => [
file.path,
{
mode: file.mode ?? (file.executable ? "100755" : "100644"),
content: file.content,
},
]),
);
expect([...coreFiles.keys()].sort()).toEqual([...cliFiles.keys()].sort());
for (const [path, cliFile] of cliFiles) {
expect(coreFiles.get(path), path).toEqual(cliFile);
}
});
}

it("keeps platform-lane slash commands out of repo workflows", async () => {
const rendered = await renderFacilityInit({
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

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

Loading