mirror of
https://github.com/przeprogramowani/10x-cli.git
synced 2026-09-19 03:30:01 +08:00
feat: auto-register the surrounding product repo as first base repo (#27)
Running 'bench-kit init' from inside a product repo is the common flow, so init now detects the git repo containing the invocation cwd (git rev-parse --show-toplevel + origin remote + HEAD) and replaces the template's demo-app placeholder in bench.config.yaml with that repo, editing the YAML document in place so company-zone comments survive. The detection also lands in instance.json (incl. HEAD as a candidate pin for the first task). No detection, no origin, or detecting the instance itself → the placeholder stays. Adds the 'yaml' dependency. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
"@clack/prompts": "0.9.1",
|
||||
"cac": "7.0.0",
|
||||
"proper-lockfile": "4.1.2",
|
||||
"yaml": "^2.9.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "1.3.12",
|
||||
@@ -159,6 +160,8 @@
|
||||
|
||||
"uri-js-replace": ["uri-js-replace@1.0.1", "", {}, "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g=="],
|
||||
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
"yaml-ast-parser": ["yaml-ast-parser@0.0.43", "", {}, "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
+2
-1
@@ -32,7 +32,8 @@
|
||||
"dependencies": {
|
||||
"@clack/prompts": "0.9.1",
|
||||
"cac": "7.0.0",
|
||||
"proper-lockfile": "4.1.2"
|
||||
"proper-lockfile": "4.1.2",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "1.3.12",
|
||||
|
||||
@@ -22,8 +22,9 @@ import {
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import type { CAC } from "cac";
|
||||
import { parseDocument } from "yaml";
|
||||
import { experimentalEnabled, requireExperimental } from "../lib/experimental";
|
||||
import {
|
||||
ExitCodes,
|
||||
@@ -38,12 +39,28 @@ import {
|
||||
|
||||
export const TEMPLATE_REPO_URL = "https://github.com/przeprogramowani/10x-bench-kit";
|
||||
|
||||
/** The template's placeholder base-repo entry that init may replace. */
|
||||
export const PLACEHOLDER_BASE_REPO = "demo-app";
|
||||
|
||||
/** A git repo detected around the directory init was invoked from. */
|
||||
export interface DetectedBaseRepo {
|
||||
/** Repo root directory (used to avoid registering the instance itself). */
|
||||
rootDir: string;
|
||||
/** Base-repo name for bench.config.yaml (basename of the repo root). */
|
||||
name: string;
|
||||
/** The `origin` remote URL. */
|
||||
url: string;
|
||||
/** Current HEAD — a candidate pin for the first task. */
|
||||
headCommit: string;
|
||||
}
|
||||
|
||||
/** Instance manifest written next to the template's VERSION file. */
|
||||
export interface InstanceManifest {
|
||||
templateVersion: string;
|
||||
templateRef: string;
|
||||
templateSource: string;
|
||||
initializedAt: string;
|
||||
detectedBaseRepo?: DetectedBaseRepo;
|
||||
}
|
||||
|
||||
interface BenchKitFlags extends GlobalFlags {
|
||||
@@ -63,6 +80,8 @@ export interface BenchKitDeps {
|
||||
cloneTemplate(ref: string | null, destDir: string): Promise<{ ok: boolean; error: string }>;
|
||||
/** Runs git with `args` inside `cwd` (fresh `git init` + first commit). */
|
||||
runGit(args: string[], cwd: string): Promise<{ ok: boolean; error: string }>;
|
||||
/** Detects the git repo containing `cwd` (null when absent or origin-less). */
|
||||
detectBaseRepo(cwd: string): Promise<DetectedBaseRepo | null>;
|
||||
now(): Date;
|
||||
}
|
||||
|
||||
@@ -158,11 +177,25 @@ export async function runBenchKitInit(
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
const copied = materialize(scratch, targetDir, { skipExisting: repair });
|
||||
|
||||
// Running init from inside a product repo is the common flow — register
|
||||
// that repo as the first base repo instead of leaving the placeholder.
|
||||
let baseRepo: DetectedBaseRepo | null = null;
|
||||
if (!repair) {
|
||||
const detected = await deps.detectBaseRepo(process.cwd());
|
||||
if (detected !== null && resolve(detected.rootDir) !== targetDir) {
|
||||
if (registerBaseRepo(join(targetDir, "bench.config.yaml"), detected)) {
|
||||
baseRepo = detected;
|
||||
verbose(ctx, `registered base repo ${detected.name} (${detected.url})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const manifest: InstanceManifest = {
|
||||
templateVersion,
|
||||
templateRef: requestedRef ?? "latest",
|
||||
templateSource: TEMPLATE_REPO_URL,
|
||||
initializedAt: deps.now().toISOString(),
|
||||
...(baseRepo === null ? {} : { detectedBaseRepo: baseRepo }),
|
||||
};
|
||||
writeFileSync(
|
||||
join(targetDir, ".bench-kit", "instance.json"),
|
||||
@@ -181,6 +214,9 @@ export async function runBenchKitInit(
|
||||
]
|
||||
: [
|
||||
`Created a benchmark instance in '${targetDir}' from template ${templateVersion}.`,
|
||||
baseRepo === null
|
||||
? "No product repo detected here — add your base repos to bench.config.yaml."
|
||||
: `Registered '${baseRepo.name}' (${baseRepo.url}) as the first base repo in bench.config.yaml.`,
|
||||
committed
|
||||
? "Initialized a fresh git repository with an initial commit."
|
||||
: "Initialized a fresh git repository (initial commit skipped — commit the files yourself).",
|
||||
@@ -192,6 +228,7 @@ export async function runBenchKitInit(
|
||||
templateVersion,
|
||||
templateRef: manifest.templateRef,
|
||||
filesCopied: copied,
|
||||
baseRepo,
|
||||
gitInitialized: !repair,
|
||||
committed,
|
||||
});
|
||||
@@ -286,6 +323,31 @@ function materialize(
|
||||
return copied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the template's placeholder base-repo entry with the detected
|
||||
* repo, editing bench.config.yaml in place (comments preserved via yaml
|
||||
* document editing). Returns false when the config has no placeholder to
|
||||
* replace — company content is never overwritten on a guess.
|
||||
*/
|
||||
export function registerBaseRepo(configPath: string, repo: DetectedBaseRepo): boolean {
|
||||
if (!existsSync(configPath)) return false;
|
||||
const doc = parseDocument(readFileSync(configPath, "utf8"));
|
||||
const firstName = doc.getIn(["base_repos", 0, "name"]);
|
||||
if (firstName !== PLACEHOLDER_BASE_REPO) return false;
|
||||
doc.setIn(["base_repos", 0, "name"], repo.name);
|
||||
doc.setIn(["base_repos", 0, "url"], repo.url);
|
||||
// The entry is real now — drop the template's per-field placeholder
|
||||
// comments (file-level comments stay).
|
||||
const entry = doc.getIn(["base_repos", 0], true);
|
||||
if (entry && typeof entry === "object" && "items" in entry) {
|
||||
for (const pair of (entry as { items: { key?: { commentBefore?: string | null } }[] }).items) {
|
||||
if (pair.key) pair.key.commentBefore = null;
|
||||
}
|
||||
}
|
||||
writeFileSync(configPath, doc.toString());
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Fresh `git init` + first commit. A failed commit degrades to a warning. */
|
||||
async function freshGitInit(
|
||||
ctx: OutputContext,
|
||||
@@ -321,18 +383,34 @@ async function freshGitInit(
|
||||
// Default (real) side effects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function run(cmd: string, args: string[], cwd?: string): Promise<{ ok: boolean; error: string }> {
|
||||
function run(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
cwd?: string,
|
||||
): Promise<{ ok: boolean; stdout: string; error: string }> {
|
||||
return new Promise((resolvePromise) => {
|
||||
const child = spawn(cmd, args, { cwd, stdio: ["ignore", "ignore", "pipe"] });
|
||||
const child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("error", (err) => resolvePromise({ ok: false, error: err.message }));
|
||||
child.on("close", (code) => resolvePromise({ ok: code === 0, error: stderr }));
|
||||
child.on("error", (err) => resolvePromise({ ok: false, stdout, error: err.message }));
|
||||
child.on("close", (code) => resolvePromise({ ok: code === 0, stdout, error: stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
/** `git -C <cwd> …` returning trimmed stdout, or null on failure. */
|
||||
async function gitQuery(cwd: string, args: string[]): Promise<string | null> {
|
||||
const result = await run("git", ["-C", cwd, ...args]);
|
||||
if (!result.ok) return null;
|
||||
const value = result.stdout.trim();
|
||||
return value === "" ? null : value;
|
||||
}
|
||||
|
||||
const defaultDeps: BenchKitDeps = {
|
||||
async toolAvailable(cmd) {
|
||||
const result = await run(cmd, ["--version"]);
|
||||
@@ -347,5 +425,14 @@ const defaultDeps: BenchKitDeps = {
|
||||
runGit(args, cwd) {
|
||||
return run("git", args, cwd);
|
||||
},
|
||||
async detectBaseRepo(cwd) {
|
||||
const rootDir = await gitQuery(cwd, ["rev-parse", "--show-toplevel"]);
|
||||
if (rootDir === null) return null;
|
||||
const url = await gitQuery(cwd, ["remote", "get-url", "origin"]);
|
||||
if (url === null) return null;
|
||||
const headCommit = await gitQuery(cwd, ["rev-parse", "HEAD"]);
|
||||
if (headCommit === null) return null;
|
||||
return { rootDir, name: basename(rootDir), url, headCommit };
|
||||
},
|
||||
now: () => new Date(),
|
||||
};
|
||||
|
||||
@@ -93,7 +93,19 @@ function buildTemplateFixture(version = "0.1.0"): string {
|
||||
writeFileSync(join(dir, ".bench-kit", "VERSION"), `${version}\n`);
|
||||
mkdirSync(join(dir, "tasks", "demo"), { recursive: true });
|
||||
writeFileSync(join(dir, "tasks", "demo", "prompt.md"), "demo prompt\n");
|
||||
writeFileSync(join(dir, "bench.config.yaml"), "base_repos: []\n");
|
||||
writeFileSync(
|
||||
join(dir, "bench.config.yaml"),
|
||||
[
|
||||
"# Konfiguracja instancji benchmarku.",
|
||||
"base_repos:",
|
||||
" - name: demo-app",
|
||||
" # (placeholder)",
|
||||
" url: git@github.com:example-org/demo-app.git",
|
||||
"judge:",
|
||||
" model: anthropic/claude-fable-5",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
return dir;
|
||||
}
|
||||
|
||||
@@ -114,6 +126,7 @@ function fakeDeps(templateDir: string, overrides: Partial<BenchKitDeps> = {}): F
|
||||
gitCalls.push(args);
|
||||
return Promise.resolve({ ok: true, error: "" });
|
||||
},
|
||||
detectBaseRepo: () => Promise.resolve(null),
|
||||
now: () => new Date("2026-08-13T12:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
@@ -154,6 +167,63 @@ describe("10x bench-kit init", () => {
|
||||
expect(envelope.data.committed).toBe(true);
|
||||
});
|
||||
|
||||
it("registers the surrounding product repo as the first base repo", async () => {
|
||||
const template = buildTemplateFixture();
|
||||
const target = join(tempDir("bench-kit-target-"), "instance");
|
||||
const detected = {
|
||||
rootDir: "/somewhere/shop-app",
|
||||
name: "shop-app",
|
||||
url: "git@github.com:acme/shop-app.git",
|
||||
headCommit: "a".repeat(40),
|
||||
};
|
||||
const { deps } = fakeDeps(template, {
|
||||
detectBaseRepo: () => Promise.resolve(detected),
|
||||
});
|
||||
|
||||
const result = await captureStreams(() =>
|
||||
runBenchKitInit(JSON_CTX, target, {}, deps),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBeUndefined();
|
||||
const config = readFileSync(join(target, "bench.config.yaml"), "utf8");
|
||||
expect(config).toContain("name: shop-app");
|
||||
expect(config).toContain("url: git@github.com:acme/shop-app.git");
|
||||
expect(config).not.toContain("demo-app");
|
||||
// Comments survive the in-place edit.
|
||||
expect(config).toContain("# Konfiguracja instancji benchmarku.");
|
||||
|
||||
const manifest = JSON.parse(readFileSync(join(target, ".bench-kit", "instance.json"), "utf8"));
|
||||
expect(manifest.detectedBaseRepo.name).toBe("shop-app");
|
||||
expect(manifest.detectedBaseRepo.headCommit).toBe("a".repeat(40));
|
||||
|
||||
const envelope = parseEnvelope(result.stdout);
|
||||
expect(envelope.data.baseRepo.name).toBe("shop-app");
|
||||
});
|
||||
|
||||
it("keeps the placeholder when init runs inside the instance itself", async () => {
|
||||
const template = buildTemplateFixture();
|
||||
const target = join(tempDir("bench-kit-target-"), "instance");
|
||||
const { deps } = fakeDeps(template, {
|
||||
detectBaseRepo: () =>
|
||||
Promise.resolve({
|
||||
rootDir: target,
|
||||
name: "instance",
|
||||
url: "git@github.com:acme/instance.git",
|
||||
headCommit: "b".repeat(40),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await captureStreams(() =>
|
||||
runBenchKitInit(JSON_CTX, target, {}, deps),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBeUndefined();
|
||||
const config = readFileSync(join(target, "bench.config.yaml"), "utf8");
|
||||
expect(config).toContain("name: demo-app");
|
||||
const envelope = parseEnvelope(result.stdout);
|
||||
expect(envelope.data.baseRepo).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a non-empty directory that is not an instance", async () => {
|
||||
const template = buildTemplateFixture();
|
||||
const target = tempDir("bench-kit-target-");
|
||||
|
||||
Reference in New Issue
Block a user