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
57 changes: 52 additions & 5 deletions clients/devbox/src/add.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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" +
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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:", () => {
Expand Down
15 changes: 11 additions & 4 deletions clients/devbox/src/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -47,19 +50,23 @@ 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).
* Writes the FULL schema (install/update/ports) — a partial entry crashes the projects
* 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`
);
Expand Down
9 changes: 6 additions & 3 deletions docs/multi-project.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ profile is automatic — no SSH host-aliases or `includeIf` gymnastics:

## Projects

Cloned into `/home/<user>/projects/<name>`, `bun install`ed (via mise), and any
`.env.example` scaffolded to `.env` (you fill in real secrets).
Cloned into `/home/<user>/projects/<name>`, `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

Expand Down Expand Up @@ -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
Expand Down