diff --git a/clients/devbox/src/add.test.ts b/clients/devbox/src/add.test.ts index 3bda7d0..c045bdc 100644 --- a/clients/devbox/src/add.test.ts +++ b/clients/devbox/src/add.test.ts @@ -1,5 +1,18 @@ -import { describe, expect, test } from "bun:test"; -import { addProjectToYaml, addServerToYaml, projectEntry, serverEntry, titleize, toSshUrl } from "./add"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, test } from "bun:test"; +import { addProjectToYaml, addServerToYaml, detectProject, projectEntry, serverEntry, titleize, toSshUrl } from "./add"; + +/** Make a throwaway git repo with an origin remote; optionally drop a package.json. */ +function makeRepo(withPackageJson: boolean): string { + const dir = mkdtempSync(join(tmpdir(), "devbox-add-")); + spawnSync("git", ["init", "-q"], { cwd: dir }); + spawnSync("git", ["remote", "add", "origin", "git@github.com:org/fixture.git"], { cwd: dir }); + if (withPackageJson) writeFileSync(join(dir, "package.json"), '{"name":"fixture"}\n'); + return dir; +} describe("toSshUrl", () => { test("https → git@host:owner/repo.git", () => { @@ -23,8 +36,10 @@ describe("toSshUrl", () => { }); describe("projectEntry", () => { - test("6-space indented block matching group_vars, with full schema", () => { - expect(projectEntry({ name: "myproj", repo: "git@github.com:org/myproj.git", branch: "main" })).toBe( + test("install: true → run `bun install` comment", () => { + expect( + projectEntry({ name: "myproj", repo: "git@github.com:org/myproj.git", branch: "main", install: true }), + ).toBe( " - name: myproj\n" + ' repo: "git@github.com:org/myproj.git"\n' + " branch: main\n" + @@ -33,6 +48,38 @@ describe("projectEntry", () => { " ports: []\n", ); }); + + test("install: false → no-package.json comment (toolkit, not a bun project)", () => { + expect( + projectEntry({ name: "ansible-toolkit", repo: "git@github.com:org/ansible-toolkit.git", branch: "main", install: false }), + ).toBe( + " - name: ansible-toolkit\n" + + ' repo: "git@github.com:org/ansible-toolkit.git"\n' + + " branch: main\n" + + " install: false # no package.json at repo root — nothing to install\n" + + " update: false # don't git-pull over Claude's local edits\n" + + " ports: []\n", + ); + }); +}); + +describe("detectProject install auto-detection", () => { + const dirs: string[] = []; + afterAll(() => { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); + }); + + test("install: true when the repo has a root package.json", () => { + const dir = makeRepo(true); + dirs.push(dir); + expect(detectProject({ cwd: dir }).install).toBe(true); + }); + + test("install: false when the repo has no root package.json", () => { + const dir = makeRepo(false); + dirs.push(dir); + expect(detectProject({ cwd: dir }).install).toBe(false); + }); }); describe("titleize", () => { @@ -82,7 +129,7 @@ profiles: branch: main `; -const snippet = projectEntry({ name: "myproj", repo: "git@github.com:org/myproj.git", branch: "main" }); +const snippet = projectEntry({ name: "myproj", repo: "git@github.com:org/myproj.git", branch: "main", install: true }); describe("addProjectToYaml", () => { test("inserts at the end of the correct profile's projects, before servers:", () => { diff --git a/clients/devbox/src/add.ts b/clients/devbox/src/add.ts index 67fc1fa..4edcdc1 100644 --- a/clients/devbox/src/add.ts +++ b/clients/devbox/src/add.ts @@ -36,9 +36,12 @@ const git = (args: string[], cwd: string): string | null => { return r.status === 0 && r.stdout ? r.stdout.trim() : null; }; -export type Detected = { name: string; repo: string; branch: string }; +export type Detected = { name: string; repo: string; branch: string; install: boolean }; -/** Inspect the git repo at `cwd` (default: process.cwd()) to fill name/repo/branch. */ +/** Inspect the git repo at `cwd` (default: process.cwd()) to fill name/repo/branch. + * `install` is auto-detected from a root package.json: a repo without one (an Ansible + * or shell toolkit, e.g. claude-devbox itself) gets `install: false`, so the projects + * role's `bun install` doesn't fail with "could not find a package.json file". */ export function detectProject(opts: { name?: string; branch?: string; cwd?: string }): Detected { const cwd = opts.cwd ?? process.cwd(); const top = git(["rev-parse", "--show-toplevel"], cwd); @@ -47,7 +50,8 @@ export function detectProject(opts: { name?: string; branch?: string; cwd?: stri if (!origin) die("this repo has no 'origin' remote — add one, or pass the repo url by hand"); const branch = opts.branch ?? git(["rev-parse", "--abbrev-ref", "HEAD"], cwd) ?? "main"; const name = opts.name ?? top.split("/").pop()!; - return { name, repo: toSshUrl(origin), branch }; + const install = existsSync(join(top, "package.json")); + return { name, repo: toSshUrl(origin), branch, install }; } /** The YAML block for one project, indented to match group_vars (6-space list items). @@ -55,11 +59,14 @@ export function detectProject(opts: { name?: string; branch?: string; cwd?: stri * role, because a missing `update` key makes Jinja's `item.update` resolve to the dict's * built-in .update() method instead of the value. Defaults mirror all.example.yml. */ export function projectEntry(d: Detected): string { + const installLine = d.install + ? " install: true # run `bun install` after clone\n" + : " install: false # no package.json at repo root — nothing to install\n"; return ( ` - name: ${d.name}\n` + ` repo: "${d.repo}"\n` + ` branch: ${d.branch}\n` + - ` install: true # run \`bun install\` after clone\n` + + installLine + ` update: false # don't git-pull over Claude's local edits\n` + ` ports: []\n` ); diff --git a/docs/multi-project.md b/docs/multi-project.md index aa920d2..bfc4119 100644 --- a/docs/multi-project.md +++ b/docs/multi-project.md @@ -41,8 +41,9 @@ profile is automatic — no SSH host-aliases or `includeIf` gymnastics: ## Projects -Cloned into `/home//projects/`, `bun install`ed (via mise), and any -`.env.example` scaffolded to `.env` (you fill in real secrets). +Cloned into `/home//projects/`, `bun install`ed (via mise) when the repo +has a root `package.json` and `install: true`, and any `.env.example` scaffolded to +`.env` (you fill in real secrets). ## Remote-control servers @@ -72,7 +73,9 @@ Preview from your client — see [realtime-sync.md](realtime-sync.md). `projects:` entry and an always-on Remote Control `servers:` entry into `all.yml`, then prints the playbook command (`--tags projects,remote`). Pass `--no-server` for a project with no phone-reachable RC service, or tune it with `--server-name`, -`--spawn`, `--capacity`. +`--spawn`, `--capacity`. `install:` is auto-detected from a root `package.json` — a +repo without one (an Ansible/shell toolkit) gets `install: false`, so the clone +doesn't fail on `bun install`. **By hand:** edit `all.yml`, re-run the playbook (idempotent + additive). A new profile creates the user + SSH key (add it to GitHub); a new `servers` entry brings