Skip to content
Open
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
101 changes: 91 additions & 10 deletions packages/cli/src/detect.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,30 +20,104 @@ function git(cwd, args) {
}
}

// A non-Node repo used to start on the "empty job site" the method warns
// against: detection only knew package.json, so a Python project got an empty
// check list and a "add your language setup steps" stub. Detect the next
// biggest ecosystem too, so the crew gets a real toolchain to build and verify
// against. Zero runtime dependencies — no TOML parser, just presence checks and
// narrow text probes; `init` still asks the operator to confirm every default.
function detectPython(dir) {
const has = (file) => existsSync(join(dir, file));
const pyproject = has("pyproject.toml") ? readFileSync(join(dir, "pyproject.toml"), "utf8") : "";
const isPython =
has("pyproject.toml") || has("requirements.txt") || has("setup.py") || has("setup.cfg") || has("Pipfile") || has("Pipfile.lock");
if (!isPython) return null;

// Tool selection drives both the install command and how checks are invoked
// (poetry/pipenv run inside their managed environment; pip runs in place).
let packageManager;
let prefix;
let install;
if (has("poetry.lock") || /\[tool\.poetry/.test(pyproject)) {
packageManager = "poetry";
prefix = "poetry run ";
install = "poetry install";
} else if (has("Pipfile") || has("Pipfile.lock")) {
packageManager = "pipenv";
prefix = "pipenv run ";
install = "pipenv install --dev";
} else {
packageManager = "pip";
prefix = "";
install = has("requirements.txt") ? "pip install -r requirements.txt" : "pip install -e .";
}

// Only propose a check we have configuration evidence for — a dependency
// being present is not proof the team runs it.
const checks = [];
if (/\[tool\.ruff/.test(pyproject) || has("ruff.toml") || has(".ruff.toml")) checks.push(`${prefix}ruff check .`);
if (/\[tool\.black\]/.test(pyproject)) checks.push(`${prefix}black --check .`);
if (/\[tool\.mypy\]/.test(pyproject) || has("mypy.ini") || has(".mypy.ini")) checks.push(`${prefix}mypy .`);
if (
/\[tool\.pytest/.test(pyproject) ||
has("pytest.ini") ||
has("tox.ini") ||
has("conftest.py") ||
existsSync(join(dir, "tests")) ||
existsSync(join(dir, "test"))
) {
checks.push(`${prefix}pytest`);
}

let pythonVersion = "3.x";
if (has(".python-version")) {
const pinned = readFileSync(join(dir, ".python-version"), "utf8").trim().split("\n")[0].trim();
if (pinned) pythonVersion = pinned;
}

// Dependency install runs in the toolchain step, mirroring `npm ci`;
// `provision` stays for DB/seeds/browsers, which cannot be inferred here.
return { packageManager, checks, provision: "", install, pythonVersion };
}

export function detect(dir) {
const pkg = readJson(join(dir, "package.json"));
const scripts = pkg?.scripts ?? {};

const packageManager = existsSync(join(dir, "pnpm-lock.yaml"))
const nodePackageManager = existsSync(join(dir, "pnpm-lock.yaml"))
? "pnpm"
: existsSync(join(dir, "yarn.lock"))
? "yarn"
: existsSync(join(dir, "package-lock.json"))
? "npm"
: pkg
? "npm"
: "none";
: null;

const runner = packageManager === "none" ? null : packageManager === "npm" ? "npm run" : `${packageManager} run`;
const checks = [];
if (runner) {
// Node is detected first: a repo carrying package.json is a Node repo even if
// it also ships a helper script in another language. Only when there is no
// Node manifest do we probe the other ecosystems.
let packageManager;
let checks = [];
let provision = "";
let install;
let pythonVersion;
if (nodePackageManager) {
packageManager = nodePackageManager;
const runner = nodePackageManager === "npm" ? "npm run" : `${nodePackageManager} run`;
for (const name of ["typecheck", "lint", "test", "build"]) {
if (scripts[name]) checks.push(`${runner} ${name}`);
}
provision = scripts["setup"] ? `${runner} setup` : "";
} else {
const python = detectPython(dir);
if (python) {
({ packageManager, checks, provision, install, pythonVersion } = python);
} else {
packageManager = "none";
}
}

const provision = runner && scripts["setup"] ? `${runner} setup` : "";

const isGitRepo = git(dir, ["rev-parse", "--is-inside-work-tree"]) === "true";
let defaultBranch = "main";
const originHead = git(dir, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
Expand All @@ -58,9 +132,14 @@ export function detect(dir) {
const remoteMatch = remote.match(/[/:]([^/:]+)\/([^/]+?)(\.git)?$/);
if (remoteMatch) org = remoteMatch[1];

const migrationDirs = ["migrations", "supabase/migrations", "db/migrations", "prisma/migrations"].filter((d) =>
existsSync(join(dir, d))
);
const migrationDirs = [
"migrations",
"supabase/migrations",
"db/migrations",
"prisma/migrations",
"alembic/versions",
"migrations/versions",
].filter((d) => existsSync(join(dir, d)));

// Existing check workflows (by their `name:`), so the doctor knows what to
// watch. Facility's own workflows are excluded — the watchtower covers them.
Expand Down Expand Up @@ -89,6 +168,8 @@ export function detect(dir) {
packageManager,
checks,
provision,
install,
pythonVersion,
org,
workflowNames,
deploymentProviders: [...deploymentProviders].sort(),
Expand Down
24 changes: 20 additions & 4 deletions packages/cli/src/init.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ import { accent, banner, bold, dim, heading, item, ok, skip, warn } from "./ui.m

const CHECKOUT_SHA = "34e114876b0b11c390a56381ad16ebd13914f8d5"; // actions/checkout v4
const SETUP_NODE_SHA = "49933ea5288caeca8642d1e84afbd3f7d6820020"; // actions/setup-node v4
const SETUP_PYTHON_SHA = "a26af69be951a213d495a4c3e4e4022e16d87065"; // actions/setup-python v5
const PNPM_SHA = "b906affcce14559ad1aafd4ab0e942779e9f58b1"; // pnpm/action-setup v4
const AWS_AUTH_SHA = "517a711dbcd0e402f90c77e7e2f81e849156e31d"; // aws-actions/configure-aws-credentials v6.2.2
const GOOGLE_AUTH_SHA = "7c6bc770dae815cd3e89ee6cdf493a5fab2cc093"; // google-github-actions/auth v3
const AUTH_MODES = new Set(["api-key", "oauth", "wif", "bedrock", "vertex"]);

function toolchainSteps(packageManager, { conditional = false } = {}) {
function toolchainSteps(detected, { conditional = false } = {}) {
const { packageManager } = detected;
const guard = conditional ? "\n if: steps.workflow-change.outputs.changed != 'true'" : "";
if (packageManager === "pnpm") {
return [
Expand Down Expand Up @@ -49,9 +51,23 @@ function toolchainSteps(packageManager, { conditional = false } = {}) {
"",
].join("\n");
}
if (packageManager === "poetry" || packageManager === "pipenv" || packageManager === "pip") {
// python-version is quoted so YAML does not read e.g. 3.10 as the float 3.1.
const steps = [
"",
` - uses: actions/setup-python@${SETUP_PYTHON_SHA} # v5${guard}`,
" with:",
` python-version: ${JSON.stringify(detected.pythonVersion || "3.x")}`,
];
if (packageManager === "poetry") steps.push("", ` - run: python -m pip install --upgrade poetry${guard}`);
else if (packageManager === "pipenv") steps.push("", ` - run: python -m pip install --upgrade pipenv${guard}`);
if (detected.install) steps.push("", ` - run: ${detected.install}${guard}`);
steps.push("");
return steps.join("\n");
}
return [
"",
" # facility: no Node toolchain detected. Add your language setup steps",
" # facility: no toolchain detected. Add your language setup steps",
" # here (compilers, package managers) so the crew can build and test.",
"",
].join("\n");
Expand Down Expand Up @@ -410,8 +426,8 @@ export async function init(flags, pkgRoot, version) {
CHECKS_RUN: checksRun(checks),
CHECKS_LIST: checksList(checks),
ALLOW_CHECKS_JSON: checksAllowJson(checks),
TOOLCHAIN_STEPS: toolchainSteps(detected.packageManager),
TOOLCHAIN_STEPS_CONDITIONAL: toolchainSteps(detected.packageManager, { conditional: true }),
TOOLCHAIN_STEPS: toolchainSteps(detected),
TOOLCHAIN_STEPS_CONDITIONAL: toolchainSteps(detected, { conditional: true }),
BOARD_STEP: boardStep(org, project),
BOARD_REVIEW_STEP: boardReviewStep(org, project),
CANARY_BOT: canaryBot,
Expand Down
89 changes: 89 additions & 0 deletions packages/cli/test/detect.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { detect } from "../src/detect.mjs";

const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const cli = join(pkgRoot, "bin", "facility.mjs");

function makeRepo(files) {
const dir = mkdtempSync(join(tmpdir(), "facility-detect-"));
execFileSync("git", ["init", "-b", "main"], { cwd: dir });
for (const [name, content] of Object.entries(files)) {
const full = join(dir, name);
mkdirSync(dirname(full), { recursive: true });
writeFileSync(full, content);
}
return dir;
}

test("detect: a Poetry project installs with Poetry and runs checks inside it", () => {
const dir = makeRepo({
"pyproject.toml": '[tool.poetry]\nname = "demo"\n\n[tool.ruff]\n\n[tool.mypy]\n\n[tool.pytest.ini_options]\n',
"poetry.lock": "",
"tests/test_x.py": "def test_x():\n assert True\n",
});
const d = detect(dir);
assert.equal(d.packageManager, "poetry");
assert.equal(d.install, "poetry install");
assert.equal(d.provision, "");
assert.deepEqual(d.checks, ["poetry run ruff check .", "poetry run mypy .", "poetry run pytest"]);
});

test("detect: a pip + requirements project proposes pip install -r and bare checks", () => {
const dir = makeRepo({
// ruff is only a listed dependency, not configured — it must not become a check.
"requirements.txt": "pytest\nruff\n",
"tests/test_y.py": "def test_y():\n assert True\n",
});
const d = detect(dir);
assert.equal(d.packageManager, "pip");
assert.equal(d.install, "pip install -r requirements.txt");
assert.deepEqual(d.checks, ["pytest"]);
});

test("detect: a pip project without requirements installs the package itself", () => {
const dir = makeRepo({ "pyproject.toml": '[project]\nname = "demo"\n[tool.ruff]\n' });
const d = detect(dir);
assert.equal(d.packageManager, "pip");
assert.equal(d.install, "pip install -e .");
assert.deepEqual(d.checks, ["ruff check ."]);
});

test("detect: Node detection is unchanged by Python support", () => {
const dir = makeRepo({
"package.json": JSON.stringify({ name: "n", scripts: { lint: "eslint .", test: "vitest run", setup: "docker compose up -d" } }),
"package-lock.json": "{}",
// A stray Python file must not flip a Node repo to pip.
"requirements.txt": "pytest\n",
});
const d = detect(dir);
assert.equal(d.packageManager, "npm");
assert.deepEqual(d.checks, ["npm run lint", "npm run test"]);
assert.equal(d.provision, "npm run setup");
assert.equal(d.install, undefined);
});

test("init renders a pinned Python toolchain for a Python repo", () => {
const dir = makeRepo({
"requirements.txt": "pytest\n",
".python-version": "3.12\n",
"tests/test_z.py": "def test_z():\n assert True\n",
});
const res = spawnSync(process.execPath, [cli, "init", "--yes", `--dir=${dir}`], { cwd: dir, encoding: "utf8" });
assert.equal(res.status, 0, res.stderr);
const crew = readFileSync(join(dir, ".github/workflows/facility-crew.yml"), "utf8");
assert.ok(
crew.includes("uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5"),
"setup-python must be pinned to a full commit SHA",
);
// Quoted so YAML does not misread 3.12 as a float.
assert.ok(crew.includes('python-version: "3.12"'), "python version must be quoted and taken from .python-version");
assert.ok(crew.includes("pip install -r requirements.txt"), "the dependency install must be rendered");
assert.ok(crew.includes("pytest"), "the detected check must be rendered");
assert.ok(!/no toolchain detected/.test(crew), "the empty-job-site stub must not appear for a detected stack");
});